最終結果は8200ポイントで、38/1311位でした。
※不慣れな用語が多かったため、都度調べながら回答を進めました。
そのため誤りや不備が含まれている可能性がございます。何卒ご容赦ください。
また、一部の問題については、時間の都合により十分な解答を書き上げることができませんでした。
あらかじめご了承ください。
- (全問題共通 フラグ変換用プログラム)
- Crypto 100-1 Letters from a Friend
- Crypto 100-2 Mason, Lumen
- Crypto 100-3 Mason, Umbra
- Crypto 100-4 Ashes to Ashes
- Crypto 200-2 A Short Walk on a Long Hill
- Crypto 200-3 Pale Fire
- Crypto 400 This is Not the Way
- Misc 100-1 Honey, I Shrunk the Kids
- Misc 100-4 Mason, Terror Incognita
- Misc 200-1 Mason, In Light
- OSINT 200-1 The Paper Trail
- Reverse 100-1 Seven Easy Pieces
- Reverse 100-2 Left at the Light
- Reverse 200-1 On Hinge and Pin
- Stego 100-1 Ink Between the Lines
- Stego 100-3 Low Tide at Midnight
- Web 100-1 All Paths Lead Home
- Web 100-2 This Must Be the Place
- Web 300-2 The Shape of Water
(全問題共通 フラグ変換用プログラム)
この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される
挑戦内容:
- サーバーからランダムな ASCII プレフィックスを取得
- そのプレフィックスを両方のメッセージの先頭に付けた状態で、ハッシュ衝突する2つの異なるメッセージを生成
- 衝突したハッシュをサーバーに提出
攻撃方法
このハッシュ関数は完全に線形(XORと回転のみ)なので、
古典的な「2ブロックの差分補正攻撃」が使えます。
具体的には
- ブロック単位で差分 Δ を入れる
- 次のブロックで回転 Δ を XOR して補正
- これにより最終状態 S が変化せず、衝突が発生
式で表すと
M1 = Prefix + A1 + A2
M2 = Prefix + (A1 XOR Δ) + (A2 XOR rotate13(Δ))
これで ashes_hash(M1) == ashes_hash(M2) が成立します。
ポイント
攻撃のコード & 解答
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 のサブグループ検証抜けを突いた短周期攻撃の問題でした
問題の概要
問題文を分析
- エンドポイント:
/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 ===")
- 得た xxx を x.to_bytes(16,’big’) にし、SHA256(…||b”short-walk”) で AES鍵を導出。GET /ciphertext で取得した blob を iv||ct に分割し、指定どおり AES‑CTR で復号して平文(フラグ)を得る
- GET /params で p を取得、GET /nonce で nonce を取得
- 小さな素数 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 のときはスキップ)
- その A を POST /oracle に送ってサーバの HMAC を回収
- ローカルで 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) が得られる
- K のバイト表現はサーバ実装次第で固定長(= ceil(p/8))だったり最小長だったりするので、両方試す
- 複数の(互いに素な)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 の概要は次の通りです。
- p−1 の平方根 m ≈ √(p−1) をとる
- g^0, g^1, …, g^(m−1) をテーブル化
- g^(−m) を前計算
- 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
問題概要
サーバーから n と e(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 つです
e/nの連分数展開- 全収束分
(k_i, d_i)の走査 (e*d_i - 1) % k_i == 0を満たす候補の抽出- 判別式が平方数であることで真の
(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」と表示されます
プログラムの特徴としては以下のとおりです
配列は
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}

コメント