基本上,所有multipart/form-data解析器都无法完全遵守 RFC,而在验证文件名或用户上传的内容时,总是有多种方法可以绕过验证。我们将针对 PHP、Node.js 和 Python 解析器以及流行的 WAF 和负载均衡器(如 HAProxy、FortiWeb、Barracuda,甚至一些 OpenResty Lua 多部分解析器)测试各种绕过技术。
几个月前,在我们的 Octofence WAAP 项目中,我们决定放弃旧的 WAF 引擎 (ModSecurity),用 Lua 开发我们自己的引擎。我想说,这不是世界上最容易的任务(尤其是因为我们决定完全兼容 SecLang)。为了测试我们的新引擎,我尝试了一些绕过技术,找到了不止一种绕过multipart/form-data正文解析器的方法。我注意到 Lua 中可用的解析器(为 OpenResty 开发的少数解析器和来自 Kong Gateway 的解析器)不适合有效验证用户输入。这些解析器没有严格遵循 RFC 中规定的准则,而是经常尝试处理各种情况,包括不符合标准的情况(我真的可以理解为什么)。这种过于灵活的方法乍一看可能很有用,但当涉及到输入验证时,它实际上会带来安全问题。
例如,当您需要过滤文件名(例如,仅允许某些文件扩展名)或多部分请求的特定部分时,不强制执行规则的解析器可能会让恶意输入很容易溜走。意识到这一点后,我明白依赖这些解析器并不符合我们的需求。我们的新 WAF 引擎需要一个严格遵守 RFC 指南的解决方案,以确保对用户输入进行可靠的验证。
Web 应用程序防火墙真的会检查multipart/form-data请求的文件名和内容吗?是的,它们会。例如,这是一条检查文件名的核心规则集规则(我们稍后会深入介绍)。
那么,欢迎来到我称之为“多部分解析器?自己动手重新发明轮子并在 stackoverflow 上搜索时感到孤独,因为没有人关心你的问题,所以你最终尝试了在互联网上可以找到的每个多部分解析器,并意识到它们都不遵循 RFC ”。
为什么我们需要验证正在上传的文件?
我想我们都知道答案,但无论如何,让我们深入探讨一下。
基本上所有文件上传 SDK、插件、库等都带有工具和功能来验证用户要上传的内容。例如:一种常见的验证方法是通过检查内容类型、检查文件扩展名或检查正文开头的魔法字节来检查上传的文件是否为图像。另一个示例是检查某一部分的长度,以便只接受小于特定大小的文件。以下是非全面/不完整的概述:
文件扩展名检查:验证文件是否具有允许的扩展名(例如:.jpg、、)。这有助于将上传限制为特定文件类型,并防止接受不需要的文件。一种常见的技术是上传类似的东西而不是.png文件,以便让网络服务器将文件内容通过 proxy_pass 传递给 fastcgi 或类似程序。.gif/backdoor.php/my_profile_picture.png
MIME 类型验证:检查文件上传请求中的 Content-Type 标头,以确保它与文件扩展名的预期 MIME 类型匹配。例如,图像文件的 MIME 类型应为image/jpeg或image/png。这里我指的是该部分的 Content-Type 标头,而不是请求的 Content-Type 标头,后者应始终为multipart/form-data:
Magic Bytes 检查:分析文件的 magic 字节(类似于文件开头的签名字节)以确认其实际格式,而不管提供的扩展名或 MIME 类型如何。
来自 维基百科 https://en.wikipedia.org/wiki/List_of_file_signatures
文件大小限制:检查文件大小是否未超过预定义的限制。显然是为了防止用户存储非常大的文件,但这通常可以保护应用程序免受 DoS 攻击,其中过大的文件可能会消耗服务器资源。
病毒和恶意软件扫描:使用防病毒软件扫描上传的文件以查找恶意内容。此外,许多开放式和商业 WAF 都这样做。
内容检查(通常通过 WAF):检查文件的内容,确保它不包含嵌入的脚本、可执行代码或其他危险元素(尤其是在不应包含此类数据的文件(如图像或文档)中)。
文件名验证(通常通过 WAF):验证文件名以查找非法字符和序列,例如 ../,这可能导致路径遍历攻击。确保文件名不包含可能被文件系统误解的字符。
了解多部分表单数据:规范和特性
该 application/x-www-form-urlencoded 格式将表单字段编码为 URL 编码的键值对,这适用于简单的文本数据。但是,在处理二进制数据或文件时,它达不到要求。这就是 multipart/form-data 发挥作用的地方。
multipart/form-data 格式专门用于处理包括文件上传和常规表单字段的表单。它将表单数据分成多个部分,每个部分由唯一的边界字符串分隔。每个部分都包含其自己的一组标头,例如 Content-Disposition 和 Content-Type,它们提供有关所包含内容的元数据。
让我们举个例子。通过使用 x-www-form-urlencoded 格式,我们可以通过 & 分隔的 key=value 序列来表示参数和值:
username=foo&password=bar&redirect_to=https://example.com
同一请求可以转换为 multipart/form-data 消息:
--boundary
Content-Disposition: form-data; name="username"
foo
--boundary
Content-Disposition: form-data; name="password"
bar
--boundary
Content-Disposition: form-data; name="redirect_to"
https://example.com
--boundary--
在实践中,许多(或所有)x-www-form-urlencoded 有效负载可以转换为 multipart/form-data 格式并获得等效的结果,但并不总是相反。
什么是边界字符串?
在multipart/form-data请求中分隔消息的每个部分时,需要唯一的边界分隔符。此边界在 HTTP 请求的 Content-Type 标头中声明,例如:
...
Content-Type: multipart/form-data; boundary=xxx
--xxx
<part headers>
<part body>
--xxx--
必须仔细选择边界字符串,以确保它不会出现在表单数据的实际内容中。它通常包括随机字符序列。解析器应该(但我们将在那之后看到这不是真的)依赖这个边界来准确地将传入的数据拆分为不同的部分。
内容处置
多部分部分中最重要的标头是 Content-Disposition 标头,用于定义参数名称和(可选)内容的文件名。例如:
--boundary
Content-Disposition: form-data; name="foo"
bar
--boundary
Content-Disposition: form-data; name="user_image"; filename="image.png"
... image ...
--boundary--
在 PHP 应用程序中,我们将在 $_POST和 $_FILES 数组中得到以下结果:
$_POST => [
[foo] => bar
]
$_FILES => [
[user_image] => Array
[
[name] => image.png
[type] =>
[tmp_name] => /tmp/php3p8EMT
[error] => 0
[size] => 13
]
]
Parts 中的其他标题
multipart/form-data 消息的每个部分都包含其自己的一组标头,这些标头提供有关所包含内容的元数据。通过 RFC,唯一可用的两个标头是 Content-Disposition 和 Content-Type。
Content-Type 部件标题指定部件内容的媒体类型,例如,JPEG 图像文件的 image/jpeg 格式。例如:
...
Content-Type: multipart/form-data; boundary=xxx
--xxx
Content-Disposition: form-data; name="image"; filename="photo.jpg"
Content-Type: image/jpg
<image file content>
--xxx--
同样,我们将看到,基本上所有现有的多部分解析器都通过解析(或忽略)其他标头(如 Content-Transfer-Encoding)来忽略此 RFC 指令。
现在,我们都使用 multipart 达成了共识,让我们深入研究一下我针对 Web 应用程序中最常用的 multipart 解析器测试的绕过技术。
Bypass #0:在分段中进行 urlencoded
最简单但最有效的绕过技术之一是将请求的内容类型从 URL 编码切换到 multipart/form-data(我的意思是,不仅是请求 Content-Type 标头,而且是实际的正文内容类型从 key=value 到 multipart)。当验证器没有任何多部分解析器时,它需要标准 application/x-www-form-urlencoded 格式的数据,并且知道如何相应地解析和验证它。
假设有一个 WAF,它检查参数中 SQL 注入模式的 POST 请求。它解析 URL 编码的数据,但不处理 multipart/form-data 数据。攻击者可以将其请求的内容类型更改为 multipart/form-data,并将他们的恶意输入封装在其中。由于 WAF 不解析多部分正文,因此不会检查恶意输入,并且未经筛选即可到达应用程序服务器。
攻击者有时可以通过在多部分消息中嵌入 URL 编码语法来欺骗验证机制:
验证组件(可能需要 URL 编码的数据)以一种方式解释参数值,而后端应用程序以不同的方式处理多部分数据。因此,验证程序可能会看到良性值,而应用程序会收到恶意负载。
我们将深入探讨 HAProxy ACL。
Bypass #1:重复的名称参数
另一种绕过输入验证的技术涉及在多部分请求部分的 Content-Disposition 标头中复制 name 参数。此方法利用了不同解析器处理同一标头中多个名称参数的方式的差异,特别是验证程序和目标应用程序。
在multipart/form-data请求中,每个部分都包含一个Content-Disposition标头,该标头应(根据RFC,它必须为TBH)指定一个name参数来指示表单字段的名称。通过有意复制 name 参数,您可以操纵解析行为:
-
验证者的观点:验证组件可以解析 Content-Disposition 标头并提取它遇到的 firstname 参数。然后,它会根据此参数名称应用验证规则。
-
应用视角:目标应用程序可能会解析相同的标头,但会提取 lastname 参数,或者可能会以不同的方式连接它们。因此,它使用与验证器检查的参数名称不同的参数名称处理输入。
重复名称参数的示例
Bypass #1.1:重复的文件名参数
绕过阻止上传具有特定扩展名(如 .php 或 .exe)的文件的 WAF 规则的一种有效技术是在 Content-Disposition 标头中复制 filename 参数。显然,对于每个 Content-Disposition 标头,您应该只有一个 filename 参数。当在同一内容处置标头上发送 2 个 filename 参数时,会发生什么情况?
--xxx
Content-Disposition: form-data; name="file"; filename="a.txt"; filename="backdoor.php"
<?php system("id"); ?>
--xxx--
对于如何处理 Headers 中的重复参数,没有严格的标准。不同的解析器可能会以不同的方式处理这种情况,有些解析器采用参数的第一个匹配项,有些解析器采用最后一个匹配项,有些解析器可能会连接或合并它们。
Bypass #1.2: 重复的内容处置
至于 Bypass 技术 1.1,但复制了 Content-Disposition 标头。
正如我之前所说,在 multipart/form-data 主体中,每个部分都包含描述该部分内容的标题。Content-Disposition 标头通常指定表单字段的名称,如果是文件上传,则指定文件名。可以想象,每个部分都应该只有一个 Content-Disposition 标头。那么,当 2 个不同的 Content-Disposition 在同一个部分发送时会发生什么?
--boundary
Content-Disposition: form-data; name="file"; filename="safe.txt"
Content-Disposition: form-data; name="file"; filename="malicious.php"
<file content>
--boundary--
当涉及到重复的报头或参数时,RFC 6266 规定收件人不应拒绝邮件,而应尝试恢复可用的字段值。这在处理格式错误的请求方面提供了一定的灵活性。但是,如果接收者是 WAF(或 RFC 6266 特别提到的验证者),则情况会有所不同。在这些情况下,严格验证是关键,重复的参数应该会引发危险信号。因此,虽然某些系统可能很宽容,但 WAF 的工作是阻止任何可疑的东西!
RFC 6266 第 4.1 节https://datatracker.ietf.org/doc/html/rfc6266?ref=blog.sicuranext.com#section-4.1:具有相同参数名称的多个实例的 Content-Disposition 标头字段值无效。
RFC 6266 第 3 节https://datatracker.ietf.org/doc/html/rfc6266?ref=blog.sicuranext.com#section-3:发件人不得生成无效的 Content-Disposition 标头字段。收件人可以采取措施从无效的报头字段中恢复可用字段值,但不应直接拒绝邮件,除非这是明确需要的行为(例如,实现是验证器)。因此,无效字段的默认处理方式是忽略它们。
我们稍后😄再见
Bypass #2: 打破 CRLF 标准
许多 multipart 解析器都受此 bypass 的影响。
在表单数据的每个部分内,多部分解析器如何处理标头和正文之间的分离,可以找到可能的验证旁路。许多解析器会查找特定的 CRLF 序列 rnrn 来识别多部分消息中标头的结尾和正文的开头(是的,这正是 RFC 所说的)。但是,如果此序列被破坏,例如,由于省略了一个回车符 (r),则某些解析器可能无法正确检查该部分。
考虑这样一个场景:应用程序在将请求代理到用 PHP 编写的后端应用程序之前,使用 WAF(或类似工具)来验证输入字段。如果多部分数据与预期格式略有不同,则解析器可能无法正确解析它,并可能完全跳过验证过程。同时, PHP 的解析器 它并不那么迂腐,并成功地解析了 “格式错误” 的数据。
过滤器旁路从 headers 和 body 之间的 requence 中删除 r
这种差异可能会导致安全问题,即恶意输入绕过初始验证层。
Bypass #3: 删除双引号
通过删除参数周围的双引号或将其替换为单引号来更改 Content-Disposition 标头的语法可能会导致绕过 WAF/输入验证。
在标准的 multipart/form-data 请求中,Content-Disposition 标头中的 name 和 filename 等参数通常用双引号括起来。例如:name=“file” 或 filename=“image.png”。
但是,如果攻击者删除双引号或将其替换为单引号,则标头可能如下所示:
--boundary
Content-Disposition: form-data; name="file"; filename=backdoor.php
<file content>
--boundary--
此更改可能会导致 WAF 和后端应用程序之间的解析不一致。WAF 分段解析器可能无法识别没有双引号的 filename 参数,并将该段解释为常规表单字段,而不是文件上传。因此,它可能不会应用旨在防止上传具有危险扩展名(如 .php 或 .exe)的文件的安全规则。
另一方面,用 PHP 或 Node.js 等语言编写的后端应用程序具有更宽松的解析器,这些解析器接受不带引号或带单引号的参数。这些解析器可以正确提取 filename 参数并按预期处理上传的文件。因此,应用程序使用提供的文件名保存文件,从而可能允许攻击者上传恶意脚本。
Bypass #4: 缺少关闭边界字符串
PHP 应用程序接受 “truncated” multipart 消息。
您可能会认为没有结束边界的多部分消息是无效的......但在 PHP 中不是!故意省略多部分消息末尾的结束边界字符串可能会导致绕过 WAF 和输入验证。
正如我之前所写的,多部分消息的每个部分都由边界字符串分隔,并且消息以 --<boundary string>-- 终止。但是,如果攻击者删除了结束边界字符串,许多应用程序仍可能接受它:
PHP 允许无结束边界的部分
PHP 用户也在此处报告:
PHP 传递了不完整的 Multipart/form-data,这与 RFC 规定的要求相冲突。这可能导致 WAF 逃避-https://blog.sicuranext.com/breaking-down-multipart-parsers-validation-bypass/#bypass-fortiweb-waf
Bypass #5: filename*=utf-8'' in request
这是我最喜欢的一个。
RFC 6266 更新了定义 Content-Disposition响应标头字段的 RFC 2616。此规范接管了 HTTP 中使用的 Content-Disposition 的定义和注册,并阐明了国际化方面。
...此规范之前的许多用户代理实现不理解 “filename*” 参数。因此,当单个报头字段值中同时存在 “filename” 和 “filename*” 时,收件人应选择 “filename*” 并忽略 “filename”。这样,发件人可以通过发送更具表现力的 “filename*” 参数和 “filename” 参数作为传统收件人的回退来避免特殊大小写的特定用户代理...
基本上,filename* 参数允许文件名包含特殊字符并指定编码。这对于包含非英语语言字符或未以标准 ASCII 编码表示的特殊符号的文件名特别有用。
例如,考虑一个名为 Fabrizio_Deandré.pdf 的文件,其中包含意大利语中常见的重音字符 é。要确保服务器和应用程序正确解释文件名,您可以将 filename* 参数与 UTF-8 编码和特殊字符的百分比编码一起使用:
...
Content-Disposition: form-data; name="file"; filename*=UTF-8''Fabrizio_Deandr%C3%A9.pdf
...
因此,如果我们在前面使用 WAF 测试 Web 应用程序的文件上传,该 WAF 验证文件扩展名以阻止 .php 文件等后门或 XSS 文件(如 .html),则发送文件名字符串百分比编码的 filename*= 参数可以轻松绕过 WAF 规则。
以核心规则集规则 933110 “PHP Injection Attack:PHP Script File Upload Found” 为例,该规则阻止文件名以 PHP 相关扩展名(.php、.phps、.phtml、.php5 等)结尾的文件上传。
在撰写本文时,规则是:
SecRule FILES|... "@rx .*.ph(?:pd*|tml|ar|ps|t|pt).*$"
"id:933110,
phase:2,
block,
capture,
t:none,t:lowercase,
msg:'PHP Injection Attack: PHP Script File Upload Found',
logdata:'Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}: %{MATCHED_VAR}',
..."
正如你所看到的,该规则不会对 FILES(上传的文件名数组)中的值进行 URL 解码,因此 filename*=utf-8''file%2ephp 的任何变体都可以绕过它。但是等等:这个绕过在 ModSecurity 上不起作用。它取决于 multipart 解析器验证,在 ModSecurity 的情况下,multipart body processor 会抛出一个错误(我们稍后会讨论)。但是,你可能知道,不仅 ModSecurity 使用核心规则集。许多其他项目(例如我们全新的 Octofence WAAP 引擎)将其与不同的多部分解析器一起使用。
这里的问题是,通过读取 RFC 6266,并不清楚 filename* 参数是只在响应多部分中使用,还是也可以在请求中使用。事实上,许多语言(例如 PHP)在请求中不支持它,这让我认为这个参数只能在响应正文中使用,但同样......这只是我的假设。
Node.js + Busboy
Python3 Flask 应用程序
⚠️ 屏幕截图中的 PHP 版本
在本文中,您会注意到 HTTP 响应的屏幕截图显示 PHP 版本已过时或不是“最新稳定版”。尽管如此,这里描述的与 PHP 相关的所有技术也适用于最新的稳定版本,在撰写本文时应该是 8.3.12。
# php -v
PHP 8.3.12 (cli) (built: Oct 17 2024 02:21:29) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.3.12, Copyright (c) Zend Technologies
绕过 OpenResty Lua 多部分解析器
OpenResty 是一个基于 Nginx 构建的高性能 Web 平台,旨在高效处理大量请求。它通过集成强大的 LuaJIT 引擎来扩展 Nginx,允许开发人员直接在 Nginx 环境中使用 Lua 脚本。这种灵活性支持创建动态、高速的 Web 应用程序、API 和微服务,这些应用程序具有自定义逻辑、流量操作和实时分析等强大功能。OpenResty 广泛用于负载均衡、缓存和安全(例如,实施自定义 Web 应用程序防火墙)等任务,使其成为开发人员构建可扩展和优化的 Web 系统的热门选择。
OpenResty Edge 是面向业务关键型应用的企业级闭源分布式流量管理平台,具有适用于多云和混合组织的管理平台、企业级流量管理和负载均衡软件、API 网关软件、分布式私有 CDN 软件和 Web 应用程序防火墙软件 (https://blog.openresty.com/en/edge-enable-waf/)。
既然我不能免费试用(😭),我只能假设 OpenResty Edge WAF 使用的是 OpenResty 项目所有者 Yichun “agentzh” Zhang 创建的 OpenResty Multipart Parser,你可以在这里找到:
OpenResty/Lua 的简单多部分数据解析器-https://github.com/agentzh/lua-resty-multipart-parser
因此,为了测试它,我使用 lua-resty-multipart-parser 编写了一个 Lua 文件上传验证器,我在其中检查上传的文件是否具有有效的扩展名。否则,验证者会使用 403 Forbidden 阻止请求:
local parser = require "resty.multipart.parser"
ngx.req.read_body()
local body = ngx.req.get_body_data()
local p, err = parser.new(body, ngx.var.http_content_type)
if not p then
ngx.say("failed to create parser: ", err)
return
end
while true do
local part_body, name, mime, filename = p:parse_part()
if not part_body then
break
end
local allowed_extensions = {
"jpg",
"png",
"gif"
}
-- check if filename has an allowed extension
local extension = string.match(filename, "%.([^.]+)$")
if extension then
local is_allowed = false
for _, ext in ipairs(allowed_extensions) do
if ext == extension then
is_allowed = true
break
end
end
if not is_allowed then
ngx.say("File extension not allowed: ", extension)
ngx.exit(ngx.HTTP_BAD_REQUEST)
end
end
end
现在,想象一下这样一个场景:上面的 Lua 代码被部署为 PHP 应用程序前面的 Web 应用程序防火墙,作为验证文件上传的第一层防御。可以进行以下旁通操作:
重复的文件名参数
不允许使用文件扩展名,.php Lua 过滤器正确阻止
bypass:重复文件名参数上的不同解析器行为
断开 CRLF 序列
正确的 rnrn 标头和正文之间的顺
过滤器旁路从 headers 和 body 之间的 requence 中删除 r
删除 filename 参数上的双引号
阻止上传 PHP 文件名
通过删除文件名参数中的双引号来绕过
绕过 Nodejs 和 Busboy
Busboy 是一个广泛使用的 Node.js 库,旨在解析多部分/表单数据请求,这些请求通常用于包含二进制数据的文件上传和表单提交。它作为高性能、低级流式解析器运行,使开发人员能够高效处理大型文件和数据流,而不会消耗过多的内存资源。
Busboy 使用一种 “高度宽松” 的方法来解析多部分/表单数据请求,我认为这是为了灵活性(我想这是一件好事),但可能会带来安全问题。与更严格的解析器不同,Busboy 接受并处理各种多部分消息,容忍与严格 RFC 合规性的偏差。一个重要问题 (IMO) 是它对请求Content-Disposition 标头中的 filename*= 语法的支持。此扩展参数允许对文件名进行 URL 编码,从而启用可能被输入验证或安全筛选器阻止的字符。
例如,攻击者可以使用 filename*= 对文件名进行编码,从而绕过旨在阻止危险文件类型的扩展名过滤器。通过指定类似 filename*=UTF-8''backdoor%2ephp 的文件名,服务器将 %2ephp 的 URL 解码为 .php,从而在初始解析或过滤期间有效地 “隐藏” 文件扩展名。
此外,接受 filename=* 意味着您可以在单个 Content-Disposition 标头中发送多个文件名参数。攻击者可以通过包含 filename= 和 filename*= 参数来利用这一点,在应用程序堆栈的不同组件(例如使用 busboy 的 nodejs 应用程序前面的 WAF)如何解释文件名方面产生歧义。此技术的一个例子是:
...
Content-Disposition: form-data; name="file"; filename="image.png"; filename*=UTF-8''backdoor.php
...
在这种情况下,WAF 通常会尝试验证 filename= 参数并查看可接受的 image.png 文件名,而处理上传的 Node.js/Flask 应用程序使用 filename*= 参数并将文件保存为 backdoor.php。
让我们做一个测试:
尝试将 js 文件上传到被过滤器或 WAF 阻止的 Nodejs 后端
添加第二个 filename*= 参数以绕过过滤器或 WAF
绕过 Python Flask 中的文件上传
让我们通过测试以下应用程序来探索 Flask 如何处理文件上传:
from flask import Flask, request, jsonify, json
app = Flask(__name__)
@app.route('/', methods=['POST'])
def debug_multipart():
# Crea un dizionario per salvare i dati multipart
multipart_data = {
"form": {},
"files": {}
}
# dump request.form.keys()
print(json.dumps(list(request.form.keys()), indent=4))
# Itera attraverso le chiavi nel request.form per ottenere i campi del form
count = 0
for key in request.form.keys():
part_data = request.form.get(key)
headers = {
"Content-Disposition": request.headers.get('Content-Disposition'),
"Content-Type": request.headers.get('Content-Type')
}
multipart_data['form'][count] = {
"value": part_data,
"headers": headers
}
count += 1
count = 0
for key in request.files:
file = request.files.get(key)
# get file content
file_content = file.read().decode('utf-8')
headers = {
"Content-Disposition": file.headers.get('Content-Disposition'),
"Content-Type": file.headers.get('Content-Type')
}
multipart_data['files'][count] = {
"filename": file.filename,
"content_type": file.content_type,
"content_length": file.content_length,
"headers": headers,
"content": file_content
}
count += 1
return jsonify(multipart_data), 200
if __name__ == '__main__':
# run listening on all interfaces
app.run(
debug=True,
host='0.0.0.0',
port=5000
)
至于 Node.js + Busboy,Flask 允许在请求中使用 filename*=语法,因此可能的绕过可能是发送带有 filename*= 参数的 filename 参数:
绕过使用 filename*= 语法
通过发送重复的 filename 参数的 Content-Disposition 标头,Flask 的行为有所不同:
绕过 FortiWeb WAF
FortiWeb 是由 Fortinet 开发的 WAF,旨在保护 Web 应用程序免受 SQL 注入、XSS 和其他常见攻击等威胁。它提供高级功能,例如基于机器学习的异常检测、爬虫程序缓解以及与威胁情报服务的集成。为了探索它的功能并试用他的多部分解析器,我激活了 AWS Marketplace 上提供的免费试用版:
我为 WAF 配置了“Inline Extended Protection”的“Web Protection Profile”,它应该是最高和更合理的保护级别。
例如,让我们尝试一个简单的多部分请求,在该请求中,我们在 user_name 参数中发送远程代码执行有效负载。
允许的请求
请求被阻止,因为 RCE 负载
正如我们在 bypass technique #3 中讨论的那样,几乎所有解析器(正确地)都希望 rn 序列将 Headers 与 Part 中的 body 分开,或者将边界 start 与 Headers 分开。中断此序列通常会导致解析器在解析 multipart 正文时失败。由于 PHP 允许格式不佳的多部分主体,例如缺少 rn 序列或没有结束边界的未终止消息,在这种情况下,我们可以通过断开 rn 序列来绕过它:
绕过从 CRLF 序列中删除 r
或通过删除结束边界字符串:
PHP 允许未完成的多部分消息
我们可以附加我们的有效负载而不会被 FortiWeb 阻止:
如果我们在使用 Busboy(或 Python/Flask 应用程序)解析多部分消息的 Node.js 应用程序前面有 FortiWeb,我们可以通过使用技术 #5 和 filename*= 参数上传具有不允许的扩展名的文件来轻松绕过它:
允许上传 PNG 文件
尝试上传类似 backdoor.exe 的内容:
阻止 backdoor.exe 的文件上传
要绕过它,我们可以简单地添加 filename= 参数,让 FortiWeb 分析 filename= 参数,而 Busboy 使用 filename*= one。
当目标使用 busboy 时绕过 FortWeb 文件上传文件名
绕过 Barracuda WAF
Barracuda Networks 成立于 2003 年,最初以其电子邮件垃圾邮件过滤产品而受到关注。多年来,该公司扩展了其产品线,包括防火墙、云安全、数据保护和 WAF 解决方案。Barracuda WAF 是作为其安全产品组合的关键部分推出的。它现在广泛部署在本地和云环境中,为各种规模的企业提供可扩展的保护。
多亏了 Barracuda WAF 在 AWS Marketplace 上的免费试用,我才能够使用以下配置进行试用:
让我们尝试上传一个 PHP 文件:
请求被梭子鱼 WAF 阻止
如您所见,Barracuda WAF 阻止了我的尝试。下面是被阻止请求的审计日志,我们可以在其中看到 WAF 规则的详细信息:“禁止的文件扩展名”:
即使在这种情况下,我们也可以通过发送重复的 Content-Disposition 标头来绕过它。由于 Barracuda WAF 分析的是最后一个,而 PHP 解析的是第一个,因此我们可以这样绕过规则:
如果我们在使用 Busboy 解析多部分消息的 Node.js 应用程序前面有 Barracuda WAF,我们可以通过使用技术 #5 和 filename*= 参数上传扩展名不允许的文件来轻松绕过它:
绕过 HAProxy ACL
HAProxy 的访问控制列表功能是一项强大的功能,允许根据广泛的标准进行灵活的流量管理和请求过滤。ACL 可以检查 HTTP 请求和响应的不同部分,以做出路由和安全决策。
HAProxy 甚至允许您检查请求正文,其中一个函数是 req.body_param() 函数,它允许您从 x-www-form-urlencoded 正文中提取参数。此功能使 HAProxy 能够检查特定参数并根据其值做出决策。
因为 HAProxy 的 req.body_param() 函数只解析 x-www-form-urlencoded 数据,而不处理多部分表单数据,所以任何依赖它的 ACL 都可以很容易地绕过。攻击者可以将其请求转换为使用 HAProxy 不会解析的 multipart/form-data 来绕过 ACL 条件。此外,发送重复的参数可能会混淆解析逻辑,特别是当上游应用程序始终采用最后一个参数时(我将在后面展示一个示例)。
例:以下 ACL 仅允许 admin 从 IP 1.2.3.4 登录
acl deny_login req.body_param(username) -m str admin ! src 1.2.3.4
http-request deny if deny_login
ACL 定义:名为 deny_login 的 ACL 检查请求正文中的 username 参数是否等于 admin,以及客户端的 IP 地址是否为 1.2.3.4。
基于 ACL 的操作:如果同时满足两个条件(用户名为 admin 且客户端 IP 不是 1.2.3.4),则 http-request deny 指令将拒绝该请求。
让我们创建一个 PoC,其中 HAProxy 位于 PHP 应用程序前面。HAProxy 将有一个 ACL 来验证允许哪些 IP 地址以管理员用户身份登录。如果对管理页面的登录请求来自不受信任的 IP,HAProxy 将阻止该请求。
这是 ACL
frontend http-in
bind *:80
# Enable HTTP body inspection
option http-buffer-request
# ACL to check if the "username" parameter exists in the form body
acl has_username req.body -m sub username
# ACL to check if the value of the "username" parameter is "admin"
acl is_admin req.body_param(username) admin
# ACL to check if the client IP is "1.2.3.4"
acl is_not_allowed_ip src 1.2.3.4
# Deny the request if the "username" parameter exists, is "admin",
# and the client's IP is not "1.2.3.4"
http-request deny if has_username is_admin !is_not_allowed_ip
default_backend servers
backend servers
server server1 172.17.0.1:8081
此 ACL 检查用户名是否为 admin,源 IP 地址是否为 1.2.3.4。
-
选项 http-buffer-request:启用请求正文的缓冲,允许 HAProxy 检查正文中的表单数据。
-
ACL has_username:检查请求正文中是否包含 “username” 参数,表示正在尝试登录。
-
ACL is_admin:检查 “username” 参数的值是否为 “admin”。
-
ACL is_not_allowed_ip:检查请求是否来自 IP “1.2.3.4”。
如果满足所有三个条件,则 http-request deny 规则会阻止请求:请求包含“username”参数,用户名为“admin”,客户端 IP 不是“1.2.3.4”。如果满足这些条件,则请求将被拒绝,从而有效地阻止不受信任的 IP 访问管理员登录。
从上面的屏幕截图中可以看出,我可以发送带有 username=foo 的请求,而不会被 HAProxy 阻止。但是,当我尝试发送 username=admin 时,它阻止了我,如下面的屏幕截图所示。发生这种情况的原因是,显然,我没有从 IP 地址 1.2.3.4 进行连接:
现在,由于 HAProxy 没有 multipart/form-data 解析器(考虑到它解析 x-www-form-urlencoded,这太疯狂了),绕过此 ACL 的最简单方法是简单地将请求转换为 multipart。这之所以有效,是因为 ACL 非常基本,但我们将做一个示例,其中包含一个更具挑战性的要绕过的示例:
绕过 req.body_param 在 body 中找不到任何 username=admin
好的,让我们尝试绕过更复杂的 ACL。想象一下这样一个场景:HAProxy 在输入验证过程中检查电子邮件的格式,并使用以下 ACL 阻止所有可能的注入有效负载(例如 SQL 注入或类似内容):
# Enable HTTP body inspection
option http-buffer-request
# ACL to check if the "email" parameter exists in the form body
acl has_email req.body -m sub email
# ACL to check if the "email" parameter matches the regex for a valid email format
acl valid_email req.body_param(email) -m reg ^[a-zA-Z0-9.-]+@[a-zA-Z0-9.-]+$
# Deny the request if the "email" parameter exists but does not match the valid email regex
http-request deny if has_email !valid_email
-
选项 http-buffer-request:启用请求正文的缓冲,就像之前的 ACL 一样。
-
ACL has_email:检查请求正文中是否包含 email。
-
ACL valid_email:使用正则表达式来验证 “email” 参数的值是否与有效的电子邮件格式匹配(例如 [email protected])。
如果 “email” 参数存在,但不符合有效电子邮件地址的指定正则表达式,则 http-request 拒绝规则会阻止请求。
让我们尝试在 email 参数中发送 SQL 注入有效负载:
从上面的屏幕截图中可以看出,我的请求被 HAProxy ACL 阻止,因为 email 参数的值与配置的正则表达式不匹配。在这种情况下,无法像以前那样通过将其转换为 multipart/form-data 请求来绕过此 ACL,因为正则表达式仍然无法匹配 email 参数值的正确格式。例如:
请求被阻止是因为 req.body_param() 无法解析以 & 分隔的 key=value 格式,从而导致空值或 null。要绕过这一点,可以以某种方式制作我们的有效负载,通过将 req.body_param() 函数作为注释嵌入到 SQL 注入有效负载中来成功解析像 email=foo@bar 这样的字符串。例如:
额外旁路:由于 PHP 在参数重复的情况下,采用最后一个,因此更简单的绕过技术是发送两个电子邮件参数,将恶意负载放在第二个参数中:
绕过 AWS WAF、Lambda
这同样适用于 HAProxy 以及 AWS WAF 和 Lambda 函数(它们都没有嵌入式分段解析器)。这可以通过将请求从 x-www-form-urlencoded 转换为 multipart/form-data 来绕过验证和安全规则。
我已经在本文中探讨了所有 AWS WAF 绕过的可能性:
AWS WAF 绕过:无效的 JSON 对象和 Unicode 转义序列-https://blog.sicuranext.com/aws-waf-bypass/
ModSecurity Multipart 解析器
也许您会问“像 ModSecurity 这样的开源 WAF 怎么样?在 Yahoo 在 Intigriti 上举办的 Bug Hunting 活动之后,ModSecurity 多部分解析器得到了令人印象深刻的改进,当时许多 Bug 猎人试图绕过 ModSecurity 和 OWASP 核心规则集,并取得了许多成功案例。最重要的绕过是 Terjanq 发现的绕过,他设法利用 ModSecurity 多部分解析器来绕过整个引擎。
在那之后,ModSecurity 修复了其 multipart 解析器上的许多错误...... 😄 我的意思是,消息验证非常严格,如果您在部件体的一行开头插入 -- ,WAF 将阻止您的请求。我认为这有点太敏感了,导致许多用户最终禁用了它并禁用了所有 “body validation” 规则。
矛盾的是,ModSecurity 的多部分解析器的情况更加令人担忧,因为我们都知道它被缺乏理解问题和定义自己规则能力的用户广泛使用。正如我所说,多部分格式验证规则通常会被禁用,从而使受保护的 Web 应用程序完全暴露。
允许的请求
被 ModSecurity 引擎阻止的请求
你可能会认为这个块是由于 -- 是一个 SQL 注释序列,但事实并非如此。ModSecurity 阻止了该请求,因为它将 -- 解析为边界的开头,并且无法识别内容类型中声明的边界字符串:
[Tue Oct 22 10:16:32.474593 2024] [security2:error] [pid 85:tid 157] [client 172.19.0.1:33138] ModSecurity: Access denied with code 403 (phase 2). Operator EQ matched 1 at MULTIPART_UNMATCHED_BOUNDARY. [id "200004"] [msg "Multipart parser detected a possible unmatched boundary."] [hostname "example.com"] [uri "/app.php"] [unique_id "Zxd7gI3hBjYljdPtks7DZQAAAII"]
结论
如果说我从深入研究多部分/表单数据解析器的世界中学到了什么,那就是它们真的很难把事情做好。无论是未能正确处理边界、文件上传中缺少关键细节,还是只是让恶意负载通过,这些解析器经常让人感觉像是用胶带和希望绑在一起。
收获?我们应该始终记住,对于 Web 应用程序,x-www-form-urlencoded 和 multipart/form-data 是可互换的,这两个解析器之一的弱点可能会导致安全问题。
原文始发于微信公众号(Ots安全):分解多部分解析器:绕过文件上传验证
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论