【漏洞学习】JavaScript Prototype Pollution(JavaScript原型污染漏洞)
前言
近期遇到基于Node.js、hapi框架开发的后端应用,于是开始学习JavaScript语言特有的漏洞类型。
刚好记得PortSwigger Academy有JS的原型污染漏洞专题,便从它们的学习材料和靶场环境进行学习入门。由于我只关注后端的部分,所以前端的部分就略过了,其实原理是一样的。
为什么服务端原型污染漏洞比客户端原型污染漏洞更难检测?
(1) 没源码,除非是开源的。
(2) 缺少像浏览器的DevTools,无法像使用浏览器的 DevTools 检查 DOM 那样在运行时检查对象。因此很难判断何时成功污染了原型,除非对网站的行为造成了明显的变化。(当然,有源码的情况除外)
(3) DoS风险:成功污染服务器端环境中的对象通常会破坏应用程序功能甚至是服务器。很容易无意中导致拒绝服务 (DoS),因此生产中的测试可能很危险。即使你确实发现了一个漏洞,如果在测试过程中破坏了站点,要开发exp就很麻烦了。
(4) 原型污染的持久性:如果是前端的原型污染,在浏览器中进行测试时,只需刷新页面即可撤消所有更改并再次获得干净的环境。而一旦污染了服务器端原型,这种更改就会在 Node 进程的整个生命周期中持续存在,你还没方法重置它。
漏洞检测方式1:通过污染属性回显来探测原型污染(Detecting server-side prototype pollution via polluted property reflection,这里将reflection翻译成回显感觉比较贴切)
开发人员很容易掉入的一个陷阱就是忘记或忽略这样一个事实:JavaScript for...in 循环会遍历对象的所有可枚举属性,包括通过原型链继承的属性。
这意味着如果某个对象的原型链被污染(即攻击者通过原型污染注入了恶意属性),这些属性在 for...in 循环中也会被迭代到。开发人员如果没有意识到这一点,可能会错误地使用污染后的属性。
举个例子:
let obj = { a: 1, b: 2 };
// 攻击者通过原型污染注入属性
Object.prototype.polluted = "injected property";
for (let key in obj) {
console.log(key); // 输出 "a", "b", "polluted"
}
在这个例子中,polluted 属性是通过原型链继承的,并且被 for...in 循环迭代到,这就是开发者可能忽略或未预料到的行为。
这同样适用于数组,在数组中,for...in
循环首先遍历每个索引,而索引在本质上只是一个数字属性键,然后再遍历任何继承属性。
const myArray = ['a','b'];
Object.prototype.foo = 'bar';
for(const arrayKey in myArray){
console.log(arrayKey);
}
// Output: 0, 1, foo
Note:原生构造函数的属性除外.
JavaScript 内置的构造函数(比如 Object、Array 等)所生成的原生属性并不会被 for...in 枚举到,因为它们默认是 不可枚举的。这是为了避免程序无意中遍历到 JavaScript 内部的系统属性,从而保持代码的健壮性。举个例子:
let arr = [1, 2, 3];
for (let key in arr) {
console.log(key); // 只会输出数组的索引 "0", "1", "2",不会输出 `length` 属性
}
// 数组的 `length` 属性是不可枚举的
console.log(arr.propertyIsEnumerable('length')); // 输出 false
在这个例子中,arr.length 属性虽然是数组的重要属性,但它不会被 for...in 枚举,因为它是不可枚举的。
无论哪种情况,如果应用程序稍后在响应中包含返回的属性,则这可以提供一种探测服务器端原型污染的简单方法。
向应用程序或 API 提交 JSON 数据的 POST 或 PUT 请求是此类行为的主要候选对象,因为服务器通常会响应新对象或更新对象的 JSON 表示。在这种情况下,您可以尝试在全局 Object.prototype 中污染任意属性,如下所示:
POST /user/update HTTP/1.1
Host: vulnerable-website.com
...
{
"user":"wiener",
"firstName":"Peter",
"lastName":"Wiener",
"__proto__":{
"foo":"bar"
}
}
如果网站存在漏洞,您注入的属性将出现在响应中更新的对象中:
HTTP/1.1 200 OK
...
{
"username":"wiener",
"firstName":"Peter",
"lastName":"Wiener",
"foo":"bar"
}
一旦确定服务器端原型污染是可能的,就可以寻找潜在的gadgets来利用漏洞。任何涉及更新用户数据的功能都值得研究,因为这些功能通常涉及将输入数据合并到应用程序中代表用户的现有对象中。如果可以向自己的用户添加任意属性,就有可能导致许多漏洞,包括权限提升。
靶场实验1:Privilege escalation via server-side prototype pollution
先通过污染属性回显检测原型污染漏洞:
确认存在漏洞后,污染isAdmin
属性,实现权限提升:
漏洞检测方式2:无需污染属性回显即可探测原型污染(Detecting server-side prototype pollution without polluted property reflection)
大多数情况下,即使您成功污染了服务器端原型对象,也不会在响应中看到受影响的属性。这给判断注入是否成功带来了挑战。
一种方法是尝试注入与服务器潜在配置选项相匹配的属性。然后,您可以比较注入前后服务器的行为,看看配置更改是否生效。如果是,这就充分说明你已经成功找到了服务器端原型污染漏洞。
这里介绍三种非破坏性的无污染属性回显的探测方法。
(1) Status code override - 覆盖状态码
在一些基于 JavaScript 的后端框架(如 Express)中,开发者可以自定义 HTTP 响应状态码。即使返回一个常见的 HTTP 状态(如 200 OK),响应的 JSON 格式的 body 中可能包含额外的错误对象,用于提供更多关于错误发生原因的细节。比如,尽管 HTTP 响应状态是 200 OK,但错误对象可能会显示一个不同的状态码,如 401 Unauthorized。
以下是一个典型的响应示例:
HTTP/1.1 200 OK
...
{
"error": {
"success": false,
"status": 401,
"message": "You do not have permission to access this resource."
}
}
这个响应虽然返回了 HTTP 状态码 200 OK,但在响应体内通过 status 字段提供了一个更详细的错误状态码(401),表示用户没有权限访问资源。
原型污染如何影响状态码?
在 Node.js 中,http-errors 模块被用于生成类似上面的错误响应。该模块中的 createError 函数有一部分代码如下:
function createError () {
//...
if (type === 'object' && arg instanceof Error) {
err = arg
status = err.status || err.statusCode || status
} else if (type === 'number' && i === 0) {
//...
if (typeof status !== 'number' ||
(!statuses.message[status] && (status < 400 || status >= 600))) {
status = 500
}
//...
}
上面的代码在生成错误响应时,会尝试从传入的错误对象中读取 status 或 statusCode 属性,设置为 HTTP 状态码。如果这些属性未被明确设置,那么代码会使用一个默认状态码(如 500)。
这里的漏洞点在于:如果开发者没有显式地为错误对象设置状态码,攻击者可能通过原型污染技术来影响这些属性,从而改变返回的状态码。
探测步骤:
(1) 找到触发错误响应的方式: 首先,攻击者需要找到一种方式来触发服务器的错误响应。通常这可以通过故意发送无效请求来实现,观察返回的默认状态码。
(2) 攻击者接下来会尝试通过原型污染技术,在全局对象(Object.prototype)上注入自己的 status 属性。为了便于检测,可以选择一个比较罕见的状态码,确保不会因为其他原因返回这个状态码(如使用 418 等状态码)。
如:
__proto__.status = 418;
再次触发错误响应并检查状态码: 通过再次发送触发错误的请求,检查响应中的状态码。如果成功污染原型链,返回的状态码可能会被覆盖为攻击者注入的 status 值。例如,如果污染成功,返回的状态码可能变成 418。
(2) JSON spaces override - 覆盖JSON空格
JSON spaces override 是一种利用 Express 框架中的 json spaces 选项来检测 原型污染漏洞 的非破坏性方式。
在 Express 框架中,json spaces
是一个选项,用来配置 JSON 响应数据的缩进格式,通常用来美化输出(如设置为 2 就会有 2 个空格的缩进)。很多开发者不会主动设置这个属性,因为默认值通常足够。但这个属性如果未定义,就可能通过 原型链污染 的方式被恶意修改。
攻击流程:
(1) 原型污染设置 json spaces
属性:
攻击者可以通过污染 Object.prototype 来设置一个 json spaces
属性。因为所有对象都会通过原型链继承这个属性,当服务器生成 JSON 响应时,如果没有显式设置 json spaces
,则会使用污染后的值。
比如:
Object.prototype['json spaces'] = 10;
这样,Express 在处理返回 JSON 数据时,会按照 10 个空格缩进进行格式化。
(2) 观察 JSON 响应的缩进变化:
攻击者发送一个请求并获取服务器返回的 JSON 响应。如果缩进从默认值(如 2 个空格)变成了 10 个空格,则说明 json spaces
属性通过原型链被污染成功,表明服务器存在原型污染漏洞。
(3) 恢复缩进测试漏洞的可控性:
攻击者可以再次污染原型链,将 json spaces
属性恢复为默认值(如 2)。通过这种方式,可以验证污染是否确实生效,并通过观察缩进的变化来确认漏洞的存在。
该方法的优点:
- 无害且安全:这种方法不会对服务器或数据造成实际破坏,仅仅是通过观察 JSON 响应的缩进变化来确认原型污染。也可以通过简单的调整还原到原来的状态,因此不会对系统产生长久影响。
- 不依赖特定属性的反射:不像其他探测方法依赖特定的属性被反射到响应中,这种方法只依赖 Express 的 json spaces 选项的响应效果。也就是说,不需要依赖某个用户输入的值出现在响应中即可确认漏洞。
Note:Burp 使用技巧
当在 Burp Suite 中测试这个漏洞时,需要注意切换到 Raw 选项卡。默认情况下,Burp 会自动格式化 JSON 响应,使缩进一致化,从而无法检测缩进的变化。只有在 Raw 视图下,才能直接看到服务器返回的原始 JSON 数据及其缩进格式,方便识别漏洞。
** Express 版本的影响**
Express 4.17.4
之后的版本 已经修复了该漏洞,因此那些仍在使用旧版本的 Express 网站可能会受到这种攻击的影响。
(3) Charset override - 覆盖字符集
在 Express 框架中,通常使用中间件(middleware)来预处理请求数据,比如常见的 body-parser 模块用于解析请求体并生成 req.body 对象。当解析请求体时,其中一个重要的选项是 encoding(编码方式),它决定了如何解析请求体的字符集。
默认情况下,Express 会使用 UTF-8 作为编码格式,除非从请求头的 Content-Type 中明确指定了其他字符集。
攻击原理
攻击的关键在于,getCharset(req) 函数负责从 Content-Type 请求头解析字符集(charset)参数。如果这个参数未明确指定,代码会返回一个空字符串,这个值可能会通过原型污染被控制。
var charset = getCharset(req) or 'utf-8'
function getCharset (req) {
try {
return (contentType.parse(req).parameters.charset || '').toLowerCase()
} catch (e) {
return undefined
}
}
read(req, res, next, parse, debug, {
encoding: charset,
inflate: inflate,
limit: limit,
verify: verify
})
原型污染目标:
(1) content-type 头部污染:通过原型污染,我们可以向 Object.prototype 中注入一个 content-type 属性,其中包含我们想要的字符集信息(如 charset=utf-7)。
(2) 编码方式的覆盖:在下一次请求时,服务器将会优先使用通过原型链继承的 content-type 信息,导致服务器错误地解析字符集并对响应内容进行不同的编码处理。
攻击流程
(1) 发送含有 UTF-7 编码字符串的请求: 攻击者在响应中可见的字段中插入一个 UTF-7 编码的字符串,比如在 role 字段中插入 +AGYAbwBv-(UTF-7 编码的 “foo” 字符串)。
{
"sessionId":"0123456789",
"username":"wiener",
"role":"+AGYAbwBv-"
}
(2) 服务器返回:由于服务器默认不支持 UTF-7,这个字符串应该会以原始形式返回,即 +AGYAbwBv-
。
(3) 进行原型污染:攻击者尝试污染原型链,注入 content-type 属性,指定 charset=utf-7。
{
"sessionId":"0123456789",
"username":"wiener",
"role":"default",
"__proto__": {
"content-type": "application/json; charset=utf-7"
}
}
(4) 再次发送请求:此时,由于原型污染,服务器将会使用 UTF-7 编码解析响应,返回时 +AGYAbwBv- 会被解码成 foo,响应变成:
{
"sessionId":"0123456789",
"username":"wiener",
"role":"foo"
}
Node.js 中的漏洞
在 Node.js 的 _http_incoming
模块中,处理请求头时存在一个 bug,当请求头中存在重复的属性(如 content-type
),Node.js 会忽略后续的重复属性。这意味着,如果我们通过原型链污染了 content-type
属性,当服务器处理请求时,它会跳过真正的 Content-Type
头部,转而使用污染后的值。
IncomingMessage.prototype._addHeaderLine = _addHeaderLine;
function _addHeaderLine(field, value, dest) {
// 如果属性已经存在则跳过
if (dest[field] === undefined) {
dest[field] = value;
}
}
由于这个检查机制会包括原型链继承的属性,因此通过原型污染注入的 content-type 可以覆盖掉请求中真正的 Content-Type 头部,从而让服务器使用错误的字符集。
PS:方法不止这三种,可在 Server-side prototype pollution: Black-box detection without the DoS by Gareth Heyes 议题中了解到更多。
靶场实验2:Detecting server-side prototype pollution without polluted property reflection
1、使用Status code override 来检测
2、使用 JSON spaces override 来检测
3、使用Charset override 来检测
绕过针对__proto__
的防护
网站通常试图通过过滤__proto__
等可疑key来防止或修复原型污染漏洞。这种针对key过滤方法并不是一个稳健的长期解决方案,因为有很多方法可以绕过它。例如,攻击者可以:
(1) 双写绕过:__pro__proto__to__
。
(2) 使用myObject.constructor.prototype
替代 __proto__
来访问原型对象。
靶场实验3:Bypassing flawed input filters for server-side prototype pollution
这里实测双写__proto__
无法绕过防护,只能用使用对象的constructor.prototype
属性进行绕过.
利用原型污染实现RCE
在服务器端,原型污染攻击可以使攻击者通过污染 Object.prototype,影响后续执行的代码和方法,尤其是在 Node.js 应用中。原型污染可能会影响到处理请求的方式,并可能导致更严重的漏洞,如远程代码执行 (RCE)。
1、识别潜在的命令执行点
Node.js 中许多命令执行的入口函数来自 child_process 模块(例如 fork()、spawn()、execSync())。这些函数常用于执行系统命令或创建新的子进程。如果某个请求会触发这些函数,并且接受的参数可通过原型污染控制,那么就有可能利用它进行远程代码执行。
为了识别此类潜在的命令执行点,攻击者可以通过向原型污染注入一些特殊的 payload 来探测。如果命令执行过程中存在污染点,攻击者可以通过 Burp Collaborator 或其他工具探测到这些交互。
使用 NODE_OPTIONS 环境变量
NODE_OPTIONS 是一个特殊的环境变量,它允许为每个新启动的 Node.js 进程定义一组默认的命令行参数。因为它作为 env 对象的一部分,理论上可以通过原型污染来控制该变量。通过在原型链中注入 NODE_OPTIONS,攻击者可以将恶意参数注入到每个新启动的进程中。例如:
"__proto__": {
"shell": "node",
"NODE_OPTIONS": "--inspect=YOUR-COLLABORATOR-ID.oastify.com\"\".oastify\"\".com"
}
这个例子中,NODE_OPTIONS 被污染为带有恶意 --inspect 参数的值,该值会通知 Collaborator 主机以检测何时创建了新进程。这种方法帮助攻击者发现那些可控的命令行参数点。
Tips:
使用双引号("")可以帮助逃避 Web 应用防火墙 (WAF) 的检测。
2、通过 child_process.fork() 实现 RCE
child_process.fork()
方法用于创建新的子进程,并且它接受一个 options
对象。该对象中的 execArgv
属性是一个字符串数组,包含创建子进程时使用的命令行参数。若开发者没有为 execArgv
赋值,攻击者可以通过原型污染来控制它。
execArgv
的一个有趣参数是 --eval
,它允许执行任意的 JavaScript 代码。例如,攻击者可以使用以下方式通过污染 execArgv
执行恶意代码:
"execArgv": [
"--eval=require('<module>')"
]
这样,攻击者可以加载任意的模块甚至执行特定的恶意逻辑。
使用 execSync() 执行系统命令
child_process.execSync()
方法可以直接执行任意字符串作为系统命令。通过将 JavaScript 注入和系统命令注入结合,攻击者可以进一步提升原型污染的危害,获得对服务器的完全控制。比如,将污染与 execSync() 相结合,能够执行任何系统命令,最终实现完全的 RCE。
靶场实验4:Remote code execution via server-side prototype pollution
先探测是否存在原型污染漏洞:
通过DNSLog进行RCE探测:
(1) 先进行原型污染:
(2) 点击界面上的"Run maintence jobs"按钮(单击该按钮,观察到这将触发后台任务,清理数据库和文件系统。这就是可能产生节点子进程的典型功能示例),从而触发命令执行,收到Dnslog:
或直接使用execArgv
也是可以的:
(3) 执行指定命令,完成靶场:
3、通过 child_process.execSync() 实现 RCE
execSync() 是 Node.js 中一个同步执行系统命令的方法。它会阻塞线程,直到执行完毕,常用于执行系统命令。此方法接受一个 options 对象,开发者可以通过该对象配置命令的执行方式。
与 fork() 不同的是,execSync()
没有 execArgv
参数,但可以通过污染 options 对象中的 shell
和input
属性来注入恶意命令。
-
input 属性:input 是一个字符串,传递给子进程的 stdin(标准输入流),并被 execSync() 当作系统命令来执行。如果该属性没有定义,攻击者可以通过原型污染来注入命令。
-
shell 属性:shell 允许开发者指定执行命令时所使用的 shell。通常,execSync() 使用系统默认的 shell。如果未定义,攻击者可以通过污染来设置自己的 shell(如 bash 或其他)。
通过同时污染 shell 和 input 属性,攻击者可以执行自己定义的系统命令,而不是应用开发者预期的命令。例如:
"shell": "vim",
"input": ":! <command>\n"
这种情况下,攻击者指定 vim
作为 shell,并通过 input
传递命令(如 :! <command>
),然后通过换行符(\n
)模拟按下回车键,执行命令。
几个限制与注意事项
(1) shell 只能接受可执行文件名:shell 选项只能接受 shell 程序的名字,而不能附带其他命令行参数。例如,攻击者可以指定 bash 或 vim,但不能直接附加参数。
(2) -c
参数的使用限制:execSync()
默认使用 -c
参数来传递命令给 shell,而大多数 shell 都使用 -c
来让你传递命令字符串。然而,Node.js 使用 -c
来执行语法检查,这会阻止实际命令的执行。因此,不能简单地将 Node 本身作为攻击的 shell。
(3) 需要支持从 stdin 接收命令的 shell:由于 input 是通过 stdin 传递命令的,因此攻击者选择的 shell 必须支持从 stdin 接收命令。比如 vim 和 ex(文本编辑器)可以满足这个要求。
处理 stdin 不支持的工具
有些工具,如 curl
,默认不从 stdin 读取数据。这时,可以通过以下方式解决:
- 使用
curl -d @-
:curl 提供了一个-d @-
选项,允许从 stdin 读取数据并作为 POST 请求的主体发送。 - 使用
xargs
:xargs
可以将 stdin 转换为命令的参数列表。例如,你可以使用xargs
将 stdin 中的内容传递给任意命令执行。
靶场实验5:Exfiltrating sensitive data via server-side prototype pollution
(1)探测是否存在原型污染漏洞
(2) RCE探测:
(3) 泄露/home/carlos
目录的内容到Burp Collaborator server
(4) 泄露/home/carlos
目录下指定文件的内容到Burp Collaborator server