前言
前面一篇博文《Android Zip解压缩目录穿越导致文件覆盖漏洞》介绍过 Android 系统 Zip 文件解压缩场景下的目录穿越漏洞,近期在学习 JavaWeb 代码审计的时候从 github 看到《OpenHarmony-Java-secure-coding-guide.md》中“从 ZipInputStream 中解压文件必须进行安全检查”章节提及 JavaWeb 系统同样涉及此类目录穿越漏洞,同时还涉及 Zip 炸弹的攻击场景,故在此学习记录下。
Zip Slip
此类漏洞指的是解压 zip 文件时没有校验各解压文件的名字,如果文件名包含 ../
会导致解压文件被释放到目标目录之外的目录。
在《Android Zip解压缩目录穿越导致文件覆盖漏洞》一文已经讲过原理,不再过多赘述。直接沿用原来的恶意 Zip 生成代码和 Zip 解压缩代码即可:
import zipfiledef zip_slip_file(output_path):try:with open("source/test.txt", "r") as f:binary = f.read()zipFile = zipfile.ZipFile(output_path, "a", zipfile.ZIP_DEFLATED)zipFile.writestr("../../test.txt", binary)zipFile.close()except Exception as e:print(e)if __name__ == '__main__':zip_slip_file(r'result/test.zip')
public static void unzipFile(String zipPtath, String outputDirectory) throws IOException {File file = new File(outputDirectory);if (!file.exists()) {file.mkdirs();}InputStream inputStream = new FileInputStream(zipPtath); ;ZipInputStream zipInputStream = new ZipInputStream(inputStream);byte[] buffer = new byte[1024 * 1024];int count;ZipEntry zipEntry;while ((zipEntry = zipInputStream.getNextEntry()) != null){if (!zipEntry.isDirectory()) {String fileName = zipEntry.getName();System.out.println("解压文件的名字: " + fileName + ",解压文件的大小: " + zipEntry.getSize());file = new File(outputDirectory + File.separator + fileName);file.createNewFile();FileOutputStream fileOutputStream = new FileOutputStream(file);while ((count = zipInputStream.read(buffer)) > 0) {fileOutputStream.write(buffer, 0, count);}fileOutputStream.close();}}zipInputStream.close();System.out.println("解压完成!");}
运行结果如下,成功进行路径穿越:
在实际漏洞利用中,可借助上述 Zip Slip 漏洞,对系统重要文件或可执行文件进行被覆盖,从而造成系统故障或任意代码执行的危害。
Zip 炸弹
重点介绍下 Zip 炸弹,先看下 OpenHarmony Java 安全编码指导文档的相关描述:
Zip 炸弹的大致原理是 zip 炸弹文件中有大量刻意重复的数据,这种重复数据在压缩的时候是可以被丢弃的,这也就是压缩后的文件其实并不大的原因。最为典型的 Zip 炸弹就是 42.zip,一个 42KB 的文件,解压完其实是个 4.5 PB(1 PB=1024 TB) 的“炸弹”,详细原理可参见:A better zip bomb。
漏洞演示
Github 有生成 Zip 炸弹的现成项目: CreeperKong/zipbomb-generator。
脚本用法很简单,如下指定生成包含一个 3.9G 左右大小的 test.zip 文件:
python zipbomb.py --mode=quoted_overlap --num-files=1 --compressed-size=3999999 > test.zip
修改 --num-files=10
参数则可以令 zip 中包含 10 个重复的上述文件(当然还可以包含更多):
由上面可见,如果 Web 服务器从客户端发送过来的 http 报文中提取 zip 文件并进行解压缩的时候没校验 zip 文件夹内部文件的大小的话,将导致攻击者可以传递 zip 炸弹耗尽服务器资源,形成严重的 Dos 攻击。
历史上知名组件的相关漏洞的话可以参见 ZIP bomb vulnerability in HuTool:
错误修补
上文提到使用 zipEntry.getSize()
函数获取 zip 文件大小是不可取,zipEntry.getSize()
是从 zip 文件中的固定字段中读取单个文件压缩前的大小,如何篡改并欺骗服务器?
先模仿一段存在缺陷的修复代码:
public static void unzipFile(String zipPtath, String outputDirectory) throws IOException {File file = new File(outputDirectory);if (!file.exists()) {file.mkdirs();}InputStream inputStream = new FileInputStream(zipPtath); ;ZipInputStream zipInputStream = new ZipInputStream(inputStream);byte[] buffer = new byte[1024 * 1024];int count;ZipEntry zipEntry;while ((zipEntry = zipInputStream.getNextEntry()) != null){if (!zipEntry.isDirectory()) {String fileName = zipEntry.getName();System.out.println("解压文件的名字: " + fileName + ",解压文件的大小: " + zipEntry.getSize());// 判断被压缩的文件的大小,单个文件不得大于4Mbif(zipEntry.getSize() < 4096){file = new File(outputDirectory + File.separator + fileName);file.createNewFile();FileOutputStream fileOutputStream = new FileOutputStream(file);while ((count = zipInputStream.read(buffer)) > 0) {fileOutputStream.write(buffer, 0, count);}fileOutputStream.close();}else {System.out.println("文件大小超出限制!");return;}}}zipInputStream.close();System.out.println("解压完成!");}
上述代码判断被压缩的文件的大小,单个文件不得大于 4Mb,如何绕过?
步骤很简单,首先下载用于修改二进制文件的 010editor 软件,安装后打开上面演示用的 Zip 炸弹 test.zip(包含了一个 3.9G 的大文件):
修改图示 frUncompressedsize 字段的值后,重新运行 Java 程序对其进行解压缩,可成功绕过文件大小限制,解压出目标文件:
用 VSCode 可成功打开上述解压缩出来的文件(文件内容全都是 aaaaa……),意味着文件并未损坏:
可以看到,此时zipEntry.getSize()
函数获取到的压缩文件大小已经变成我们修改完以后的值(10),同时成功解压缩出 3.9G 大小的目标文件,成功绕过了修复代码对于压缩文件的大小限制。
值得注意的是,从上述截图也可以看到修改了 zip 文件的 frUncompressedsize
字段的值以后,解压缩 zip 文件会报错,如果直接使用 7-zip 进行解压缩的话更是直接报错而终止,提取不出任何文件:
java.util.zip.ZipException: invalid entry size (expected 10 but got 396289 bytes)at java.util.zip.ZipInputStream.readEnd(ZipInputStream.java:384)at java.util.zip.ZipInputStream.read(ZipInputStream.java:196)at java.io.FilterInputStream.read(FilterInputStream.java:107)at Util.Util.unzipFile(Util.java:42)at Main.main(Main.java:14)
但是通过实践也可以看到,通过上述 Java 代码可成功解压缩出来目标文件,这样子的话就不影响我们通过修改 zip 文件的 frUncompressedsize
字段的值,制作 zip 炸弹绕过服务端的文件大小校验检测,完成攻击利用。
安全编码
最后直接看看《OpenHarmony-Java-secure-coding-guide》提供的 Zip 文件解压缩的安全编码示例:
private static final long MAX_FILE_COUNT = 100L;
private static final long MAX_TOTAL_FILE_SIZE = 1024L * 1024L;...public void unzip(FileInputStream zipFileInputStream, String dir) throws IOException {long fileCount = 0;long totalFileSize = 0;try (ZipInputStream zis = new ZipInputStream(zipFileInputStream)) {ZipEntry entry;String entryName;String entryFilePath;File entryFile;byte[] buf = new byte[10240];int length;while ((entry = zis.getNextEntry()) != null) {entryName = entry.getName();//先对文件名的合法性进行校验entryFilePath = sanitizeFileName(entryName, dir);entryFile = new File(entryFilePath);if (entry.isDirectory()) {creatDir(entryFile);continue;}fileCount++;//对zip压缩包中的文件数量进行限制,设置了上限阈值if (fileCount > MAX_FILE_COUNT) {throw new IOException("The ZIP package contains too many files.");}//此处不再同通过zipEntry.getSize()函数获取 zip 文件大小,而是通过文件数据流直接读取整个文件的数据并统计大小try (FileOutputStream fos = new FileOutputStream(entryFile)) {while ((length = zis.read(buf)) != -1) {totalFileSize += length;zipBombCheck(totalFileSize);fos.write(buf, 0, length);}}}}
}//防止压缩文件名携带../导致的Zip Slip路径穿越漏洞
private String sanitizeFileName(String fileName, String dir) throws IOException {File file = new File(dir, fileName);String canonicalPath = file.getCanonicalPath();if (canonicalPath.startsWith(dir)) {return canonicalPath;}throw new IOException("Path Traversal vulnerability: ...");
}private void creatDir(File dirPath) throws IOException {boolean result = dirPath.mkdirs();if (!result) {throw new IOException("Create dir failed, path is : " + dirPath.getPath());}...
}//防止zip炸弹
private void zipBombCheck(long totalFileSize) throws IOException {if (totalFileSize > MAX_TOTAL_FILE_SIZEG) {throw new IOException("Zip Bomb! The size of the file extracted from the ZIP package is too large.");}
}
上述示例中,一共做了 3 项目安全检查:
- 在解压每个文件之前对其文件名进行校验,如果校验失败,整个解压过程会被终止,防止路径穿越漏洞;
- 解压缩过程中,对每个文件通过文件数据流识别其实际大小,如果达到指定的阈值(MAX_TOTAL_FILE_SIZE),会抛出异常终止解压操作;
- 同时,程序会统计解压出来的文件的数量,如果达到指定阈值(MAX_FILE_COUNT),会抛出异常终止解压操作。
总结
从上面的安全示例编码可以看到,简简单单的一个常见 Zip 文件解压缩过程,需要做的安全校验却并不少。总的来说,研发人员在编写对用户可见的 zip 文件上传功能时,一定要严格校验好 zip 文件中待解压缩的文件文件名是否包含../
非法字符,校验带解压的文件大小,同时禁止通过 zipEntry.getSize()
函数获取 zip 文件大小,最后也需要校验下解压缩出来的文件总数(设置阈值,毕竟积少成多,通过大量中小型文件也可以完成 zip 炸弹攻击)。
本文参考文章:
- Java代码审计指南;
- OpenHarmony-Java-secure-coding-guide;
- 压缩炸弹(zipbomb)制作(附演示);
- 一个42KB的文件,是如何解压完变成一个4.5PB的数据;
- https://github.com/CreeperKong/zipbomb-generator;