前言
前面一篇博文《Android Zip解压缩目录穿越导致文件覆盖漏洞》介绍过 Android 系统 Zip 文件解压缩场景下的目录穿越漏洞,近期在学习 JavaWeb 代码审计的时候从 github 看到《OpenHarmony-Java-secure-coding-guide.md》中“从 ZipInputStream 中解压文件必须进行安全检查”章节提及 JavaWeb 系统同样涉及此类目录穿越漏洞,同时还涉及 Zip 炸弹的攻击场景,故在此学习记录下。
Zip Slip
此类漏洞指的是解压 zip 文件时没有校验各解压文件的名字,如果文件名包含 ../
会导致解压文件被释放到目标目录之外的目录。
在《Android Zip解压缩目录穿越导致文件覆盖漏洞》一文已经讲过原理,不再过多赘述。直接沿用原来的恶意 Zip 生成代码和 Zip 解压缩代码即可:
import zipfile
def 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());
// 判断被压缩的文件的大小,单个文件不得大于4Mb
if(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;