2.4 Web文件上传漏洞
文件上传在Web业务中很常见,如用户上传头像、编写文章上传图片等。在实现文件上传时,如果后端没有对用户上传的文件做好处理,会导致非常严重的安全问题,如服务器被上传恶意木马或者垃圾文件。因其分类众多,本节主要介绍PHP常见的一些上传问题。
2.4.1 基础文件上传漏洞
图2-4-1是一段基础的PHP上传代码,却存在文件上传漏洞。PHP的文件上传通常使用move_uploaded_file方法配合$_FILES变量实现,图中的代码直接使用了用户上传文件的文件名作为后端保存的文件名,会导致任意文件上传漏洞。所以在该上传点可以上传恶意PHP脚本文件(见图2-4-2)。
图2-4-1
图2-4-2
2.4.2 截断绕过上传限制
2.4.2.1 00截断
00截断是绕过上传限制的一种常见方法。在C语言中,“\0”是字符串的结束符,如果用户能够传入“\0”,就能够实现截断。
00截断绕过上传限制适用的场景为,后端先获取用户上传文件的文件名,如x.php\00.jpg,再根据文件名获得文件的实际后缀jpg;通过后缀的白名单校验后,最终在保存文件时发生截断,实现上传的文件为x.php。
PHP的底层代码为C语言,自然存在这种问题,但是实际PHP使用$_FILES实现文件上传时并不存在00截断绕过上传限制问题,因为PHP在注册$_FILES全局变量时已经产生了截断。上传文件名为x.php\00.jpg的文件,而注册到$_FILES['name']的变量值为x.php,根据该值得到的后缀为php,因此无法通过后缀的白名单校验,测试截图见图2-4-3(文件名中包含不可见字符“\0”)。
图2-4-3
PHP处理上传请求的部分调用栈如下:
在rfc1867_post_handle方法中调用multipart_buffer_headers方法,通过对mbuff上传包进行处理,得到header结构体:
在multipart_buffer_headers方法中存在如下代码:
从boundary中逐行读出数据,使用“:”分割出key和value;当处理filename时,key值为Content-Disposition,value值为form-data;name="file";filename="a.php\0.jpg";然后执行smart_string_appends宏定义的最终实现为memcpy,当value复制到&buf_value时,“\0”造成了截断。在截断后,将buf_value.c添加到entry中,再通过zend_llist_add_element将entry添加到header结构体中。
用于注册$_FILES['name']的filename变量从header结构体中获得,所以最终注册到$_FILES['name']的文件名为产生截断后的文件名。
在Java中,jdk7u40以下版本存在00截断问题,7u40后的版本,在上传、写入文件等操作中都会调用File的isInvalid()方法判断文件名是否合法,即不允许文件名中含有“\0”,如果文件名不合法,将抛出异常退出流程。
2.4.2.2 转换字符集造成的截断
虽然PHP的$_FILES文件上传不存在00截断绕过上传限制的问题,不过在文件名进行字符集转换的场景下也可能出现截断绕过。PHP在实现字符集转换时通常使用iconv()函数,UTF-8在单字节时允许的字符范围为0x00~0x7F,如果转换的字符不在该范围内,则会造成PHP_ICONV_ERR_ILLEGAL_SEQ异常,低版本PHP在PHP_ICONV_ERR_ILLEGAL_SEQ异常后不再处理后面字符造成截断问题,见图2-4-4。可以看出,当PHP版本低于5.4时,转换字符集能够造成截断,但5.4及以上版本会返回false。
图2-4-4
若PHP版本低于5.4,只要out_buffer不为空,无论err为何值都能正常返回,见图2-4-5。
图2-4-5
而当PHP版本为5.4及以上时,只有err为PHP_ICONV_ERR_SUCCESS即成功转换且out_buffer不为空时,才会正常返回,否则返回FALSE,见图2-4-6。
转换字符集造成的截断在绕过上传限制中适用的场景为,先在后端获取上传的文件后缀,经过后缀白名单判断后,如果有对文件名进行字符集转换操作,那么可能出现安全问题。例如,在图2-4-7中可以上传x.php\x99.jpg文件,最终保存的文件名为x.php(见图2-4-8)。实际案例可以参见http://www.yulegeyu.com/2019/06/18/Metinfo6-Arbitrary-File-Upload-Via-Iconv-Truncate。
图2-4-6
图2-4-7
图2-4-8
2.4.3 文件后缀黑名单校验绕过
黑名单校验上传文件后缀,即通过创建一个后缀名的黑名单列表,在上传时判断文件后缀名是否在黑名单列表中,在黑名单中则不进行任何操作,不在则可以上传,从而实现对上传文件的过滤。
2.4.3.1 上传文件重命名
测试代码见图2-4-9,在文件名重命名的场景下,可控的只有文件后缀,通常使用一些比较偏门的可解析的文件后缀绕过黑名单限制。
PHP常见的可执行后缀为php3、php5、phtml、pht等,ASP常见的可执行后缀为cdx、cer、asa等,JSP可以尝试jspx等。见图2-4-10,在上传PHP文件被限制时,可以通过上传PHTML文件实现绕过,见图2-4-11和图2-4-12。
可解析后缀在不同环境下不尽相同,需要多尝试一些后缀。如果环境为Windows系统,那么可以尝试"php"、"php::$DATA"、"php."等后缀;或先上传"a.php:.jpg",生成空a.php文件,再上传"a.ph<"写入文件内容。在Windows环境下,文件名不区分大小写,而in_array区分大小写,所以可以尝试大小写后缀名绕过黑名单。若Web服务器配置了SSI,还可以尝试上传SHTML、SHT等文件命令执行。
图2-4-9
图2-4-10
图2-4-11
图2-4-12
2.4.3.2 上传文件不重命名
在上传文件不重命名的场景下,除了寻找一些比较偏门的可解析的文件后缀,还可以通过上传.htaccess或.user.ini配置文件实现绕过。
1.上传.htaccess文件绕过黑名单
.htaccess是Apache分布式配置文件的默认名称,也可以在Apache主配置文件中通过AccessFileName指令修改分布式配置文件的名称。Apache主配置文件中通过AllowOverride指令配置.htaccess文件中可以覆盖主配置文件的那些指令,在低于2.3.8的版本中,AllowOverride指令默认为All,在2.3.9及更高版本中默认为None,即在高版本Apache中,默认情况下.htaccess已无任何作用。不过即使AllowOverride为All,为了避免安全问题,也不能覆盖所有主配置文件中的指令,具体可覆盖指令可查看:http://httpd.apache.org/docs/2.2/mod/directive-dict.html#Context。在低于2.3.8版本时,因为默认AllowOverride为all,可以尝试上传.htaccess文件修改部分配置,使用SetHandler指令使php解析指定文件,见图2-4-13。
先上传.htaccess文件,配置Files使PHP解析yu.txt文件,见图2-4-14。
再上传yu.txt文件到当前目录下,此时yu.txt已被当做PHP文件解析。
除了上文中的SetHandler application/x-httpd-php,其实利用方法还有下面这种写法:
具体的利用方式与上文相同,在此不再赘述。
图2-4-13
图2-4-14
2.上传.user.ini文件绕过黑名单
自PHP 5.3.0起支持基于每个目录的.htaccess风格的INI文件,此类文件仅被CGI/FastCGI SAPI处理,其默认文件名为.user.ini。当然,也可以在主配置文件中使用user_ini.filename指令修改该配置文件名。
PHP文件被执行时,除了加载主php.ini,还会在每个目录下扫描INI文件,从被执行的PHP文件所在目录开始,一直上升到Web根目录。
同样,为了保证安全性,在.user.ini文件中也不能覆盖所有php.ini中的配置。PHP中的每个配置都有其所属的模式,模式指定了该配置能在哪些地方被修改,见图2-4-15。
图2-4-15
从官方手册可知,配置存在4个模式,且PHP_INI_PREDIR模式只能在php.ini、.htaccess、httpd.conf中进行配置,但是在实际中,PHP_INI_PREDIR模式的配置也可以在.user.ini文件中进行配置,还存在一种php.ini only模式。disable_functions就是php.ini only模式,详细配置模式可以从官方手册中查看:https://www.php.net/manual/zh/ini.list.php。
在PHP_INI_PERDIR模式中存在两个特殊的配置:auto_append_file、auto_prepend_file。auto_prepend_file配置的作用为指定一个文件在主文件解析前解析,auto_append_file的作用为指定一个文件在主文件解析后解析,见图2-4-16。
图2-4-16
在实际利用时,通常会使用auto_prepend_file。获取auto_prepend_file、auto_append_file配置信息后,如果prepend_file_p不为空,则先调用zend_execute_scripts解析prepend_file_p,再调用zend_execute_scripts解析primary_file(主文件)和append_file_p。
由于append_file_p最后被执行,如果在解析primary_file的opcode时出现Fatal error或exit,那么append_file_p不再会被zend_execute_scripts解析。
不过使用.user.ini配置文件绕过上传黑名单有着很大的局限性。从上可以看出,只有在当前目录下有PHP文件被执行时,才会加载当前目录下的.user.ini文件,而在上传目录下通常不会存在PHP文件,绕过见图2-4-17。
图2-4-17
先上传配置文件,配置在主文件解析前解析yu.txt文件,见图2-4-18。上传yu.txt文件,访问当前目录下的任意PHP文件,见图2-4-19。在解析upload.php文件前,先解析yu.txt文件,成功触发phpinfo()。
图2-4-18
图2-4-19
2.4.4 文件后缀白名单校验绕过
白名单校验文件后缀比黑名单校验更安全、普遍,绕过白名单通常需要借助Web服务器的各解析漏洞或ImageMagick等组件漏洞。
2.4.4.1 Web服务器解析漏洞
1.IIS解析漏洞
IIS 6中存在两个解析漏洞:“*.asp”文件夹下的所有文件会被当做脚本文件进行解析,文件名为“yu.asp;a.jpg”的文件会被解析为ASP文件,上传“x.asp,a.jpg”文件获取到的后缀为jpg,能够通过白名单的校验。
2.Nginx解析漏洞
Nginx的解析漏洞为配置不当造成的问题,在Nginx未配置try_files且FPM未设置security.limit_extensions的场景下,可能出现解析漏洞。Nginx的配置如下:
先上传x.jpg文件,再访问x.jpg/1.php,location为.php结尾,会交给FPM处理,此时$fastcgi_script_name的值为x.jpg/1.php;在PHP开启cgi.fix_pathinfo配置时,x.jpg/1.php文件不存在,开始fallback去掉最右边的“/”及后续内容,继续判断x.jpg是否存在;这时若x.jpg存在,则会用PHP处理该文件,如果FPM没有配置security.limit_extensions限制执行文件后缀必须为php,则会产生解析漏洞,见图2-4-20。
图2-4-20
2.4.4.2 APACHE解析漏洞
1.多后缀文件解析漏洞
在Apache中,单个文件支持拥有多个后缀,如果多个后缀都存在对应的handler或media-type,那么对应的handler会处理当前文件。
在AddHandler application/x-httpd-php.php配置下,x.php.xxx文件会使用application/x-httpd-php处理当前文件,见图2-4-21。
图2-4-21
在以上Apache配置下,当使用AddType(非之前的AddHandler)时,多后缀文件会从最右后缀开始识别,如果后缀不存在对应的MIME type或Handler,则会继续往左识别后缀,直到后缀有对应的MIME type或Handler。x.php.xxx文件由于xxx后缀没有对应的handler或mime type,这时往左识别出PHP后缀,就会将该文件交给application/x-httpd-php处理,见图2-4-22。如果白名单中存在偏门后缀,那么可以尝试使用这种方法。
2.Apache CVE-2017-15715漏洞
浏览https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15715,根据该CVE的描述可以看出,在HTTPD 2.4.0到2.4.29版本中,FilesMatch指令正则中“$”能够匹配到换行符,可能导致黑名单绕过。
图2-4-22
以上Apache配置,原意是只解析以.php结尾的文件,但是由于15715漏洞导致.php\n结尾的文件也能被解析,那么可以上传x.php\n文件绕过黑名单。不过在PHP $_FILES上传的过程中,$_FILES['name']会清除“\n”字符导致不能利用,这里使用file_put_contents实现上传,测试代码见图2-4-23。
图2-4-23
在以上代码中,上传PHP文件失败,见图2-4-24。
图2-4-24
上传x.php\n文件可以成功,见图2-4-25。
图2-4-25
2.4.5 文件禁止访问绕过
在测试中经常会遇到一些允许任意上传的功能,在访问上传的脚本文件时才发现并不能被解析或访问,通常是在Web服务器中配置上传目录下的脚本文件禁止访问。在上传目录下的文件无法被访问时,最好的绕过方法肯定是将目录穿越上传到根目录,如尝试上传../x.php等类似文件。但是这种方法对于$_FILES上传是不能实现的,因为PHP在注册$_FILES['name']时调用_basename()方法处理了文件名,见图2-4-26和图2-4-27。
图2-4-26
图2-4-27
_basename方法会获得最后一个"/"或"\"后面的字符,所以上传../x.php文件并不能够实现目录穿越,因为在经过_basename后注册到_FILES['name']的值为x.php。
2.4.5.1.htaccess禁止脚本文件执行绕过
低于9.22版本的jQuery-File-Upload在自带的上传脚本(server/php/index.php)中,验证上传文件后缀使用的正则为:
也就是允许任意文件上传。之所以有底气允许任意文件上传,是因为在它的上传目录下自带.htaccess文件配置上传的脚本文件无法被执行。
但是从Apache 2.3.9起,AllowOverride默认为None,所以在.htaccess下任何指令都不能使用,这里的SetHandler、ForceType指令也就毫无作用,直接上传PHP文件即被执行。后续官方将正则修改为'accept_file_types'=>'/\.(gif|jpe?g|png)$/i'。
2.4.5.2 文件上传到OSS
随着云对象存储的发展,越来越多的网站选择把文件上传到OSS中。当然,上传到OSS中的脚本文件不会被服务端解析,所以很多开发者在文件上传到OSS时会允许任意文件上传。虽然服务端不会解析脚本文件,但是可以通过上传HTML、SVG等文件让浏览器解析实现XSS。不过XSS在aliyuncs.com域下并没有什么用。
不过现在OSS都会提供绑定域名功能,见图2-4-28,很多网站会把OSS绑在自己的二级域名下,这时上传HTML文件导致的XSS就能利用了,这里不再赘述。
图2-4-28
2.4.5.3 配合文件包含绕过
在PHP文件包含中,程序一般会限制包含的文件后缀只能为“.php”或其他特定后缀,见图2-4-29。在00截断越来越罕见的今天,如果上传目录脚本文件无法被访问或不被解析,见图2-4-30,那么可以上传一个PHP文件配合文件包含实现解析,见图2-4-31。
图2-4-29
图2-4-30
图2-4-30(续)
图2-4-31
类似的场景还有SSTI,常为用户选择可以加载的模板,但是模板文件后缀通常会被写死,所以这时可以通过任意文件上传模板文件,然后渲染上传的模板实现SSTI。例如:http://www.yulegeyu.com/2019/02/15/Some-vulnerabilities-in-JEECMSV9/。
2.4.5.4 一些可被绕过的Web配置
上传目录中禁止文件执行通常在Web服务器中配置,在不当配置下可能存在绕过。
1.pathinfo导致的绕过问题
Nginx的配置如下:
由于pathinfo在各大框架的流行,很多计算机支持pathinfo,会把location类似x.php/xxxx的路径也交给FPM解析,但是x.php/xxx并不符合deny all的匹配规则,导致绕过,见图2-4-32。
图2-4-32
2.location匹配顺序导致的绕过问题
在Nginx配置中经常出现多个location都能匹配请求URI的场景,这时具体交给哪个location语句块处理,就需要看location块的匹配优先级。Nginx配置如下:
Nginx的location块匹配优先级为先匹配普通location,再匹配正则location。如果存在多个普通location都匹配URI,则会按照最长前缀原则选择location。在普通location匹配完成后,如果不是完全匹配,那么并不会结束,而是继续交给正则location检测,如果正则匹配成功,就会覆盖普通location匹配的结果。所以在以上配置中,deny all被正则location匹配所覆盖,upload目录下的PHP文件依旧能够正常执行,见图2-4-33。
图2-4-33
正确的配置方法应该在普通匹配前加上“^~”,表示只要该普通匹配成功,就算不是完全匹配也不再进行正则匹配,所以在该配置下能够成功禁止PHP文件的解析,见图2-4-34。
图2-4-34
以上配置与普通匹配不同,正则location只要匹配成功,就不再考虑后面的location块。正则location匹配顺序与在配置文件中的物理顺序有关,物理顺序在前的会先进行匹配。所以在以上的配置中,两个匹配都为正则匹配,那么按照匹配顺序upload目录下的PHP文件依旧会交给FPM解析,见图2-4-35。
图2-4-35
3.利用Apache解析漏洞绕过
Apache通常使用以上配置禁止上传目录中的脚本文件被访问,此时可以利用Apache的解析漏洞上传yu.php.aaa文件,使其不符合deny all的匹配规则实现绕过,见图2-4-36。
图2-4-36
2.4.6 绕过图片验证实现代码执行
部分开发者认为,上传文件的内容如果是一张正常的图片就不可能再执行代码,所以允许任意后缀文件上传,但是在PHP中,检测文件是否为正常图片的方法往往能被绕过。
1.getimagesize绕过
getimagesize函数用来测定任何图像文件的大小并返回图像的尺寸以及文件类型,如果文件不是有效的图像文件,则将返回FALSE并产生一条E_WARNING级错误,见图2-4-37。
图2-4-37
尝试直接上传PHP文件失败,见图2-4-38。
图2-4-38
getimagesize的绕过比较简单,只要将PHP代码添加到图片内容后就能成功绕过,见图2-4-39,此时上传的PHP文件能够正常解析,见图2-4-40。
图2-4-39
图2-4-40
同时,getimagesize支持测定XBM格式图片——一种纯文本图片格式。getimagesize在测定XBM时会逐行读取XBM文件,如果某一行符合#define%s%d,就会格式化取出字符串和数字。如果最后height和width不为空,那么getimagesize就会测定成功。因为是逐行读取,所以height和width可以放到任意一行。
使用XBM可以通过getimagesize验证并且同时利用imagemagick。
2.imagecreatefromjpeg绕过
imagecreatefromjpeg方法会渲染图像生成新的图像,在图像中注入脚本代码经过渲染后,脚本代码会消失,不过该方法也已经存在成熟的绕过脚本:https://github.com/BlackFan/jpg_payload。测试代码见图2-4-41。
图2-4-41
绕过需要先上传正常图片文件,再下载回渲染后的图片,运行jpg_payload.php处理下载回来的图片,将代码注入图片文件,然后上传新生成的图片,能看出经过imagecreatefromjpeg后注入的脚本代码依然存在,见图2-4-42。
图2-4-42
2.4.7 上传生成的临时文件利用
PHP在上传文件过程中会生成临时文件,在上传完成后会删除临时文件。在存在包含漏洞却找不到上传功能且无文件可包含时,可以尝试包含上传生成的临时文件配合利用。
图2-4-42
1.LFI via phpinfo
由于上传生成的临时文件的文件名存在6位随机字符,并且在上传完成后会删除该文件,因此在有限的时间内找到临时文件名是一个很大的问题。不过phpinfo中会输出当前环境下的所有变量,如果存在$_FILES变量,也会输出,所以如果目标存在phpinfo文件,往phpinfo上传一个文件,就可以轻松拿到tmp_name,见图2-4-43。LFI配合phpinfo场景已经存在成熟的利用脚本了,这里不再赘述。
图2-4-43
2.LFI via Upload_Progress
当session.upload_progress.enabled选项开启时,PHP能在每个文件上传时监测上传进度。从PHP 5.4起,该配置可用且默认开启。当上传文件时,同时POST与INI中设置的session.upload_progress.name同名变量,PHP检测到这种POST请求时,会往Session中添加一组数据,写入上传进度等信息,其索引为session.upload_progress.prefix与$_POST[session.upload_progress.name]值连接在一起的值。session.upload_progress.prefix默认为upload_progress_,session.upload_progress.name默认为php_session_upload_progress,所以上传时需要POST php_session_upload_progress。这时上传文件名会写入SESSION,PHPSESSION默认以文件保存,进而可以配合LFI,见图2-4-44。
图2-4-44
由于session.upload_progress.cleanup配置默认为ON,即在读取完POST数据后会清除upload_progress所添加的Session,因此这里需要用到条件竞争,在Session文件被清除前包含到Session文件,最终实现代码执行。条件竞争结果见图2-4-45。
图2-4-45
3.LFI via Segmentation fault
Segmentation fault方法实现思路为,向出现Segmentation fault异常的地址上传文件,导致在垃圾回收前异常退出,上传生成的临时文件就不会被删除,最后通过大量上传文件同时枚举临时文件名的所有可能,最终实现LFI的利用,见图2-4-44。在PHP 7中,如果用户可以控制file函数的参数,即可产生Segmentation fault。至于Segfault形成原因,可以直接看Nu1L战队队员wupco的分析:https://hackmd.io/s/Hk-2nUb3Q。
2.4.8 使用file_put_contents实现文件上传
除了使用FILES实现上传,在测试中也会遇到另一种上传格式,这种方法通常在获取到文件内容后使用file_put_contents等方法实现文件上传,见图2-4-46。
图2-4-46
1.file_put_contents上传文件黑名单绕过
在文件名可控场景下,FILES上传中即使开发者没有过滤“/../”字符,PHP在注册FILES['name']变量时也会自身做_basename处理,导致用户不能传入“/../”等字符。在file_put_contents方法中,文件地址参数可能为绝对路径,所以PHP肯定不会对该参数做basename处理,在文件名可控情况下,file_put_contents上传文件能够实现目录穿越。
当图2-4-47所示代码出现在Nginx+PHP环境且upload目录下无可执行文件时,需要找到其他方法绕过黑名单。file_put_contents的文件名为“yu.php/.”时,能够正常写入yu.php文件,并且代码获取的后缀为空字符串,所以能够绕过黑名单,见图2-4-48。
图2-4-47
图2-4-48
当用file_put_contents时,zend_virtual_cwd.c的virtual_file_ex方法中调用tsrm_realpath_r方法标准化路径。file_put_contents方法的部分调用栈如下。
在tsrm_realpath_r方法中添加如下代码:
在该方法中,如果路径以“/.”结尾,就会把len定义为“/”字符的索引,然后执行:
截断掉“/.”字符,处理成正常的路径。不过这种方法只能新建文件,在覆盖一个存在的文件时会出现错误,见图2-4-49。
图2-4-49
同样,在tsrm_realpath_r方法中存在以下代码:
php_sys_lstat为lstat方法的宏定义,lstat方法用于获取文件的信息,执行失败则返回-1,执行成功则返回0。所以当文件不存在时,lstat返回-1,进入if语句块,save变量被重置为0,文件存在时lstat返回0,不进入if语句块,save变量依旧为1。
当save变量为1时,进入以下语句块:
在最初判断路径末尾为“/.”后,is_dir被赋值为1。不过在截断“/.”字符后lstat获取的路径信息不再是目录而是文件,即directory为0。is_dir和directory两者不相同的情况下会返回-1。
当返回值为-1时,定义错误号码,最终写文件失败。
2.死亡之die绕过
很多网站会把Log或缓存直接写入PHP文件,为了防止日志或缓存文件执行代码,会在文件开头加入<?php exit();?>。在图2-4-50代码中,用户可以完全控制filename,包括协议。
图2-4-50
在官方手册(见https://www.php.net/manual/zh/filters.string.php)中可以发现存在许多过滤器,所以这里可以使用一些字符串过滤器把exit()处理掉,从而让后面写入的代码能够被执行,可以使用base64_decode进行处理。
PHP的base64_decode方法默认非严格模式,除了跳过填充字符“=”,如果存在字符使得base64_reverse_table[ch]<0,也会跳过。
从base64_reverse_table中可以发现,只有当字符的ASCII值为43、47~57、65~90、97~122时,才有base64_reverse_table[ch]>=0,对应的字符为+、/、0~9、a~z、A~Z,其余字符都会被跳过。“<?php exit();?>\n”除去了被跳过的字符,剩余phpexit,在base64解码时每4字节一组,所以需要再填充1字节,最终被解码为乱码后面的代码就能正常执行,见图2-4-51。
图2-4-51
2.4.9 ZlP上传带来的上传问题
为了实现批量上传,很多系统支持上传ZIP压缩包,再在后端解压ZIP文件,如果没有对解压出来的文件做好处理,就会导致安全问题,以前PHPCMS就出现过未处理好上传的ZIP导致的安全问题。
1.未处理解压文件
图2-4-52中的代码仅在上传时限制文件后缀必须为zip,但是没有对解压的文件做任何处理,所以把PHP文件压缩为ZIP文件,再上传ZIP文件,后端解压后实现任意文件上传,见图2-4-53。
图2-4-52
图2-4-53
2.未递归检测上传目录导致绕过
为了解决解压文件带来的安全问题,很多程序会在解压完ZIP后,检测上传目录下是否存在脚本文件,如果存在,则删除。
例如,图2-4-54中的代码在解压完成后,会通过readdir获取上传目录下的所有文件、目录,如果发现后缀不是jpg、gif、png的文件,则删除。但是以上代码仅仅检测了上传目录,没有递归检测上传目录下的所有目录,所以如果解压出一个目录,那么目录下的文件不会被检测到。虽然hello目录的后缀不在白名单列表中,但是unlink一个目录不会成功,仅会抛出warning,所以目录和目录下的文件就被保留了,见图2-4-55。
图2-4-54
图2-4-55
当然,也可以在压缩包内新建目录x.jpg,直接跳过unlink,连warning都不会抛出,见图2-4-56。
图2-4-56
3.条件竞争导致绕过
在图2-4-57所示的代码中,递归检测了上传目录下的所有目录,所以之前的绕过方式不再可行。
图2-4-57
这种场景下可以通过条件竞争的方式绕过,即在文件被删除前访问文件,生成另一个脚本文件到非上传目录中,见图2-4-58和图2-4-59。
图2-4-58
图2-4-59
通过不断上传文件与访问文件,在文件被删除前访问到了文件,最终生成脚本文件到其他目录中实现绕过,见图2-4-60。
图2-4-60
4.解压产生异常退出实现绕过
为了避免条件竞争问题,图2-4-61中的代码把文件解压到了一个随机目录中,由于目录名不可预测,因此不再能够进行条件竞争。ZipArchive对象中的extractTo方法在解压失败时会返回false,很多程序在解压失败后会立即退出程序,但是其实可以构造出一种解压到一半然后解压失败的ZIP包。使用010 Editor修改生成的ZIP包,将2.php后的内容修改为0xff然后保存生成的新ZIP文件,见图2-4-62。
由于解压失败,在check_dir方法前执行了exit,已解压出的脚本文件就不会被删除。这时再枚举目录的所有可能,最终跑到脚本文件,见图2-4-63。
5.解压特殊文件实现绕过
为了修复异常退出导致的绕过,将代码修改为以下代码,在解压失败后也会调用check_dir方法删除目录下的非法文件,所以这时使用异常退出方法也不再可行。
图2-4-61
图2-4-62
图2-4-63
在以上场景中,如果在解压ZIP文件时能够让解压出的文件名含有“../”字符实现目录穿越跳出上传目录,那么解压出的脚本文件不会被check_dir删除。PHP解压ZIP文件有两种常用方法,一种是PHP自带的扩展ZipArchive,另一种是第三方的PclZip。
首先测试ZipArchive,构造一个含有“../”字符的压缩包,生成一个正常压缩包,然后使用010 editor修改压缩包文件,见图2-4-64。
图2-4-64
上传该ZIP文件后,解压出的文件依旧在随机目录下,没有实现目录穿越,见图2-4-65。
在/ext/zip/php_zip.c文件中,ZIPARCHIVE_METHOD(extractTo)方法调用了php_zip_extract_file方法来解压文件。
图2-4-65
在php_zip_extract_file方法中,先使用virtual_file_ex对路径规范化,从注释中也能看出规范化后的结果,再调用php_zip_make_relative_path将路径处理为相对路径。
例如,压缩包中含有/../aaaaaaaaa.php文件,先经过virtual_file_ex方法中tsrm_realpath_r处理后,变为/aaaaaaaaa.php,再经过php_zip_make_relative_path处理,变为相对路径aaaaaaaaa.php,因而不能够实现目录穿越。不过Windows下的virtual_file_ex和Linux处理不同,Windows中不会使用tsrm_realpath_r方法处理路径,所以在Windows下可以使用这种方法,具体代码可查看zend/zend_virtual_cwd.c文件。
另一种解压ZIP的常用方法是,PclZip没有规范化路径,所以可以实现目录穿越。测试代码见图2-4-66。
图2-4-66
PclZip构造压缩包时,需要注意包内的第一个文件应该是正常文件,如果第一个文件是目录,那么穿越文件在Linux下利用会失败。主要原因是文件写入临时目录时,会使用privDirCheck方法判断目录是否存在,如果不存在,就会递归创建目录。
假设生成的临时目录为dd409260aea46a90e61b9a69fb9726ef,压缩包内的第一个文件为/../../a.php。开始进入privDirCheck目录检测、创建流程,由于dd409260aea46a90e61b9a69fb-9726ef目录不存在,Linux下不存在的目录不能穿越,因此
方法会返回false。
privDirCheck方法的大概流程如下。
<1>is_dir('./upload/dd409260aea46a90e61b9a69fb9726ef/../..')返回false,获取父目录./upload/dd409260aea46a90e61b9a69fb9726ef/..,调用privDirCheck方法。
<2>is_dir('./upload/dd409260aea46a90e61b9a69fb9726ef/..')依然返回false,获取父目录./upload/dd409260aea46a90e61b9a69fb9726ef,调用privDirCheck方法
<3>is_dir('./upload/dd409260aea46a90e61b9a69fb9726ef')依然返回false,获取父目录./upload,调用privDirCheck方法。
<4>is_dir('./upload')目录存在,返回true,然后开始递归创建不存在的子目录。
<5>mkdir('./upload/dd409260aea46a90e61b9a69fb9726ef'),成功创建dd40目录。
<6>mkdir('./upload/dd409260aea46a90e61b9a69fb9726ef/..'),目录穿越成功,实际执行的为mkdir('./upload')。由于upload目录已存在,则出现错误,返回错误编号,最终从压缩包中提取文件失败。
综上,需要压缩包的第一个文件是正常文件,则先创建临时目录,后面的文件目录穿越不会再出现问题。当然,Windows下就算目录不存在也可以目录穿越,不需要考虑这个问题。
构造一个含有特殊文件的压缩包进行上传,见图2-4-67,最终实现了利用,见图2-4-68。
图2-4-67
图2-4-68