這份 AIS3 pre-exam 2025 解題報告詳細記錄了多個 CTF 挑戰的解決方法。內容涵蓋了利用路徑穿越、目錄遍歷和 URL 編碼等 Web 漏洞來取得旗標。此外,也說明了透過資料庫資訊洩漏獲取 2FA 碼、利用電子發票查詢平台以及識別圖片陷阱的技巧。報告最後更深入探討了如何利用弱亂數生成器(LCG)缺陷,攻擊 ECDSA 簽章系統以推導私鑰,以及逆向 RSA 質數生成過程來解密密文。
AIS3 pre-exam 2025 Writeup
Tomorin db 🐧
瀏覽題目之後長這樣,然後直接點 flag 會跳轉到 YouTube,看了一下題目裡面的 main.go 原來是這樣啊
那就試試看 ./ 之類的路徑繞過吧,再來試試蠻常用的 URL Encode
成功拿到 flag!
Login Screen 1
題目大概就是一個登入介面,然後要輸入 2FA Code,照往常慣例先翻題目資料夾,恩? docker-compose.yml 裡面怎麼有一個 .db 的檔案跟網站目錄掛一起,直接從網站戳戳看好了
直接連過去 http://login-screen.ctftime.uk:36368/users.db 竟然能下載下來!
AIS3 Tiny Server - Web / Misc
題目就是一個 Web Server 然後他說 flag 放在根目錄,反正就是目錄穿越或路徑逃脫,這裡是用 curl http://chals1.ais3.org:20580/%2e%2e/%2e%2e/%2e%2e/ 就是 ../../.. 加 URL Encoding 去試試看
然後目錄底下有兩個跟 flag 相關的,一個是檔案,另一個可以直接 curl 出來,就拿到 flag 了
Ramen CTF
題目有一張發票,那肯定是要找電子發票可以查資料的地方,先把他塞進一般性發票查詢 - 電子發票整合服務平台 從左邊的發票 QRCode 可以得知發票號碼等必要查詢的資訊
查詢之後結果就出來了 AIS3{樂山溫泉拉麵:蝦拉麵}
Welcome
直接複製貼上的話就會被騙 AIS3{This_Is_Just_A_Fake_Flag_~~} 我的解法就是照著圖片手打 然後就對了!
Flag: AIS3{Welcome_And_Enjoy_The_CTF_!}
SlowECDSA
這題考的是 ECDSA 簽章系統的弱點,特別是當 nonce k 使用線性同餘生成器(LCG)時的攻擊。
題目使用 LCG 來產生簽章的 nonce:
- LCG 參數:
a = 1103515245,c = 12345 - 使用 NIST P-192 橢圓曲線
- 可以取得同一訊息的兩筆連續簽章
當我們有兩個使用連續 LCG 狀態簽署的訊息時:
s₁ = k₁⁻¹(H + r₁d) mod ns₂ = k₂⁻¹(H + r₂d) mod n- 其中
k₂ = ak₁ + c mod n(LCG 關係)
透過代數運算可得私鑰:
d = [H·(K−1) + c·s₂] · (r₂ − K·r₁)⁻¹ mod n
其中 K = a·s₂·s₁⁻¹ mod n解題程式:
from hashlib import sha1
from Crypto.PublicKey.ECC import _curves
from Crypto.Util.number import inverse
import socket
import os
def solve_slowecdsa():
# 連接伺服器
s = socket.socket()
s.connect(('chals1.ais3.org', 19000))
# LCG 參數
a = 1103515245
c = 12345
n = _curves['NIST P-192'].order
# 取得兩組簽章
def get_signature():
s.recv(1024) # 清除 buffer
s.send(b"get_example\n")
data = s.recv(1024).decode()
r = int(data.split('r: ')[1].split('\n')[0], 16)
sig_s = int(data.split('s: ')[1].split('\n')[0], 16)
return r, sig_s
r1, s1 = get_signature()
r2, s2 = get_signature()
# 計算訊息 hash
H = int.from_bytes(sha1(b"example_msg").digest(), 'big') % n
# 使用 LCG 關係推導私鑰
s1_inv = inverse(s1, n)
K = (a * s2 * s1_inv) % n
numerator = (H * (K - 1) + c * s2) % n
denominator = (r2 - K * r1) % n
d = (numerator * inverse(denominator, n)) % n
print(f"[+] 找到私鑰 d = {hex(d)}")
# 使用私鑰簽署 "give me flag"
msg = b"give me flag"
H_flag = int.from_bytes(sha1(msg).digest(), 'big') % n
# 產生新的 k(隨機)
k = int.from_bytes(os.urandom(24), 'big') % n
if k == 0:
k = 1
# ECDSA 簽章
G = _curves['NIST P-192'].generator
R = k * G
r = R.x % n
s = (inverse(k, n) * (H_flag + r * d)) % n
# 送出驗證
s.recv(1024)
s.send(b"verify\n")
s.recv(1024)
s.send(msg + b"\n")
s.recv(1024)
s.send(hex(r).encode() + b"\n")
s.recv(1024)
s.send(hex(s).encode() + b"\n")
# 接收 flag
result = s.recv(1024).decode()
print(result)
s.close()
if __name__ == "__main__":
solve_slowecdsa()Random_RSA
使用 LCG 產生隨機數,然後找下一個質數作為 p 和 q LCG 參數:a = 48271, c = 0, m = 2³¹ - 1 給了三個連續的 LCG 輸出 h0, h1, h2
- 從 h0, h1, h2 驗證 LCG 參數
- 模擬 LCG 序列,暴力搜尋能整除 n 的質數 p
- 標準 RSA 解密
解題程式:
from sympy import nextprime, isprime
from Crypto.Util.number import inverse, long_to_bytes
import re
def solve_random_rsa():
# 解析輸出檔案
with open('output.txt', 'r') as f:
content = f.read()
# 提取參數
params = {}
for match in re.findall(r'(\w+)=(\d+)', content):
params[match[0]] = int(match[1])
n = params['n']
e = params['e']
c = params['c']
h0 = params['h0']
# LCG 參數
a = 48271
m = (1 << 31) - 1
# 從 seed 開始搜尋
state = h0
found = False
print("[*] 開始搜尋質數 p...")
for i in range(10000):
# 生成下一個 LCG 狀態
state = (a * state) % m
# 找下一個質數
p_candidate = nextprime(state)
# 檢查是否能整除 n
if n % p_candidate == 0:
p = p_candidate
q = n // p
# 驗證 q 也是質數
if isprime(q):
print(f"[+] 找到 p = {p}")
print(f"[+] 找到 q = {q}")
found = True
break
if not found:
print("[-] 搜尋失敗")
return
# RSA 解密
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
plaintext = pow(c, d, n)
flag = long_to_bytes(plaintext).decode()
print(f"[+] Flag: {flag}")
if __name__ == "__main__":
solve_random_rsa()Stream
解法
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
1. 讀取 81 行十六進位字串。
2. 對前 80 行逐 byte (0~255) 嘗試碰撞 SHA-512,找出唯一會讓 (cipher ^ digest) 成為完整 256-bit 平方數的 byte,
進而推算出每行的 rand256。
3. 將 80 個 rand256 拆成 640 個 32-bit little-endian 輸出,餵進 RandCrack (逆向 MT19937)。
4. 取得下一個 rand256 (第 81 個),再 XOR 平方即可還原 flag。
"""
import hashlib
import math
from pathlib import Path
from typing import List
# 可以自行調整的參數 #
OUTPUT_FILE = Path("output.txt") # 題目給的密文檔案
DIGEST_CACHE = [hashlib.sha512(bytes([b])).digest() for b in range(256)]
# 一些逆向 MT19937 需要的小工具 #
def _undo_right(y: int, shift: int) -> int:
"""還原 y = x ^ (x >> shift)"""
result = 0
for i in range(32 // shift + 1):
part = y >> (shift * i)
result ^= part
# 保留 32 bit
return result & 0xFFFFFFFF
def _undo_left(y: int, shift: int, mask: int) -> int:
"""還原 y = x ^ ((x << shift) & mask)"""
result = 0
for i in range(32 // shift + 1):
part = y << (shift * i)
result ^= part & mask
return result & 0xFFFFFFFF
def untemper(y: int) -> int:
"""逆向 CPython random 模組的 temper 步驟"""
y = _undo_right(y, 18)
y = _undo_left(y, 15, 0xEFC60000)
y = _undo_left(y, 7, 0x9D2C5680)
y = _undo_right(y, 11)
return y
class RandCrack:
"""
精簡版 RandCrack:送入 32-bit 輸出即可重建 MT19937 狀態,
之後呼叫 predict_getrandbits(n) 產生任意 n-bit 亂數。
"""
def __init__(self) -> None:
self.state: List[int] = []
self.index = 624 # 讓 twist() 在填滿 624 後才會動到
def submit(self, value: int) -> None:
"""提交一個 32-bit 輸出"""
assert 0 <= value < 2**32
self.state.append(untemper(value))
def _twist(self) -> None:
"""重現 MT 的 twist 步驟"""
for i in range(624):
y = (self.state[i] & 0x80000000) | (self.state[(i + 1) % 624] & 0x7FFFFFFF)
self.state[i] = self.state[(i + 397) % 624] ^ (y >> 1)
if y & 1:
self.state[i] ^= 0x9908B0DF
self.index = 0
def _extract(self) -> int:
"""取出下一個 32-bit 值(同 random.getrandbits(32) 行為)"""
if self.index >= 624:
self._twist()
y = self.state[self.index]
self.index += 1
# temper
y ^= (y >> 11)
y ^= (y << 7) & 0x9D2C5680
y ^= (y << 15) & 0xEFC60000
y ^= (y >> 18)
return y & 0xFFFFFFFF
# 對外介面 #
def ready(self) -> bool:
return len(self.state) >= 624
def predict_getrandbits(self, k: int) -> int:
"""等同 random.getrandbits(k)"""
assert self.ready(), "state incomplete"
bits = 0
for _ in range((k + 31) // 32): # 每次抓 32 bit
bits = (bits << 32) | self._extract()
# 保留最低 k bit
return bits & ((1 << k) - 1)
# 主要破解邏輯 #
def recover_rand256(cipher: int) -> int:
"""給定一行 cipher,嘗試所有 256 個 digest,找到唯一平方根 < 2**256"""
for b in range(256):
digest_int = int.from_bytes(DIGEST_CACHE[b], "big")
candidate = cipher ^ digest_int
root = math.isqrt(candidate)
if root * root == candidate and root.bit_length() <= 256:
return root
raise ValueError("No square root found – 這不應該發生")
def split_le32(x: int) -> List[int]:
"""將 256-bit 整數拆成 8 個 32-bit little-endian 區段(與 random 實作對齊)"""
return [(x >> (32 * i)) & 0xFFFFFFFF for i in range(8)] # i=0 為最低位
def main() -> None:
# 1. 讀檔 -> int list
ciphers = [int(line.strip(), 16) for line in OUTPUT_FILE.read_text().strip().splitlines()]
assert len(ciphers) == 81, "檔案行數錯誤,應該是 81 行"
# 2. 還原前 80 個 rand256
rand256_list = [recover_rand256(c) for c in ciphers[:80]]
print(f"[+] 復原 rand256 數量:{len(rand256_list)}")
# 3. 拆成 32-bit,餵給 RandCrack
rc = RandCrack()
for r in rand256_list:
for chunk in split_le32(r):
rc.submit(chunk)
assert rc.ready(), "RandCrack 狀態尚未填滿 (理論上 624 個 chunk 就夠)"
# 4. 產生第 81 個 rand256
next_rand = rc.predict_getrandbits(256)
print("[+] 預測下一個 rand256 完成")
# 5. 解 flag
flag_int = ciphers[80] ^ (next_rand ** 2)
flag_len = (flag_int.bit_length() + 7) // 8
flag = flag_int.to_bytes(flag_len, "big").decode()
print(f"[!] Flag = {flag}")
if __name__ == "__main__":
main()$ python3 solve_flag.py
[+] 復原 rand256 數量:80
[+] 預測下一個 rand256 完成
[!] Flag = AIS3{no_more_junks...plz}web flag checker
下載 index.wasm 然後我先用 wasm-decompile 工具反編譯一次,然後分析主要檢查函數 func9
主要檢查函數是 flagchecker(a
算法邏輯: Flag必須恰好40個字符 分成5組,每組8字節 使用常數 -39934163 計算每組的旋轉量 對每組執行左旋轉操作 與預設的5個64位目標值比較
**旋轉量計算**:
使用魔數 -39934163 計算每個區塊的旋轉位數:
- index 0: 45位
- index 1: 28位
- index 2: 42位
- index 3: 39位
- index 4: 61位
**逆向求解腳本**:
```python
def solve_wasm():
# 從 WASM 提取的目標值
targets = [
0x6f773357449B13EB,
0x316d6e317672337d,
0x1f2fa8f18bdcbc59,
0x5f1cdbcc3301ef9e,
0x52e88e56c85dc5dc
]
# 計算每個區塊的旋轉量
magic_number = -39934163
rotations = []
for i in range(5):
rotation = (magic_number >> (i * 6)) & 63
rotations.append(rotation)
print(f"旋轉量: {rotations}") # [45, 28, 42, 39, 61]
# 對每個目標值執行右旋轉(逆操作)
flag_parts = []
for i, (target, rot) in enumerate(zip(targets, rotations)):
# 64-bit 右旋轉
original = ((target >> rot) | (target << (64 - rot))) & 0xFFFFFFFFFFFFFFFF
# 轉換為小端序 bytes
flag_parts.append(original.to_bytes(8, 'little'))
# 組合得到完整 flag
flag = b''.join(flag_parts).decode('ascii')
return flag
# 執行解題
flag = solve_wasm()
print(f"Flag: {flag}")AIS3 Tiny Server - Reverse
這題需要逆向一個實作了簡單 HTTP 伺服器的二進位檔。
分析步驟:
- 使用 IDA Pro 開啟 32-bit ELF 執行檔
- 找到 main function (sub_12B0)
- 追蹤 HTTP 請求處理邏輯
- 發現關鍵的 flag 驗證函數
sub_1E20
sub_1E20 反編譯結果:
*BOOL4 *_cdecl sub_1E20(int a1)
{
unsigned int v1; // ecx
char v2; // si
char v3; // al
int i; // eax
char v5; // dl
_BYTE v7[10]; // [esp+7h] [ebp-49h] BYREF
_DWORD v8[11]; // [esp+12h] [ebp-3Eh]
__int16 v9; // [esp+3Eh] [ebp-12h]
v1 = 0;
v2 = 51;
v9 = 20;
v3 = 114;
v8[0] = 1480073267;
v8[1] = 1197221906;
v8[2] = 254628393;
v8[3] = 920154;
v8[4] = 1343445007;
v8[5] = 874076697;
v8[6] = 1127428440;
v8[7] = 1510228243;
v8[8] = 743978009;
v8[9] = 54940467;
v8[10] = 1246382110;
qmemcpy(v7, "rikki_l0v3", sizeof(v7));
while ( 1 )
{
*((_BYTE *)v8 + v1++) = v2 ^ v3;
if ( v1 == 45 )
break;
v2 = *((_BYTE *)v8 + v1);
v3 = v7[v1 % 0xA];
}
for ( i = 0; i != 45; ++i )
{
v5 = *(_BYTE *)(a1 + i);
if ( !v5 || v5 != *((_BYTE *)v8 + i) )
return 0;
}
return *(_BYTE *)(a1 + 45) == 0;
}分析結果:
- sub_1E20() 使用 XOR 來加密驗證 flag
- 密鑰是 “rikki_l0v3” (10 bytes)
- v8 陣列包含加密後的資料

解密腳本:
def decrypt_tiny_server():
# 從 IDA 提取的加密資料 (v8 陣列)
encrypted_dwords = [
1480073267, 1197221906, 254628393, 920154,
1343445007, 874076697, 1127428440, 1510228243,
743978009, 54940467, 1246382110
]
# XOR 密鑰
key = b"rikki_l0v3"
# 轉換 DWORD 陣列為 bytes (little-endian)
encrypted_bytes = b''
for dword in encrypted_dwords:
encrypted_bytes += dword.to_bytes(4, 'little')
# 執行 XOR 解密
decrypted = bytearray()
for i in range(45): # flag 長度為 45
decrypted.append(encrypted_bytes[i] ^ key[i % 10])
return decrypted.decode('ascii')
# 解密 flag
flag = decrypt_tiny_server()
print(f"Flag: {flag}")Flag: AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}








