2573 words
13 minutes
AIS3 pre-exam 2025 Writeup
2025-12-25
AI 摘要

這份 AIS3 pre-exam 2025 解題報告詳細記錄了多個 CTF 挑戰的解決方法。內容涵蓋了利用路徑穿越、目錄遍歷和 URL 編碼等 Web 漏洞來取得旗標。此外,也說明了透過資料庫資訊洩漏獲取 2FA 碼、利用電子發票查詢平台以及識別圖片陷阱的技巧。報告最後更深入探討了如何利用弱亂數生成器(LCG)缺陷,攻擊 ECDSA 簽章系統以推導私鑰,以及逆向 RSA 質數生成過程來解密密文。

AIS3 pre-exam 2025 Writeup#

Clean-Shot-2025-12-25-at-19-51-26-2x.png

Tomorin db 🐧#

Clean-Shot-2025-05-30-at-05-02-43-2x.png

瀏覽題目之後長這樣,然後直接點 flag 會跳轉到 YouTube,看了一下題目裡面的 main.go 原來是這樣啊

Clean-Shot-2025-05-30-at-05-04-42-2x.png

那就試試看 ./ 之類的路徑繞過吧,再來試試蠻常用的 URL Encode

Clean-Shot-2025-05-30-at-05-06-04-2x.png

成功拿到 flag!

Login Screen 1#

題目大概就是一個登入介面,然後要輸入 2FA Code,照往常慣例先翻題目資料夾,恩? docker-compose.yml 裡面怎麼有一個 .db 的檔案跟網站目錄掛一起,直接從網站戳戳看好了

Clean-Shot-2025-05-30-at-05-11-32-2x.png

直接連過去 http://login-screen.ctftime.uk:36368/users.db 竟然能下載下來!

Clean-Shot-2025-05-30-at-05-39-46-2x.png

接下來讓我來翻翻裡面有什麼 Clean-Shot-2025-05-30-at-05-23-15-2x.png欸怎麼有 admin 的 2FA Code,接下來塞進題目之後拿到 flag! Clean-Shot-2025-05-30-at-05-25-06-2x.png

AIS3 Tiny Server - Web / Misc#

題目就是一個 Web Server 然後他說 flag 放在根目錄,反正就是目錄穿越或路徑逃脫,這裡是用 curl http://chals1.ais3.org:20580/%2e%2e/%2e%2e/%2e%2e/ 就是 ../../.. 加 URL Encoding 去試試看 Clean-Shot-2025-05-30-at-05-49-49-2x.png然後目錄底下有兩個跟 flag 相關的,一個是檔案,另一個可以直接 curl 出來,就拿到 flag 了

Ramen CTF#

Clean-Shot-2025-05-30-at-05-26-36-2x.png題目有一張發票,那肯定是要找電子發票可以查資料的地方,先把他塞進一般性發票查詢 - 電子發票整合服務平台 從左邊的發票 QRCode 可以得知發票號碼等必要查詢的資訊 image.png查詢之後結果就出來了 AIS3{樂山溫泉拉麵:蝦拉麵}

Welcome#

Clean-Shot-2025-05-30-at-05-52-25-2x.png直接複製貼上的話就會被騙 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 n
  • s₂ = 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

  1. 從 h0, h1, h2 驗證 LCG 參數
  2. 模擬 LCG 序列,暴力搜尋能整除 n 的質數 p
  3. 標準 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) 輔助的位旋轉函數是 f_i(a, b)

算法邏輯: 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 伺服器的二進位檔。

分析步驟

  1. 使用 IDA Pro 開啟 32-bit ELF 執行檔
  2. 找到 main function (sub_12B0)
  3. 追蹤 HTTP 請求處理邏輯
  4. 發現關鍵的 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 陣列包含加密後的資料

CleanShot 2025-06-05 at 07.41.43@2x

解密腳本

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!!!}

AIS3 pre-exam 2025 Writeup
https://blog.ichika.tw/posts/ais3-2025-pre-exam-writeup/
Author
Eric / e0pwr
Published at
2025-12-25