這份 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 sha1from Crypto.PublicKey.ECC import _curvesfrom Crypto.Util.number import inverseimport socketimport 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, isprimefrom Crypto.Util.number import inverse, long_to_bytesimport 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 hashlibimport mathfrom pathlib import Pathfrom 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位
**逆向求解腳本**:```pythondef 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')
# 解密 flagflag = decrypt_tiny_server()print(f"Flag: {flag}")Flag: AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}







