这篇文章发表于 945 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
经常能够看到zip文件在web方向的一些应用,总能给人带来各种惊喜,小编今天就给大家来盘点一下zip文件奇奇怪怪的利用或者说是处理的方式(垃圾营销号式开头。
所以是怎么回事呢?啊对对对就是这么一回事。
1linephp - 0ctf2021
这个题目取自非常经典的文件包含题。
1 |
|
这题和HITCON2018 One Line PHP
的唯一区别就在于其后缀必须是.php
的文件才能够实现包含。看了下php配置文件,发现session.upload_progress.enabled
选项是On
的状态,估计可以通过一些手法将某个文件传到web服务器里面的/tmp
目录下,条件竞争肯定是有的。问题就在于传什么样的文件。
这里需要传一个构造过的zip包,然后令yxxx=zip:///tmp/sess_evil#1
这样,让后续的代码能够include它。但显然通过session.upload_progress搞上去的文件最开始的字符串内容是upload_progress_
,所以必须想办法绕过@substr(file($_)[0],0,6) === '@<?php'
的判断。
根据上图,我们不难发现End of Central Directory
里面有一个offset of start of central
,左下的示意图说明程序处理zip文件的时候首先从这个文件末尾出发,根据偏移找到哦啊Central Directory
的头位置,然后再根据relative offset of local header
找到整个zip文件头的位置,也就指向local file header signature
。
这里的解决办法就是修改这两个offset,这样zip协议能够跳开前面几个字符找到正确的位置。另外,CRC的值我们也不必进行修改,因为这里的crc-32其实是根据crc前面的相关信息计算出来的结果。因为最开始的字符串内容是upload_progress_
,其长度为16,因此这两个offset(从010editor来看就是elDirectoryOffset
和deHeaderOffset
)都要加上16。
miniblog# - zer0pts2022
由于网上没有较为详细的wp,所以这里顺带写一份。在这之前还有道miniblog++
,可以看下这篇文章。
根据miniblog++
所体现的思路,这里应该也是SSTI RCE,但是这里的过滤比较严格,所以想写入相关的payload就必须通过zip压缩包的解压。
解题思路
就直接说解题思路了吧:首先注册一个用户A,通过flask-unsign
将相关的信息从flask的cookie中解密出来,利用这些信息构造一个zip包(这个zip包只需要保证其中所有的字符都小于0x7f,0x7f是因为flask的HTTP请求只能发送Unicode),再注册一个用户B,其用户名就是该zip包的内容,使用用户B的身份直接从网站导出backup.db
,最后使用用户A的身份将backup.db
导入,即成功。
这里给出exp,可能不是很理解,请自行结合题目好好看看:
1 | import re, os, json, sys |
这里用户B主要是为了对抗题目中的加密,但用户名就是该zip包的内容意味着在题目端加密前所生成的zip包其实是错误的(因为它相较于正常的包,其开头部分多出了PK...SIGNATURE:
,末尾部分一定多出了B用户的密码hash),但这个错误的包竟然会被无误地解压!我一直以为自己构造的zip还需要修改offset这些,结果根本不用。
源代码分析
这是为什么呢?这里就要对zipfile
包进行简单的调试来看看其逻辑,以下摘出重要代码:
1 | def _RealGetContents(self): |
从上面的代码来看,解析zip包会先找到End of Central Directory
,也就会先寻找'PK\x05\x06'
字符串,然后再一块块处理。以上的zipfile代码很快会被执行,里面的endrec
变量和concat
变量非常有趣,这两个变量帮我们剔除了开头和结尾多出的部分内容:
- 对于
concat
变量而言,它的计算是ECD的开头位置减去CD的偏移和CD的大小,由此可计算出开头部分的多余量为480-352-96=32。这说明什么问题?我们可以在开头随便加东西,除了'PK\x05\x06'
字符串! - 如果您跟进了
_EndRecData
函数,就会发现它找末尾的签名是先找到End of Central Directory
开头,之后根据在ECD中找到的elCommentLength
(010editor里面是这样写的)从前往后将signature提取出来,所以后面的内容就被忽略掉了。这说明什么问题?我们可以在末尾随便加东西!
可见python3对zip处理的逻辑非常有意思,但当然不止python。貌似绝大多数语言都是这样处理的,也许在定义中zip文件的前面和后面存在一些内容是合法的?
Desperate cat - rwctf2022
这道题很有趣。除了官方writeup外,这篇文章也是非常不错的,主要来看看涉及到jar包上传的解法。jar包、war包其实都是zip文件。
触发jar包中遇到的问题
先提一下复现中遇到的问题吧。我这里以EL配合StringInterpreter触发类实例化方法为例构造jar包。在测试jar包的时候,我参考这篇文章,使用如下的java代码进行编译打包:
1 | import org.apache.jasper.compiler.StringInterpreter; |
需要先调试这个jar包能否触发。按照wp的操作,若tomcat成功对这个放在WEB-INF/lib
文件夹下的jar包reload的话(注:该文章可能对reload相关的调试有好处),我们就可以通过Class.forname("Exploit")
在ideaj调试窗口的小计算器那里搜索到相关信息。然后就是要触发它,在wp中我们知道最后是newInstance那里导致恶意方法被执行,但newInstance并不会执行main方法,不过会执行构造方法(也就是和class类重名的Exploit方法)。上面的代码为方便直接通过java -cp Exploit.jar Exploit
进行测试,所以才这样写的。
源代码分析
构造jar包这里不多说了,直接参考这个项目即可。构造方法和一般zip包貌似有点点区别。
我们需要知道这里为什么java处理jar包的时候也会忽略前后填充上去的垃圾字符,很快就能够断定主要代码并不在tomcat的源码里面,而是在java.util.jar.JarFile
和java.util.zip.ZipFile
里面。可以查看openjdk的项目,找到\src\java.base\share\classes\java\util\jar\JarFile.java
里面800多行的位置,里面都是对jar包的处理,很快就能发现这些都不是重点,需要到ZipFile.java
中进行探索。
很快就会发现,这里依旧是对zip文件的central directory进行初始化为先,该操作中最先进行的便是findEND
方法,也就是寻找end of central directory。其实也是根据那个(byte)'\005'
和(byte)'\006'
找到这部分的信息,之后同样地,我们可以发现这段代码:
1 | long cenpos = end.endpos - end.cenlen; // position of CEN table |
这里计算的就是zip包起始的位置,思考过后就发现它已经考虑了偏移!和python那里的处理思路一模一样。
于是我也看了下nodejs里面的adm-zip
包对zip的处理方式,很快找到了readMainHeader
函数,尽管逻辑有些许差别,但其实也可以达成相同的效果!基本说明这样的处理方式基本是默认或者明摆着的要求了!但测试一下发现像上面这样构造出来的jar包并不能成功解压(要改下offset吧可能),而miniblog#
利用到的zip包能成功解压。
Symple Unzipper - justctf2022
这道题其实可以简化一下,代码如下。要求解压出来的文件依然是软链接。
1 | from zipfile import is_zipfile |
您可以尝试一下,若是通过ln -s /etc/passwd pass && zip -ry p.zip pass && rm pass
这样的命令(其中的y
参数是保持软链接),测试后发现解压出来的pass
文件中只有/etc/passwd
字符串,并不是软链接。
但是可以看到那个解压命令是extract_archive
,因此如果是其他的文件类型,也是可以被解压的。经过测试,我们很快就能发现ln -s /etc/passwd pass && tar -cvf p.tar pass && rm pass
能够成功地经过该代码解压之后依旧保持着软链接的状态! 另外,如果需要保证文件所属身份一致的话,可以将创建tar文件的命令更为tar --owner=root --group=root -cvf p.tar pass
。
因此我们现在有个思路,就是把这个软链接用tar压缩,随后做些修改,让这个tar文件能够通过is_zipfile
检测。
当然可以使用这个项目直接试试(有人就是这样做出来的),不过您也可以尝试先研究下is_zipfile
函数到底都干了写什么。
1 | def _check_zipfile(fp): |
很快就能进一步锁定_EndRecData
函数,结合里面的注释和以上代码,我们不难想到,这段代码的意图是根据该文件End of Central Directory的存在性判断这个文件是否为zip文件。如果您已经理解了之前的内容,我想这检测代码您甚至都能自己随手写而且比它更好 XD
所以这题很简单,只要在那个带软链接的tar文件最后加上50 4B 05 06 00 00 00 00 01 00 01 00 4A 00 00 00 49 00 00 00 00 00
(其实就是随便某个zip文件的End of Central Directory,已经过HEX编码)的字符串即可。
finecms解压缩上传漏洞
这个来源于P神的这篇文章中的第三个例子,他通过修改zip包实现了让zip解压会报错但恶意文件能被成功解压出来。可是那篇文章并没有描述为什么这样修改可以实现攻击,所以这里填个坑。我们来编译调试一下源代码。
php-src编译调试
不同版本的PHP似乎安装zip拓展的命令行和方式都会有所不同(而且官方文档似乎没更新好),这里随便举个例子。
首先是安装libzip拓展,然后可以先对其进行编译安装(其实这里没必要编译安装,直接sudo apt install libzip-dev
应该也是可以的):
1 | mkdir build && cd build |
在其编译安装完成后进行php源代码的编译安装,比如我是这样的:
1 | sudo apt install -y pkg-config build-essential autoconf bison re2c \ |
接着用Clion打开这个文件夹,简单配置下。其中调试选项如下图配置:
源代码分析
很快我们就能够定位到ext/zip/php_zip.c
这个关键文件,其中的PHP_METHOD(ZipArchive, extractTo)
就是最先进入的、用来解压的函数。其中非常关键的逻辑如下:
1 | for (i = 0; i < filecount; i++) { |
这里根据zip里面压缩着的文件一个个解压,只要执行过php_zip_extract_file
函数,相应的文件夹之下就会出现那个对应的文件。也就是说如果zip包里面第一个文件能被解压但是第二个文件有错误的话,整个命令的的执行会报错但第一个文件在报错前已经被写下来了。
这里让它报错的办法可以是利用010Editor修改第二个文件的deFileName
为多个/
,其调用栈如下:
1 | _php_stream_fopen plain_wrapper.c:1094 |
最终触发的错误是_php_stream_fopen
函数中的fd = open(realpath, open_flags, 0666);
语句报错(其中的realpath
指向不存在的地方)。
如果您看过源码,会发现还有其他办法,比如可以对zip里面的文件名长度做些文章(只要长度超过4096即可),就像如下脚本这样构造:
1 | import zipfile |