UWSP Pointer Overflow CTF 2025 Writeup

write-up

最終結果は8200ポイントで、38/1311位でした。

※不慣れな用語が多かったため、都度調べながら回答を進めました。
そのため誤りや不備が含まれている可能性がございます。何卒ご容赦ください。

また、一部の問題については、時間の都合により十分な解答を書き上げることができませんでした。
あらかじめご了承ください。

(全問題共通 フラグ変換用プログラム)

このCTFではLeet文字が使われていて、変換方法はRuleに記載されています。

変換コード

def to_leet(input_string):
    leet_dict = {
        'a': '4', 'b': 'b', 'c': 'c', 'd': 'd', 'e': '3',
        'f': 'f', 'g': '6', 'h': 'h', 'i': '1', 'j': 'j',
        'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': '0',
        'p': 'p', 'q': 'q', 'r': 'r', 's': '5', 't': '7',
        'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z'
    }
    
    output_string = ''.join(leet_dict.get(c, c) for c in input_string)
    return output_string

inp = input("入力してください >> ")
print(to_leet(inp))

Crypto 100-1 Letters from a Friend

IVIVS UNVVH ZBKIQ IAGNY WTRBT LIVRZ JCTJE LJELM ZDIVI RRZKI QIAGF ELXUH DRZIZ BKIQI AGYYM JYDLK MBGLX TIRVF FCTCB XKIQI AGHFV ZRUYK PEGDS UAYOP NKQXS UNVVH VKTGM XULXN IWAWY FWLNU IWZMR QIBIW VVPZX PHVAZ ORRUJ ZAINV NVZAU HSKPI EHXIM TRDYV LABUI JNVVH SU

(問題より引用)

ヴィジュネル暗号の解読問題でした

暗号鍵をFRIENDと予想して復号した結果がこちらです

DEARF RIEND MYFRI ENDIH OPEYO UAREW ELLFR IENDI MADEA NEWFR IENDA NDTHE YAREM YFRIE NDTHE FLAGT EXTIS CANIC ALLYO UFRIE NDCON VERTT HATAN DSUBM ITITF RIEND IHOPE THISW ASNTT OOHAR DFRIE NDKAS ISKIP LUSVI GNERE ISEAS IERWH ENTHE RESRE PEATE DWORD SFRIE ND

整形すると

Dear Friend,
My friend, I hope you are well.
Friend, I made a new friend and they are my friend.
Can I call you friend?
Convert that and submit it, friend.
I hope this wasn’t too hard, friend.
Skip plus giving here is easier when the repeated words “friend”.
Can I call you friend

をルールに従ってフラグにしました

Answer : poctf{uwsp_c4n_1_c4ll_y0u_fr13nd

Crypto 100-2 Mason, Lumen

総当りできそうだったので

import sys
from multiprocessing import Pool, cpu_count
from typing import Optional

def rol32(x: int, r: int) -> int:
    return ((x << r) & 0xFFFFFFFF) | (x >> (32 - r))

def keystream_bytes(key24: int, nonce: int, length: int) -> bytes:
    key32 = (key24 & 0xFFFFFF) | (((key24 & 0xFFFFFF) << 24) & 0xFFFFFFFF)
    state = (nonce ^ key32) & 0xFFFFFFFF
    out = bytearray()
    for i in range(length):
        state = (state + key32) & 0xFFFFFFFF
        state = rol32(state, 7)
        k = ((state & 0xFF) ^ ((state >> 8) & 0xFF)) & 0xFF
        out.append(k)
        state = (state ^ (i & 0xFFFFFFFF)) & 0xFFFFFFFF
    return bytes(out)

def is_mostly_printable(b: bytes, threshold: float = 0.90) -> bool:
    if len(b) == 0:
        return False
    printable = sum(1 for c in b if 32 <= c <= 126 or c in (9,10,13))
    return (printable / len(b)) >= threshold

def search_chunk(args):
    start, chunk_size, nonce, ct = args
    end = min(start + chunk_size, 1 << 24)
    for k in range(start, end):
        ks = keystream_bytes(k, nonce, len(ct))
        pt = bytes([a ^ b for a, b in zip(ct, ks)])
        # Heuristics: look for common flag markers or high printable ratio
        if b'flag{' in pt.lower() or b'ctf{' in pt.lower() or is_mostly_printable(pt, 0.95):
            # found candidate
            return (k, pt)
    return None

def main():
    if len(sys.argv) != 3:
        print("Usage: python3 decrypt.py nonce.txt ciphetext.bin")
        sys.exit(1)

    nonce_file = sys.argv[1]
    ct_file = sys.argv[2]

    nonce_hex = open(nonce_file, "r").read().strip()
    nonce = int(nonce_hex, 16) & 0xFFFFFFFF
    ct = open(ct_file, "rb").read()

    MAX_KEY = 1 << 24
    CHUNK = 1 << 12  # 4096 keys per chunk (tuneable)
    starts = list(range(0, MAX_KEY, CHUNK))

    print(f"Nonce: {nonce_hex} -> {nonce:#010x}, ciphertext length: {len(ct)}")
    print(f"Total keys: {MAX_KEY}, chunks: {len(starts)}, chunk size: {CHUNK}")
    print(f"Using {cpu_count()} CPU cores for parallel search...")

    found = None
    with Pool() as pool:
        for result in pool.imap_unordered(search_chunk, ((s, CHUNK, nonce, ct) for s in starts), chunksize=8):
            if result is not None:
                found = result
                pool.terminate()
                break

    if found is None:
        print("No candidate found.")
        sys.exit(1)

    key, pt = found
    print("=== Candidate found ===")
    print(f"key (int): {key}")
    print(f"key (hex 6 chars): {key:06x}")
    # try to decode as utf-8, fallback to latin-1
    try:
        text = pt.decode("utf-8")
    except:
        text = pt.decode("latin-1", errors="replace")
    print("plaintext (decoded):")
    print(text)

if __name__ == "__main__":
    main()

実行結果

Nonce: 3f2d1a9e -> 0x3f2d1a9e, ciphertext length: 32
Total keys: 16777216, chunks: 4096, chunk size: 4096
Using 2 CPU cores for parallel search...
=== Candidate found ===
key (int): 4289633
key (hex 6 chars): 417461
plaintext (decoded):
poctf{uwsp_l177l3_l16h7_0f_m1n3}

Answer : poctf{uwsp_l177l3_l16h7_0f_m1n3}

Crypto 100-3 Mason, Umbra

Shifting down on the Crypto challenge on this one. Let's go with a simple classic: A shift cipher with a little twist.

C: ZyMDP{E GCZ_4V mR3W157 5_P1 b 3}

(実際の問題より引用)

シフト+16すると

PoCTF{U WSP_4L cH3M157 5_F1 r 3}

空白を消して小文字に直すと

poctf{uwsp_4lch3m1575_f1r3}

Answer : poctf{uwsp_4lch3m1575_f1r3}

Crypto 100-4 Ashes to Ashes

概要

この問題では、128ビットのハッシュ関数 ashes_hash が与えられます

特徴:

  • 16バイト単位でブロックを処理
  • ブロックごとに交互に左または右に13ビット回転
  • XORと回転のみで構成された線形関数
  • 初期ベクトル IV = 0
  • 定数 C = 0xA5 * 16 が各ブロック処理後にXORされる

挑戦内容:

  1. サーバーからランダムな ASCII プレフィックスを取得
  2. そのプレフィックスを両方のメッセージの先頭に付けた状態で、ハッシュ衝突する2つの異なるメッセージを生成
  3. 衝突したハッシュをサーバーに提出

攻撃方法

このハッシュ関数は完全に線形(XORと回転のみ)なので、
古典的な「2ブロックの差分補正攻撃」が使えます。

具体的には

  1. ブロック単位で差分 Δ を入れる
  2. 次のブロックで回転 Δ を XOR して補正
  3. これにより最終状態 S が変化せず、衝突が発生

式で表すと

M1 = Prefix + A1 + A2
M2 = Prefix + (A1 XOR Δ) + (A2 XOR rotate13(Δ))

これで ashes_hash(M1) == ashes_hash(M2) が成立します。

ポイント

  • プレフィックス長を16バイト境界に揃えるため、必要ならゼロパディング
  • Δ は非ゼロの任意の128ビット値で良い
  • 交互回転の偶奇に注意(偶数ブロックは左回転、奇数は右回転)

攻撃のコード & 解答

import requests
import secrets
import json
from typing import Tuple

C = bytes([0xA5]) * 16
MASK128 = (1 << 128) - 1

def _to_u128(b: bytes) -> int:
    return int.from_bytes(b, "little")

def _from_u128(x: int) -> bytes:
    return x.to_bytes(16, "little")

def _rol13(x: int) -> int:
    return ((x << 13) | (x >> (128 - 13))) & MASK128

def _ror13(x: int) -> int:
    return ((x >> 13) | (x << (128 - 13))) & MASK128

def ashes_hash(msg: bytes) -> bytes:
    if len(msg) % 16 != 0:
        msg += b"\x00" * (16 - (len(msg) % 16))

    S = 0
    c = _to_u128(C)

    for i in range(0, len(msg), 16):
        m = _to_u128(msg[i:i+16])
        if ((i // 16) % 2) == 0:
            S = _rol13(S ^ m) ^ c
        else:
            S = _ror13(S ^ m) ^ c

    return _from_u128(S)

def pad_prefix_to_block(prefix: bytes) -> Tuple[bytes,int]:
    if len(prefix) % 16 == 0:
        return prefix, len(prefix) // 16
    pad_len = 16 - (len(prefix) % 16)
    return prefix + b"\x00" * pad_len, (len(prefix) + pad_len) // 16

def make_collision(prefix: bytes):
    pref_padded, first_idx = pad_prefix_to_block(prefix)

    A1 = secrets.token_bytes(16)
    A2 = secrets.token_bytes(16)

    while True:
        delta = secrets.token_bytes(16)
        if any(b != 0 for b in delta):
            break

    delta_int = _to_u128(delta)

    if first_idx % 2 == 0:
        rot_delta = _from_u128(_rol13(delta_int))
    else:
        rot_delta = _from_u128(_ror13(delta_int))

    B1 = bytes(a ^ b for a, b in zip(A1, delta))
    B2 = bytes(a ^ b for a, b in zip(A2, rot_delta))

    m1 = pref_padded + A1 + A2
    m2 = pref_padded + B1 + B2

    min_extra = 32
    extra_needed = max(0, min_extra - (len(m1) - len(prefix)))
    if extra_needed > 0:
        zeros = b"\x00" * extra_needed
        m1 += zeros
        m2 += zeros

    return m1, m2, first_idx

BASE = "https://crypto100-4.pointeroverflowctf.com"

def get_prefix():
    r = requests.post(f"{BASE}/prefix", timeout=10, verify=False)
    r.raise_for_status()
    j = r.json()
    return j["session_id"], j["prefix"]

def submit(sid, m1_hex, m2_hex):
    payload = {"session_id": sid, "m1_hex": m1_hex, "m2_hex": m2_hex}
    headers = {"Content-Type": "application/json"}
    r = requests.post(f"{BASE}/submit", data=json.dumps(payload),
                      headers=headers, timeout=10, verify=False) #自己証明書が使用されているため
    return r

def main():
    print("[*] GET /prefix...")
    sid, prefix = get_prefix()
    print("session_id:", sid)
    print("prefix:", prefix)

    pref_bytes = prefix.encode("ascii")

    print("[*] constructing collision...")
    m1, m2, idx = make_collision(pref_bytes)
    print("first custom block index:", idx)

    h1 = ashes_hash(m1)
    h2 = ashes_hash(m2)

    print("local hash1:", h1.hex())
    print("local hash2:", h2.hex())

    if h1 != h2:
        print("local collision failed")
        return

    m1_hex = m1.hex()
    m2_hex = m2.hex()

    print("[*] submitting messages...")
    resp = submit(sid, m1_hex, m2_hex)
    print("status:", resp.status_code)
    print("response:", resp.text)

if __name__ == "__main__":
    main()

実行結果

[*] GET /prefix...
session_id: 4013df2a-a8f8-42f5-adb0-f016a8ac7c37
prefix: ajrsc72f7377pthg
[*] constructing collision...
first custom block index: 1
local hash1: 9df310cec4b3cc98c5964305ce6ef6eb
local hash2: 9df310cec4b3cc98c5964305ce6ef6eb
[*] submitting messages...
status: 200
response: {"flag":"poctf{uwsp_54v3_y0ur_f0rk_7h3r35_p13}","status":"ok"}

Answer : poctf{uwsp_54v3_y0ur_f0rk_7h3r35_p13}

Crypto 200-2 A Short Walk on a Long Hill

Diffie–Hellman のサブグループ検証抜けを突いた短周期攻撃の問題でした

問題の概要

  • サーバは Diffie–Hellman の公開値 A を検証せずに受け取り、共有鍵 K=Ax mod pK=A^x\bmod pK=Axmodp を使って HMAC(nonce) を返す
  • これを利用して「小さな位数(サブグループ)」の元を送り、秘密指数 xxx の小さな剰余(x mod rx \bmod rxmodr)を漏らさせる攻撃(small‑subgroup / short‑walk 攻撃)
  • 多数の互いに素な小位数の情報を CRT で結合すれば、長い xxx を復元できる

問題文を分析

  • エンドポイント:/params(p, B)、/nonce(固定の32バイト)、/oracle(任意の A を送って HMAC を受け取る)、/ciphertext(暗号化データ)
  • 制約:サーバは A の検証(位数チェック)をしていないというヒントがある → 任意の A を送れる
  • サーバは K をそのバイト列(どの長さで表現するか注意)で HMAC 鍵にしているので、ローカルで候補 K を列挙して HMAC 照合できる
  • 鍵導出と復号手順は問題文で明示(SHA256(x.to_bytes(16,'big') || b"short-walk") を AES鍵、IV/CTRの扱いも指定)

攻撃プログラムの流れ

import requests
import hashlib
import hmac
import math
from Crypto.Cipher import AES
from functools import reduce

# ターゲットサーバーの URL
BASE = "http://crypto200-2.pointeroverflowctf.com:11697"

# ---- HTTP ヘルパー関数 ----
def get_params():
    """
    /params から Diffie-Hellman パラメータ p と B を取得
    p, B は decimal string で返されるので int に変換
    """
    r = requests.get(BASE + "/params")
    r.raise_for_status()
    j = r.json()
    p = int(j["p"])
    B = int(j["B"])
    return p, B

def get_nonce():
    """
    /nonce から 32 バイトのランダム nonce を取得
    """
    r = requests.get(BASE + "/nonce")
    r.raise_for_status()
    return r.content

def get_ciphertext():
    """
    /ciphertext から暗号文を取得
    16 進文字列や base64 の場合も自動デコード
    """
    r = requests.get(BASE + "/ciphertext")
    r.raise_for_status()
    data = r.content.strip()
    try:
        return bytes.fromhex(data.decode())
    except Exception:
        import base64
        try:
            return base64.b64decode(data)
        except Exception:
            return data

def oracle_query(A_int):
    """
    /oracle に自作の公開値 A を送信し、サーバーが返す HMAC を取得
    A_int: 整数形式の公開値
    返り値: HMAC の hex 文字列
    """
    j = {"A": str(A_int)}
    r = requests.post(BASE + "/oracle", json=j)
    r.raise_for_status()
    jj = r.json()
    return jj["mac"]

# ---- 数学ヘルパー関数 ----
def egcd(a, b):
    """
    拡張ユークリッドの互除法
    ax + by = gcd(a,b) の整数解 x, y を返す
    """
    if b == 0:
        return (a, 1, 0)
    g, x1, y1 = egcd(b, a % b)
    return (g, y1, x1 - (a // b) * y1)

def crt_pair(a1, m1, a2, m2):
    """
    2 つの合同式を中国剰余定理 (CRT) で結合
    x ≡ a1 (mod m1), x ≡ a2 (mod m2) を解く
    """
    g, s, t = egcd(m1, m2)
    if (a1 - a2) % g != 0:
        return None
    l = m1 // g * m2
    x = (a1 * (m2//g) * t + a2 * (m1//g) * s) % l
    return (x % l, l)

def crt_list(congruences):
    """
    複数の合同式 [(a1,m1), (a2,m2), ...] を CRT で結合
    """
    x, m = congruences[0]
    for a2, m2 in congruences[1:]:
        res = crt_pair(x, m, a2, m2)
        if res is None:
            raise ValueError("CRT failed")
        x, m = res
    return x, m

# ---- 小さい素数生成 ----
def primes_upto(n):
    """
    エラトステネスの篩で n 以下の素数リストを作成
    """
    sieve = bytearray(b'\x01') * (n+1)
    sieve[0:2] = b'\x00\x00'
    for i in range(2, int(n**0.5)+1):
        if sieve[i]:
            step = i
            start = i*i
            sieve[start:n+1:step] = b'\x00' * (((n - start)//step) + 1)
    return [i for i, isprime in enumerate(sieve) if isprime]

# ---- 攻撃本体 ----
def find_order_of_element(a, p, max_check=2000):
    """
    a の位数(mod p)を調べる
    小さい素数で構築した a に対して正確な位数 s を求める
    """
    if a % p == 0:
        return 0
    cur = 1
    for k in range(1, max_check+1):
        cur = (cur * a) % p
        if cur == 1:
            return k
    return None

def try_small_walk():
    """
    短周期攻撃 ("short walk") を実行して秘密指数 x を復元
    -> AES キーを生成し、暗号文を復号
    """
    p, B = get_params()
    print("[*] p bitlen:", p.bit_length())
    nonce = get_nonce()
    print("[*] got nonce len:", len(nonce))

    p_byte_len = (p.bit_length() + 7) // 8

    # 小さな素数を大量に使って小さな位数の合同式を作る
    small_primes = primes_upto(1000)
    congruences = []
    total_mod = 1

    for r in small_primes:
        # 2^{(p-1)/r} を計算して、位数が r 以下の元を作る
        exp = (p - 1) // r
        A = pow(2, exp, p)
        if A == 1:
            continue
        s = find_order_of_element(A, p, max_check=r)
        if s is None or s == 1:
            continue
        print(f"[*] Trying r={r}, detected order s={s}; querying oracle...")
        mac_hex = oracle_query(A).lower().strip()

        # HMAC を順列で計算して、正しい t を特定
        found = False
        for t in range(s):
            K = pow(A, t, p)
            K_bytes = K.to_bytes(p_byte_len, 'big')
            hm = hmac.new(K_bytes, nonce, hashlib.sha256).hexdigest()
            if hm == mac_hex:
                print(f"[+] Found congruence: x ≡ {t} (mod {s})")
                congruences.append((t, s))
                total_mod *= s
                found = True
                break
        if not found:
            for t in range(s):
                K = pow(A, t, p)
                K_min = K.to_bytes((K.bit_length()+7)//8 or 1, 'big')
                hm = hmac.new(K_min, nonce, hashlib.sha256).hexdigest()
                if hm == mac_hex:
                    print(f"[+] Found congruence with minimal K_bytes: x ≡ {t} (mod {s})")
                    congruences.append((t, s))
                    total_mod *= s
                    found = True
                    break
        if not found:
            print(f"[-] No match for r={r} (order {s}) -- skipping")
        if total_mod > (1 << 128):
            print("[*] accumulated modulus > 2^128, trying CRT combine")
            break

    if not congruences:
        raise RuntimeError("No congruences found; try more/other small primes or different base")

    # 中国剰余定理で x を復元
    x_rec, mod = crt_list(congruences)
    print(f"[*] CRT result: x ≡ {x_rec} (mod {mod})")
    x = x_rec

    # AES キーを生成(問題文の指示に従う)
    x_bytes_16 = x.to_bytes(16, 'big')
    aes_key = hashlib.sha256(x_bytes_16 + b"short-walk").digest()
    print("[*] Derived AES key (sha256):", aes_key.hex())

    blob = get_ciphertext()
    iv = blob[:16]
    ct = blob[16:]
    nonce8 = iv[:8]
    init_ctr = int.from_bytes(iv[8:], 'big')

    # AES-CTR 復号(ECB でカウンター暗号)
    aes_ecb = AES.new(aes_key, AES.MODE_ECB)
    plain = bytearray()
    blocks = (len(ct) + 15) // 16
    for i in range(blocks):
        counter = init_ctr + i
        counter_block = nonce8 + counter.to_bytes(8, 'big')
        keystream = aes_ecb.encrypt(counter_block)
        chunk = ct[i*16:(i+1)*16]
        out = bytes(a ^ b for a, b in zip(chunk, keystream[:len(chunk)]))
        plain.extend(out)

    try:
        pt = plain.decode('utf-8')
    except:
        pt = plain.decode('latin-1', errors='replace')

    print("[*] Decrypted plaintext (maybe flag):")
    print(pt)
    return pt

if __name__ == "__main__":
    print("=== Starting short-walk exploit ===")
    plaintext = try_small_walk()
    print("=== Done ===")
  1. 得た xxx を x.to_bytes(16,’big’) にし、SHA256(…||b”short-walk”) で AES鍵を導出。GET /ciphertext で取得した blob を iv||ct に分割し、指定どおり AES‑CTR で復号して平文(フラグ)を得る
  2. GET /params で p を取得、GET /nonce で nonce を取得
  3. 小さな素数 r を順に選ぶ(例: 3,5,7,11,…)。各 r について A=g(p−1)/r mod pA = g^{(p-1)/r} \bmod pA=g(p−1)/rmodp のようにして位数が r に割れる元を作る(A==1 のときはスキップ)
  4. その A を POST /oracle に送ってサーバの HMAC を回収
  5. ローカルで t=0..r−1t=0..r-1t=0..r−1 を試し、K=At mod pK=A^t\bmod pK=Atmodp をバイト列にして HMAC(nonce) を計算。サーバの MAC と一致する t を見つければ x≡t(modr)x \equiv t \pmod rx≡t(modr) が得られる
  6. K のバイト表現はサーバ実装次第で固定長(= ceil(p/8))だったり最小長だったりするので、両方試す
  7. 複数の(互いに素な)r で得た剰余を中国剰余定理 (CRT) で合成し、十分大きなモジュラスを得る(例: 積が 21282^{128}2128 を超えるまで)
=== Starting short-walk exploit ===
[*] p bitlen: 1035
[*] got nonce len: 32
[*] Trying r=3, detected order s=3; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 0 (mod 3)
[*] Trying r=7, detected order s=7; querying oracle...
[+] Found congruence: x ≡ 1 (mod 7)
[*] Trying r=11, detected order s=11; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 9 (mod 11)
[*] Trying r=13, detected order s=13; querying oracle...
[+] Found congruence: x ≡ 12 (mod 13)
[*] Trying r=17, detected order s=17; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 5 (mod 17)
[*] Trying r=19, detected order s=19; querying oracle...
[+] Found congruence: x ≡ 5 (mod 19)
[*] Trying r=23, detected order s=23; querying oracle...
[+] Found congruence: x ≡ 10 (mod 23)
[*] Trying r=29, detected order s=29; querying oracle...
[+] Found congruence: x ≡ 23 (mod 29)
[*] Trying r=31, detected order s=31; querying oracle...
[+] Found congruence: x ≡ 10 (mod 31)
[*] Trying r=37, detected order s=37; querying oracle...
[+] Found congruence: x ≡ 19 (mod 37)
[*] Trying r=41, detected order s=41; querying oracle...
[+] Found congruence: x ≡ 24 (mod 41)
[*] Trying r=43, detected order s=43; querying oracle...
[+] Found congruence: x ≡ 41 (mod 43)
[*] Trying r=47, detected order s=47; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 11 (mod 47)
[*] Trying r=53, detected order s=53; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 23 (mod 53)
[*] Trying r=59, detected order s=59; querying oracle...
[+] Found congruence: x ≡ 4 (mod 59)
[*] Trying r=61, detected order s=61; querying oracle...
[+] Found congruence: x ≡ 1 (mod 61)
[*] Trying r=67, detected order s=67; querying oracle...
[+] Found congruence: x ≡ 6 (mod 67)
[*] Trying r=71, detected order s=71; querying oracle...
[+] Found congruence: x ≡ 30 (mod 71)
[*] Trying r=73, detected order s=73; querying oracle...
[+] Found congruence: x ≡ 41 (mod 73)
[*] Trying r=79, detected order s=79; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 78 (mod 79)
[*] Trying r=83, detected order s=83; querying oracle...
[+] Found congruence with minimal K_bytes: x ≡ 16 (mod 83)
[*] Trying r=89, detected order s=89; querying oracle...
[+] Found congruence: x ≡ 58 (mod 89)
[*] Trying r=97, detected order s=97; querying oracle...
[+] Found congruence: x ≡ 36 (mod 97)
[*] Trying r=101, detected order s=101; querying oracle...
[+] Found congruence: x ≡ 67 (mod 101)
[*] Trying r=103, detected order s=103; querying oracle...
[+] Found congruence: x ≡ 7 (mod 103)
[*] accumulated modulus > 2^128, trying CRT combine
[*] CRT result: x ≡ 15607543889841794955 (mod 2398482352892522817270652163869225839621)
[*] Derived AES key (sha256): 3c57430602b494c48e604c53cf81718d2ef30f096440ff716c764fc5e3b7768c
[*] Decrypted plaintext (maybe flag):
poctf{uwsp_7h3_l16h7_4nd_7h3_d4rk}
=== Done ===

Answer : poctf{uwsp_7h3_l16h7_4nd_7h3_d4rk}

Crypto 200-3 Pale Fire

弱いDH鍵交換を利用する離散対数攻撃問題でした。

問題の脆弱性

この問題では、Diffie–Hellman の公開値 A, B, それに使われたパラメータ p = 0x7fffffff(31bit), g = 3 が与えられています。
問題点は p が小さすぎることです。

DH の公開値は

A = g^a mod p
B = g^b mod p

p が 31bit しかないため、離散対数が 高速で解けてしまいます
つまり、本来秘密の値である a と b をどちらも計算により取り戻せます。
a と b が分かれば共通鍵 K も求まるので、暗号文の XOR も復号できます。

解法

1. 離散対数 a, b を求める

与えられた公開値 A, B に対して

A = g^a mod p  
B = g^b mod p

を満たす a, b を求めます。
p が 31bit (約 2×10⁹) なので、Baby-step Giant-step 法(BSGS)を使うと数秒で離散対数を解けます。

BSGS の概要は次の通りです。

  1. p−1 の平方根 m ≈ √(p−1) をとる
  2. g^0, g^1, …, g^(m−1) をテーブル化
  3. g^(−m) を前計算
  4. B から g^(−m) を掛けていき、テーブルとの一致を探す

一致した場所を組み合わせることで

x = i*m + j

という形で離散対数が得られます。

この処理を A に対して行えば a が、B に対して行えば b が求まります。


2. 共通鍵 K の計算

a と b が求まれば、Diffie–Hellman の共通鍵は

K = g^(a*b) mod p

となります。
本来は Alice が B^a, Bob が A^b を計算する形ですが、
a, b の両方が分かっているためどちらの式でも K を得られます。


3. 鍵ストリーム生成と復号

問題の暗号化処理は XOR です。

暗号文には SHA1(K) を鍵ストリームとして使い、
平文と XOR したものが ciphertext になっています。

処理

key = SHA1(str(K))
plaintext[i] = ciphertext[i] XOR key[i]

つまり K が正しく求まれば
SHA1(K) のバイト列と XOR を取るだけで平文が復元できます。

短い暗号文なので、SHA1(K) の先頭部分さえあれば完全に復号できます。

コード

import hashlib

p = int("0x7fffffff",16)g = int("0x3",16)
A = int("0x70b7c967",16)
B = int("0x0307f998",16)
ct = bytes.fromhex("c1440a2d7cd4f59bdc6340f971831cf173aa8867a501")

# 離散対数
a = 58363728

# 共有秘密
s = pow(B, a, p)
s_bytes = s.to_bytes((s.bit_length()+7)//8 or 1, 'big')

# 3) SHA256 を鍵材料にして XOR 復号
key = hashlib.sha256(s_bytes).digest()  # 32 bytes
# XOR
pt = bytes([ct[i] ^ key[i % len(key)] for i in range(len(ct))])

print("shared s =", hex(s))
print("plaintext =", pt)
print("as string =", pt.decode('ascii', errors='replace'))

実行結果

shared s = 0x1f18b75a
plaintext = b'poctf{uwsp_k4m3h4m3h4}'
as string = poctf{uwsp_k4m3h4m3h4}

Answer : poctf{uwsp_k4m3h4m3h4}

Crypto 400 This is Not the Way

問題概要

サーバーから ne(RSA 公開鍵)と ciphertext を取ってきて復号する問題でした。

ただしこの RSA 鍵は「private exponent d が小さすぎる」という脆弱性を持っていて、
これは Wiener Attack(小さな d に対する continued fractions 攻撃) で破れます。

実際、問題文にも「nerfed down to classic small-d continued fractions (Wiener) trick」と書かれています。

攻撃の説明

Wiener Attack は「d が N^(1/4) より十分小さい場合」「e/φ(n) の構造が悪くない場合」に
e/n の連分数展開に現れる convergent (k, d) を直接拾い上げられる攻撃。

正しい d は必ず収束分の分母として出現するのがポイントです。

攻撃の流れ

この問題で必要な工程だけ簡潔に抜き出すと以下の 4 つです

  1. e/n の連分数展開
  2. 全収束分 (k_i, d_i) の走査
  3. (e*d_i - 1) % k_i == 0 を満たす候補の抽出
  4. 判別式が平方数であることで真の (p, q) を確定

ここまで来れば d が確定し復号できます。

この問題は d が明らかに小さいのですぐに見つかります。

攻撃コードと実行結果

import requests

import urllib3

import base64

import math

from math import gcd

#サーバーが自己証明書を利用しているため

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ——————————————————————-

# 連分数 (continued fraction) 展開

#   e/N を連分数に展開する

# ——————————————————————-

def continued_fraction_expansion(a, b, max_iters=10000):

    cf = []

    x, y = a, b

    for _ in range(max_iters):

        if y == 0:

            break

        q = x // y

        cf.append(q)

        x, y = y, x – q * y

        if y == 0:

            break

    return cf

# ——————————————————————-

# convergents 計算

#   連分数 cf から(K_i, D_i) の候補列を生成する。

# ——————————————————————-

def convergents_from_cf(cf):

    convs = []

    for i in range(len(cf)):

        num, den = 1, 0

        for q in reversed(cf[:i+1]):

            num, den = q * num + den, num

        convs.append((num, den))

    return convs

# ——————————————————————-

# 拡張ユークリッドで逆元を計算

# ——————————————————————-

def modinv(a, m):

    a = a % m

    if a == 0:

        return None

    g, x, y = extended_gcd(a, m)

    if g != 1:

        return None

    return x % m

# ——————————————————————-

# 拡張ユークリッド互除法

# ——————————————————————-

def extended_gcd(a, b):

    if b == 0:

        return (a, 1, 0)

    g, x1, y1 = extended_gcd(b, a % b)

    return (g, y1, x1 – (a // b) * y1)

# ——————————————————————-

# 完全平方判定

#   n が平方数なら √n を返す。それ以外は None

# ——————————————————————-

def is_perfect_square(n):

    if n < 0:

        return None

    r = int(math.isqrt(n))

    if r * r == n:

        return r

    return None

# ——————————————————————-

# バイト変換 (RSA 復号後の整数 m → バイト列)

# ——————————————————————-

def int_to_bytes(i):

    length = (i.bit_length() + 7) // 8

    return i.to_bytes(length, “big”)

# ——————————————————————-

# Wiener攻撃

#   連分数 → convergents から d 候補を生成していき

#   φ(N), p, q を再構築し、正しい場合は成功。

# ——————————————————————-

def wiener_attack(e, N):

    cf = continued_fraction_expansion(e, N)

    convs = convergents_from_cf(cf)

    for (k, d_candidate) in convs:

        if k == 0:

            continue

        # (e*d – 1) / k が整数でなければ d 候補ではない

        if (e * d_candidate – 1) % k != 0:

            continue

        phi_candidate = (e * d_candidate – 1) // k

        # p+q を s とすると s = N – phi(N) + 1

        s = N – phi_candidate + 1

        discr = s * s – 4 * N

        # 判別式が平方数なら二次方程式が解ける

        if discr >= 0:

            t = is_perfect_square(discr)

            if t is not None:

                p = (s + t) // 2

                q = (s – t) // 2

                if p * q == N:

                    return int(d_candidate), int(p), int(q)

    return None, None, None

# ——————————————————————-

# メイン処理

#   1. /pubkey を取得

#   2. N,e を読み取り

#   3. /ciphertext を取得し Base64 デコード

#   4. Wiener攻撃で d,p,q を復元

#   5. RSA 復号してフラグを表示

# ——————————————————————-

def main():

    base = “https://crypto400.pointeroverflowctf.com”

    pubkey_url = base + “/pubkey”

    ct_url = base + “/ciphertext”

    print(“[*] fetching pubkey (verify=False)”)

    r = requests.get(pubkey_url, timeout=10, verify=False)

    r.raise_for_status()

    pub = r.json()

    N = int(pub[“n”], 16)    # n は16進文字列

    e = int(pub[“e”], 16)    # e も16進

    k = pub.get(“k”, None)  # ciphertext の長さ(bytes)

    print(“[*] N:”, N)

    print(“[*] e:”, e)

    print(“[*] k:”, k)

    print(“[*] fetching ciphertext (verify=False)”)

    r = requests.get(ct_url, timeout=10, verify=False)

    r.raise_for_status()

    c_b64 = r.json()[“c_b64”]

    c = int.from_bytes(base64.b64decode(c_b64), “big”)

    print(“[*] trying…”)

    d, p, q = wiener_attack(e, N)

    if d:

        print(“[+] d:”, d)

        print(“[+] p:”, p)

        print(“[+] q:”, q)

        # 復号 m = c^d mod N

        m = pow(c, d, N)

        m_bytes = int_to_bytes(m)

        try:

            print(“[+] plaintext:”, m_bytes.decode())

        except:

            print(“[+] plaintext (hex):”, m_bytes.hex())

    else:

        print(“[-] failed”)

if __name__ == “__main__”:

    main()

実行結果

[*] fetching pubkey (verify=False)
[*] N: 80259680827380056066605449119868418534258360629567600792767328314646199353983743056440042037575122258091659142709493747229970521567238869068304955308980545373510893378407706500597055529036854086825270783044095887334657178662826722053278770736917648750263444491858294469977556825204602391855103974014464068429
[*] e: 70725967224248496015808547908933612402721837913624711197737344196862772275544975970064296217122180604098743750856334372507567363416948062099434459879147212132503001633629215351338493323577849084112805493451586863410215912339870168487668473123233003644721431285723487644417142331755657294139051178165993554017
[*] k: 128
[*] fetching ciphertext (verify=False)
[*] trying...
[+] d: 1221848707576875932110075867453467524272734255353
[+] p: 9483174911502472128927042142991862939333649902806058824945956243656617124784953709605978412152751628813663343433720320494620988081921964273845456225379271
[+] q: 8463376619789043686294261437689801174395798028403273322547907678603215723979426039364584633711472683557583899480898291789134137524422235113352283657498699
[+] plaintext: poctf{uwsp_1v3_b33n_c4ll3d_w0r53}

Answer : poctf{uwsp_1v3_b33n_c4ll3d_w0r53}

Misc 100-1 Honey, I Shrunk the Kids

import requests

base_url = "https://misc100-1.pointeroverflowctf.com/oracle?q="
current_guess = "poctf{uwsp_"
candidates = "4bcd3f6h1jklmn0pqr57uvwxyz_}" #ルールに書かれていた使える文字一覧

while True:
    best_len = None
    best_char = None

    for c in candidates:
        query = current_guess + c
        r = requests.get(base_url + query)
        compressed_len = r.json()["compressed_len"]

        if best_len is None or compressed_len < best_len:
            best_len = compressed_len
            best_char = c

    current_guess += best_char
    print("Current guess:", current_guess)

    if best_char == "}":
        break

print("Flag found : ", current_guess)

これで解けました

Answer : poctf{uwsp_pr1c3l355_7h0u6h_17_15}

Misc 100-4 Mason, Terror Incognita

パズルを解くだけです。

Answer : poctf{uwsp_1_c4nn07_d0_7h3_n33dful}

Misc 200-1 Mason, In Light

letter.txtにアクロスティック形式でフラグが隠されていました
(voicemail.wavは関係ないようです)

各業の先頭の文字を集めると

theycallmevillain

となり、整形してフラグの形式にあわせると

7h3y_c4ll_m3_v1ll41n

Answer : poctf{uwsp_7h3y_c4ll_m3_v1ll41n}

OSINT 200-1 The Paper Trail

写真を画像検索するとRedditのページが見つかり、近くの高校を調べると「Homer High School」が見つかりました。

1940年のyearbookも公開されていて、12ページを見てみると

RAYMOND COPE
Quiet men are most surprising.

と書かれていました。

Answer : poctf{uwsp_qu137_m3n_4r3_m057_5urpr151n6}

Reverse 100-1 Seven Easy Pieces

1. デコンパイル

「a tiny .NET console program」と記載されていたため、とりあえずdnSpyで解析するとProgram.csが出てきました。

using System;

// Token: 0x02000002 RID: 2

public class Program

{

    // Token: 0x06000002 RID: 2 RVA: 0x00002058 File Offset: 0x00000258

    public static void Main()

    {

        int num = 0;

        int num2;

        for (;;)

        {

            switch (num)

            {

            case 0:

            {

                int[] array = new int[]

                {

                    71,

                    88,

                    84,

                    67,

                    81,

                    76,

                    66,

                    64,

                    68,

                    71,

                    104,

                    2,

                    7,

                    90,

                    4,

                    104,

                    81,

                    91,

                    3,

                    1,

                    104,

                    0,

                    95,

                    6,

                    2,

                    104,

                    0,

                    66,

                    69,

                    89,

                    4,

                    83,

                    104,

                    7,

                    66,

                    0,

                    104,

                    0,

                    7,

                    104,

                    85,

                    4,

                    74

                };

                num2 = 0;

                string text = Console.ReadLine();

                if (text == null)

                {

                    return;

                }

                if (text.Length != 43)

                {

                    goto Block_2;

                }

                num = 1;

                continue;

            }

            case 1:

                for (int i = 0; i < 6; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 2;

                continue;

            case 2:

                for (int i = 6; i < 12; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 3;

                continue;

            case 3:

                for (int i = 12; i < 18; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 4;

                continue;

            case 4:

                for (int i = 18; i < 24; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 5;

                continue;

            case 5:

                for (int i = 24; i < 30; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 6;

                continue;

            case 6:

                for (int i = 30; i < 36; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 7;

                continue;

            case 7:

                for (int i = 36; i < 43; i++)

                {

                    int[] array;

                    string text;

                    if ((int)(text.get_Chars(i) ^ ‘7’) != array[i])

                    {

                        num2++;

                    }

                }

                num = 8;

                continue;

            case 8:

                goto IL_27E;

            }

            return;

        }

        return;

        Block_2:

        Console.WriteLine(“nope”);

        return;

        IL_27E:

        if (num2 == 0)

        {

            Console.WriteLine(“Correct!”);

            return;

        }

        Console.WriteLine(“nope”);

    }

}

2. プログラムを整理

ユーザーに文字列の入力を要求するし、入力文字列が正しい場合に「Correct!」、間違っていると「nope」と表示されます

プログラムの特徴としては以下のとおりです

  • 入力文字列の長さ: 43文字
  • バイナリの中に 整数配列 (int[] array) が埋め込まれている
  • 各文字は '7' と XOR されて配列の値と比較されている

配列は

int[] array = new int[]
{
    71, 88, 84, 67, 81, 76, 66, 64, 68, 71, 104, 2,
    7, 90, 4, 104, 81, 91, 3, 1, 104, 0, 95, 6,
    2, 104, 0, 66, 69, 89, 4, 83, 104, 7, 66, 0,
    104, 0, 7, 104, 85, 4, 74
};

となっていて、入力文字列の長さ(43)と同じです

Pythonで復号

実際に使ったコードです

arr = [71, 88, 84, 67, 81, 76, 66, 64, 68, 71, 104, 2,
7, 90, 4, 104, 81, 91, 3, 1, 104, 0, 95, 6,
2, 104, 0, 66, 69, 89, 4, 83, 104, 7, 66, 0,
104, 0, 7, 104, 85, 4, 74]

flag = ''.join(chr(x ^ ord('7')) for x in arr)
print(flag)

Answer : poctf{uwsp_50m3_fl46_7h15_7urn3d_0u7_70_b3}

Reverse 100-2 Left at the Light

ghidraで解析してみました。

FUN_140001390が気になったので、確認してみると


undefined8 FUN_140001390(undefined8 param_1,double param_2,undefined8 param_3,undefined8 param_4

(略)

      if (uVar6 + 4 == 0x3c) {
        pcVar4 = "nope.";
        iVar3 = memcmp(local_1c8,"cG9jdGZ7dXdzcF93aDMzenlfd2gxNWszcjVfNG5kX3cxNXB5X3cxbmQ1fQ==",0 x3c
                      );
        if (iVar3 == 0) {
          puts("ok! flag:");
          uVar8 = 0;
          pcVar4 = "Submit what you entered.";
        }
      }
    }
(略)
cG9jdGZ7dXdzcF93aDMzenlfd2gxNWszcjVfNG5kX3cxNXB5X3cxbmQ1fQ==

この文字列はbase64形式に見えたため、デコードしたところフラグが出てきました。

Answer : poctf{uwsp_wh33zy_wh15k3r5_4nd_w15py_w1nd5}

Reverse 200-1 On Hinge and Pin

概要

apktool d rev200-1.apk

これでapkの中身が出てきました

smali_classes3/com/poctf/onhingeandpin/Crypto.smali

が気になったので解析してみると、鍵である文字列

ONOFFONOFF

assets\enc_flag.bin

をXORしているだけのようです

コード

# 鍵
key = b"ONOFFONOFF"

# enc_flag.bin を読み込む
with open("enc_flag.bin", "rb") as f:
    data = f.read()

# XOR復号
out = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])

# フラグ表示
print(out.decode('utf-8'))

Answer : poctf{uwsp_c4nc3l_c0u7ur3}

Stego 100-1 Ink Between the Lines

シンプルなWhitespace系に見えたので以下のプログラムを使いました。

file_path = "leaflet.txt"

binary_string = ""

with open(file_path, "r", encoding="utf-8") as f:
    for line in f:
        # 改行を除去
        ending = line.rstrip("\n")
        # 行末の空白・タブを抽出
        import re
        match = re.search(r'([ \t]+)$', ending)
        if match:
            chars = match.group(1)
            for c in chars:
                if c == ' ':
                    binary_string += '0'
                elif c == '\t':
                    binary_string += '1'

# 8bitごとに分けてASCIIに変換
flag = ""
for i in range(0, len(binary_string), 8):
    byte = binary_string[i:i+8]
    if len(byte) == 8:
        flag += chr(int(byte, 2))

print("Flag:")
print(flag)

さらにルールに従って一部の文字を置き換える必要があるので

# 元のアルファベット
original = "abcdefghijklmnopqrstuvwxyz "

# 対応表
mapped = "4bcd3f6h1jklmn0pqr57uvwxyz_"

# 変換用辞書を作成
trans_dict = {o: m for o, m in zip(original, mapped)}

# 文字列を変換する関数
def leet_convert(s):
    result = ""
    for c in s:
        if c.lower() in trans_dict:
            # 元の文字が小文字か大文字かを保持
            if c.isupper():
                result += trans_dict[c.lower()].upper()
            else:
                result += trans_dict[c]
        else:
            result += c  # アルファベット以外はそのまま
    return result

text = input("入力してください >> ")
converted = leet_convert(text)
print(converted)

Answer : poctf{uwsp_5w337h34r7_15_7hy_c4ll}

Stego 100-3 Low Tide at Midnight

画像にQRコードが入っているようですが、ノイズになっていて色を反転するだけではないようです。

加工の方法がわからなかったので、ノイズのQRコードをトレースして読み取れるようにしました。

Answer : poctf{uwsp_f0r3v3r_bl0w1n6_bubbl35}

Web 100-1 All Paths Lead Home

レガシーモード( UTF-7 RFC 2152)と ディレクトリトラバーサルの問題でした。

問題の概要

  • トップページ (/) では .txt ファイル名を入力して「Open」ボタンを押すと /view?file=***.txt へ飛ぶ
  • 存在しないファイルを指定すると 404 ページが返り、本文中に
    Hint: legacy compatibility is documented at /docs/legacy.html
    という一文が追加されている
  • /docs/legacy.html を開くと「legacy デコーダ」について言及。
    • 文字コードは UTF-7(RFC 2152)
    • 「shifted sequence」+...- を使って「禁止文字」を安全に送信できる
    • 有効化するにはクエリ文字列 &legacy=1 を付ける

legacyデコーダについて

文字UTF-7 表現
.+AC4-
/+AC8-

../+AC4-+AC4-+AC8-
よって
../../secret/flag.txt

+AC4-+AC4-+AC8-+AC4-+AC4-+AC8-secret+AC8-flag.txt

最終ペイロード&答え

以下のパスにアクセスするとフラグを入手できます

/view?file=%2BAC4-%2BAC4-%2BAC8-secret%2BAC8-flag.txt&legacy=1

Answer : poctf{uwsp_c4nc3l_m3_0rd3r5}

Web 100-2 This Must Be the Place

問題の概要

  • 入力欄 (name) に文字列を入れると、サーバは単純にその文字列を HTML に埋め込んで返す
  • しかし「フィルタがある」と書いてあるため、明らかに <script>…</script> などはブロックされる
  • フラグは /flag から same-origin fetch でしか取れない
    → ブラウザ上で JS を動かす(XSS)しか手がない

ペイロード

<img src=x onerror="fetch('/flag',{headers:{'X-Flag-Token':window.FLAG_TOKEN}}).then(r=>r.text()).then(alert)">

Answer : poctf{uwsp_1_d0_b173_my_7humb_51r}

Web 300-2 The Shape of Water

CTFの定番ということで、試しに /flag.txt にアクセスするとフラグが出てきました。
(意図された解法ではないかもしれませんが…)

Answer : poctf{uwsp_7h15_15_n3c3554ry_l1f3_f33d5_0n_l1f3}

コメント

タイトルとURLをコピーしました