Skip to content

Commit

Permalink
Fix some native jar wrappers not being recognized as zip archives
Browse files Browse the repository at this point in the history
  • Loading branch information
Col-E committed Dec 26, 2024
1 parent 06e0965 commit 975a7ce
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package software.coley.recaf.info.properties.builtin;

import jakarta.annotation.Nonnull;
import software.coley.recaf.info.FileInfo;
import software.coley.recaf.info.properties.BasicProperty;
import software.coley.recaf.info.properties.Property;

/**
* Built in property to track {@link FileInfo} instances that have a ZIP file header within their contents.
*
* @author Matt Coley
*/
public class ZipMarkerProperty extends BasicProperty<Boolean> {
public static final String KEY = "has-zip-marker";

/**
* New property value.
*/
public ZipMarkerProperty() {
super(KEY, true);
}

@Override
public boolean persistent() {
return false;
}

/**
* @param info
* File info instance
*
* @return {@code true} when the file has a ZIP file header in the contents.
*/
public static boolean get(@Nonnull FileInfo info) {
Property<Boolean> property = info.getProperty(KEY);
if (property != null) {
Boolean value = property.value();
return value != null && value;
}
return false;
}

/**
* Marks the class as inheriting containing a ZIP file header.
*
* @param info
* File info instance.
*/
public static void set(@Nonnull FileInfo info) {
info.setProperty(new ZipMarkerProperty());
}

/**
* @param info
* File info instance.
*/
public static void remove(@Nonnull FileInfo info) {
info.removeProperty(KEY);
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package software.coley.recaf.services.workspace.io;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.objectweb.asm.ClassReader;
import org.slf4j.Logger;
import software.coley.cafedude.classfile.VersionConstants;
import software.coley.recaf.analytics.logging.Logging;
import software.coley.recaf.info.FileInfo;
import software.coley.recaf.info.Info;
import software.coley.recaf.info.builder.*;
import software.coley.recaf.info.properties.builtin.IllegalClassSuspectProperty;
import software.coley.recaf.info.properties.builtin.ZipMarkerProperty;
import software.coley.recaf.util.ByteHeaderUtil;
import software.coley.recaf.util.IOUtil;
import software.coley.recaf.util.android.AndroidXmlUtil;
Expand Down Expand Up @@ -59,14 +61,14 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO
logger.debug("CafeDude patched class: {}", name);
try {
return new JvmClassInfoBuilder(patched)
.skipValidationChecks(false)
.build();
.skipValidationChecks(false)
.build();
} catch (Throwable t1) {
logger.error("CafeDude patching output is still non-compliant with ASM for file: {}", name);
return new FileInfoBuilder<>()
.withRawContent(data)
.withName(name)
.build();
.withRawContent(data)
.withName(name)
.build();
}
}
} catch (Throwable t) {
Expand All @@ -82,6 +84,56 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO
}

// Comparing against known file types.
boolean hasZipMarker = ByteHeaderUtil.matchAtAnyOffset(data, ByteHeaderUtil.ZIP);
FileInfo info = readAsSpecializedFile(name, data);
if (info != null) {
if (hasZipMarker)
ZipMarkerProperty.set(info);
return info;
}

// Check for ZIP containers (For ZIP/JAR/JMod/WAR)
// - While this is more common, some of the known file types may match 'ZIP' with
// our 'any-offset' condition we have here.
// - We need 'any-offset' to catch all ZIP cases, but it can match some of the file types
// above in some conditions, which means we have to check for it last.
if (hasZipMarker) {
ZipFileInfoBuilder builder = new ZipFileInfoBuilder()
.withProperty(new ZipMarkerProperty())
.withRawContent(data)
.withName(name);

// Record name, handle extension to determine info-type
String extension = IOUtil.getExtension(name);
if (extension == null) return builder.build();
return switch (extension.toUpperCase()) {
case "JAR" -> builder.asJar().build();
case "APK" -> builder.asApk().build();
case "WAR" -> builder.asWar().build();
case "JMOD" -> builder.asJMod().build();
default -> builder.build();
};
}

// No special case known for file, treat as generic file
// Will be automatically mapped to a text file if the contents are all mappable characters.
return new FileInfoBuilder<>()
.withRawContent(data)
.withName(name)
.build();
}

/**
* @param name
* Name of file.
* @param data
* File content.
*
* @return The {@link FileInfo} subtype of matched special cases <i>(Media, executables, etc.)</i>
* or {@code null} if no special case is matched.
*/
@Nullable
private static FileInfo readAsSpecializedFile(@Nonnull String name, byte[] data) {
if (ByteHeaderUtil.match(data, ByteHeaderUtil.DEX)) {
return new DexFileInfoBuilder()
.withRawContent(data)
Expand Down Expand Up @@ -125,35 +177,7 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO
.withName(name)
.build();
}

// Check for ZIP containers (For ZIP/JAR/JMod/WAR)
// - While this is more common, some of the known file types may match 'ZIP' with
// our 'any-offset' condition we have here.
// - We need 'any-offset' to catch all ZIP cases, but it can match some of the file types
// above in some conditions, which means we have to check for it last.
if (ByteHeaderUtil.matchAtAnyOffset(data, ByteHeaderUtil.ZIP)) {
ZipFileInfoBuilder builder = new ZipFileInfoBuilder()
.withRawContent(data)
.withName(name);

// Record name, handle extension to determine info-type
String extension = IOUtil.getExtension(name);
if (extension == null) return builder.build();
return switch (extension.toUpperCase()) {
case "JAR" -> builder.asJar().build();
case "APK" -> builder.asApk().build();
case "WAR" -> builder.asWar().build();
case "JMOD" -> builder.asJMod().build();
default -> builder.build();
};
}

// No special case known for file, treat as generic file
// Will be automatically mapped to a text file if the contents are all mappable characters.
return new FileInfoBuilder<>()
.withRawContent(data)
.withName(name)
.build();
return null;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@
import software.coley.lljzip.util.ExtraFieldTime;
import software.coley.lljzip.util.MemorySegmentUtil;
import software.coley.recaf.analytics.logging.Logging;
import software.coley.recaf.info.*;
import software.coley.recaf.info.DexFileInfo;
import software.coley.recaf.info.FileInfo;
import software.coley.recaf.info.Info;
import software.coley.recaf.info.JarFileInfo;
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.info.ModulesFileInfo;
import software.coley.recaf.info.ZipFileInfo;
import software.coley.recaf.info.builder.FileInfoBuilder;
import software.coley.recaf.info.builder.ZipFileInfoBuilder;
import software.coley.recaf.info.properties.builtin.*;
import software.coley.recaf.services.Service;
import software.coley.recaf.util.IOUtil;
Expand All @@ -22,17 +29,34 @@
import software.coley.recaf.util.io.ByteSource;
import software.coley.recaf.util.io.ByteSources;
import software.coley.recaf.util.io.LocalFileHeaderSource;
import software.coley.recaf.workspace.model.bundle.*;
import software.coley.recaf.workspace.model.resource.*;
import software.coley.recaf.workspace.model.bundle.AndroidClassBundle;
import software.coley.recaf.workspace.model.bundle.BasicFileBundle;
import software.coley.recaf.workspace.model.bundle.BasicJvmClassBundle;
import software.coley.recaf.workspace.model.bundle.BasicVersionedJvmClassBundle;
import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle;
import software.coley.recaf.workspace.model.resource.WorkspaceDirectoryResource;
import software.coley.recaf.workspace.model.resource.WorkspaceFileResource;
import software.coley.recaf.workspace.model.resource.WorkspaceFileResourceBuilder;
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder;

import java.io.File;
import java.io.IOException;
import java.lang.foreign.MemorySegment;
import java.net.URI;
import java.net.URL;
import java.nio.file.*;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.TreeMap;

/**
* Basic implementation of the resource importer.
Expand Down Expand Up @@ -105,6 +129,17 @@ private WorkspaceResource handleSingle(@Nonnull WorkspaceFileResourceBuilder bui
if (readInfoAsFile.isZipFile()) {
ZipFileInfo readInfoAsZip = readInfoAsFile.asZipFile();
return handleZip(builder, readInfoAsZip, source);
} else if (ZipMarkerProperty.get(readInfoAsFile)) {
// In some cases the file may have been matched as something else (like an executable)
// but also count as a ZIP container. Applications that bundle Java applications into native exe files
// tend to do this.
try {
return handleZip(builder, new ZipFileInfoBuilder(readInfoAsFile.toFileBuilder()).build(), source);
} catch (Throwable t) {
// Some files will just so happen to have a ZIP marker in their bytes but not represent an actual ZIP.
// This is fine because by this point we have an info-type to fall back on.
logger.debug("Saw ZIP marker in file {} but could not parse as ZIP.", name);
}
}

// Check for DEX file format.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.coley.recaf.info.*;
import software.coley.recaf.info.properties.builtin.ZipMarkerProperty;
import software.coley.recaf.test.TestClassUtils;
import software.coley.recaf.util.ZipCreationUtils;
import software.coley.recaf.util.io.ByteSource;
Expand Down Expand Up @@ -98,12 +99,35 @@ void testImportZip() throws IOException {

// However, if we provide various extensions then we can use the file name to infer what kind of ZIP it is.
read = importer.readInfo("data.jar", zipSource);
assertTrue(read instanceof JarFileInfo);
assertInstanceOf(JarFileInfo.class, read);
read = importer.readInfo("data.war", zipSource);
assertTrue(read instanceof WarFileInfo);
assertInstanceOf(WarFileInfo.class, read);
read = importer.readInfo("data.jmod", zipSource);
assertTrue(read instanceof JModFileInfo);
assertInstanceOf(JModFileInfo.class, read);
read = importer.readInfo("data.apk", zipSource);
assertTrue(read instanceof ApkFileInfo);
assertInstanceOf(ApkFileInfo.class, read);
}

/** @see ResourceImporterTest#testImportFileWithExeHeaderAsZipIfZipContentsAreValid() */
@Test
void testImportFileWithoutZipPrefixHasZipMarkerAssigned() throws IOException {
// Create virtual ZIP with single 'Hello.txt' and suffix the file with a PE header.
byte[] zipFileBytes = ZipCreationUtils.createSingleEntryZip("Hello.txt", "Hello world".getBytes(StandardCharsets.UTF_8));
byte[] inputBytes = new byte[4096];
inputBytes[0] = 0x4D;
inputBytes[1] = 0x5A;
System.arraycopy(zipFileBytes, 0, inputBytes, inputBytes.length - zipFileBytes.length, zipFileBytes.length);
ByteSource exeSource = ByteSources.wrap(inputBytes);

// We should import the info as an executable since the header matches
// but note that it has the ZIP marker in its contents.
Info read = importer.readInfo("example.exe", exeSource);
assertTrue(read.isFile());
assertTrue(read.asFile().isNativeLibraryFile());
assertFalse(read.asFile().isZipFile());
assertEquals(BasicNativeLibraryFileInfo.class, read.getClass());

// The ZIP marker should exist.
assertTrue(ZipMarkerProperty.get(read.asFile()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.coley.recaf.info.BasicNativeLibraryFileInfo;
import software.coley.recaf.info.FileInfo;
import software.coley.recaf.info.Info;
import software.coley.recaf.info.JarFileInfo;
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.info.properties.builtin.ZipAccessTimeProperty;
import software.coley.recaf.info.properties.builtin.ZipCommentProperty;
import software.coley.recaf.info.properties.builtin.ZipCreationTimeProperty;
import software.coley.recaf.info.properties.builtin.ZipMarkerProperty;
import software.coley.recaf.info.properties.builtin.ZipModificationTimeProperty;
import software.coley.recaf.test.TestClassUtils;
import software.coley.recaf.test.dummy.HelloWorld;
Expand Down Expand Up @@ -449,4 +452,21 @@ void testZipProperties() throws IOException {
assertEquals(timeModify, ZipModificationTimeProperty.get(fileInfo), "Missing modification time");
assertEquals(timeAccess, ZipAccessTimeProperty.get(fileInfo), "Missing access time");
}

/** @see InfoImporterTest#testImportFileWithoutZipPrefixHasZipMarkerAssigned() */
@Test
void testImportFileWithExeHeaderAsZipIfZipContentsAreValid() throws IOException {
// Create virtual ZIP with single 'Hello.txt' and suffix the file with a PE header.
byte[] zipFileBytes = ZipCreationUtils.createSingleEntryZip("Hello.txt", "Hello world".getBytes(StandardCharsets.UTF_8));
byte[] inputBytes = new byte[4096];
inputBytes[0] = 0x4D;
inputBytes[1] = 0x5A;
System.arraycopy(zipFileBytes, 0, inputBytes, inputBytes.length - zipFileBytes.length, zipFileBytes.length);
ByteSource exeSource = ByteSources.wrap(inputBytes);

// When we import the "executable" we should still load the ZIP file contents since it has a ZIP marker
// and is able to be processed as a ZIP archive.
WorkspaceResource resource = importer.importResource(exeSource);
assertTrue(resource.getFileBundle().containsKey("Hello.txt"));
}
}

0 comments on commit 975a7ce

Please sign in to comment.