feat(res): use file headers to detect extension for obfuscated resources (PR #2495)

* feat(res): add feature to use headers for detect resource extensions if resource obfuscated

* fix(res): read first 4kb data, for detect headers & use utf8 charset for decode bytes to string
This commit is contained in:
Yaroslav
2025-05-20 23:07:41 +03:00
committed by GitHub
parent d0351a88ba
commit 00608f8e51
18 changed files with 263 additions and 10 deletions
@@ -227,6 +227,12 @@ public class JadxCLIArgs {
)
protected UseKotlinMethodsForVarNames useKotlinMethodsForVarNames = UseKotlinMethodsForVarNames.APPLY;
@Parameter(
names = { "--use-headers-for-detect-resource-extensions" },
description = "Use headers for detect resource extensions if resource obfuscated"
)
protected boolean useHeadersForDetectResourceExtensions = false;
@Parameter(
names = { "--rename-flags" },
description = "fix options (comma-separated list of):"
@@ -354,6 +360,7 @@ public class JadxCLIArgs {
args.setDeobfuscationMaxLength(deobfuscationMaxLength);
args.setDeobfuscationWhitelist(Arrays.asList(deobfuscationWhitelistStr.split(" ")));
args.setUseSourceNameAsClassNameAlias(getUseSourceNameAsClassNameAlias());
args.setUseHeadersForDetectResourceExtensions(useHeadersForDetectResourceExtensions);
args.setSourceNameRepeatLimit(sourceNameRepeatLimit);
args.setUseKotlinMethodsForVarNames(useKotlinMethodsForVarNames);
args.setResourceNameSource(resourceNameSource);
@@ -586,6 +593,10 @@ public class JadxCLIArgs {
return fsCaseSensitive;
}
public boolean isUseHeadersForDetectResourceExtensions() {
return useHeadersForDetectResourceExtensions;
}
public CommentsLevel getCommentsLevel() {
return commentsLevel;
}
+11 -1
View File
@@ -91,6 +91,7 @@ public class JadxArgs implements Closeable {
private boolean skipResources = false;
private boolean skipSources = false;
private boolean useHeadersForDetectResourceExtensions;
/**
* Predicate that allows to filter the classes to be process based on their full name
@@ -817,6 +818,14 @@ public class JadxArgs implements Closeable {
this.loadJadxClsSetFile = loadJadxClsSetFile;
}
public void setUseHeadersForDetectResourceExtensions(boolean useHeadersForDetectResourceExtensions) {
this.useHeadersForDetectResourceExtensions = useHeadersForDetectResourceExtensions;
}
public boolean isUseHeadersForDetectResourceExtensions() {
return useHeadersForDetectResourceExtensions;
}
/**
* Hash of all options that can change result code
*/
@@ -825,7 +834,7 @@ public class JadxArgs implements Closeable {
+ inlineAnonymousClasses + inlineMethods + moveInnerClasses + allowInlineKotlinLambda
+ deobfuscationOn + deobfuscationMinLength + deobfuscationMaxLength + deobfuscationWhitelist
+ useSourceNameAsClassNameAlias + sourceNameRepeatLimit
+ resourceNameSource
+ resourceNameSource + useHeadersForDetectResourceExtensions
+ useKotlinMethodsForVarNames
+ insertDebugLines + extractFinally
+ debugInfo + escapeUnicode + replaceConsts + restoreSwitchOverString
@@ -888,6 +897,7 @@ public class JadxArgs implements Closeable {
+ ", pluginOptions=" + pluginOptions
+ ", cfgOutput=" + cfgOutput
+ ", rawCFGOutput=" + rawCFGOutput
+ ", useHeadersForDetectResourceExtensions=" + useHeadersForDetectResourceExtensions
+ '}';
}
}
@@ -4,6 +4,9 @@ import java.io.File;
import org.jetbrains.annotations.Nullable;
import jadx.core.deobf.FileTypeDetector;
import jadx.core.utils.StringUtils;
import jadx.core.utils.exceptions.JadxException;
import jadx.core.xmlgen.ResContainer;
import jadx.core.xmlgen.entry.ResourceEntry;
import jadx.zip.IZipEntry;
@@ -11,7 +14,7 @@ import jadx.zip.IZipEntry;
public class ResourceFile {
private final JadxDecompiler decompiler;
private final String name;
private final ResourceType type;
private ResourceType type;
private @Nullable IZipEntry zipEntry;
private String deobfName;
@@ -53,22 +56,63 @@ public class ResourceFile {
return ResourcesLoader.loadContent(decompiler, this);
}
public boolean setAlias(ResourceEntry ri) {
public boolean setAlias(ResourceEntry entry, boolean useHeders) {
StringBuilder sb = new StringBuilder();
sb.append("res/").append(ri.getTypeName()).append(ri.getConfig());
sb.append("/").append(ri.getKeyName());
int lastDot = name.lastIndexOf('.');
if (lastDot != -1) {
sb.append(name.substring(lastDot));
sb.append("res/").append(entry.getTypeName()).append(entry.getConfig());
sb.append("/").append(entry.getKeyName());
if (useHeders) {
try {
int maxBytesToReadLimit = 4096;
byte[] bytes = ResourcesLoader.decodeStream(this, (size, is) -> {
int bytesToRead;
if (size > 0) {
bytesToRead = (int) Math.min(size, maxBytesToReadLimit);
} else if (size == 0) {
bytesToRead = 0;
} else {
bytesToRead = maxBytesToReadLimit;
}
if (bytesToRead == 0) {
return new byte[0];
}
return is.readNBytes(bytesToRead);
});
String fileExtension = FileTypeDetector.detectFileExtension(bytes);
if (!StringUtils.isEmpty(fileExtension)) {
sb.append(fileExtension);
} else {
sb.append(getExtFromName(name));
}
} catch (JadxException ignored) {
}
} else {
sb.append(getExtFromName(name));
}
String alias = sb.toString();
if (!alias.equals(name)) {
setDeobfName(alias);
type = ResourceType.getFileType(alias);
return true;
}
return false;
}
private String getExtFromName(String name) {
// the image .9.png extension always saved, when resource shrinking by aapt2
if (name.contains(".9.png")) {
return ".9.png";
}
int lastDot = name.lastIndexOf('.');
if (lastDot != -1) {
return name.substring(lastDot);
}
return "";
}
public @Nullable IZipEntry getZipEntry() {
return zipEntry;
}
@@ -165,7 +165,7 @@ public final class ResourcesLoader implements IResourcesLoader {
}
private static ResContainer decodeImage(ResourceFile rf, InputStream inputStream) {
String name = rf.getOriginalName();
String name = rf.getDeobfName();
if (name.endsWith(".9.png")) {
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
Res9patchStreamDecoder decoder = new Res9patchStreamDecoder();
@@ -0,0 +1,129 @@
package jadx.core.deobf;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import jadx.core.utils.FileSignature;
import jadx.core.utils.StringUtils;
public class FileTypeDetector {
private static final Pattern DOCTYPE_PATTERN = Pattern.compile("\\s*<!doctype *(\\w+)[ >]", Pattern.CASE_INSENSITIVE);
private static final List<FileSignature> FILE_SIGNATURES = new ArrayList<>();
static {
register("png", "89 50 4E 47");
register("jpg", "FF D8 FF");
register("gif", "47 49 46 38");
register("webp", "52 49 46 46 ?? ?? ?? ?? 57 45 42 50 56 50 38");
register("bmp", "42 4D");
register("bmp", "42 41");
register("bmp", "43 49");
register("bmp", "43 50");
register("bmp", "49 43");
register("bmp", "50 54");
register("mp4", "00 00 00 ?? 66 74 79 70 69 73 6F 36");
register("mp4", "00 00 00 ?? 66 74 79 70 6D 70 34 32");
register("m4a", "00 00 00 ?? 66 74 79 70 4D 34 41 20");
register("mp3", "49 44 33");
register("ogg", "4F 67 67 53");
register("wav", "52 49 46 46 ?? ?? ?? ?? 57 41 56 45");
register("ttf", "00 01 00 00");
register("ttc", "74 74 63 66");
register("otf", "4F 54 54 4F");
register("xml", "03 00 08 00");
}
public static void register(String fileType, String signature) {
FILE_SIGNATURES.add(new FileSignature(fileType, signature));
}
private static String detectByHeaders(byte[] data) {
for (FileSignature sig : FILE_SIGNATURES) {
if (FileSignature.matches(sig, data)) {
if (sig.getFileType().equals("png") && isNinePatch(data)) {
return ".9.png";
}
return "." + sig.getFileType();
}
}
return null;
}
public static String detectFileExtension(byte[] data) {
// detect ext by headers
String extByHeaders = detectByHeaders(data);
if (!StringUtils.isEmpty(extByHeaders)) {
return extByHeaders;
}
// detect ext by readable text
String text = new String(data, StandardCharsets.UTF_8);
if (text.startsWith("-----BEGIN CERTIFICATE-----")) {
return ".cer";
}
if (text.startsWith("-----BEGIN PRIVATE KEY-----")) {
return ".key";
}
if (text.contains("<html>")) {
return ".html";
}
Matcher m = DOCTYPE_PATTERN.matcher(text);
if (m.lookingAt()) {
return "." + m.group(1).toLowerCase();
}
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new java.io.ByteArrayInputStream(data));
String rootTag = doc.getDocumentElement().getNodeName();
if ("svg".equalsIgnoreCase(rootTag)) {
return ".svg";
}
if ("plist".equalsIgnoreCase(rootTag)) {
return ".plist";
}
if ("kml".equalsIgnoreCase(rootTag)) {
return ".kml";
}
return ".xml";
} catch (Exception ignored) {
}
return null;
}
private static int readInt(byte[] data, int offset) {
return (data[offset] & 0xFF) << 24
| (data[offset + 1] & 0xFF) << 16
| (data[offset + 2] & 0xFF) << 8
| (data[offset + 3] & 0xFF);
}
private static boolean isNinePatch(byte[] data) {
int offset = 8;
while (offset + 8 < data.length) {
int chunkLength = readInt(data, offset);
int chunkType = readInt(data, offset + 4);
if (chunkType == 0x6e705463) { // 'npTc'
return true;
}
offset += 8 + chunkLength + 4; // chunk + data + CRC
}
return false;
}
}
@@ -271,6 +271,7 @@ public class RootNode {
if (args.isSkipResources()) {
return;
}
boolean useHeaders = args.isUseHeadersForDetectResourceExtensions();
long start = System.currentTimeMillis();
int renamedCount = 0;
ResourceStorage resStorage = parser.getResStorage();
@@ -285,7 +286,7 @@ public class RootNode {
for (ResourceFile resource : resources) {
ResourceEntry resEntry = entryNames.get(resource.getOriginalName());
if (resEntry != null) {
if (resource.setAlias(resEntry)) {
if (resource.setAlias(resEntry, useHeaders)) {
renamedCount++;
}
}
@@ -0,0 +1,37 @@
package jadx.core.utils;
public class FileSignature {
private final byte[] signatureBytes;
private final String fileType;
public FileSignature(String fileType, String signatureHex) {
this.fileType = fileType;
String[] parts = signatureHex.split(" ");
this.signatureBytes = new byte[parts.length];
for (int i = 0; i < parts.length; i++) {
if (parts[i].length() != 2) {
throw new RuntimeException(signatureHex);
}
if (!parts[i].equals("??")) {
this.signatureBytes[i] = (byte) Integer.parseInt(parts[i], 16);
}
}
}
public static boolean matches(FileSignature sig, byte[] data) {
if (data.length < sig.signatureBytes.length) {
return false;
}
for (int i = 0; i < sig.signatureBytes.length; i++) {
byte b = sig.signatureBytes[i];
if (b != data[i]) {
return false;
}
}
return true;
}
public String getFileType() {
return fileType;
}
}
@@ -446,6 +446,10 @@ public class JadxSettings extends JadxCLIArgs {
this.resourceNameSource = source;
}
public void setUseHeadersForDetectResourceExtension(boolean enable) {
this.useHeadersForDetectResourceExtensions = enable;
}
public void updateRenameFlag(JadxArgs.RenameEnum flag, boolean enabled) {
if (enabled) {
renameFlags.add(flag);
@@ -234,6 +234,13 @@ public class JadxSettingsWindow extends JDialog {
needReload();
});
JCheckBox useHeaders = new JCheckBox();
useHeaders.setSelected(settings.isUseHeadersForDetectResourceExtensions());
useHeaders.addItemListener(e -> {
settings.setUseHeadersForDetectResourceExtension(e.getStateChange() == ItemEvent.SELECTED);
needReload();
});
JComboBox<GeneratedRenamesMappingFileMode> generatedRenamesMappingFileModeCB =
new JComboBox<>(GeneratedRenamesMappingFileMode.values());
generatedRenamesMappingFileModeCB.setSelectedItem(settings.getGeneratedRenamesMappingFileMode());
@@ -265,6 +272,7 @@ public class JadxSettingsWindow extends JDialog {
deobfGroup.addRow(NLS.str("preferences.deobfuscation_min_len"), minLenSpinner);
deobfGroup.addRow(NLS.str("preferences.deobfuscation_max_len"), maxLenSpinner);
deobfGroup.addRow(NLS.str("preferences.deobfuscation_res_name_source"), resNamesSource);
deobfGroup.addRow(NLS.str("preferences.deobfuscation_res_use_headers"), useHeaders);
deobfGroup.addRow(NLS.str("preferences.generated_renames_mapping_file_mode"), generatedRenamesMappingFileModeCB);
deobfGroup.addRow(NLS.str("preferences.deobfuscation_whitelist"),
NLS.str("preferences.deobfuscation_whitelist.tooltip"), editWhitelistedEntities);
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=Umgang mit Map-Dateien
preferences.deobfuscation_min_len=Minimale Namenlänge
preferences.deobfuscation_max_len=Maximale Namenlänge
preferences.deobfuscation_res_name_source=Bessere Ressourcennamenquelle
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
preferences.deobfuscation_whitelist=Pakete und Klassen von Deobfuskierung ausschließen
preferences.deobfuscation_whitelist.editDialog=Weiße Liste für Deobfuskierung
preferences.deobfuscation_whitelist.tooltip=Liste der durch ':' getrennten Pakete (Suffix '.*') und Klassenamen, die nicht deobfuskiert werden sollen
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=Map file handle mode
preferences.deobfuscation_min_len=Minimum name length
preferences.deobfuscation_max_len=Maximum name length
preferences.deobfuscation_res_name_source=Better resources name source
preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
preferences.deobfuscation_whitelist=Exclude packages and classes from deobfuscation
preferences.deobfuscation_whitelist.editDialog=Whitelist for deobfuscation
preferences.deobfuscation_whitelist.tooltip=List of ':' separated packages (suffix '.*') and class names that will not be deobfuscated
@@ -277,6 +277,7 @@ preferences.deobfuscation_on=Activar desobfuscación
preferences.deobfuscation_min_len=Longitud mínima del nombre
preferences.deobfuscation_max_len=Longitud máxima del nombre
#preferences.deobfuscation_res_name_source=Better resources name source
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
#preferences.deobfuscation_whitelist=Exclude packages and classes from deobfuscation
#preferences.deobfuscation_whitelist.editDialog=Whitelist for deobfuscation
#preferences.deobfuscation_whitelist.tooltip=List of ':' separated packages (suffix '.*') and class names that will not be deobfuscated
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=Mode penanganan file pemetaan
preferences.deobfuscation_min_len=Panjang nama minimum
preferences.deobfuscation_max_len=Panjang nama maksimum
preferences.deobfuscation_res_name_source=Sumber nama sumber daya yang lebih baik
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
#preferences.deobfuscation_whitelist=Exclude packages and classes from deobfuscation
#preferences.deobfuscation_whitelist.editDialog=Whitelist for deobfuscation
#preferences.deobfuscation_whitelist.tooltip=List of ':' separated packages (suffix '.*') and class names that will not be deobfuscated
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=맵 파일 처리 모드
preferences.deobfuscation_min_len=최소 이름 길이
preferences.deobfuscation_max_len=최대 이름 길이
preferences.deobfuscation_res_name_source=더 나은 리소스 이름 소스
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
#preferences.deobfuscation_whitelist=Exclude packages and classes from deobfuscation
#preferences.deobfuscation_whitelist.editDialog=Whitelist for deobfuscation
#preferences.deobfuscation_whitelist.tooltip=List of ':' separated packages (suffix '.*') and class names that will not be deobfuscated
@@ -277,6 +277,7 @@ preferences.deobfuscation_on=Ativar desofuscação
preferences.deobfuscation_min_len=Tamanho mínimo do nome
preferences.deobfuscation_max_len=Tamanho máximo do nome
preferences.deobfuscation_res_name_source=Melhora nome da fonte dos recursos
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
#preferences.deobfuscation_whitelist=Exclude packages and classes from deobfuscation
#preferences.deobfuscation_whitelist.editDialog=Whitelist for deobfuscation
#preferences.deobfuscation_whitelist.tooltip=List of ':' separated packages (suffix '.*') and class names that will not be deobfuscated
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=Режим обработки м
preferences.deobfuscation_min_len=Минимальная длина имени
preferences.deobfuscation_max_len=Максимальная длина имени
preferences.deobfuscation_res_name_source=Расшифровка имен ресурсов
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
preferences.deobfuscation_whitelist=Исключить пакеты и классы из деобфускации
preferences.deobfuscation_whitelist.editDialog=Белый список деобфускации
preferences.deobfuscation_whitelist.tooltip=Разделяйте пакеты через ':' (суффикс '.*') и имя класса которое не будет деобфусцировано
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=映射文件句柄模式
preferences.deobfuscation_min_len=最小命名长度
preferences.deobfuscation_max_len=最大命名长度
preferences.deobfuscation_res_name_source=更好的资源名称来源
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
preferences.deobfuscation_whitelist=从反混淆中排除包和类
preferences.deobfuscation_whitelist.editDialog=反混淆白名单
preferences.deobfuscation_whitelist.tooltip=以‘:’分隔的包(后缀‘.*’)和类名列表,它们不会被反混淆。
@@ -277,6 +277,7 @@ preferences.generated_renames_mapping_file_mode=Map 檔案處理模式
preferences.deobfuscation_min_len=最小名稱長度
preferences.deobfuscation_max_len=最大名稱長度
preferences.deobfuscation_res_name_source=較佳的資源名稱來源
#preferences.deobfuscation_res_use_headers=Use headers for detect resource extensions
preferences.deobfuscation_whitelist=去模糊化時排除套件和類別
preferences.deobfuscation_whitelist.editDialog=去模糊化白名單
preferences.deobfuscation_whitelist.tooltip=將不會被去模糊化的套件(後綴 '.*')和類別名稱清單,以 ':' 分隔