feat: custom zip reader implementation to fight tampering

fix(zip): use size info from CD if LFH entry is incorrect

refactor: move custom zip implementation into new module

feat: move ZipSecurity into jadx-zip module
This commit is contained in:
Skylot
2025-02-23 20:51:28 +00:00
parent 5d720dd29c
commit d84f0389ec
54 changed files with 1557 additions and 514 deletions
@@ -0,0 +1,48 @@
package jadx.cli;
import java.util.Set;
import jadx.api.JadxArgs;
import jadx.api.security.JadxSecurityFlag;
import jadx.api.security.impl.JadxSecurity;
import jadx.commons.app.JadxCommonEnv;
import jadx.zip.security.DisabledZipSecurity;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.JadxZipSecurity;
public class JadxAppCommon {
public static void applyEnvVars(JadxArgs jadxArgs) {
Set<JadxSecurityFlag> flags = JadxSecurityFlag.all();
IJadxZipSecurity zipSecurity;
boolean disableXmlSecurity = JadxCommonEnv.getBool("JADX_DISABLE_XML_SECURITY", false);
if (disableXmlSecurity) {
flags.remove(JadxSecurityFlag.SECURE_XML_PARSER);
// TODO: not related to 'xml security', but kept for compatibility
flags.remove(JadxSecurityFlag.VERIFY_APP_PACKAGE);
}
boolean disableZipSecurity = JadxCommonEnv.getBool("JADX_DISABLE_ZIP_SECURITY", false);
if (disableZipSecurity) {
flags.remove(JadxSecurityFlag.SECURE_ZIP_READER);
zipSecurity = DisabledZipSecurity.INSTANCE;
} else {
JadxZipSecurity jadxZipSecurity = new JadxZipSecurity();
int maxZipEntriesCount = JadxCommonEnv.getInt("JADX_ZIP_MAX_ENTRIES_COUNT", -2);
if (maxZipEntriesCount != -2) {
jadxZipSecurity.setMaxEntriesCount(maxZipEntriesCount);
}
int zipBombMinUncompressedSize = JadxCommonEnv.getInt("JADX_ZIP_BOMB_MIN_UNCOMPRESSED_SIZE", -2);
if (zipBombMinUncompressedSize != -2) {
jadxZipSecurity.setZipBombMinUncompressedSize(zipBombMinUncompressedSize);
}
int setZipBombDetectionFactor = JadxCommonEnv.getInt("JADX_ZIP_BOMB_DETECTION_FACTOR", -2);
if (setZipBombDetectionFactor != -2) {
jadxZipSecurity.setZipBombDetectionFactor(setZipBombDetectionFactor);
}
zipSecurity = jadxZipSecurity;
}
jadxArgs.setSecurity(new JadxSecurity(flags, zipSecurity));
}
}
+1 -22
View File
@@ -1,7 +1,5 @@
package jadx.cli;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -10,11 +8,8 @@ import jadx.api.JadxDecompiler;
import jadx.api.impl.AnnotatedCodeWriter;
import jadx.api.impl.NoOpCodeCache;
import jadx.api.impl.SimpleCodeWriter;
import jadx.api.security.JadxSecurityFlag;
import jadx.api.security.impl.JadxSecurity;
import jadx.cli.LogHelper.LogLevelEnum;
import jadx.cli.plugins.JadxFilesGetter;
import jadx.commons.app.JadxCommonEnv;
import jadx.core.utils.exceptions.JadxArgsValidateException;
import jadx.plugins.tools.JadxExternalPluginsLoader;
@@ -54,7 +49,7 @@ public class JadxCLI {
jadxArgs.setPluginLoader(new JadxExternalPluginsLoader());
jadxArgs.setFilesGetter(JadxFilesGetter.INSTANCE);
initCodeWriterProvider(jadxArgs);
applyEnvVars(jadxArgs);
JadxAppCommon.applyEnvVars(jadxArgs);
try (JadxDecompiler jadx = new JadxDecompiler(jadxArgs)) {
jadx.load();
if (checkForErrors(jadx)) {
@@ -87,22 +82,6 @@ public class JadxCLI {
}
}
private static void applyEnvVars(JadxArgs jadxArgs) {
Set<JadxSecurityFlag> flags = JadxSecurityFlag.all();
boolean modified = false;
boolean disableXmlSecurity = JadxCommonEnv.getBool("JADX_DISABLE_XML_SECURITY", false);
if (disableXmlSecurity) {
flags.remove(JadxSecurityFlag.SECURE_XML_PARSER);
// TODO: not related to 'xml security', but kept for compatibility
flags.remove(JadxSecurityFlag.VERIFY_APP_PACKAGE);
modified = true;
}
// TODO: migrate 'ZipSecurity'
if (modified) {
jadxArgs.setSecurity(new JadxSecurity(flags));
}
}
private static boolean checkForErrors(JadxDecompiler jadx) {
if (jadx.getRoot().getClasses().isEmpty()) {
if (jadx.getArgs().isSkipResources()) {
@@ -10,7 +10,6 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -18,8 +17,10 @@ import org.slf4j.LoggerFactory;
import jadx.api.JadxArgs;
import jadx.core.dex.nodes.RootNode;
import jadx.core.utils.android.TextResMapFile;
import jadx.core.utils.files.ZipFile;
import jadx.core.xmlgen.ResTableBinaryParser;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
import jadx.zip.ZipReader;
import static jadx.core.utils.files.FileUtils.expandDirs;
@@ -53,18 +54,19 @@ public class ConvertArscFile {
LOG.info("Input entries count: {}", resMap.size());
RootNode root = new RootNode(new JadxArgs()); // not really needed
ZipReader zipReader = new ZipReader();
rewritesCount = 0;
for (Path resFile : inputResFiles) {
ResTableBinaryParser resTableParser = new ResTableBinaryParser(root, true);
if (resFile.getFileName().toString().endsWith(".jar")) {
// Load resources.arsc from android.jar
try (ZipFile zip = new ZipFile(resFile.toFile())) {
ZipEntry entry = zip.getEntry("resources.arsc");
try (ZipContent zip = zipReader.open(resFile.toFile())) {
IZipEntry entry = zip.searchEntry("resources.arsc");
if (entry == null) {
LOG.error("Failed to load \"resources.arsc\" from {}", resFile);
continue;
}
try (InputStream inputStream = zip.getInputStream(entry)) {
try (InputStream inputStream = entry.getInputStream()) {
resTableParser.decode(inputStream);
}
}
+3
View File
@@ -0,0 +1,3 @@
## jadx zip
Custom zip reader implementation to fight tampering and provide additional security checks
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("jadx-library")
}
@@ -0,0 +1,36 @@
package jadx.zip;
import java.io.File;
import java.io.InputStream;
public interface IZipEntry {
/**
* Zip entry name
*/
String getName();
/**
* Uncompressed bytes
*/
byte[] getBytes();
/**
* Stream of uncompressed bytes.
*/
InputStream getInputStream();
long getCompressedSize();
long getUncompressedSize();
boolean isDirectory();
File getZipFile();
/**
* Return true if {@link #getBytes()} method is more optimal to use other than
* {@link #getInputStream()}
*/
boolean preferBytes();
}
@@ -0,0 +1,9 @@
package jadx.zip;
import java.io.Closeable;
import java.io.IOException;
public interface IZipParser extends Closeable {
ZipContent open() throws IOException;
}
@@ -0,0 +1,35 @@
package jadx.zip;
import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Nullable;
public class ZipContent implements Closeable {
private final IZipParser zipParser;
private final List<IZipEntry> entries;
private final Map<String, IZipEntry> entriesMap;
public ZipContent(IZipParser zipParser, List<IZipEntry> entries) {
this.zipParser = zipParser;
this.entries = entries;
this.entriesMap = entries.stream().collect(Collectors.toMap(IZipEntry::getName, Function.identity()));
}
public List<IZipEntry> getEntries() {
return entries;
}
public @Nullable IZipEntry searchEntry(String fileName) {
return entriesMap.get(fileName);
}
@Override
public void close() throws IOException {
zipParser.close();
}
}
@@ -0,0 +1,111 @@
package jadx.zip;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.jetbrains.annotations.Nullable;
import jadx.zip.fallback.FallbackZipParser;
import jadx.zip.parser.JadxZipParser;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.JadxZipSecurity;
/**
* Jadx wrapper to provide custom zip parser ({@link JadxZipParser})
* with fallback to default Java implementation.
*/
public class ZipReader {
private final ZipReaderOptions options;
public ZipReader() {
this(ZipReaderOptions.getDefault());
}
public ZipReader(Set<ZipReaderFlags> flags) {
this(new ZipReaderOptions(new JadxZipSecurity(), flags));
}
public ZipReader(IJadxZipSecurity security) {
this(new ZipReaderOptions(security, ZipReaderFlags.none()));
}
public ZipReader(ZipReaderOptions options) {
this.options = options;
}
@SuppressWarnings("resource")
public ZipContent open(File zipFile) throws IOException {
try {
JadxZipParser jadxParser = new JadxZipParser(zipFile, options);
IZipParser detectedParser = detectParser(zipFile, jadxParser);
if (detectedParser != jadxParser) {
jadxParser.close();
}
return detectedParser.open();
} catch (Exception e) {
if (options.getFlags().contains(ZipReaderFlags.DONT_USE_FALLBACK)) {
throw new IOException("Failed to open zip: " + zipFile, e);
}
// switch to fallback parser
return buildFallbackParser(zipFile).open();
}
}
/**
* Visit valid entries in a zip file.
* Return not null value from visitor to stop iteration.
*/
public <R> @Nullable R visitEntries(File file, Function<IZipEntry, R> visitor) {
try (ZipContent content = open(file)) {
for (IZipEntry entry : content.getEntries()) {
R result = visitor.apply(entry);
if (result != null) {
return result;
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to process zip file: " + file.getAbsolutePath(), e);
}
return null;
}
public void readEntries(File file, BiConsumer<IZipEntry, InputStream> visitor) {
visitEntries(file, entry -> {
if (!entry.isDirectory()) {
try (InputStream in = entry.getInputStream()) {
visitor.accept(entry, in);
} catch (Exception e) {
throw new RuntimeException("Failed to process zip entry: " + entry, e);
}
}
return null;
});
}
public ZipReaderOptions getOptions() {
return options;
}
private IZipParser detectParser(File zipFile, JadxZipParser jadxParser) {
if (zipFile.getName().endsWith(".apk")
|| options.getFlags().contains(ZipReaderFlags.DONT_USE_FALLBACK)) {
return jadxParser;
}
if (!jadxParser.canOpen()) {
return buildFallbackParser(zipFile);
}
// default
if (options.getFlags().contains(ZipReaderFlags.FALLBACK_AS_DEFAULT)) {
return buildFallbackParser(zipFile);
}
return jadxParser;
}
private FallbackZipParser buildFallbackParser(File zipFile) {
return new FallbackZipParser(zipFile, options);
}
}
@@ -0,0 +1,32 @@
package jadx.zip;
import java.util.EnumSet;
import java.util.Set;
public enum ZipReaderFlags {
/**
* Search all local file headers by signature without reading
* 'central directory' and 'end of central directory' entries
*/
IGNORE_CENTRAL_DIR_ENTRIES,
/**
* Enable additional checks to verify zip data and report possible tampering
*/
REPORT_TAMPERING,
/**
* Use fallback (java built-in implementation) parser as default.
* Custom implementation will be used for '*.apk' files only.
*/
FALLBACK_AS_DEFAULT,
/**
* Use only jadx custom parser and do not switch to fallback on errors.
*/
DONT_USE_FALLBACK;
public static Set<ZipReaderFlags> none() {
return EnumSet.noneOf(ZipReaderFlags.class);
}
}
@@ -0,0 +1,29 @@
package jadx.zip;
import java.util.Set;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.JadxZipSecurity;
public class ZipReaderOptions {
public static ZipReaderOptions getDefault() {
return new ZipReaderOptions(new JadxZipSecurity(), ZipReaderFlags.none());
}
private final IJadxZipSecurity zipSecurity;
private final Set<ZipReaderFlags> flags;
public ZipReaderOptions(IJadxZipSecurity zipSecurity, Set<ZipReaderFlags> flags) {
this.zipSecurity = zipSecurity;
this.flags = flags;
}
public IJadxZipSecurity getZipSecurity() {
return zipSecurity;
}
public Set<ZipReaderFlags> getFlags() {
return flags;
}
}
@@ -0,0 +1,61 @@
package jadx.zip.fallback;
import java.io.File;
import java.io.InputStream;
import java.util.zip.ZipEntry;
import jadx.zip.IZipEntry;
public class FallbackZipEntry implements IZipEntry {
private final FallbackZipParser parser;
private final ZipEntry zipEntry;
public FallbackZipEntry(FallbackZipParser parser, ZipEntry zipEntry) {
this.parser = parser;
this.zipEntry = zipEntry;
}
public ZipEntry getZipEntry() {
return zipEntry;
}
@Override
public String getName() {
return zipEntry.getName();
}
@Override
public boolean preferBytes() {
return false;
}
@Override
public byte[] getBytes() {
return parser.getBytes(this);
}
@Override
public InputStream getInputStream() {
return parser.getInputStream(this);
}
@Override
public long getCompressedSize() {
return zipEntry.getCompressedSize();
}
@Override
public long getUncompressedSize() {
return zipEntry.getSize();
}
@Override
public boolean isDirectory() {
return zipEntry.isDirectory();
}
@Override
public File getZipFile() {
return parser.getZipFile();
}
}
@@ -0,0 +1,109 @@
package jadx.zip.fallback;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.zip.IZipEntry;
import jadx.zip.IZipParser;
import jadx.zip.ZipContent;
import jadx.zip.ZipReaderOptions;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.LimitedInputStream;
public class FallbackZipParser implements IZipParser {
private static final Logger LOG = LoggerFactory.getLogger(FallbackZipParser.class);
private final File file;
private final IJadxZipSecurity zipSecurity;
private final boolean useLimitedDataStream;
private ZipFile zipFile;
public FallbackZipParser(File file, ZipReaderOptions options) {
this.file = file;
this.zipSecurity = options.getZipSecurity();
this.useLimitedDataStream = zipSecurity.useLimitedDataStream();
}
@Override
public ZipContent open() throws IOException {
zipFile = new ZipFile(file);
int maxEntriesCount = zipSecurity.getMaxEntriesCount();
if (maxEntriesCount == -1) {
maxEntriesCount = Integer.MAX_VALUE;
}
List<IZipEntry> list = new ArrayList<>();
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
FallbackZipEntry zipEntry = new FallbackZipEntry(this, entries.nextElement());
if (isValidEntry(zipEntry)) {
list.add(zipEntry);
if (list.size() > maxEntriesCount) {
throw new IllegalStateException("Max entries count limit exceeded: " + list.size());
}
}
}
return new ZipContent(this, list);
}
private boolean isValidEntry(IZipEntry zipEntry) {
boolean validEntry = zipSecurity.isValidEntry(zipEntry);
if (!validEntry) {
LOG.warn("Zip entry '{}' is invalid and excluded from processing", zipEntry);
}
return validEntry;
}
public byte[] getBytes(FallbackZipEntry entry) {
try (InputStream is = getEntryStream(entry)) {
return is.readAllBytes();
} catch (Exception e) {
throw new RuntimeException("Failed to read bytes for entry: " + entry.getName(), e);
}
}
public InputStream getInputStream(FallbackZipEntry entry) {
try {
return getEntryStream(entry);
} catch (Exception e) {
throw new RuntimeException("Failed to open input stream for entry: " + entry.getName(), e);
}
}
private InputStream getEntryStream(FallbackZipEntry entry) throws IOException {
InputStream entryStream = zipFile.getInputStream(entry.getZipEntry());
InputStream stream;
if (useLimitedDataStream) {
stream = new LimitedInputStream(entryStream, entry.getUncompressedSize());
} else {
stream = entryStream;
}
return new BufferedInputStream(stream);
}
public File getZipFile() {
return file;
}
@Override
public void close() throws IOException {
try {
if (zipFile != null) {
zipFile.close();
}
} finally {
zipFile = null;
}
}
}
@@ -0,0 +1,93 @@
package jadx.zip.parser;
import java.io.File;
import java.io.InputStream;
import jadx.zip.IZipEntry;
public final class JadxZipEntry implements IZipEntry {
private final JadxZipParser parser;
private final String fileName;
private final int compressMethod;
private final int entryStart;
private final int dataStart;
private final long compressedSize;
private final long uncompressedSize;
JadxZipEntry(JadxZipParser parser, String fileName, int entryStart, int dataStart,
int compressMethod, long compressedSize, long uncompressedSize) {
this.parser = parser;
this.fileName = fileName;
this.entryStart = entryStart;
this.dataStart = dataStart;
this.compressMethod = compressMethod;
this.compressedSize = compressedSize;
this.uncompressedSize = uncompressedSize;
}
public boolean isSizesValid() {
if (compressedSize <= 0) {
return false;
}
if (uncompressedSize <= 0) {
return false;
}
return compressedSize <= uncompressedSize;
}
public String getName() {
return fileName;
}
@Override
public long getCompressedSize() {
return compressedSize;
}
@Override
public long getUncompressedSize() {
return uncompressedSize;
}
@Override
public boolean isDirectory() {
return fileName.endsWith("/");
}
@Override
public boolean preferBytes() {
return true;
}
@Override
public byte[] getBytes() {
return parser.getBytes(this);
}
@Override
public InputStream getInputStream() {
return parser.getInputStream(this);
}
public int getEntryStart() {
return entryStart;
}
public int getDataStart() {
return dataStart;
}
public int getCompressMethod() {
return compressMethod;
}
@Override
public File getZipFile() {
return parser.getZipFile();
}
@Override
public String toString() {
return parser.getZipFile().getName() + ':' + fileName;
}
}
@@ -0,0 +1,403 @@
package jadx.zip.parser;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.zip.IZipEntry;
import jadx.zip.IZipParser;
import jadx.zip.ZipContent;
import jadx.zip.ZipReaderFlags;
import jadx.zip.ZipReaderOptions;
import jadx.zip.fallback.FallbackZipParser;
import jadx.zip.security.IJadxZipSecurity;
/**
* Custom and simple zip parser to fight tampering.
* Many zip features aren't supported:
* - Compression methods other than STORE or DEFLATE
* - Zip64
* - Checksum verification
* - Multi file archives
*/
public final class JadxZipParser implements IZipParser {
private static final Logger LOG = LoggerFactory.getLogger(JadxZipParser.class);
private static final byte LOCAL_FILE_HEADER_START = 0x50;
private static final int LOCAL_FILE_HEADER_SIGN = 0x04034b50;
private static final int CD_SIGN = 0x02014b50;
private static final int END_OF_CD_SIGN = 0x06054b50;
private final File zipFile;
private final ZipReaderOptions options;
private final IJadxZipSecurity zipSecurity;
private final Set<ZipReaderFlags> flags;
private final boolean verify;
private RandomAccessFile file;
private FileChannel fileChannel;
private ByteBuffer byteBuffer;
private int endOfCDStart = -2;
private @Nullable ZipContent fallbackZipContent;
public JadxZipParser(File zipFile, ZipReaderOptions options) {
this.zipFile = zipFile;
this.options = options;
this.zipSecurity = options.getZipSecurity();
this.flags = options.getFlags();
this.verify = options.getFlags().contains(ZipReaderFlags.REPORT_TAMPERING);
}
@Override
public ZipContent open() throws IOException {
load();
try {
int maxEntriesCount = zipSecurity.getMaxEntriesCount();
if (maxEntriesCount == -1) {
maxEntriesCount = Integer.MAX_VALUE;
}
List<IZipEntry> entries;
if (flags.contains(ZipReaderFlags.IGNORE_CENTRAL_DIR_ENTRIES)) {
entries = searchLocalFileHeaders(maxEntriesCount);
} else {
entries = loadFromCentralDirs(maxEntriesCount);
}
return new ZipContent(this, entries);
} catch (Exception e) {
if (flags.contains(ZipReaderFlags.DONT_USE_FALLBACK)) {
throw new IOException("Failed to open zip: " + zipFile + ", error: " + e.getMessage(), e);
}
LOG.warn("Zip open failed, switching to fallback parser, zip: {}", zipFile, e);
return initFallbackParser();
}
}
@SuppressWarnings("RedundantIfStatement")
public boolean canOpen() {
try {
load();
int eocdStart = searchEndOfCDStart();
ByteBuffer buf = byteBuffer;
buf.position(eocdStart + 4);
int diskNum = readU2(buf);
if (diskNum == 0xFFFF) {
// Zip64
return false;
}
return true;
} catch (Exception e) {
LOG.warn("Jadx parser can't open zip file: {}", zipFile, e);
return false;
}
}
private boolean isValidEntry(JadxZipEntry zipEntry) {
boolean validEntry = zipSecurity.isValidEntry(zipEntry);
if (!validEntry) {
LOG.warn("Zip entry '{}' is invalid and excluded from processing", zipEntry);
}
return validEntry;
}
private void load() throws IOException {
if (byteBuffer != null) {
// already loaded
return;
}
file = new RandomAccessFile(zipFile, "r");
long size = file.length();
if (size >= Integer.MAX_VALUE) {
throw new IOException("Zip file is too big");
}
int fileLen = (int) size;
if (fileLen < 100 * 1024 * 1024) {
// load files smaller than 100MB directly into memory
byte[] bytes = new byte[fileLen];
file.readFully(bytes);
byteBuffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer();
file.close();
file = null;
} else {
// for big files - use a memory mapped file
fileChannel = file.getChannel();
byteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
}
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
}
private List<IZipEntry> searchLocalFileHeaders(int maxEntriesCount) {
List<IZipEntry> entries = new ArrayList<>();
while (true) {
int start = searchEntryStart();
if (start == -1) {
return entries;
}
JadxZipEntry zipEntry = loadFileEntry(start);
if (isValidEntry(zipEntry)) {
entries.add(zipEntry);
if (entries.size() > maxEntriesCount) {
throw new IllegalStateException("Max entries count limit exceeded: " + entries.size());
}
}
}
}
private List<IZipEntry> loadFromCentralDirs(int maxEntriesCount) throws IOException {
int eocdStart = searchEndOfCDStart();
if (eocdStart < 0) {
throw new RuntimeException("End of central directory not found");
}
ByteBuffer buf = byteBuffer;
buf.position(eocdStart + 10);
int entriesCount = readU2(buf);
buf.position(eocdStart + 16);
int cdOffset = buf.getInt();
if (entriesCount > maxEntriesCount) {
throw new IllegalStateException("Max entries count limit exceeded: " + entriesCount);
}
List<IZipEntry> entries = new ArrayList<>(entriesCount);
buf.position(cdOffset);
for (int i = 0; i < entriesCount; i++) {
JadxZipEntry zipEntry = loadCDEntry();
if (isValidEntry(zipEntry)) {
entries.add(zipEntry);
}
}
return entries;
}
private JadxZipEntry loadCDEntry() {
ByteBuffer buf = byteBuffer;
int start = buf.position();
buf.position(start + 28);
int fileNameLen = readU2(buf);
int extraFieldLen = readU2(buf);
int commentLen = readU2(buf);
buf.position(start + 42);
int fileEntryStart = buf.getInt();
int entryEnd = start + 46 + fileNameLen + extraFieldLen + commentLen;
JadxZipEntry entry = loadFileEntry(fileEntryStart);
if (verify) {
compareCDAndLFH(buf, start, entry);
}
if (!entry.isSizesValid()) {
entry = fixEntryFromCD(entry, start);
}
buf.position(entryEnd);
return entry;
}
private JadxZipEntry fixEntryFromCD(JadxZipEntry entry, int start) {
ByteBuffer buf = byteBuffer;
buf.position(start + 10);
int comprMethod = readU2(buf);
buf.position(start + 20);
int comprSize = buf.getInt();
int unComprSize = buf.getInt();
return new JadxZipEntry(this, entry.getName(), start, entry.getDataStart(), comprMethod, comprSize, unComprSize);
}
private static void compareCDAndLFH(ByteBuffer buf, int start, JadxZipEntry entry) {
buf.position(start + 10);
int comprMethod = readU2(buf);
if (comprMethod != entry.getCompressMethod()) {
LOG.warn("Compression method differ in CD {} and LFH {} for {}",
comprMethod, entry.getCompressMethod(), entry);
}
buf.position(start + 20);
int comprSize = buf.getInt();
int unComprSize = buf.getInt();
if (comprSize != entry.getCompressedSize()) {
LOG.warn("Compressed size differ in CD {} and LFH {} for {}",
comprSize, entry.getCompressedSize(), entry);
}
if (unComprSize != entry.getUncompressedSize()) {
LOG.warn("Uncompressed size differ in CD {} and LFH {} for {}",
unComprSize, entry.getUncompressedSize(), entry);
}
}
private JadxZipEntry loadFileEntry(int start) {
ByteBuffer buf = byteBuffer;
buf.position(start + 8);
int comprMethod = readU2(buf);
buf.position(start + 18);
int comprSize = buf.getInt();
int unComprSize = buf.getInt();
int fileNameLen = readU2(buf);
int extraFieldLen = readU2(buf);
String fileName = readString(buf, fileNameLen);
int dataStart = start + 30 + fileNameLen + extraFieldLen;
buf.position(dataStart + comprSize);
return new JadxZipEntry(this, fileName, start, dataStart, comprMethod, comprSize, unComprSize);
}
private int searchEndOfCDStart() throws IOException {
if (endOfCDStart != -2) {
return endOfCDStart;
}
ByteBuffer buf = byteBuffer;
int pos = buf.limit() - 22;
int minPos = Math.max(0, pos - 0xffff);
while (true) {
buf.position(pos);
int sign = buf.getInt();
if (sign == END_OF_CD_SIGN) {
endOfCDStart = pos;
return pos;
}
pos--;
if (pos < minPos) {
throw new IOException("End of central directory record not found");
}
}
}
private int searchEntryStart() {
ByteBuffer buf = byteBuffer;
while (true) {
int start = buf.position();
if (start + 4 > buf.limit()) {
return -1;
}
byte b = buf.get();
if (b == LOCAL_FILE_HEADER_START) {
buf.position(start);
int sign = buf.getInt();
if (sign == LOCAL_FILE_HEADER_SIGN) {
return start;
}
}
}
}
InputStream getInputStream(JadxZipEntry entry) {
return new ByteArrayInputStream(getBytes(entry));
}
synchronized byte[] getBytes(JadxZipEntry entry) {
int compressMethod = entry.getCompressMethod();
if (verify) {
if (compressMethod == 0) {
if (entry.getCompressedSize() != entry.getUncompressedSize()) {
LOG.warn("Not equal sizes for STORE method: compressed: {}, uncompressed: {}, entry: {}",
entry.getCompressedSize(), entry.getUncompressedSize(), entry);
}
} else if (compressMethod != 8) {
LOG.warn("Unknown compress method: {} in entry: {}", compressMethod, entry);
}
}
if (compressMethod == 8) {
try {
return ZipDeflate.decompressEntryToBytes(byteBuffer, entry);
} catch (Exception e) {
if (isEncrypted(entry)) {
throw new RuntimeException("Entry is encrypted, failed to decompress: " + entry, e);
}
if (flags.contains(ZipReaderFlags.DONT_USE_FALLBACK)) {
throw new RuntimeException("Failed to decompress zip entry: " + entry + ", error: " + e.getMessage(), e);
}
LOG.warn("Entry '{}' parse failed, switching to fallback parser", entry, e);
return useFallbackParser(entry);
}
}
// treat any other compression methods values as UNCOMPRESSED
return bufferToBytes(entry.getDataStart(), (int) entry.getUncompressedSize());
}
@SuppressWarnings("resource")
private byte[] useFallbackParser(JadxZipEntry entry) {
IZipEntry zipEntry = initFallbackParser().searchEntry(entry.getName());
if (zipEntry == null) {
throw new RuntimeException("Fallback parser can't find entry: " + entry);
}
return zipEntry.getBytes();
}
@SuppressWarnings("resource")
private ZipContent initFallbackParser() {
if (fallbackZipContent == null) {
try {
fallbackZipContent = new FallbackZipParser(zipFile, options).open();
} catch (Exception e) {
throw new RuntimeException("Fallback parser failed to open file: " + zipFile, e);
}
}
return fallbackZipContent;
}
private boolean isEncrypted(JadxZipEntry entry) {
int flags = readFlags(entry);
return (flags & 1) != 0;
}
private int readFlags(JadxZipEntry entry) {
ByteBuffer buf = byteBuffer;
buf.position(entry.getEntryStart() + 6);
return readU2(buf);
}
byte[] bufferToBytes(int start, int size) {
ByteBuffer buf = byteBuffer;
byte[] data = new byte[size];
buf.position(start);
buf.get(data);
return data;
}
private static int readU2(ByteBuffer buf) {
return buf.getShort() & 0xFFFF;
}
private static String readString(ByteBuffer buf, int fileNameLen) {
byte[] bytes = new byte[fileNameLen];
buf.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
@Override
public void close() throws IOException {
try {
if (fileChannel != null) {
fileChannel.close();
}
if (file != null) {
file.close();
}
if (fallbackZipContent != null) {
fallbackZipContent.close();
}
} finally {
fileChannel = null;
file = null;
byteBuffer = null;
endOfCDStart = -2;
fallbackZipContent = null;
}
}
public File getZipFile() {
return zipFile;
}
@Override
public String toString() {
return "JadxZipParser{" + zipFile + '}';
}
}
@@ -0,0 +1,24 @@
package jadx.zip.parser;
import java.nio.ByteBuffer;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
final class ZipDeflate {
static byte[] decompressEntryToBytes(ByteBuffer buf, JadxZipEntry entry) throws DataFormatException {
buf.position(entry.getDataStart());
ByteBuffer entryBuf = buf.slice();
entryBuf.limit((int) entry.getCompressedSize());
byte[] out = new byte[(int) entry.getUncompressedSize()];
Inflater inflater = new Inflater(true);
inflater.setInput(entryBuf);
int written = inflater.inflate(out);
if (written != out.length) {
throw new DataFormatException("Unexpected size of decompressed entry: " + entry
+ ", got: " + written + ", expected: " + out.length);
}
inflater.end();
return out;
}
}
@@ -0,0 +1,35 @@
package jadx.zip.security;
import java.io.File;
import jadx.zip.IZipEntry;
public class DisabledZipSecurity implements IJadxZipSecurity {
public static final DisabledZipSecurity INSTANCE = new DisabledZipSecurity();
@Override
public boolean isValidEntry(IZipEntry entry) {
return true;
}
@Override
public boolean isValidEntryName(String entryName) {
return true;
}
@Override
public boolean isInSubDirectory(File baseDir, File file) {
return true;
}
@Override
public boolean useLimitedDataStream() {
return false;
}
@Override
public int getMaxEntriesCount() {
return -1;
}
}
@@ -0,0 +1,35 @@
package jadx.zip.security;
import java.io.File;
import jadx.zip.IZipEntry;
public interface IJadxZipSecurity {
/**
* Check if zip entry is valid and safe to process
*/
boolean isValidEntry(IZipEntry entry);
/**
* Check if the zip entry name is valid.
* This check should be part of {@link #isValidEntry(IZipEntry)} method.
*/
boolean isValidEntryName(String entryName);
/**
* Use limited InputStream for entry uncompressed data
*/
boolean useLimitedDataStream();
/**
* Max entries count expected in a zip file, fail zip open if the limit exceeds.
* Return -1 to disable entries count check.
*/
int getMaxEntriesCount();
/**
* Check if a file will be inside baseDir after a system resolves its path
*/
boolean isInSubDirectory(File baseDir, File file);
}
@@ -0,0 +1,126 @@
package jadx.zip.security;
import java.io.File;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.zip.IZipEntry;
public class JadxZipSecurity implements IJadxZipSecurity {
private static final Logger LOG = LoggerFactory.getLogger(JadxZipSecurity.class);
private static final File CWD = getCWD();
/**
* The size of uncompressed zip entry shouldn't be bigger of compressed in zipBombDetectionFactor
* times
*/
private int zipBombDetectionFactor = 100;
/**
* Zip entries that have an uncompressed size of less than zipBombMinUncompressedSize are considered
* safe
*/
private int zipBombMinUncompressedSize = 25 * 1024 * 1024;
private int maxEntriesCount = 100_000;
@Override
public boolean isValidEntry(IZipEntry entry) {
return isValidEntryName(entry.getName()) && !isZipBomb(entry);
}
@Override
public boolean useLimitedDataStream() {
return false;
}
@Override
public int getMaxEntriesCount() {
return maxEntriesCount;
}
/**
* Checks that entry name contains no any traversals and prevents cases like "../classes.dex",
* to limit output only to the specified directory
*/
@Override
public boolean isValidEntryName(String entryName) {
if (entryName.contains("..")) { // quick pre-check
if (entryName.contains("../") || entryName.contains("..\\")) {
LOG.error("Path traversal attack detected in entry: '{}'", entryName);
return false;
}
}
try {
File currentPath = CWD;
File canonical = new File(currentPath, entryName).getCanonicalFile();
if (isInSubDirectoryInternal(currentPath, canonical)) {
return true;
}
} catch (Exception e) {
// check failed
}
LOG.error("Invalid file name or path traversal attack detected: {}", entryName);
return false;
}
@Override
public boolean isInSubDirectory(File baseDir, File file) {
try {
return isInSubDirectoryInternal(baseDir.getCanonicalFile(), file.getCanonicalFile());
} catch (IOException e) {
return false;
}
}
public boolean isZipBomb(IZipEntry entry) {
long compressedSize = entry.getCompressedSize();
long uncompressedSize = entry.getUncompressedSize();
boolean invalidSize = compressedSize < 0 || uncompressedSize < 0;
boolean possibleZipBomb = uncompressedSize >= zipBombMinUncompressedSize
&& compressedSize * zipBombDetectionFactor < uncompressedSize;
if (invalidSize || possibleZipBomb) {
LOG.error("Potential zip bomb attack detected, invalid sizes: compressed {}, uncompressed {}, name {}",
compressedSize, uncompressedSize, entry.getName());
return true;
}
return false;
}
private static boolean isInSubDirectoryInternal(File baseDir, File file) {
File current = file;
while (true) {
if (current == null) {
return false;
}
if (current.equals(baseDir)) {
return true;
}
current = current.getParentFile();
}
}
public void setMaxEntriesCount(int maxEntriesCount) {
this.maxEntriesCount = maxEntriesCount;
}
public void setZipBombDetectionFactor(int zipBombDetectionFactor) {
this.zipBombDetectionFactor = zipBombDetectionFactor;
}
public void setZipBombMinUncompressedSize(int zipBombMinUncompressedSize) {
this.zipBombMinUncompressedSize = zipBombMinUncompressedSize;
}
private static File getCWD() {
try {
return new File(".").getCanonicalFile();
} catch (IOException e) {
throw new RuntimeException("Failed to init current working dir constant", e);
}
}
}
@@ -1,21 +1,21 @@
package jadx.api.plugins.utils;
package jadx.zip.security;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
public class LimitedInputStream extends FilterInputStream {
private final long maxSize;
private long currentPos;
protected LimitedInputStream(InputStream in, long maxSize) {
public LimitedInputStream(InputStream in, long maxSize) {
super(in);
this.maxSize = maxSize;
}
private void checkPos() {
private void addAndCheckPos(long count) {
currentPos += count;
if (currentPos > maxSize) {
throw new IllegalStateException("Read limit exceeded");
}
@@ -25,18 +25,17 @@ public class LimitedInputStream extends FilterInputStream {
public int read() throws IOException {
int data = super.read();
if (data != -1) {
currentPos++;
checkPos();
addAndCheckPos(1);
}
return data;
}
@SuppressWarnings("NullableProblems")
@Override
public int read(byte[] b, int off, int len) throws IOException {
int count = super.read(b, off, len);
if (count > 0) {
currentPos += count;
checkPos();
addAndCheckPos(count);
}
return count;
}
@@ -44,10 +43,14 @@ public class LimitedInputStream extends FilterInputStream {
@Override
public long skip(long n) throws IOException {
long skipped = super.skip(n);
if (skipped != 0) {
currentPos += skipped;
checkPos();
if (skipped > 0) {
addAndCheckPos(skipped);
}
return skipped;
}
@Override
public boolean markSupported() {
return false;
}
}
+1
View File
@@ -4,6 +4,7 @@ plugins {
dependencies {
api(project(":jadx-plugins:jadx-input-api"))
api(project(":jadx-commons:jadx-zip"))
implementation("com.google.code.gson:gson:2.11.0")
@@ -50,9 +50,9 @@ import jadx.core.utils.DecompilerScheduler;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
import jadx.core.utils.files.ZipPatch;
import jadx.core.utils.tasks.TaskExecutor;
import jadx.core.xmlgen.ResourcesSaver;
import jadx.zip.ZipReader;
/**
* Jadx API usage example:
@@ -87,6 +87,7 @@ public final class JadxDecompiler implements Closeable {
private final JadxArgs args;
private final JadxPluginManager pluginManager;
private final List<ICodeLoader> loadedInputs = new ArrayList<>();
private final ZipReader zipReader;
private RootNode root;
private List<JavaClass> classes;
@@ -109,6 +110,7 @@ public final class JadxDecompiler implements Closeable {
this.args = Objects.requireNonNull(args);
this.pluginManager = new JadxPluginManager(this);
this.resourcesLoader = new ResourcesLoader(this);
this.zipReader = new ZipReader(args.getSecurity());
}
public void load() {
@@ -145,9 +147,7 @@ public final class JadxDecompiler implements Closeable {
private void loadInputFiles() {
loadedInputs.clear();
List<File> inputs = ZipPatch.patchZipFiles(args.getInputFiles());
args.setInputFiles(inputs);
List<Path> inputPaths = Utils.collectionMap(inputs, File::toPath);
List<Path> inputPaths = Utils.collectionMap(args.getInputFiles(), File::toPath);
List<Path> inputFiles = FileUtils.expandDirs(inputPaths);
long start = System.currentTimeMillis();
for (PluginContext plugin : pluginManager.getResolvedPluginContexts()) {
@@ -333,7 +333,7 @@ public final class JadxDecompiler implements Closeable {
// process AndroidManifest.xml first to load complete resource ids table
for (ResourceFile resourceFile : getResources()) {
if (resourceFile.getType() == ResourceType.MANIFEST) {
new ResourcesSaver(outDir, resourceFile).run();
new ResourcesSaver(this, outDir, resourceFile).run();
break;
}
}
@@ -352,7 +352,7 @@ public final class JadxDecompiler implements Closeable {
// ignore resource made from input file
continue;
}
tasks.add(new ResourcesSaver(outDir, resourceFile));
tasks.add(new ResourcesSaver(this, outDir, resourceFile));
}
executor.addParallelTasks(tasks);
}
@@ -702,6 +702,10 @@ public final class JadxDecompiler implements Closeable {
return resourcesLoader;
}
public ZipReader getZipReader() {
return zipReader;
}
@Override
public String toString() {
return "jadx decompiler " + getVersion();
@@ -2,7 +2,6 @@ package jadx.api;
import java.io.File;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.xmlgen.ResContainer;
import jadx.core.xmlgen.entry.ResourceEntry;
@@ -42,7 +41,7 @@ public class ResourceFile {
}
public static ResourceFile createResourceFile(JadxDecompiler decompiler, String name, ResourceType type) {
if (!ZipSecurity.isValidZipEntryName(name)) {
if (!decompiler.getArgs().getSecurity().isValidEntryName(name)) {
return null;
}
return new ResourceFile(decompiler, name, type);
@@ -98,6 +97,10 @@ public class ResourceFile {
return zipRef;
}
public JadxDecompiler getDecompiler() {
return decompiler;
}
@Override
public String toString() {
return "ResourceFile{name='" + name + '\'' + ", type=" + type + '}';
@@ -6,9 +6,9 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -19,18 +19,19 @@ import jadx.api.plugins.CustomResourcesLoader;
import jadx.api.plugins.resources.IResContainerFactory;
import jadx.api.plugins.resources.IResTableParserProvider;
import jadx.api.plugins.resources.IResourcesLoader;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.dex.nodes.RootNode;
import jadx.core.utils.Utils;
import jadx.core.utils.android.Res9patchStreamDecoder;
import jadx.core.utils.exceptions.JadxException;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
import jadx.core.utils.files.ZipFile;
import jadx.core.xmlgen.BinaryXMLParser;
import jadx.core.xmlgen.IResTableParser;
import jadx.core.xmlgen.ResContainer;
import jadx.core.xmlgen.ResTableBinaryParserProvider;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
import jadx.zip.ZipReader;
import static jadx.core.utils.files.FileUtils.READ_BUFFER_SIZE;
import static jadx.core.utils.files.FileUtils.copyStream;
@@ -101,21 +102,19 @@ public final class ResourcesLoader implements IResourcesLoader {
return decoder.decode(file.length(), inputStream);
}
} else {
try (ZipFile zipFile = new ZipFile(zipRef.getZipFile())) {
ZipEntry entry = zipFile.getEntry(zipRef.getEntryName());
ZipReader zipReader = rf.getDecompiler().getZipReader();
try (ZipContent content = zipReader.open(zipRef.getZipFile())) {
IZipEntry entry = content.searchEntry(zipRef.getEntryName());
if (entry == null) {
throw new IOException("Zip entry not found: " + zipRef);
}
if (!ZipSecurity.isValidZipEntry(entry)) {
return null;
}
try (InputStream inputStream = ZipSecurity.getInputStreamForEntry(zipFile, entry)) {
return decoder.decode(entry.getSize(), inputStream);
try (InputStream inputStream = entry.getInputStream()) {
return decoder.decode(entry.getUncompressedSize(), inputStream);
}
}
}
} catch (Exception e) {
throw new JadxException("Error decode: " + rf.getDeobfName(), e);
throw new JadxException("Error decode: " + rf.getOriginalName(), e);
}
}
@@ -208,7 +207,7 @@ public final class ResourcesLoader implements IResourcesLoader {
public void defaultLoadFile(List<ResourceFile> list, File file, String subDir) {
if (FileUtils.isZipFile(file)) {
ZipSecurity.visitZipEntries(file, (zipFile, entry) -> {
jadxRef.getZipReader().visitEntries(file, entry -> {
addEntry(list, file, entry, subDir);
return null;
});
@@ -218,7 +217,7 @@ public final class ResourcesLoader implements IResourcesLoader {
}
}
public void addEntry(List<ResourceFile> list, File zipFile, ZipEntry entry, String subDir) {
public void addEntry(List<ResourceFile> list, File zipFile, IZipEntry entry, String subDir) {
if (entry.isDirectory()) {
return;
}
@@ -234,7 +233,7 @@ public final class ResourcesLoader implements IResourcesLoader {
public static ICodeInfo loadToCodeWriter(InputStream is) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream(READ_BUFFER_SIZE);
copyStream(is, baos);
return new SimpleCodeInfo(baos.toString("UTF-8"));
return new SimpleCodeInfo(baos.toString(StandardCharsets.UTF_8));
}
private synchronized BinaryXMLParser loadBinaryXmlParser() {
@@ -14,6 +14,7 @@ import jadx.api.plugins.input.JadxCodeInput;
import jadx.api.plugins.options.JadxPluginOptions;
import jadx.api.plugins.pass.JadxPass;
import jadx.api.plugins.resources.IResourcesLoader;
import jadx.zip.ZipReader;
public interface JadxPluginContext {
@@ -59,4 +60,9 @@ public interface JadxPluginContext {
* Access to plugin specific files and directories
*/
IJadxFiles files();
/**
* Custom jadx zip reader to fight tampering and provide additional security checks
*/
ZipReader getZipReader();
}
@@ -4,63 +4,52 @@ import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.JadxDecompiler;
import jadx.api.plugins.JadxPluginContext;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.ZipFile;
import jadx.zip.IZipEntry;
import jadx.zip.ZipReader;
import jadx.zip.security.DisabledZipSecurity;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.JadxZipSecurity;
import jadx.zip.security.LimitedInputStream;
/**
* Deprecated, migrate to {@link ZipReader}. <br>
* Prefer already configured instance from {@link JadxDecompiler#getZipReader()} or
* {@link JadxPluginContext#getZipReader()}.
*/
@Deprecated
public class ZipSecurity {
private static final Logger LOG = LoggerFactory.getLogger(ZipSecurity.class);
private static final boolean DISABLE_CHECKS = Utils.getEnvVarBool("JADX_DISABLE_ZIP_SECURITY", false);
/**
* size of uncompressed zip entry shouldn't be bigger of compressed in
* {@link #ZIP_BOMB_DETECTION_FACTOR} times
*/
private static final int ZIP_BOMB_DETECTION_FACTOR = 100;
/**
* Zip entries that have an uncompressed size of less than {@link #ZIP_BOMB_MIN_UNCOMPRESSED_SIZE}
* are considered safe
*/
private static final int ZIP_BOMB_MIN_UNCOMPRESSED_SIZE = 25 * 1024 * 1024;
private static final int MAX_ENTRIES_COUNT = Utils.getEnvVarInt("JADX_ZIP_MAX_ENTRIES_COUNT", 100_000);
private static final IJadxZipSecurity ZIP_SECURITY = buildZipSecurity();
private static final ZipReader ZIP_READER = new ZipReader(ZIP_SECURITY);
private static IJadxZipSecurity buildZipSecurity() {
if (DISABLE_CHECKS) {
return DisabledZipSecurity.INSTANCE;
}
JadxZipSecurity jadxZipSecurity = new JadxZipSecurity();
jadxZipSecurity.setMaxEntriesCount(MAX_ENTRIES_COUNT);
return jadxZipSecurity;
}
private ZipSecurity() {
}
private static boolean isInSubDirectoryInternal(File baseDir, File file) {
File current = file;
while (true) {
if (current == null) {
return false;
}
if (current.equals(baseDir)) {
return true;
}
current = current.getParentFile();
}
}
public static boolean isInSubDirectory(File baseDir, File file) {
if (DISABLE_CHECKS) {
return true;
}
try {
return isInSubDirectoryInternal(baseDir.getCanonicalFile(), file.getCanonicalFile());
} catch (IOException e) {
return false;
}
return ZIP_SECURITY.isInSubDirectory(baseDir, file);
}
/**
@@ -68,48 +57,15 @@ public class ZipSecurity {
* to limit output only to the specified directory
*/
public static boolean isValidZipEntryName(String entryName) {
if (DISABLE_CHECKS) {
return true;
}
if (entryName.contains("..")) { // quick pre-check
if (entryName.contains("../") || entryName.contains("..\\")) {
LOG.error("Path traversal attack detected in entry: '{}'", entryName);
return false;
}
}
try {
File currentPath = CommonFileUtils.CWD;
File canonical = new File(currentPath, entryName).getCanonicalFile();
if (isInSubDirectoryInternal(currentPath, canonical)) {
return true;
}
} catch (Exception e) {
// check failed
}
LOG.error("Invalid file name or path traversal attack detected: {}", entryName);
return false;
return ZIP_SECURITY.isValidEntryName(entryName);
}
public static boolean isZipBomb(ZipEntry entry) {
if (DISABLE_CHECKS) {
return false;
}
long compressedSize = entry.getCompressedSize();
long uncompressedSize = entry.getSize();
boolean invalidSize = (compressedSize < 0) || (uncompressedSize < 0);
boolean possibleZipBomb = (uncompressedSize >= ZIP_BOMB_MIN_UNCOMPRESSED_SIZE)
&& (compressedSize * ZIP_BOMB_DETECTION_FACTOR < uncompressedSize);
if (invalidSize || possibleZipBomb) {
LOG.error("Potential zip bomb attack detected, invalid sizes: compressed {}, uncompressed {}, name {}",
compressedSize, uncompressedSize, entry.getName());
return true;
}
return false;
public static boolean isZipBomb(IZipEntry entry) {
return !ZIP_SECURITY.isValidEntry(entry);
}
public static boolean isValidZipEntry(ZipEntry entry) {
return isValidZipEntryName(entry.getName())
&& !isZipBomb(entry);
public static boolean isValidZipEntry(IZipEntry entry) {
return ZIP_SECURITY.isValidEntry(entry);
}
public static InputStream getInputStreamForEntry(ZipFile zipFile, ZipEntry entry) throws IOException {
@@ -122,44 +78,15 @@ public class ZipSecurity {
}
/**
* Visit valid entries in zip file.
* Visit valid entries in a zip file.
* Return not null value from visitor to stop iteration.
*/
@Nullable
public static <R> R visitZipEntries(File file, BiFunction<ZipFile, ZipEntry, R> visitor) {
try (ZipFile zip = new ZipFile(file)) {
Enumeration<? extends ZipEntry> entries = zip.entries();
int entriesProcessed = 0;
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (isValidZipEntry(entry)) {
R result = visitor.apply(zip, entry);
if (result != null) {
return result;
}
entriesProcessed++;
if (!DISABLE_CHECKS && entriesProcessed > MAX_ENTRIES_COUNT) {
throw new JadxRuntimeException("Zip entries count limit exceeded: " + MAX_ENTRIES_COUNT
+ ", last entry: " + entry.getName());
}
}
}
} catch (Exception e) {
throw new JadxRuntimeException("Failed to process zip file: " + file.getAbsolutePath(), e);
}
return null;
public static <R> R visitZipEntries(File file, Function<IZipEntry, R> visitor) {
return ZIP_READER.visitEntries(file, visitor);
}
public static void readZipEntries(File file, BiConsumer<ZipEntry, InputStream> visitor) {
visitZipEntries(file, (zip, entry) -> {
if (!entry.isDirectory()) {
try (InputStream in = getInputStreamForEntry(zip, entry)) {
visitor.accept(entry, in);
} catch (Exception e) {
throw new JadxRuntimeException("Failed to process zip entry: " + entry.getName());
}
}
return null;
});
public static void readZipEntries(File file, BiConsumer<IZipEntry, InputStream> visitor) {
ZIP_READER.readEntries(file, visitor);
}
}
@@ -4,7 +4,9 @@ import java.io.InputStream;
import org.w3c.dom.Document;
public interface IJadxSecurity {
import jadx.zip.security.IJadxZipSecurity;
public interface IJadxSecurity extends IJadxZipSecurity {
/**
* Check if application package is safe
@@ -6,7 +6,8 @@ import java.util.Set;
public enum JadxSecurityFlag {
VERIFY_APP_PACKAGE,
SECURE_XML_PARSER;
SECURE_XML_PARSER,
SECURE_ZIP_READER;
public static Set<JadxSecurityFlag> all() {
return EnumSet.allOf(JadxSecurityFlag.class);
@@ -1,5 +1,6 @@
package jadx.api.security.impl;
import java.io.File;
import java.io.InputStream;
import java.util.Set;
@@ -12,14 +13,52 @@ import org.w3c.dom.Document;
import jadx.api.security.IJadxSecurity;
import jadx.api.security.JadxSecurityFlag;
import jadx.core.deobf.NameMapper;
import jadx.zip.IZipEntry;
import jadx.zip.security.DisabledZipSecurity;
import jadx.zip.security.IJadxZipSecurity;
import jadx.zip.security.JadxZipSecurity;
import static jadx.api.security.JadxSecurityFlag.SECURE_ZIP_READER;
public class JadxSecurity implements IJadxSecurity {
private static final Logger LOG = LoggerFactory.getLogger(JadxSecurity.class);
private final Set<JadxSecurityFlag> flags;
private final IJadxZipSecurity zipSecurity;
public JadxSecurity(Set<JadxSecurityFlag> flags) {
this.flags = flags;
this.zipSecurity = flags.contains(SECURE_ZIP_READER) ? new JadxZipSecurity() : DisabledZipSecurity.INSTANCE;
}
public JadxSecurity(Set<JadxSecurityFlag> flags, IJadxZipSecurity zipSecurity) {
this.flags = flags;
this.zipSecurity = zipSecurity;
}
@Override
public boolean isValidEntry(IZipEntry entry) {
return zipSecurity.isValidEntry(entry);
}
@Override
public boolean isValidEntryName(String entryName) {
return zipSecurity.isValidEntryName(entryName);
}
@Override
public boolean isInSubDirectory(File baseDir, File file) {
return zipSecurity.isInSubDirectory(baseDir, file);
}
@Override
public boolean useLimitedDataStream() {
return zipSecurity.useLimitedDataStream();
}
@Override
public int getMaxEntriesCount() {
return zipSecurity.getMaxEntriesCount();
}
@Override
@@ -2,13 +2,13 @@ package jadx.core.dex.visitors;
import java.io.File;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.ICodeInfo;
import jadx.api.JadxArgs;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.dex.attributes.AFlag;
import jadx.core.dex.nodes.ClassNode;
import jadx.core.dex.nodes.RootNode;
@@ -35,18 +35,15 @@ public class SaveCode {
if (codeStr.isEmpty()) {
return;
}
if (cls.root().getArgs().isSkipFilesSave()) {
JadxArgs args = cls.root().getArgs();
if (args.isSkipFilesSave()) {
return;
}
String fileName = cls.getClassInfo().getAliasFullPath() + getFileExtension(cls.root());
save(codeStr, dir, fileName);
}
public static void save(String code, File dir, String fileName) {
if (!ZipSecurity.isValidZipEntryName(fileName)) {
if (!args.getSecurity().isValidEntryName(fileName)) {
return;
}
save(code, new File(dir, fileName));
save(codeStr, new File(dir, fileName));
}
public static void save(ICodeInfo codeInfo, File file) {
@@ -55,7 +52,7 @@ public class SaveCode {
public static void save(String code, File file) {
File outFile = FileUtils.prepareFile(file);
try (PrintWriter out = new PrintWriter(outFile, "UTF-8")) {
try (PrintWriter out = new PrintWriter(outFile, StandardCharsets.UTF_8)) {
out.println(code);
} catch (Exception e) {
LOG.error("Save file error", e);
@@ -32,6 +32,7 @@ import jadx.core.plugins.files.JadxFilesData;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
import jadx.zip.ZipReader;
public class PluginContext implements JadxPluginContext, JadxPluginRuntimeData, Comparable<PluginContext> {
private final JadxDecompiler decompiler;
@@ -190,6 +191,11 @@ public class PluginContext implements JadxPluginContext, JadxPluginRuntimeData,
closeable);
}
@Override
public ZipReader getZipReader() {
return decompiler.getZipReader();
}
@Override
public boolean equals(Object other) {
if (this == other) {
@@ -24,9 +24,9 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Collectors;
@@ -276,6 +276,11 @@ public class FileUtils {
StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
public static void writeFile(Path file, byte[] data) throws IOException {
FileUtils.makeDirsForFile(file);
Files.write(file, data, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
}
public static void writeFile(Path file, InputStream is) throws IOException {
FileUtils.makeDirsForFile(file);
Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
@@ -358,20 +363,18 @@ public class FileUtils {
return new String(hexChars, StandardCharsets.US_ASCII);
}
private static final byte[] ZIP_FILE_MAGIC = { 0x50, 0x4B, 0x03, 0x04 };
public static boolean isZipFile(File file) {
try (InputStream is = new FileInputStream(file)) {
byte[] headers = new byte[4];
int read = is.read(headers, 0, 4);
if (read == headers.length) {
String headerString = bytesToHex(headers);
if (Objects.equals(headerString, "504b0304")) {
return true;
}
}
int len = ZIP_FILE_MAGIC.length;
byte[] headers = new byte[len];
int read = is.read(headers);
return read == len && Arrays.equals(headers, ZIP_FILE_MAGIC);
} catch (Exception e) {
LOG.error("Failed read zip file: {}", file.getAbsolutePath(), e);
LOG.error("Failed to read zip file: {}", file.getAbsolutePath(), e);
return false;
}
return false;
}
public static String getPathBaseName(Path file) {
@@ -1,32 +0,0 @@
package jadx.core.utils.files;
import java.io.File;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
/**
* Deprecated zip file wrapper
*/
public class ZipFile extends java.util.zip.ZipFile {
public ZipFile(File file) throws IOException {
this(file, OPEN_READ);
}
public ZipFile(File file, int mode) throws IOException {
this(file, mode, StandardCharsets.UTF_8);
}
public ZipFile(String name, Charset charset) throws IOException {
this(new File(name), OPEN_READ, charset);
}
public ZipFile(String name) throws IOException {
this(name, StandardCharsets.UTF_8);
}
public ZipFile(File file, int mode, Charset charset) throws IOException {
super(file, mode, charset);
}
}
@@ -1,199 +0,0 @@
package jadx.core.utils.files;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.lang.reflect.UndeclaredThrowableException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ZipPatch {
private static final Logger LOG = LoggerFactory.getLogger(ZipPatch.class);
public static List<File> patchZipFiles(List<File> inputs) {
List<File> result = new ArrayList<>(inputs.size());
for (File input : inputs) {
try {
result.add(patchZipFile(input));
} catch (Throwable e) {
LOG.warn("Failed to patch zip file: {}", input.getAbsolutePath(), e);
result.add(input);
}
}
return result;
}
private static File patchZipFile(File file) throws IOException {
String fileName = file.getPath().toLowerCase();
if (!fileName.endsWith(".apk") && !fileName.endsWith(".zip")) {
return file;
}
var cDirEntriesToFix = new ArrayList<Long>();
var localHeaders = new ArrayList<Long>();
List<Long> localHeaderToFix;
try (var raFile = new RandomAccessFile(file, "r")) {
var endOfCDirOffset = findEndOfCentralDir(raFile);
raFile.seek(endOfCDirOffset + 0x10);
var cDirOffset = Integer.toUnsignedLong(Integer.reverseBytes(raFile.readInt()));
raFile.seek(endOfCDirOffset + 0x0a);
var cDirNumEntries = Short.toUnsignedLong(Short.reverseBytes(raFile.readShort()));
for (long i = 0, off = cDirOffset; i < cDirNumEntries; i++) {
var info = readHeader(raFile, off);
if (!info.validCompression()) {
cDirEntriesToFix.add(off);
}
raFile.seek(off + 0x2a);
localHeaders.add(Integer.toUnsignedLong(Integer.reverseBytes(raFile.readInt())));
off += info.dataOffset;
}
localHeaderToFix = localHeaders
.stream()
.filter(off -> !readHeaderVexxed(raFile, off).validCompression())
.collect(Collectors.toList());
if (cDirEntriesToFix.isEmpty() && localHeaderToFix.isEmpty()) {
return file;
}
}
var newFile = copyFile(file);
try (var newRaFile = new RandomAccessFile(newFile, "rwd")) {
for (var off : cDirEntriesToFix) {
var info = readHeader(newRaFile, off);
newRaFile.seek(off + 0x0a);
newRaFile.writeShort(0);
newRaFile.seek(off + 0x14);
newRaFile.writeInt(Integer.reverseBytes((int) info.uncompressedSize));
}
for (var off : localHeaderToFix) {
var info = readHeader(newRaFile, off);
newRaFile.seek(off + 0x08);
newRaFile.writeShort(0);
newRaFile.seek(off + 0x12);
newRaFile.writeInt(Integer.reverseBytes((int) info.uncompressedSize));
newRaFile.seek(off + 0x1c);
newRaFile.writeShort(0);
moveBlockBack(newRaFile, off + info.dataOffset, info.uncompressedSize, info.extraLen);
}
}
LOG.info("Input zip file patched: {}", file.getAbsolutePath());
return newFile;
}
private static void moveBlockBack(RandomAccessFile file, long offset, long size, long delta) throws IOException {
var buffer = new byte[1024 * 1024];
while (size > 0) {
var len = (int) Math.min(buffer.length, size);
file.seek(offset);
file.read(buffer, 0, len);
file.seek(offset - delta);
file.write(buffer, 0, len);
size -= len;
offset += len;
}
}
private static File copyFile(File file) throws IOException {
var newFile = FileUtils.createTempFile(file.getName()).toFile();
try (var in = new FileInputStream(file)) {
try (var out = new FileOutputStream(newFile)) {
in.transferTo(out);
}
}
return newFile;
}
private static long findEndOfCentralDir(RandomAccessFile file) throws IOException {
var offset = file.length() - 0x15L + 1;
do {
if (offset <= 0) {
throw new IllegalArgumentException("File is not a valid ZIP: End of central directory record not found");
}
file.seek(--offset);
} while (Integer.reverseBytes(file.readInt()) != 0x06054b50);
return offset;
}
private static class HeaderInfo {
short compression;
long uncompressedSize;
long dataOffset;
long extraLen;
boolean validCompression() {
return compression == 0x0 || compression == 0x8;
}
}
private static HeaderInfo readHeaderVexxed(RandomAccessFile file, long offset) {
try {
return readHeader(file, offset);
} catch (IOException e) {
throw new UndeclaredThrowableException(e);
}
}
private static HeaderInfo readHeader(RandomAccessFile file, long offset) throws IOException {
var info = new HeaderInfo();
file.seek(offset);
var signature = Integer.reverseBytes(file.readInt());
if (signature != 0x02014b50 && signature != 0x04034b50) {
throw new IllegalArgumentException(
String.format("Invalid ZIP header signature %x at offset %x",
signature, offset));
}
var isCentralHeader = signature == 0x02014b50;
var delta = isCentralHeader ? 0 : -2;
file.seek(offset + 0x0a + delta);
info.compression = Short.reverseBytes(file.readShort());
file.seek(offset + 0x18 + delta);
info.uncompressedSize = Integer.toUnsignedLong(Integer.reverseBytes(file.readInt()));
file.seek(offset + 0x1c + delta);
var nameLen = Short.toUnsignedLong(Short.reverseBytes(file.readShort()));
info.extraLen = Short.toUnsignedLong(Short.reverseBytes(file.readShort()));
var commentLen = 0L;
if (isCentralHeader) {
commentLen = Short.toUnsignedLong(Short.reverseBytes(file.readShort()));
}
info.dataOffset = (isCentralHeader ? 0x2e : 0x1e) + nameLen + info.extraLen + commentLen;
return info;
}
}
@@ -13,7 +13,6 @@ import org.slf4j.LoggerFactory;
import jadx.api.ICodeInfo;
import jadx.api.args.ResourceNameSource;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.dex.attributes.AFlag;
import jadx.core.dex.nodes.FieldNode;
import jadx.core.dex.nodes.IFieldInfoRef;
@@ -392,7 +391,7 @@ public class ResTableBinaryParser extends CommonBinaryParser implements IResTabl
private static final ResourceEntry STUB_ENTRY = new ResourceEntry(-1, "stub", "stub", "stub", "");
private ResourceEntry buildResourceEntry(PackageChunk pkg, String config, int resRef, String typeName, String origKeyName) {
if (!ZipSecurity.isValidZipEntryName(origKeyName)) {
if (!root.getArgs().getSecurity().isValidEntryName(origKeyName)) {
// malicious entry, ignore it
// can't return null here, return stub without adding it to storage
return STUB_ENTRY;
@@ -8,9 +8,10 @@ import java.nio.file.StandardCopyOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.JadxDecompiler;
import jadx.api.ResourceFile;
import jadx.api.ResourcesLoader;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.api.security.IJadxSecurity;
import jadx.core.dex.visitors.SaveCode;
import jadx.core.utils.exceptions.JadxException;
import jadx.core.utils.exceptions.JadxRuntimeException;
@@ -21,10 +22,12 @@ public class ResourcesSaver implements Runnable {
private final ResourceFile resourceFile;
private final File outDir;
private final IJadxSecurity security;
public ResourcesSaver(File outDir, ResourceFile resourceFile) {
public ResourcesSaver(JadxDecompiler decompiler, File outDir, ResourceFile resourceFile) {
this.resourceFile = resourceFile;
this.outDir = outDir;
this.security = decompiler.getArgs().getSecurity();
}
@Override
@@ -52,7 +55,7 @@ public class ResourcesSaver implements Runnable {
private void save(ResContainer rc, File outDir) {
File outFile = new File(outDir, rc.getFileName());
if (!ZipSecurity.isInSubDirectory(outDir, outFile)) {
if (!security.isInSubDirectory(outDir, outFile)) {
LOG.error("Invalid resource name or path traversal attack detected: {}", outFile.getPath());
return;
}
@@ -23,6 +23,7 @@ import jadx.api.impl.InMemoryCodeCache;
import jadx.api.metadata.ICodeNodeRef;
import jadx.api.usage.impl.EmptyUsageInfoCache;
import jadx.api.usage.impl.InMemoryUsageInfoCache;
import jadx.cli.JadxAppCommon;
import jadx.cli.plugins.JadxFilesGetter;
import jadx.core.dex.nodes.ClassNode;
import jadx.core.dex.nodes.ProcessState;
@@ -66,6 +67,7 @@ public class JadxWrapper {
JadxArgs jadxArgs = getSettings().toJadxArgs();
jadxArgs.setPluginLoader(new JadxExternalPluginsLoader());
project.fillJadxArgs(jadxArgs);
JadxAppCommon.applyEnvVars(jadxArgs);
decompiler = new JadxDecompiler(jadxArgs);
guiPluginsContext = initGuiPluginsContext(decompiler, mainWindow);
@@ -53,7 +53,7 @@ public class HexArea extends AbstractCodeArea {
public void load() {
byte[] bytes = null;
if (binaryNode instanceof JResource) {
JResource jResource = ((JResource) binaryNode);
JResource jResource = (JResource) binaryNode;
try {
bytes = ResourcesLoader.decodeStream(jResource.getResFile(), (size, is) -> is.readAllBytes());
} catch (JadxException e) {
@@ -15,7 +15,6 @@ import org.jetbrains.annotations.Nullable;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.tools.data.JadxPluginListCache;
import jadx.plugins.tools.data.JadxPluginMetadata;
@@ -24,6 +23,7 @@ import jadx.plugins.tools.resolvers.github.LocationInfo;
import jadx.plugins.tools.resolvers.github.data.Asset;
import jadx.plugins.tools.resolvers.github.data.Release;
import jadx.plugins.tools.utils.PluginUtils;
import jadx.zip.ZipReader;
import static jadx.core.utils.GsonUtils.buildGson;
import static jadx.plugins.tools.utils.PluginFiles.PLUGINS_LIST_CACHE;
@@ -125,14 +125,15 @@ public class JadxPluginsList {
private static List<JadxPluginMetadata> loadListBundle(Path tmpListFile) {
Gson gson = buildGson();
List<JadxPluginMetadata> entries = new ArrayList<>();
ZipSecurity.readZipEntries(tmpListFile.toFile(), (entry, in) -> {
new ZipReader().visitEntries(tmpListFile.toFile(), entry -> {
if (entry.getName().endsWith(".json")) {
try (Reader reader = new InputStreamReader(in)) {
try (Reader reader = new InputStreamReader(entry.getInputStream())) {
entries.addAll(gson.fromJson(reader, LIST_TYPE));
} catch (Exception e) {
throw new RuntimeException("Failed to read plugins list entry: " + entry.getName());
}
}
return null;
});
return entries;
}
@@ -3,24 +3,29 @@ package jadx.plugins.input.apkm
import jadx.api.plugins.input.ICodeLoader
import jadx.api.plugins.input.JadxCodeInput
import jadx.api.plugins.utils.CommonFileUtils
import jadx.api.plugins.utils.ZipSecurity
import jadx.plugins.input.dex.DexInputPlugin
import jadx.zip.ZipReader
import java.io.File
import java.nio.file.Path
class ApkmCustomCodeInput(
private val plugin: ApkmInputPlugin,
private val dexInputPlugin: DexInputPlugin,
private val zipReader: ZipReader,
) : JadxCodeInput {
override fun loadFiles(input: List<Path>): ICodeLoader {
val apkFiles = mutableListOf<File>()
for (file in input.map { it.toFile() }) {
if (!file.name.endsWith(".apkm")) continue
// Check if this is a valid APKM file
val manifest = ApkmUtils.getManifest(file) ?: continue
val manifest = ApkmUtils.getManifest(file, zipReader) ?: continue
if (!ApkmUtils.isSupported(manifest)) continue
// Load all files ending with .apk
ZipSecurity.visitZipEntries<Any>(file) { zip, entry ->
zipReader.visitEntries<Any>(file) { entry ->
if (entry.name.endsWith(".apk")) {
val tmpFile = ZipSecurity.getInputStreamForEntry(zip, entry).use {
val tmpFile = entry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
apkFiles.add(tmpFile)
@@ -29,7 +34,7 @@ class ApkmCustomCodeInput(
}
}
val codeLoader = plugin.dexInputPlugin.loadFiles(apkFiles.map { it.toPath() })
val codeLoader = dexInputPlugin.loadFiles(apkFiles.map { it.toPath() })
apkFiles.forEach { CommonFileUtils.safeDeleteFile(it) }
@@ -4,21 +4,25 @@ import jadx.api.ResourceFile
import jadx.api.ResourcesLoader
import jadx.api.plugins.CustomResourcesLoader
import jadx.api.plugins.utils.CommonFileUtils
import jadx.api.plugins.utils.ZipSecurity
import jadx.zip.ZipReader
import java.io.File
class ApkmCustomResourcesLoader : CustomResourcesLoader {
class ApkmCustomResourcesLoader(
private val zipReader: ZipReader,
) : CustomResourcesLoader {
private val tmpFiles = mutableListOf<File>()
override fun load(loader: ResourcesLoader, list: MutableList<ResourceFile>, file: File): Boolean {
if (!file.name.endsWith(".apkm")) return false
// Check if this is a valid APKM file
val manifest = ApkmUtils.getManifest(file) ?: return false
val manifest = ApkmUtils.getManifest(file, zipReader) ?: return false
if (!ApkmUtils.isSupported(manifest)) return false
// Load all files ending with .apk
ZipSecurity.visitZipEntries<Any>(file) { zip, entry ->
zipReader.visitEntries<Any>(file) { entry ->
if (entry.name.endsWith(".apk")) {
val tmpFile = ZipSecurity.getInputStreamForEntry(zip, entry).use {
val tmpFile = entry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
loader.defaultLoadFile(list, tmpFile, entry.name + "/")
@@ -6,9 +6,6 @@ import jadx.api.plugins.JadxPluginInfo
import jadx.plugins.input.dex.DexInputPlugin
class ApkmInputPlugin : JadxPlugin {
private val codeInput = ApkmCustomCodeInput(this)
private val resourcesLoader = ApkmCustomResourcesLoader()
internal lateinit var dexInputPlugin: DexInputPlugin
override fun getPluginInfo() = JadxPluginInfo(
"apkm-input",
@@ -17,8 +14,8 @@ class ApkmInputPlugin : JadxPlugin {
)
override fun init(context: JadxPluginContext) {
dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
context.addCodeInput(codeInput)
context.decompiler.addCustomResourcesLoader(resourcesLoader)
val dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
context.addCodeInput(ApkmCustomCodeInput(dexInputPlugin, context.zipReader))
context.decompiler.addCustomResourcesLoader(ApkmCustomResourcesLoader(context.zipReader))
}
}
@@ -1,19 +1,18 @@
package jadx.plugins.input.apkm
import jadx.api.plugins.utils.ZipSecurity
import jadx.core.utils.GsonUtils.buildGson
import jadx.core.utils.files.FileUtils
import jadx.core.utils.files.ZipFile
import jadx.zip.ZipReader
import java.io.File
import java.io.InputStreamReader
object ApkmUtils {
fun getManifest(file: File): ApkmManifest? {
fun getManifest(file: File, zipReader: ZipReader): ApkmManifest? {
if (!FileUtils.isZipFile(file)) return null
try {
ZipFile(file).use { zip ->
val manifestEntry = zip.getEntry("info.json") ?: return null
return InputStreamReader(ZipSecurity.getInputStreamForEntry(zip, manifestEntry)).use {
zipReader.open(file).use { zip ->
val manifestEntry = zip.searchEntry("info.json") ?: return null
return InputStreamReader(manifestEntry.inputStream).use {
buildGson().fromJson(it, ApkmManifest::class.java)
}
}
@@ -18,10 +18,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.utils.CommonFileUtils;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.plugins.input.dex.sections.DexConsts;
import jadx.plugins.input.dex.sections.DexHeaderV41;
import jadx.plugins.input.dex.utils.DexCheckSum;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
import jadx.zip.ZipReader;
public class DexFileLoader {
private static final Logger LOG = LoggerFactory.getLogger(DexFileLoader.class);
@@ -31,10 +33,16 @@ public class DexFileLoader {
private final DexInputOptions options;
private ZipReader zipReader = new ZipReader();
public DexFileLoader(DexInputOptions options) {
this.options = options;
}
public void setZipReader(ZipReader zipReader) {
this.zipReader = zipReader;
}
public List<DexReader> collectDexFiles(List<Path> pathsList) {
return pathsList.stream()
.map(Path::toFile)
@@ -76,6 +84,13 @@ public class DexFileLoader {
}
}
private List<DexReader> loadFromZipEntry(byte[] content, String fileName) {
if (isStartWithBytes(content, DexConsts.DEX_FILE_MAGIC) || fileName.endsWith(".dex")) {
return loadDexReaders(fileName, content);
}
return Collections.emptyList();
}
public List<DexReader> loadDexReaders(String fileName, byte[] content) {
DexHeaderV41 dexHeaderV41 = DexHeaderV41.readIfPresent(content);
if (dexHeaderV41 != null) {
@@ -106,14 +121,25 @@ public class DexFileLoader {
private List<DexReader> collectDexFromZip(File file) {
List<DexReader> result = new ArrayList<>();
try {
ZipSecurity.readZipEntries(file, (entry, in) -> {
try (ZipContent zip = zipReader.open(file)) {
for (IZipEntry entry : zip.getEntries()) {
if (entry.isDirectory()) {
continue;
}
try {
result.addAll(load(null, in, entry.getName()));
List<DexReader> readers;
if (entry.preferBytes()) {
readers = loadFromZipEntry(entry.getBytes(), entry.getName());
} else {
readers = load(null, entry.getInputStream(), entry.getName());
}
if (!readers.isEmpty()) {
result.addAll(readers);
}
} catch (Exception e) {
LOG.error("Failed to read zip entry: {}", entry, e);
}
});
}
} catch (Exception e) {
LOG.error("Failed to process zip file: {}", file.getAbsolutePath(), e);
}
@@ -31,6 +31,7 @@ public class DexInputPlugin implements JadxPlugin {
public void init(JadxPluginContext context) {
context.registerOptions(options);
context.addCodeInput(this::loadFiles);
loader.setZipReader(context.getZipReader());
}
public ICodeLoader loadFiles(List<Path> input) {
@@ -17,16 +17,22 @@ import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.JadxPluginContext;
import jadx.api.plugins.utils.CommonFileUtils;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.api.security.IJadxSecurity;
import jadx.zip.ZipReader;
public class JavaConvertLoader {
private static final Logger LOG = LoggerFactory.getLogger(JavaConvertLoader.class);
private final JavaConvertOptions options;
private final ZipReader zipReader;
private final IJadxSecurity security;
public JavaConvertLoader(JavaConvertOptions options) {
public JavaConvertLoader(JavaConvertOptions options, JadxPluginContext context) {
this.options = options;
this.zipReader = context.getZipReader();
this.security = context.getArgs().getSecurity();
}
public ConvertResult process(List<Path> input) {
@@ -64,9 +70,13 @@ public class JavaConvertLoader {
try (JarOutputStream jo = new JarOutputStream(Files.newOutputStream(jarFile))) {
for (Path file : clsFiles) {
String clsName = AsmUtils.getNameFromClassFile(file);
if (clsName == null || !ZipSecurity.isValidZipEntryName(clsName)) {
if (clsName == null) {
throw new IOException("Can't read class name from file: " + file);
}
if (!security.isValidEntryName(clsName)) {
LOG.warn("Skip class with invalid name: {}", clsName);
continue;
}
addFileToJar(jo, file, clsName + ".class");
}
}
@@ -82,7 +92,7 @@ public class JavaConvertLoader {
PathMatcher aarMatcher = FileSystems.getDefault().getPathMatcher("glob:**.aar");
input.stream()
.filter(aarMatcher::matches)
.forEach(path -> ZipSecurity.readZipEntries(path.toFile(), (entry, in) -> {
.forEach(path -> zipReader.readEntries(path.toFile(), (entry, in) -> {
try {
String entryName = entry.getName();
if (entryName.endsWith(".jar")) {
@@ -105,8 +115,8 @@ public class JavaConvertLoader {
}
private boolean repackAndConvertJar(ConvertResult result, Path path) throws Exception {
// check if jar need a full repackage
Boolean repackNeeded = ZipSecurity.visitZipEntries(path.toFile(), (zipFile, zipEntry) -> {
// check if jar needs a full repackaging
Boolean repackNeeded = zipReader.visitEntries(path.toFile(), zipEntry -> {
String entryName = zipEntry.getName();
if (zipEntry.isDirectory()) {
if (entryName.equals("BOOT-INF/")) {
@@ -131,7 +141,7 @@ public class JavaConvertLoader {
Path jarFile = Files.createTempFile("jadx-classes-", ".jar");
result.addTempPath(jarFile);
try (JarOutputStream jo = new JarOutputStream(Files.newOutputStream(jarFile))) {
ZipSecurity.readZipEntries(path.toFile(), (entry, in) -> {
zipReader.readEntries(path.toFile(), (entry, in) -> {
try {
String entryName = entry.getName();
if (entryName.endsWith(".class")) {
@@ -142,10 +152,14 @@ public class JavaConvertLoader {
}
byte[] clsFileContent = CommonFileUtils.loadBytes(in);
String clsName = AsmUtils.getNameFromClassFile(clsFileContent);
if (clsName == null || !ZipSecurity.isValidZipEntryName(clsName)) {
if (clsName == null) {
throw new IOException("Can't read class name from file: " + entryName);
}
addJarEntry(jo, clsName + ".class", clsFileContent, entry.getLastModifiedTime());
if (!security.isValidEntryName(clsName)) {
LOG.warn("Ignore class with invalid name: {} from {}", clsName, entry);
} else {
addJarEntry(jo, clsName + ".class", clsFileContent, null);
}
} else if (entryName.endsWith(".jar")) {
Path tempJar = CommonFileUtils.saveToTempFile(in, ".jar");
result.addTempPath(tempJar);
@@ -17,9 +17,9 @@ public class JavaConvertPlugin implements JadxPlugin, JadxCodeInput {
public static final String PLUGIN_ID = "java-convert";
private final JavaConvertOptions options = new JavaConvertOptions();
private final JavaConvertLoader loader = new JavaConvertLoader(options);
private JadxPluginRuntimeData dexInput;
private JavaConvertLoader loader;
@Override
public JadxPluginInfo getPluginInfo() {
@@ -32,8 +32,9 @@ public class JavaConvertPlugin implements JadxPlugin, JadxCodeInput {
@Override
public void init(JadxPluginContext context) {
dexInput = context.plugins().getById(DexInputPlugin.PLUGIN_ID);
context.registerOptions(options);
dexInput = context.plugins().getById(DexInputPlugin.PLUGIN_ID);
loader = new JavaConvertLoader(options, context);
context.addCodeInput(this);
}
@@ -5,6 +5,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
@@ -17,7 +18,11 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.utils.CommonFileUtils;
import jadx.api.plugins.utils.ZipSecurity;
import jadx.core.plugins.files.TempFilesGetter;
import jadx.core.utils.files.FileUtils;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
import jadx.zip.ZipReader;
public class JavaInputLoader {
private static final Logger LOG = LoggerFactory.getLogger(JavaInputLoader.class);
@@ -26,8 +31,24 @@ public class JavaInputLoader {
private static final byte[] JAVA_CLASS_FILE_MAGIC = { (byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE };
private static final byte[] ZIP_FILE_MAGIC = { 0x50, 0x4B, 0x03, 0x04 };
private final ZipReader zipReader;
private final Path tempPath;
private int classUniqId = 1;
public JavaInputLoader(ZipReader zipReader, Path tempPath) {
this.zipReader = zipReader;
this.tempPath = tempPath;
}
/**
* This will use zip reader with default options and ignore provided in jadx args
*/
@Deprecated
public JavaInputLoader() {
this(new ZipReader(), TempFilesGetter.INSTANCE.getTempDir());
}
public List<JavaClassReader> collectFiles(List<Path> inputFiles) {
return inputFiles.stream()
.map(Path::toFile)
@@ -78,6 +99,23 @@ public class JavaInputLoader {
return Collections.emptyList();
}
private List<JavaClassReader> loadReaderFromZipEntry(byte[] content, String name, String parentFileName) throws IOException {
if (isStartWithBytes(content, JAVA_CLASS_FILE_MAGIC) || name.endsWith(".class")) {
String source = concatSource(parentFileName, name);
JavaClassReader reader = new JavaClassReader(getNextUniqId(), source, content);
return Collections.singletonList(reader);
}
if (isStartWithBytes(content, ZIP_FILE_MAGIC) || CommonFileUtils.isZipFileExt(name)) {
Path tempZip = Files.createTempFile(tempPath, "temp", ".zip");
FileUtils.writeFile(tempZip, content);
File zipFile = tempZip.toFile();
List<JavaClassReader> readers = collectFromZip(zipFile, concatSource(parentFileName, name));
CommonFileUtils.safeDeleteFile(zipFile);
return readers;
}
return Collections.emptyList();
}
private static String concatSource(@Nullable String parentFileName, String name) {
if (parentFileName == null) {
return name;
@@ -87,19 +125,28 @@ public class JavaInputLoader {
private List<JavaClassReader> collectFromZip(File file, String name) {
List<JavaClassReader> result = new ArrayList<>();
try {
ZipSecurity.readZipEntries(file, (entry, in) -> {
try (ZipContent zip = zipReader.open(file)) {
for (IZipEntry entry : zip.getEntries()) {
if (entry.isDirectory()) {
continue;
}
String entryName = entry.getName();
if (entryName.startsWith("META-INF/versions/")) {
// skip classes for different java versions
continue;
}
try {
String entryName = entry.getName();
if (entryName.startsWith("META-INF/versions/")) {
// skip classes for different java versions
return;
List<JavaClassReader> readers;
if (entry.preferBytes()) {
readers = loadReaderFromZipEntry(entry.getBytes(), entryName, name);
} else {
readers = loadReader(entry.getInputStream(), entryName, null, name);
}
result.addAll(loadReader(in, entryName, null, name));
result.addAll(readers);
} catch (Exception e) {
LOG.error("Failed to read zip entry: {}", entry, e);
}
});
}
} catch (Exception e) {
LOG.error("Failed to process zip file: {}", name, e);
}
@@ -25,7 +25,14 @@ public class JavaInputPlugin implements JadxPlugin {
@Override
public void init(JadxPluginContext context) {
context.addCodeInput(JavaInputPlugin::loadClassFiles);
context.addCodeInput(inputFiles -> {
JavaInputLoader loader = new JavaInputLoader(context.getZipReader(), context.files().getPluginTempDir());
List<JavaClassReader> readers = loader.collectFiles(inputFiles);
if (readers.isEmpty()) {
return EmptyCodeLoader.INSTANCE;
}
return new JavaLoadResult(readers, null);
});
}
public static ICodeLoader loadClassFiles(List<Path> inputFiles) {
@@ -3,25 +3,29 @@ package jadx.plugins.input.xapk
import jadx.api.plugins.input.ICodeLoader
import jadx.api.plugins.input.JadxCodeInput
import jadx.api.plugins.utils.CommonFileUtils
import jadx.api.plugins.utils.ZipSecurity
import jadx.core.utils.files.ZipFile
import jadx.plugins.input.dex.DexInputPlugin
import jadx.zip.ZipReader
import java.io.File
import java.nio.file.Path
class XapkCustomCodeInput(
private val plugin: XapkInputPlugin,
private val dexInputPlugin: DexInputPlugin,
private val zipReader: ZipReader,
) : JadxCodeInput {
override fun loadFiles(input: List<Path>): ICodeLoader {
val apkFiles = mutableListOf<File>()
for (file in input.map { it.toFile() }) {
val manifest = XapkUtils.getManifest(file) ?: continue
if (!file.name.endsWith(".xapk")) continue
val manifest = XapkUtils.getManifest(file, zipReader) ?: continue
if (!XapkUtils.isSupported(manifest)) continue
ZipFile(file).use { zip ->
zipReader.open(file).use { zip ->
for (splitApk in manifest.splitApks) {
val splitApkEntry = zip.getEntry(splitApk.file)
val splitApkEntry = zip.searchEntry(splitApk.file)
if (splitApkEntry != null) {
val tmpFile = ZipSecurity.getInputStreamForEntry(zip, splitApkEntry).use {
val tmpFile = splitApkEntry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
apkFiles.add(tmpFile)
@@ -30,7 +34,7 @@ class XapkCustomCodeInput(
}
}
val codeLoader = plugin.dexInputPlugin.loadFiles(apkFiles.map { it.toPath() })
val codeLoader = dexInputPlugin.loadFiles(apkFiles.map { it.toPath() })
apkFiles.forEach { CommonFileUtils.safeDeleteFile(it) }
@@ -4,20 +4,22 @@ import jadx.api.ResourceFile
import jadx.api.ResourcesLoader
import jadx.api.plugins.CustomResourcesLoader
import jadx.api.plugins.utils.CommonFileUtils
import jadx.api.plugins.utils.ZipSecurity
import jadx.zip.ZipReader
import java.io.File
class XapkCustomResourcesLoader : CustomResourcesLoader {
class XapkCustomResourcesLoader(private val zipReader: ZipReader) : CustomResourcesLoader {
private val tmpFiles = mutableListOf<File>()
override fun load(loader: ResourcesLoader, list: MutableList<ResourceFile>, file: File): Boolean {
val manifest = XapkUtils.getManifest(file) ?: return false
if (!file.name.endsWith(".xapk")) return false
val manifest = XapkUtils.getManifest(file, zipReader) ?: return false
if (!XapkUtils.isSupported(manifest)) return false
val apkEntries = manifest.splitApks.map { it.file }.toHashSet()
ZipSecurity.visitZipEntries<Any>(file) { zip, entry ->
zipReader.visitEntries(file) { entry ->
if (apkEntries.contains(entry.name)) {
val tmpFile = ZipSecurity.getInputStreamForEntry(zip, entry).use {
val tmpFile = entry.inputStream.use {
CommonFileUtils.saveToTempFile(it, ".apk").toFile()
}
loader.defaultLoadFile(list, tmpFile, entry.name + "/")
@@ -3,22 +3,20 @@ package jadx.plugins.input.xapk
import jadx.api.plugins.JadxPlugin
import jadx.api.plugins.JadxPluginContext
import jadx.api.plugins.JadxPluginInfo
import jadx.api.plugins.JadxPluginInfoBuilder
import jadx.plugins.input.dex.DexInputPlugin
class XapkInputPlugin : JadxPlugin {
private val codeInput = XapkCustomCodeInput(this)
private val resourcesLoader = XapkCustomResourcesLoader()
internal lateinit var dexInputPlugin: DexInputPlugin
override fun getPluginInfo() = JadxPluginInfo(
"xapk-input",
"XAPK Input",
"Load .xapk files",
)
override fun getPluginInfo(): JadxPluginInfo =
JadxPluginInfoBuilder.pluginId("xapk-input")
.name("XAPK Input")
.description("Load .xapk files")
.build()
override fun init(context: JadxPluginContext) {
dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
context.addCodeInput(codeInput)
context.decompiler.addCustomResourcesLoader(resourcesLoader)
val dexInputPlugin = context.plugins().getInstance(DexInputPlugin::class.java)
context.addCodeInput(XapkCustomCodeInput(dexInputPlugin, context.zipReader))
context.decompiler.addCustomResourcesLoader(XapkCustomResourcesLoader(context.zipReader))
}
}
@@ -1,19 +1,18 @@
package jadx.plugins.input.xapk
import jadx.api.plugins.utils.ZipSecurity
import jadx.core.utils.GsonUtils.buildGson
import jadx.core.utils.files.FileUtils
import jadx.core.utils.files.ZipFile
import jadx.zip.ZipReader
import java.io.File
import java.io.InputStreamReader
object XapkUtils {
fun getManifest(file: File): XapkManifest? {
fun getManifest(file: File, zipReader: ZipReader): XapkManifest? {
if (!FileUtils.isZipFile(file)) return null
try {
ZipFile(file).use { zip ->
val manifestEntry = zip.getEntry("manifest.json") ?: return null
return InputStreamReader(ZipSecurity.getInputStreamForEntry(zip, manifestEntry)).use {
zipReader.open(file).use { zip ->
val manifestEntry = zip.searchEntry("manifest.json") ?: return null
return InputStreamReader(manifestEntry.inputStream).use {
buildGson().fromJson(it, XapkManifest::class.java)
}
}
+1
View File
@@ -15,6 +15,7 @@ include("jadx-gui")
include("jadx-plugins-tools")
include("jadx-commons:jadx-app-commons")
include("jadx-commons:jadx-zip")
include("jadx-plugins:jadx-input-api")
include("jadx-plugins:jadx-dex-input")