diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/SecureZipExtractor.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/SecureZipExtractor.java
new file mode 100644
index 0000000000..1abf9122a2
--- /dev/null
+++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/SecureZipExtractor.java
@@ -0,0 +1,205 @@
+/* ###
+ * IP: GHIDRA
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package ghidra.util;
+
+import java.io.*;
+import java.nio.file.*;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.*;
+
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
+import org.apache.commons.compress.utils.InputStreamStatistics;
+
+import ghidra.util.exception.CancelledException;
+import ghidra.util.task.TaskMonitor;
+import utilities.util.FileUtilities;
+
+/**
+ * A utility class to safely extract zip files.
+ *
+ * Protects against:
+ *
+ * - Zip Slip
+ * - Zip Bomb
+ *
+ */
+public class SecureZipExtractor {
+
+ private static final double MAX_THRESHOLD_RATIO = 100.0; // 100:1 ratio
+ private static final long MAX_SINGLE_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
+ private static final long MAX_TOTAL_SIZE = 1024 * 1024 * 1024; // 1 GB
+
+ /**
+ * Securely extracts the given zip file to the given directory
+ *
+ * @param zipFile The zip {@link File} to extract
+ * @param targetDir The directory to unzip to
+ * @param monitor A cancellable {@link TaskMonitor}
+ * @throws IOException if a zip slip, zip bomb, or other IO-related error occurred
+ * @throws CancelledException if the operation was cancelled
+ */
+ public static void extractSecurely(File zipFile, File targetDir, TaskMonitor monitor)
+ throws IOException, CancelledException {
+
+ try (ZipFile archive = ZipFile.builder().setFile(zipFile).get()) {
+ long total = 0;
+ Enumeration entries = archive.getEntries();
+
+ while (entries.hasMoreElements()) {
+ monitor.checkCancelled();
+
+ ZipArchiveEntry entry = entries.nextElement();
+ File outputFile = FileUtilities.getSecureFile(targetDir, entry.getName());
+
+ if (entry.isDirectory()) {
+ outputFile.mkdirs();
+ }
+ else {
+ total += extractSecurely(archive, entry, outputFile.toPath(), total);
+ }
+ }
+ }
+ }
+
+ /**
+ * Securely writes the given zip entry to the given output file. Assumes that the output file's
+ * directory exists.
+ *
+ * @param archive The original {@link ZipFile} to extract from
+ * @param entry The {@link ZipArchiveEntry entry} to extract
+ * @param outputFile The {@link File} to extract the entry to
+ * @throws IOException if a zip bomb or other IO-related error occurred
+ */
+ public static void extractSecurely(ZipFile archive, ZipArchiveEntry entry, File outputFile)
+ throws IOException {
+ extractSecurely(archive, entry, outputFile.toPath(), 0);
+ }
+
+ /**
+ * Securely writes the given zip entry to the given output path
+ *
+ * @param archive The original {@link ZipFile} to extract from
+ * @param entry The {@link ZipArchiveEntry entry} to extract
+ * @param outputPath The {@link Path} to extract the entry to
+ * @param totalBytesExtracted The total number of bytes that have been extracted so far
+ * @return The number of bytes extracted for this entry
+ * @throws IOException if a zip bomb or other IO-related error occurred
+ */
+ private static long extractSecurely(ZipFile archive, ZipArchiveEntry entry, Path outputPath,
+ long totalBytesExtracted) throws IOException {
+
+ try (InputStream is = archive.getInputStream(entry)) {
+ if (!(is instanceof InputStreamStatistics stats)) {
+ throw new IOException(
+ "Stream does not support InputStreamStatistics tracking.");
+ }
+
+ try (OutputStream os = Files.newOutputStream(outputPath, StandardOpenOption.CREATE,
+ StandardOpenOption.TRUNCATE_EXISTING)) {
+
+ byte[] buffer = new byte[4096];
+ int bytesRead;
+
+ while ((bytesRead = is.read(buffer)) != -1) {
+ os.write(buffer, 0, bytesRead);
+
+ long uncompressed = stats.getUncompressedCount();
+ long compressed = stats.getCompressedCount();
+
+ // Check single file threshold
+ if (uncompressed > MAX_SINGLE_FILE_SIZE) {
+ throw new IOException(
+ "Zip bomb detected: Single entry exceeds maximum allowed size.");
+ }
+
+ // Check total extraction threshold
+ if (totalBytesExtracted + uncompressed > MAX_TOTAL_SIZE) {
+ throw new IOException(
+ "Zip bomb detected: Total extraction size exceeds limit.");
+ }
+
+ // Check compression ratio (only after sufficient data is read to avoid false positives)
+ if (compressed > 1024) {
+ double ratio = (double) uncompressed / compressed;
+ if (ratio > MAX_THRESHOLD_RATIO) {
+ throw new IOException(
+ "Zip bomb detected: Compression ratio limits exceeded (" +
+ ratio + ")");
+ }
+ }
+ }
+ }
+
+ // Update its permissions (supported only on UNIX platforms)
+ if (entry.getPlatform() == ZipArchiveEntry.PLATFORM_UNIX) {
+ int mode = entry.getUnixMode();
+ if (mode != 0) { // 0 indicates non-unix platform
+ Set perms = getPermissions(mode);
+ try {
+ Files.setPosixFilePermissions(outputPath, perms);
+ }
+ catch (UnsupportedOperationException e) {
+ // ignore error...possibly on Windows
+ }
+ }
+ }
+
+ return stats.getUncompressedCount();
+ }
+ }
+
+ /**
+ * Converts Unix permissions to a set of {@link PosixFilePermission}s.
+ *
+ * @param unixMode integer representation of file permissions
+ * @return set of POSIX file permissions
+ */
+ private static Set getPermissions(int unixMode) {
+
+ Set permissions = new HashSet<>();
+
+ if ((unixMode & 0400) != 0) {
+ permissions.add(PosixFilePermission.OWNER_READ);
+ }
+ if ((unixMode & 0200) != 0) {
+ permissions.add(PosixFilePermission.OWNER_WRITE);
+ }
+ if ((unixMode & 0100) != 0) {
+ permissions.add(PosixFilePermission.OWNER_EXECUTE);
+ }
+ if ((unixMode & 0040) != 0) {
+ permissions.add(PosixFilePermission.GROUP_READ);
+ }
+ if ((unixMode & 0020) != 0) {
+ permissions.add(PosixFilePermission.GROUP_WRITE);
+ }
+ if ((unixMode & 0010) != 0) {
+ permissions.add(PosixFilePermission.GROUP_EXECUTE);
+ }
+ if ((unixMode & 0004) != 0) {
+ permissions.add(PosixFilePermission.OTHERS_READ);
+ }
+ if ((unixMode & 0002) != 0) {
+ permissions.add(PosixFilePermission.OTHERS_WRITE);
+ }
+ if ((unixMode & 0001) != 0) {
+ permissions.add(PosixFilePermission.OTHERS_EXECUTE);
+ }
+
+ return permissions;
+ }
+}
diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java
index 08ff753fb0..39a023d759 100644
--- a/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java
+++ b/Ghidra/Framework/Generic/src/main/java/ghidra/util/extensions/ExtensionUtils.java
@@ -16,20 +16,18 @@
package ghidra.util.extensions;
import java.io.*;
-import java.nio.file.Files;
-import java.nio.file.attribute.PosixFilePermission;
import java.util.*;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.io.FilenameUtils;
-import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import generic.jar.ResourceFile;
import ghidra.framework.Application;
import ghidra.util.Msg;
+import ghidra.util.SecureZipExtractor;
import ghidra.util.exception.CancelledException;
import ghidra.util.task.TaskMonitor;
import utilities.util.FileUtilities;
@@ -578,21 +576,8 @@ public class ExtensionUtils {
return false;
}
- try (ZipFile zipFile = new ZipFile.Builder().setFile(file).get()) {
-
- Enumeration entries = zipFile.getEntries();
- while (entries.hasMoreElements()) {
- monitor.checkCancelled();
-
- ZipArchiveEntry entry = entries.nextElement();
- File destination = FileUtilities.getSecureFile(installDirRoot, entry.getName());
- if (entry.isDirectory()) {
- destination.mkdirs();
- }
- else {
- writeZipEntryToFile(zipFile, entry, destination);
- }
- }
+ try {
+ SecureZipExtractor.extractSecurely(file, installDirRoot, monitor);
}
catch (IOException e) {
if (!FileUtilities.deleteDir(destinationFolder)) {
@@ -620,73 +605,4 @@ public class ExtensionUtils {
}
return false;
}
-
- private static void writeZipEntryToFile(ZipFile zFile, ZipArchiveEntry entry, File destination)
- throws IOException {
- try (OutputStream outputStream =
- new BufferedOutputStream(new FileOutputStream(destination))) {
-
- // Create the file at the new location...
- IOUtils.copy(zFile.getInputStream(entry), outputStream);
-
- // ...and update its permissions. But only continue if the zip was created on a unix
- //platform. If not, we cannot use the posix libraries to set permissions.
- if (entry.getPlatform() != ZipArchiveEntry.PLATFORM_UNIX) {
- return;
- }
-
- int mode = entry.getUnixMode();
- if (mode != 0) { // 0 indicates non-unix platform
- Set perms = getPermissions(mode);
- try {
- Files.setPosixFilePermissions(destination.toPath(), perms);
- }
- catch (UnsupportedOperationException e) {
- // Need to catch this, as Windows does not support the posix call. This is not
- // an error, however, and should just silently fail.
- }
- }
- }
- }
-
- /**
- * Converts Unix permissions to a set of {@link PosixFilePermission}s.
- *
- * @param unixMode integer representation of file permissions
- * @return set of POSIX file permissions
- */
- private static Set getPermissions(int unixMode) {
-
- Set permissions = new HashSet<>();
-
- if ((unixMode & 0400) != 0) {
- permissions.add(PosixFilePermission.OWNER_READ);
- }
- if ((unixMode & 0200) != 0) {
- permissions.add(PosixFilePermission.OWNER_WRITE);
- }
- if ((unixMode & 0100) != 0) {
- permissions.add(PosixFilePermission.OWNER_EXECUTE);
- }
- if ((unixMode & 0040) != 0) {
- permissions.add(PosixFilePermission.GROUP_READ);
- }
- if ((unixMode & 0020) != 0) {
- permissions.add(PosixFilePermission.GROUP_WRITE);
- }
- if ((unixMode & 0010) != 0) {
- permissions.add(PosixFilePermission.GROUP_EXECUTE);
- }
- if ((unixMode & 0004) != 0) {
- permissions.add(PosixFilePermission.OTHERS_READ);
- }
- if ((unixMode & 0002) != 0) {
- permissions.add(PosixFilePermission.OTHERS_WRITE);
- }
- if ((unixMode & 0001) != 0) {
- permissions.add(PosixFilePermission.OTHERS_EXECUTE);
- }
-
- return permissions;
- }
}
diff --git a/Ghidra/Framework/Gui/src/main/java/generic/theme/ThemeReader.java b/Ghidra/Framework/Gui/src/main/java/generic/theme/ThemeReader.java
index 7bd2072a40..12312e3bb8 100644
--- a/Ghidra/Framework/Gui/src/main/java/generic/theme/ThemeReader.java
+++ b/Ghidra/Framework/Gui/src/main/java/generic/theme/ThemeReader.java
@@ -17,13 +17,13 @@ package generic.theme;
import java.io.*;
import java.util.Enumeration;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipFile;
-import org.apache.commons.io.FileUtils;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipFile;
import ghidra.framework.Application;
import ghidra.util.Msg;
+import ghidra.util.SecureZipExtractor;
import utilities.util.FileUtilities;
/**
@@ -70,18 +70,18 @@ class ThemeReader extends AbstractThemeReader {
}
private GTheme readZipTheme() throws IOException {
- try (ZipFile zipFile = new ZipFile(file)) {
- Enumeration extends ZipEntry> entries = zipFile.entries();
+ try (ZipFile archive = ZipFile.builder().setFile(file).get()) {
+ Enumeration entries = archive.getEntries();
while (entries.hasMoreElements()) {
- ZipEntry entry = entries.nextElement();
+ ZipArchiveEntry entry = entries.nextElement();
String name = entry.getName();
- try (InputStream is = zipFile.getInputStream(entry)) {
- if (name.endsWith(".theme")) {
+ if (name.endsWith(".theme")) {
+ try (InputStream is = archive.getInputStream(entry)) {
processThemeData(name, is);
}
- else {
- processIconFile(name, is);
- }
+ }
+ else {
+ processIconFile(name, entry, archive);
}
}
}
@@ -131,7 +131,8 @@ class ThemeReader extends AbstractThemeReader {
"Custom sections not allowed in theme files! " + section.getName());
}
- private void processIconFile(String path, InputStream is) throws IOException {
+ private void processIconFile(String path, ZipArchiveEntry entry, ZipFile archive)
+ throws IOException {
int indexOf = path.indexOf("images/");
if (indexOf < 0) {
Msg.error(this, "Unknown file: " + path);
@@ -147,7 +148,8 @@ class ThemeReader extends AbstractThemeReader {
String relativePath = path.substring(indexOf, path.length());
File dir = Application.getUserSettingsDirectory();
File iconFile = FileUtilities.getSecureFile(dir, relativePath);
- FileUtils.copyInputStreamToFile(is, iconFile);
+ iconFile.getParentFile().mkdirs();
+ SecureZipExtractor.extractSecurely(archive, entry, iconFile);
}
private void processThemeData(String name, InputStream is) throws IOException {