本文最后更新于:14 天前
引言
Java 中压缩包的必要性
在企业级应用、数据处理、文件存储、接口交互等场景中,压缩包的处理能力已经成为 Java 后端服务不可或缺的一部分。常见原因包括:
- 节省带宽与存储:打包压缩减少传输体积,特别适用于大文件传输或归档存储。
- 批量文件打包:便于文件整体上传、下载、分发与管理。
- 第三方系统对接:很多政府采购、电商平台、ERP 系统上传资料要求为压缩包格式。
- 安全合规:通过压缩与加密结合实现敏感数据保护。
Java 提供了较为灵活的文件操作能力,但对非 ZIP 格式(如 7Z、RAR)的原生支持有限,这促使开发者寻求更强大、跨平台、可扩展的解决方案。
Java 对压缩的支持
Java 8 标准库对压缩格式的支持如下:
格式 |
原生支持 |
支持程度 |
是否可写 |
是否可读 |
说明 |
ZIP |
✅ |
中等 |
✅ |
✅ |
java.util.zip 提供压缩与解压 API |
JAR |
✅ |
高 |
✅ |
✅ |
JAR 基于 ZIP,支持良好 |
GZIP |
✅ |
中 |
✅ |
✅ |
仅支持单文件压缩 |
7Z |
❌ |
无 |
❌ |
❌ |
需依赖第三方库 |
RAR |
❌ |
无 |
❌ |
部分 |
依赖第三方库,仅支持解压 |
常用第三方库概览:
库名 |
支持格式 |
是否跨平台 |
说明 |
Zip4j |
ZIP(含加密) |
✅ |
支持 AES 加密、分卷 |
Apache Commons Compress |
ZIP、7Z、TAR、GZ、AR 等 |
✅ |
支持广泛,但功能偏读取 |
junrar |
RAR(仅解压 RAR4) |
✅ |
仅兼容 RAR4,更新缓慢 |
SevenZipJBinding / JNA |
7Z、RAR、ZIP |
❌(需 native) |
支持丰富,但需要平台依赖 |
典型业务场景与痛点
场景 |
描述 |
常见挑战 |
文件上传解析 |
用户上传压缩包,系统解压并读取文件内容 |
格式兼容、编码乱码、压缩炸弹、中文目录名 |
日志归档与备份 |
将服务日志、临时文件定期压缩归档 |
性能、压缩率、多线程处理 |
报表导出 |
生成多个文件,打包 ZIP 提供下载 |
临时文件清理、内存管理 |
跨系统数据交换 |
与其他系统通过压缩文件交互数据 |
RAR/7Z 支持、加密校验、兼容性 |
安全传输 |
结合压缩与加密确保数据传输安全 |
加密算法支持、密码管理、破解防护 |
压缩基础知识
压缩格式对比
格式 |
类型 |
支持多文件 |
是否支持加密 |
典型用途 |
特点 |
ZIP |
容器格式 |
✅ |
✅(部分支持 AES) |
通用压缩、上传、下载 |
Java 原生支持,通用性强 |
7Z |
容器格式 |
✅ |
✅(强加密) |
高压缩率需求,如打包安装包 |
支持多种算法,压缩率高 |
RAR |
容器格式 |
✅ |
✅(较强) |
游戏、资源压缩包、数据备份 |
专利格式,支持分卷、恢复记录 |
GZ |
单文件压缩 |
❌ |
❌ |
Linux 日志压缩、流式传输 |
极简格式,仅压缩单文件 |
JAR |
Java 专用 ZIP |
✅ |
❌(仅支持签名校验) |
Java 类/资源打包 |
本质是 ZIP,支持清单与签名 |
注意: ZIP 与 JAR 可互相解压;但 7Z 和 RAR 对 Java 来说需第三方库解析。
压缩算法
算法 |
适用格式 |
压缩率 |
速度 |
特点 |
Deflate |
ZIP、GZ、JAR |
中 |
快 |
Java 内置实现,平衡型 |
LZMA |
7Z |
高 |
慢 |
高压缩率,适合超大文件 |
BZip2 |
TAR.BZ2、Commons |
高 |
中 |
多线程支持好(仅7-Zip CLI),CPU 占用高 压缩率较佳,适合日志归档 |
PPMd |
7Z |
极高 |
慢 |
文字类压缩效率极高 |
Rar压缩专属算法 |
RAR |
中高 |
中 |
专利格式,支持恢复记录 |
压缩算法通常不可逆使用:压缩率越高,速度越慢;适配场景需平衡性能与存储需求。
内部结构与文件头
压缩包本质上是一种文件容器格式,其结构中包含:
ZIP 文件结构
| [文件1数据块] [文件2数据块] ... [Central Directory(目录项)] [End of Central Directory Record]
|
- 文件头(Local File Header):每个文件前面包含压缩算法、时间戳、文件名等信息
- 中央目录(Central Directory):用于快速定位各文件,便于随机访问和增量更新
- EOCD(End of Central Directory):ZIP 的结束标志,存放总文件数、目录偏移等
7Z 文件结构
| [Header] [Compressed Data Blocks] [Stream Info + Metadata + CRC校验]
|
- 支持多层嵌套压缩、加密数据流、文件名加密、压缩字典自定义等
- 每个 Block 可以使用不同压缩算法
RAR 文件结构
| [Archive Header] [File Header 1 + Compressed File Data] [File Header 2 + Compressed File Data] ... [Recovery Record(可选)] [End of Archive]
|
- 每个文件块都包含校验码与恢复信息
- RAR5 引入更强加密机制,但格式解析更复杂
GZ 文件结构
| [Magic Number (1F 8B)] [Compression Method (08 for Deflate)] [Flags + Timestamp + Optional Fields] [Compressed Data] [CRC32 + Input Size]
|
- 单文件压缩格式,通常配合
.tar
打包使用(如 .tar.gz
)
Zip 处理
java.util.zip
类 |
说明 |
ZipOutputStream |
基于 DeflaterOutputStream ,负责向输出流写入 ZIP 条目与压缩数据。支持设置压缩级别。 |
ZipEntry |
表示 ZIP 中的一个“条目”(文件或目录),包含文件名、时间戳、压缩方法等元数据。 |
ZipInputStream |
基于 InflaterInputStream ,按顺序读取 ZIP 条目并解压数据,适合流式处理。 |
ZipFile |
随机访问 ZIP,支持按条目索引读取,更能指定字符集(new ZipFile(file, charset) )。 |
Deflater / Inflater |
底层压缩/解压算法引擎,可通过 ZipOutputStream#setLevel(int) 调整压缩级别。 |
CRC32
CheckedOutputStream
CheckedInputStream |
提供 CRC 校验支持,可对流做一致性校验。 |
Tip:当文件名包含非 ASCII(如中文)时,ZipFile
的 charset 构造器可避免乱码;而 ZipInputStream
支持使用 UTF-8,当条目未置 EFS 标志时,它退回 CP437。
创建压缩包
核心流程
- 打开目标输出流:
FileOutputStream → BufferedOutputStream → ZipOutputStream
- 设置压缩级别:
zipOut.setLevel(Deflater.BEST_SPEED / DEFAULT_COMPRESSION / BEST_COMPRESSION)
- 遍历源文件,对每个文件/目录:
- 创建
ZipEntry entry = new ZipEntry(relativePath)
zipOut.putNextEntry(entry)
- 读取源文件字节,写入
zipOut
zipOut.closeEntry()
- 结束:
zipOut.finish()
(可选),关闭流。
代码实例
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
| public class ZipCreator {
public static void createZip(Path sourceDir, Path targetZip, int compressionLevel) throws IOException { byte[] buffer = new byte[8 * 1024]; try (ZipOutputStream zipOut = new ZipOutputStream( new BufferedOutputStream(Files.newOutputStream(targetZip)))) {
zipOut.setLevel(compressionLevel);
Files.walk(sourceDir) .filter(path -> !Files.isDirectory(path)) .forEach(path -> { Path relPath = sourceDir.relativize(path); ZipEntry entry = new ZipEntry(relPath.toString().replace("\\", "/")); try { zipOut.putNextEntry(entry); try (InputStream in = Files.newInputStream(path)) { int len; while ((len = in.read(buffer)) > 0) { zipOut.write(buffer, 0, len); } } zipOut.closeEntry(); } catch (IOException e) { throw new UncheckedIOException(e); } }); zipOut.finish(); } }
public static void main(String[] args) throws IOException, URISyntaxException { Path src = Paths.get("data目录"); Path dest = Paths.get("archive.zip"); createZip(src, dest, Deflater.DEFAULT_COMPRESSION); System.out.println("压缩完成:" + dest.toAbsolutePath()); }
}
|
编码与效率考量
- 文件名编码:Java 8 默认对 ZipEntry 名称采用 UTF-8 并设置 EFS 标志;在 Windows Explorer(CP437)可能出现乱码。
- 建议:如果需兼容 GBK/CP437,可后续使用第三方库(如 Zip4j)或在解压时用
new ZipFile(file, Charset.forName("GBK"))
。
- 压缩效率(100MB 以下):
- 默认
Deflater.DEFAULT_COMPRESSION
(级别6)在中等速度与中等压缩率间平衡,100MB 文件常见场景耗时 <1s(现代 CPU)。
- 对实时场景可降至
BEST_SPEED
(级别1),对归档场景可提升至 BEST_COMPRESSION
(级别9)。
- I/O 缓冲:8KB–16KB 缓冲区已足够,过大并不会显著提高吞吐。
解压缩
核心流程
- 打开源 ZIP 流:
FileInputStream → BufferedInputStream → ZipInputStream
- 迭代
ZipEntry
:while ((entry = zipIn.getNextEntry()) != null)
- 构建目标文件(保留目录结构)
- 读取压缩数据:将
zipIn
中的字节写入目标文件
- 结束条目:
zipIn.closeEntry()
- 关闭流
代码示例
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
| public class ZipExtractor {
public static void unzipForUtf8(Path zipPath, Path targetDir) throws IOException { byte[] buffer = new byte[8 * 1024]; try (ZipInputStream zipIn = new ZipInputStream( new BufferedInputStream(Files.newInputStream(zipPath)))) { ZipEntry entry; while ((entry = zipIn.getNextEntry()) != null) { Path outPath = targetDir.resolve(entry.getName()); if (entry.isDirectory()) { Files.createDirectories(outPath); } else { Files.createDirectories(outPath.getParent()); try (OutputStream out = Files.newOutputStream(outPath)) { int len; while ((len = zipIn.read(buffer)) > 0) { out.write(buffer, 0, len); } } } zipIn.closeEntry(); } } }
public static void main(String[] args) throws IOException { Path zipFile = Paths.get("data目录.zip"); Path outDir = Paths.get("output"); unzipAutoDetect(zipFile, outDir); System.out.println("解压完成:" + outDir.toAbsolutePath()); } }
|
改进
在以上代码示例中,如果压缩包是通过非 UTF-8 / 非标准工具(如 7-Zip、WinRAR) 生成的 zip 文件,那么在解压缩时可能会抛出异常:
| java.lang.IllegalArgumentException: MALFORMED at java.util.zip.ZipCoder.toString(ZipCoder.java:58)
|
Windows默认使用 GBK 编码,可以在创建 ZipInputStream 时使用 GBK 编码,如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public static void unzipForGbk(Path zipPath, Path targetDir) throws IOException { byte[] buffer = new byte[8 * 1024]; try (ZipInputStream zipIn = new ZipInputStream( new BufferedInputStream(Files.newInputStream(zipPath)), Charset.forName("GBK"))) { ZipEntry entry; while ((entry = zipIn.getNextEntry()) != null) { Path outPath = targetDir.resolve(entry.getName()); if (entry.isDirectory()) { Files.createDirectories(outPath); } else { Files.createDirectories(outPath.getParent()); try (OutputStream out = Files.newOutputStream(outPath)) { int len; while ((len = zipIn.read(buffer)) > 0) { out.write(buffer, 0, len); } } } zipIn.closeEntry(); } } }
|
综上:为了适应多编码(UTF-8、GBK、CP437、ISO-8859-1),需要对方法进行改进,自动检测多编码,思想就是按照优先级尝试多种编码。
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
| public static void unzipAutoDetect(Path zipPath, Path targetDir) throws IOException { Charset[] charsets = { StandardCharsets.UTF_8, Charset.forName("GBK"), Charset.forName("Cp437"), StandardCharsets.ISO_8859_1, Charset.forName("MacRoman") };
IOException lastIOException = null; RuntimeException lastRuntimeException = null;
for (Charset cs : charsets) { System.out.println("尝试使用编码:" + cs.name()); try (ZipFile zipFile = new ZipFile(zipPath.toFile(), cs)) { Enumeration<? extends ZipEntry> entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); extractEntry(zipFile, entry, targetDir); } return; } catch (IOException ioe) { lastIOException = ioe; } catch (IllegalArgumentException iae) { lastRuntimeException = iae; } }
String msg = "无法识别 ZIP 文件名编码,尝试过的编码:" + Arrays.toString(charsets); IOException ex = new IOException(msg, lastIOException); if (lastRuntimeException != null) { ex.addSuppressed(lastRuntimeException); } throw ex; }
private static void extractEntry(ZipFile zipFile, ZipEntry entry, Path targetDir) { try (InputStream in = zipFile.getInputStream(entry)) { Path out = targetDir.resolve(entry.getName()).normalize(); if (!out.startsWith(targetDir)) { throw new IOException("非法的 ZIP 路径:" + entry.getName()); } if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (OutputStream outStream = Files.newOutputStream(out)) { byte[] buf = new byte[8 * 1024]; int len; while ((len = in.read(buf)) > 0) { outStream.write(buf, 0, len); } } } } catch (IOException e) { throw new UncheckedIOException(e); } }
|
通用解决方案
下面使用 Apache Commons Compress 结合 Zip4j,提供一个通用的 Java 工具类 CompressUtil
:
- Apache Commons Compress:格式全,API 统一;
- Zip4j:ZIP 密码保护(ZipCrypto / AES)。
工具类
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 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237
| public final class CompressUtil {
public static void createZip(Path srcPath, Path zipPath, String password, boolean aes256) throws IOException {
try (ZipFile zip = (password == null) ? new ZipFile(zipPath.toFile()) : new ZipFile(zipPath.toFile(), password.toCharArray())) {
ZipParameters baseParams = new ZipParameters(); baseParams.setCompressionMethod(CompressionMethod.DEFLATE); baseParams.setCompressionLevel(CompressionLevel.NORMAL); if (password != null) { baseParams.setEncryptFiles(true); baseParams.setEncryptionMethod( aes256 ? EncryptionMethod.AES : EncryptionMethod.ZIP_STANDARD ); if (aes256) { baseParams.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); } }
if (Files.isDirectory(srcPath)) { try (Stream<Path> stream = Files.walk(srcPath)) { stream.forEach(path -> { String entry = srcPath.relativize(path) .toString() .replace("\\", "/"); try { ZipParameters params = new ZipParameters(baseParams); if (Files.isDirectory(path)) { if (!entry.endsWith("/")) { entry = entry + "/"; } params.setFileNameInZip(entry); zip.addFolder(path.toFile(), params); } else { params.setFileNameInZip(entry); zip.addFile(path.toFile(), params); } } catch (IOException e) { throw new UncheckedIOException(e); } }); } } else { ZipParameters params = new ZipParameters(baseParams); params.setFileNameInZip(srcPath.getFileName().toString()); zip.addFile(srcPath.toFile(), params); } } }
public static void create7z(Path srcDir, Path out7z) throws IOException { try (SevenZOutputFile out = new SevenZOutputFile(out7z.toFile())) { addPathTo7z(out, srcDir, srcDir); } }
private static void addPathTo7z(SevenZOutputFile out, Path root, Path src) throws IOException { if (Files.isDirectory(src)) { try (DirectoryStream<Path> ds = Files.newDirectoryStream(src)) { for (Path p : ds) { addPathTo7z(out, root, p); } } } else { String entryName = root.relativize(src) .toString().replace("\\", "/"); SevenZArchiveEntry entry = out.createArchiveEntry(src.toFile(), entryName); out.putArchiveEntry(entry); try (InputStream is = Files.newInputStream(src)) { byte[] buf = new byte[8 * 1024]; int len; while ((len = is.read(buf)) != -1) { out.write(buf, 0, len); } } out.closeArchiveEntry(); } }
public static void createTarGz(Path srcDir, Path outTarGz) throws IOException { try (TarArchiveOutputStream taos = new TarArchiveOutputStream( new GzipCompressorOutputStream( Files.newOutputStream(outTarGz)))) {
taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); try (Stream<Path> stream = Files.walk(srcDir)) { stream .filter(p -> !p.equals(srcDir)) .forEach(p -> { try { TarArchiveEntry entry = new TarArchiveEntry( p.toFile(), srcDir.relativize(p).toString().replace("\\", "/")); taos.putArchiveEntry(entry); if (Files.isRegularFile(p)) { Files.copy(p, taos); } taos.closeArchiveEntry(); } catch (IOException e) { throw new UncheckedIOException(e); } }); } } }
public static void extract(Path archive, Path targetDir, String password) throws IOException, RarException {
String name = archive.getFileName().toString().toLowerCase(); if (name.endsWith(".zip")) { extractZip(archive, targetDir, password); } else if (name.endsWith(".7z")) { extract7z(archive, targetDir, password); } else if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) { extractTarGz(archive, targetDir); } else { throw new IllegalArgumentException("不支持的格式: " + name); } }
private static void extractZip(Path zip, Path target, String password) throws IOException { try (ZipFile probe = password == null ? new ZipFile(zip.toFile()) : new ZipFile(zip.toFile(), password.toCharArray())) {
long utf8Count = probe.getFileHeaders() .stream() .filter(FileHeader::isFileNameUTF8Encoded) .count(); boolean useUtf8 = utf8Count >= (probe.getFileHeaders().size() + 1) / 2;
try (ZipFile real = password == null ? new ZipFile(zip.toFile()) : new ZipFile(zip.toFile(), password.toCharArray())) {
if (!useUtf8) { real.setCharset(Charset.forName("GBK")); } real.extractAll(target.toString()); } } }
private static void extract7z(Path sevenZ, Path target, String password) throws IOException { try (SevenZFile zf = password == null ? new SevenZFile(sevenZ.toFile()) : new SevenZFile(sevenZ.toFile(), password.toCharArray())) {
SevenZArchiveEntry entry; byte[] buf = new byte[8 * 1024]; while ((entry = zf.getNextEntry()) != null) { Path out = target.resolve(entry.getName()).normalize(); if (!out.startsWith(target)) throw new IOException("Zip-Slip: " + entry.getName());
if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (OutputStream os = Files.newOutputStream(out)) { int len; while ((len = zf.read(buf)) > 0) { os.write(buf, 0, len); } } } } } }
private static void extractTarGz(Path tarGz, Path target) throws IOException { try (GzipCompressorInputStream gis = new GzipCompressorInputStream(Files.newInputStream(tarGz)); TarArchiveInputStream tis = new TarArchiveInputStream(gis)) {
TarArchiveEntry entry; while ((entry = tis.getNextEntry()) != null) { Path out = target.resolve(entry.getName()).normalize(); if (!out.startsWith(target)) throw new IOException("Zip-Slip: " + entry.getName()); if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (OutputStream os = Files.newOutputStream(out)) { IOUtils.copy(tis, os); } } } } }
private CompressUtil() { } }
|
测试类
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
| public class CompressUtilTest {
@Test public void testCreateZip() throws IOException { Path src = Paths.get(".gitignore"); Path dest = Paths.get("data目录压缩包.zip"); CompressUtil.createZip(src, dest, "123456", true);
System.out.println("压缩成功:" + dest); }
@Test public void testCreate7z() throws IOException { Path src = Paths.get("data目录"); Path dest = Paths.get("data目录压缩包.7z"); CompressUtil.create7z(src, dest); System.out.println("7z 压缩成功:" + dest); }
@Test public void testCreateTarGz() throws IOException { Path src = Paths.get("data目录"); Path dest = Paths.get("data目录压缩包.tar.gz"); CompressUtil.createTarGz(src, dest); System.out.println("tar.gz 压缩成功:" + dest); }
@Test public void testExtractZip() throws IOException, RarException { Path zipPath = Paths.get("data目录压缩包.zip"); Path extractDir = Paths.get("解压zip压缩包目录"); CompressUtil.extract(zipPath, extractDir, "123456"); System.out.println("ZIP 解压成功:" + extractDir); }
@Test public void testExtract7z() throws IOException, RarException { Path sevenZPath = Paths.get("data目录压缩包.7z"); Path extractDir = Paths.get("解压7z压缩包目录"); CompressUtil.extract(sevenZPath, extractDir, "123"); System.out.println("7z 解压成功:" + extractDir); }
@Test public void testExtractTarGz() throws RarException, IOException { Path tarGzPath = Paths.get("data目录压缩包.tar.gz"); Path extractDir = Paths.get("解压tar.gz压缩包目录"); CompressUtil.extract(tarGzPath, extractDir, null); System.out.println("tar.gz 解压成功:" + extractDir); }
}
|
安全防护
Zip Bomb
定义
Zip Bomb(压缩炸弹)是一种恶意构造的压缩包,体积极小(如几 KB),解压后却膨胀到极大的体积(如几 TB),以耗尽磁盘空间、内存或 CPU 资源,造成系统 DoS。
原理
- 多层嵌套压缩:将同一份数据重复压缩数十层
- 超高冗余率:利用极高压缩率算法(如重复单字符)
检测策略
设定阈值
- 读取所有条目的 “压缩大小” 与 “原始大小”
- 计算总压缩比:
sum(uncompressed) / sum(compressed)
- 若压缩比 > 100(具体根据业务可调,常见 50–200)时拒绝解压
- 或单个条目原始大小超过阈值(如 >1 GB)时拒绝解压
| try (ZipFile probe = (password == null) ? new ZipFile(zip.toFile()) : new ZipFile(zip.toFile(), password.toCharArray())) {
long totalC = 0, totalU = 0, utf8Count = 0; for (FileHeader fh : probe.getFileHeaders()) { totalC += fh.getCompressedSize(); totalU += fh.getUncompressedSize(); if (fh.isFileNameUTF8Encoded()) utf8Count++; } if (totalC > 0 && (double) totalU / totalC > 100) { throw new IOException(String.format( "检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC)); } }
|
限制嵌套深度
- 禁止多层嵌套(如 Entry 名称中出现超过 N 个 “.zip” 后缀)
限制总条目数
- 若压缩包包含文件数 >100 000,则可认为可疑
流式监控
- 在解压过程中累加已写出字节数
- 若超出预先计算的最大“安全”解压大小,立刻中断
Zip-Slip 防护
漏洞成因
压缩包条目名中含有 ../
、绝对路径(如 /etc/passwd
)或 Windows 驱动器前缀(如 C:\secret.txt
),直接拼接后会写出到项目目录之外。
防护原则
归一化
| Path out = targetDir.resolve(entryName).normalize();
|
前缀校验
| if (!out.startsWith(targetDir)) { throw new IOException("非法路径:" + entryName); }
|
过滤黑名单(可选)
- 拒绝包含
..
、以 /
或 \
开头的名称
- 拒绝包含控制字符(
\0
、\r
、\n
)
字符集安全
- 对文件名先按预期字符集解码,再检查是否含有非法 Unicode 码点(如 U+202E “RTL Override”)
只读沙箱(高级)
密码暴力破解限制
风险场景
对于带密码的 ZIP(ZipCrypto/AES),攻击者可不断尝试不同密码,若没有限制则可高速穷举,开发者需要在自己系统的接口层面进行限制,当然对于破解者本地运行程序进行破解,则无法进行限制,只能通过选择更不容易破解的加密算法。
限制策略
- 尝试次数限制
- 每个压缩包最多允许尝试 3–5 次解密失败,超过则拒绝服务
- 时间窗口与锁定
- 在 1 分钟内多次失败,锁定该用户或该压缩包 10 分钟
- 延时退避
- 每次失败后增加延时:初始 500 ms,之后 1 s、2 s、4 s…
- 验证码或二次验证
- 超过 N 次失败后,要求用户输入 CAPTCHA 或二次校验(如手机短信)
- 统一入口限流
- 日志告警
- 记录失败密码尝试日志,配合 SIEM 监控异常行为
- 加密算法选择
- 推荐使用 AES/GCM 而非传统 ZipCrypto,因后者更易被暴力破解
最终代码呈现
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 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333
|
public final class CompressUtil {
private static final double MAX_COMPRESSION_RATIO = 10.0;
private static final long MAX_UNCOMPRESSED_SIZE = 1024 * 1024 * 1024;
public static void createZip(Path srcPath, Path zipPath, String password, boolean aes256) throws IOException { srcPath = srcPath.toAbsolutePath().normalize(); try (ZipFile zip = (password == null) ? new ZipFile(zipPath.toFile()) : new ZipFile(zipPath.toFile(), password.toCharArray())) {
ZipParameters baseParams = new ZipParameters(); baseParams.setCompressionMethod(CompressionMethod.DEFLATE); baseParams.setCompressionLevel(CompressionLevel.NORMAL); if (password != null) { baseParams.setEncryptFiles(true); baseParams.setEncryptionMethod( aes256 ? EncryptionMethod.AES : EncryptionMethod.ZIP_STANDARD ); if (aes256) { baseParams.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); } }
if (Files.isDirectory(srcPath)) { try (Stream<Path> stream = Files.walk(srcPath)) { Path finalSrcPath = srcPath; stream.filter(path -> !path.equals(finalSrcPath)) .forEach(path -> { String entry = finalSrcPath.relativize(path) .toString() .replace("\\", "/"); if (entry.startsWith("/")) { entry = entry.substring(1); } if (entry.startsWith("./")) { entry = entry.substring(2); } try { ZipParameters params = new ZipParameters(baseParams); if (Files.isDirectory(path)) { if (!entry.endsWith("/")) { entry = entry + "/"; } params.setFileNameInZip(entry); zip.addFolder(path.toFile(), params); } else { params.setFileNameInZip(entry); zip.addFile(path.toFile(), params); } } catch (IOException e) { throw new UncheckedIOException(e); } }); } } else { ZipParameters params = new ZipParameters(baseParams); params.setFileNameInZip(srcPath.getFileName().toString()); zip.addFile(srcPath.toFile(), params); } } }
public static void create7z(Path srcDir, Path out7z) throws IOException { try (SevenZOutputFile out = new SevenZOutputFile(out7z.toFile())) { addPathTo7z(out, srcDir, srcDir); } }
private static void addPathTo7z(SevenZOutputFile out, Path root, Path src) throws IOException { if (Files.isDirectory(src)) { try (DirectoryStream<Path> ds = Files.newDirectoryStream(src)) { for (Path p : ds) { addPathTo7z(out, root, p); } } } else { String entryName = root.relativize(src) .toString().replace("\\", "/"); SevenZArchiveEntry entry = out.createArchiveEntry(src.toFile(), entryName); out.putArchiveEntry(entry); try (InputStream is = Files.newInputStream(src)) { byte[] buf = new byte[8 * 1024]; int len; while ((len = is.read(buf)) != -1) { out.write(buf, 0, len); } } out.closeArchiveEntry(); } }
public static void createTarGz(Path srcDir, Path outTarGz) throws IOException { try (TarArchiveOutputStream taos = new TarArchiveOutputStream( new GzipCompressorOutputStream( Files.newOutputStream(outTarGz)))) {
taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); try (Stream<Path> stream = Files.walk(srcDir)) { stream .filter(p -> !p.equals(srcDir)) .forEach(p -> { try { TarArchiveEntry entry = new TarArchiveEntry( p.toFile(), srcDir.relativize(p).toString().replace("\\", "/")); taos.putArchiveEntry(entry); if (Files.isRegularFile(p)) { Files.copy(p, taos); } taos.closeArchiveEntry(); } catch (IOException e) { throw new UncheckedIOException(e); } }); } } }
public static void extract(Path archive, Path targetDir, String password) throws IOException, RarException {
String name = archive.getFileName().toString().toLowerCase(); if (name.endsWith(".zip")) { extractZip(archive, targetDir, password); } else if (name.endsWith(".7z")) { extract7z(archive, targetDir, password); } else if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) { extractTarGz(archive, targetDir); } else { throw new IllegalArgumentException("不支持的格式: " + name); } }
private static void extractZip(Path zip, Path target, String password) throws IOException { boolean useUtf8; try (ZipFile probe = (password == null) ? new ZipFile(zip.toFile()) : new ZipFile(zip.toFile(), password.toCharArray())) {
long totalC = 0, totalU = 0, utf8Count = 0; for (FileHeader fh : probe.getFileHeaders()) { totalC += fh.getCompressedSize(); totalU += fh.getUncompressedSize(); if (fh.isFileNameUTF8Encoded()) utf8Count++; } if (totalC > 0 && (double) totalU / totalC > MAX_COMPRESSION_RATIO) { throw new IOException(String.format( "检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC)); } useUtf8 = utf8Count >= (probe.getFileHeaders().size() + 1) / 2; }
try (ZipFile real = (password == null) ? new ZipFile(zip.toFile()) : new ZipFile(zip.toFile(), password.toCharArray())) {
if (!useUtf8) { real.setCharset(Charset.forName("GBK")); } for (FileHeader fh : real.getFileHeaders()) { String name = fh.getFileName(); if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) { throw new IOException("拒绝可疑条目路径:" + name); } Path out = target.resolve(name).normalize(); if (!out.startsWith(target)) { throw new IOException("Zip-Slip 安全风险:" + name); } if (fh.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (InputStream in = real.getInputStream(fh); OutputStream os = Files.newOutputStream(out)) { byte[] buf = new byte[8 * 1024]; int len; while ((len = in.read(buf)) > 0) { os.write(buf, 0, len); } } } } } }
private static void extract7z(Path sevenZ, Path target, String password) throws IOException { long totalU = 0; try (SevenZFile probe = (password == null) ? new SevenZFile(sevenZ.toFile()) : new SevenZFile(sevenZ.toFile(), password.toCharArray())) { SevenZArchiveEntry e; while ((e = probe.getNextEntry()) != null) { totalU += e.getSize(); } } if (totalU > MAX_UNCOMPRESSED_SIZE) { throw new IOException(String.format( "检测到解压后总大小 %d bytes,超过 %d bytes,终止解压", totalU, MAX_UNCOMPRESSED_SIZE)); }
try (SevenZFile zf = (password == null) ? new SevenZFile(sevenZ.toFile()) : new SevenZFile(sevenZ.toFile(), password.toCharArray())) {
SevenZArchiveEntry entry; byte[] buf = new byte[8 * 1024]; while ((entry = zf.getNextEntry()) != null) { String name = entry.getName(); if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) { throw new IOException("拒绝可疑条目路径:" + name); } Path out = target.resolve(name).normalize(); if (!out.startsWith(target)) { throw new IOException("Zip-Slip 安全风险:" + name); } if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (OutputStream os = Files.newOutputStream(out)) { int len; while ((len = zf.read(buf)) > 0) { os.write(buf, 0, len); } } } } } }
private static void extractTarGz(Path tarGz, Path target) throws IOException { long totalC = Files.size(tarGz), totalU = 0; try (GzipCompressorInputStream gis = new GzipCompressorInputStream(Files.newInputStream(tarGz)); TarArchiveInputStream tis = new TarArchiveInputStream(gis)) { TarArchiveEntry e; while ((e = (TarArchiveEntry) tis.getNextEntry()) != null) { if (!e.isDirectory()) { totalU += e.getSize(); } } } if (totalC > 0 && (double) totalU / totalC > MAX_COMPRESSION_RATIO) { throw new IOException(String.format( "检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC)); }
try (GzipCompressorInputStream gis = new GzipCompressorInputStream(Files.newInputStream(tarGz)); TarArchiveInputStream tis = new TarArchiveInputStream(gis)) {
TarArchiveEntry entry; while ((entry = (TarArchiveEntry) tis.getNextEntry()) != null) { String name = entry.getName(); if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) { throw new IOException("拒绝可疑条目路径:" + name); } Path out = target.resolve(name).normalize(); if (!out.startsWith(target)) { throw new IOException("Zip-Slip 安全风险:" + name); } if (entry.isDirectory()) { Files.createDirectories(out); } else { Files.createDirectories(out.getParent()); try (OutputStream os = Files.newOutputStream(out)) { IOUtils.copy(tis, os); } } } } }
private CompressUtil() { } }
|
总结
本文系统梳理了 Java 压缩处理的动机、常见格式差异、核心算法和内部结构,并给出 Zip4j 与 Commons-Compress 融合的压缩解压解决方案,满足设置密码、多算法压缩解压、Zip-Bomb 检测与Zip-Slip 防护,整体上已经能满足 Java 中的压缩解压处理。