CVE-2026-39973
Description
Apktool is a tool for reverse engineering Android APK files. In versions 3.0.0 and 3.0.1, a path traversal vulnerability in brut/androlib/res/decoder/ResFileDecoder.java allows a maliciously crafted APK to write arbitrary files to the filesystem during standard decoding (apktool d). This is a security regression introduced in commit e10a045 (PR #4041, December 12, 2025), which removed the BrutIO.sanitizePath() call that previously prevented path traversal in resource file output paths. An attacker can embed ../ sequences in the resources.arsc Type String Pool to escape the output directory and write files to arbitrary locations, including ~/.ssh/config, ~/.bashrc, or Windows Startup folders, escalating to RCE. The fix in version 3.0.2 re-introduces BrutIO.sanitizePath() in ResFileDecoder.java before file write operations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.apktool:apktool-libMaven | >= 3.0.0, < 3.0.2 | 3.0.2 |
Affected products
1Patches
1e10a0450c7afrefactor: Improve renaming/injection of resources in stripped APKs (#4041)
24 files changed · +549 −310
brut.apktool/apktool-cli/src/main/java/brut/apktool/Main.java+4 −4 modified@@ -460,15 +460,15 @@ private static void cmdDecode(String[] args) throws AndrolibException { case "remove": config.setDecodeResolve(Config.DecodeResolve.REMOVE); break; - case "dummy": - config.setDecodeResolve(Config.DecodeResolve.DUMMY); - break; case "keep": config.setDecodeResolve(Config.DecodeResolve.KEEP); break; + case "dummy": + config.setDecodeResolve(Config.DecodeResolve.DUMMY); + break; default: System.err.println("Unknown resolve resources mode: " + mode); - System.err.println("Expect: 'remove', 'dummy' or 'keep'."); + System.err.println("Expect: 'remove', 'keep' or 'dummy'."); System.exit(1); return; }
brut.apktool/apktool-lib/src/main/java/brut/androlib/Config.java+2 −2 modified@@ -19,7 +19,7 @@ public class Config { public enum DecodeSources { NONE, FULL, ONLY_MAIN_CLASSES } public enum DecodeResources { NONE, FULL, ONLY_MANIFEST } - public enum DecodeResolve { REMOVE, DUMMY, KEEP } + public enum DecodeResolve { REMOVE, KEEP, DUMMY } public enum DecodeAssets { NONE, FULL } private final String mVersion; @@ -64,7 +64,7 @@ public Config(String version) { mDecodeSources = DecodeSources.FULL; mBaksmaliDebugMode = true; mDecodeResources = DecodeResources.FULL; - mDecodeResolve = DecodeResolve.REMOVE; + mDecodeResolve = DecodeResolve.KEEP; mKeepBrokenResources = false; mAnalysisMode = false; mDecodeAssets = DecodeAssets.FULL;
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/BinaryResourceParser.java+13 −1 modified@@ -812,8 +812,20 @@ private void injectMissingEntrySpecs() throws AndrolibException { } for (ResId id : mMissingEntrySpecs) { + ResTypeSpec typeSpec = mPackage.getTypeSpec(id.getTypeId()); + ResValue value; + switch (typeSpec.getName()) { + case "attr": + case "^attr-private": + value = ResAttribute.DEFAULT; + break; + default: + value = ResReference.NULL; + break; + } + mPackage.addEntrySpec(id, ResEntrySpec.DUMMY_PREFIX + id); - mPackage.addEntry(id, ResConfig.DEFAULT, ResReference.NULL); + mPackage.addEntry(id, ResConfig.DEFAULT, value); } } }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/BinaryXmlResourceParser.java+82 −49 modified@@ -18,6 +18,7 @@ import android.content.res.XmlResourceParser; import android.util.TypedValue; +import brut.androlib.Config; import brut.androlib.exceptions.AndrolibException; import brut.androlib.exceptions.FrameworkNotFoundException; import brut.androlib.exceptions.UndefinedResObjectException; @@ -61,6 +62,7 @@ public class BinaryXmlResourceParser implements XmlResourceParser { private static final int ATTRIBUTE_IX_VALUE_DATA = 4; // data private static final int ATTRIBUTE_LENGTH = 5; + private static final int ANDROID_PKG_ID = 0x01; private static final int PRIVATE_PKG_ID = 0x7F; private final ResTable mTable; @@ -215,10 +217,11 @@ public char[] getTextCharacters(int[] holderForStartAndLength) { if (text == null) { return null; } + int len = text.length(); holderForStartAndLength[0] = 0; - holderForStartAndLength[1] = text.length(); - char[] chars = new char[text.length()]; - text.getChars(0, text.length(), chars, 0); + holderForStartAndLength[1] = len; + char[] chars = new char[len]; + text.getChars(0, len, chars, 0); return chars; } @@ -310,20 +313,24 @@ public String getAttributeNamespace(int index) { int offset = getAttributeOffset(index); int namespace = mAttributes[offset + ATTRIBUTE_IX_NAMESPACE_URI]; + ResId nameId = ResId.of(getAttributeNameResource(index)); + int pkgId = nameId.getPackageId(); + // #2972 - If the namespace index is -1, the attribute is not present, but if the attribute is from system // we can resolve it to the default namespace. This may prove to be too aggressive as we scope the entire // system namespace, but it is better than not resolving it at all. - ResId id = ResId.of(getAttributeNameResource(index)); - int pkgId = id.getPackageId(); - if (namespace == -1 && pkgId == 1) { - return ANDROID_RES_NS; - } if (namespace == -1) { + if (pkgId == PRIVATE_PKG_ID) { + return getNonDefaultNamespaceUri(offset); + } + if (pkgId == ANDROID_PKG_ID) { + return ANDROID_RES_NS; + } return ""; } // Minifiers like removing the namespace, so we will default to default namespace - // unless the pkgId of the resource is private. We will grab the non-standard one. + // unless the package ID of the resource is private. We will grab the non-standard one. String value = mStringPool.getString(namespace); if (value != null && !value.isEmpty()) { return value; @@ -375,23 +382,50 @@ public String getAttributeName(int index) { // Are improperly decoded when trusting the string pool. // Leveraging the resource map allows us to get the proper value. // <item android:state_enabled="true" app:d2="false" app:d3="true"> - String resourceMapValue; try { - resourceMapValue = decodeFromResourceId(nameId); + String resourceMapValue = decodeFromResourceId(nameId); + if (resourceMapValue != null) { + return resourceMapValue; + } } catch (AndrolibException ignored) { - resourceMapValue = null; - } - if (resourceMapValue != null) { - return resourceMapValue; } + // Couldn't decode from resource map, fall back to string pool. String stringPoolValue = mStringPool.getString(name); - if (stringPoolValue != null) { - return stringPoolValue; + if (stringPoolValue == null) { + stringPoolValue = ""; } - // If it was not found in either, then we have a bogus resource. - return ResEntrySpec.MISSING_PREFIX + nameId; + // In certain optimized apps, some attributes's specs are removed despite being used. + // Inject a generic spec for the attribute, otherwise we can't rebuild. + if (nameId != ResId.NULL) { + Config config = mTable.getConfig(); + boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; + try { + ResPackage pkg = mTable.getMainPackage(); + + // #2836 - Skip item if the resource cannot be identified. + if (removeUnresolved || nameId.getPackageId() != pkg.getId()) { + LOGGER.warning(String.format( + "null attr reference: ns=%s, name=%s, id=%s", + getAttributePrefix(index), stringPoolValue, nameId)); + return stringPoolValue; + } + + if (stringPoolValue.isEmpty()) { + stringPoolValue = ResEntrySpec.MISSING_PREFIX + nameId; + } + pkg.addEntrySpec(nameId, stringPoolValue); + pkg.addEntry(nameId, ResConfig.DEFAULT, ResAttribute.DEFAULT); + } catch (AndrolibException ex) { + setFirstError(ex); + LOGGER.warning(String.format( + "Could not add missing attr: ns=%s, name=%s, id=%s", + getAttributePrefix(index), stringPoolValue, nameId)); + } + } + + return stringPoolValue; } private String decodeFromResourceId(ResId resId) throws AndrolibException { @@ -425,29 +459,48 @@ public String getAttributeValue(int index) { try { String stringPoolValue = valueRaw != -1 ? ResXmlEncoders.escapeXmlChars(mStringPool.getString(valueRaw)) : null; - String resourceMapValue = null; + String rawValue = stringPoolValue; - // Ensure we only track down obfuscated values for reference/attribute type values. Otherwise, we might - // spam lookups against resource table for invalid IDs. + boolean isExplicitType = valueType != TypedValue.TYPE_NULL; if (valueType == TypedValue.TYPE_REFERENCE || valueType == TypedValue.TYPE_DYNAMIC_REFERENCE || valueType == TypedValue.TYPE_ATTRIBUTE || valueType == TypedValue.TYPE_DYNAMIC_ATTRIBUTE) { - resourceMapValue = decodeFromResourceId(ResId.of(valueData)); + // Explicit reference format is optional. + isExplicitType = false; + // Android prefers the resource map value over what the string pool has. + // We only track down obfuscated values for reference/attribute type values. + // Otherwise, we might spam lookups against resource table for invalid IDs. + String resourceMapValue = decodeFromResourceId(ResId.of(valueData)); + if (stringPoolValue != null && resourceMapValue != null) { + // Handle a value with a format of "@yyy/xxx", but avoid "@yyy/zzz:xxx" + int slashPos = stringPoolValue.lastIndexOf('/'); + if (slashPos != -1) { + int colonPos = stringPoolValue.lastIndexOf(':'); + if (colonPos == -1) { + rawValue = stringPoolValue.substring(0, slashPos) + "/" + resourceMapValue; + } + } else if (!stringPoolValue.equals(resourceMapValue)) { + rawValue = resourceMapValue; + } + } } // Try to decode from resource table. ResId nameId = ResId.of(getAttributeNameResource(index)); - ResItem value = ResItem.parse(mTable.getCurrentPackage(), valueType, valueData, - getPreferredString(stringPoolValue, resourceMapValue)); + ResItem value = ResItem.parse(mTable.getCurrentPackage(), valueType, valueData, rawValue); if (nameId != ResId.NULL) { try { // We need the attribute entry's value to format this value. ResEntrySpec nameSpec = mTable.getEntrySpec(nameId); - ResValue nameDefValue = mTable.getDefaultEntry(nameId).getValue(); - - if (nameDefValue instanceof ResAttribute) { - decoded = ((ResAttribute) nameDefValue).formatValue(value, false); + ResValue nameValue = mTable.getDefaultEntry(nameId).getValue(); + + if (nameValue instanceof ResAttribute) { + ResAttribute nameAttr = (ResAttribute) nameValue; + if (isExplicitType && !nameAttr.hasSymbolsForValue(value)) { + nameAttr.addType(valueType); + } + decoded = nameAttr.formatValue(value, false); } else { LOGGER.warning("Unexpected attribute name: " + nameSpec); } @@ -678,26 +731,6 @@ private int findAttribute(String namespace, String attribute) { return -1; } - private static String getPreferredString(String stringPoolValue, String resourceMapValue) { - String value = stringPoolValue; - - if (stringPoolValue != null && resourceMapValue != null) { - int slashPos = stringPoolValue.lastIndexOf('/'); - int colonPos = stringPoolValue.lastIndexOf(':'); - - // Handle a value with a format of "@yyy/xxx", but avoid "@yyy/zzz:xxx" - if (slashPos != -1) { - if (colonPos == -1) { - String type = stringPoolValue.substring(0, slashPos); - value = type + "/" + resourceMapValue; - } - } else if (!stringPoolValue.equals(resourceMapValue)) { - value = resourceMapValue; - } - } - return value; - } - private void resetEventInfo() { mEvent = -1; mLineNumber = -1;
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/data/StyledString.java+7 −5 modified@@ -65,7 +65,8 @@ private String decode() { return mDecodedText; } - mBuffer = new StringBuilder(mText.length() * 2); + int len = mText.length(); + mBuffer = new StringBuilder(len * 2); mLastOffset = 0; // Recurse top-level tags. @@ -75,7 +76,7 @@ private String decode() { } // Write the remaining encoded raw text. - if (mLastOffset < mText.length()) { + if (mLastOffset < len) { mBuffer.append(ResXmlEncoders.escapeXmlChars(mText.substring(mLastOffset))); } @@ -117,10 +118,11 @@ private void decodeIterate(PeekingIterator<Span> it) { } // Write encoded raw text preceding the closing tag. - if (spanEnd > mLastOffset && mText.length() >= spanEnd) { + int len = mText.length(); + if (spanEnd > mLastOffset && len >= spanEnd) { mBuffer.append(ResXmlEncoders.escapeXmlChars(mText.substring(mLastOffset, spanEnd))); - } else if (mText.length() >= mLastOffset && mText.length() < spanEnd) { - LOGGER.warning("Span (" + name + ") exceeds text length " + mText.length()); + } else if (len >= mLastOffset && len < spanEnd) { + LOGGER.warning("Span (" + name + ") exceeds text length " + len); mBuffer.append(ResXmlEncoders.escapeXmlChars(mText.substring(mLastOffset))); } mLastOffset = spanEnd;
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/ResFileDecoder.java+2 −7 modified@@ -100,13 +100,8 @@ public void decode(ResEntry entry, Directory inDir, Directory outDir, Map<String } // Generate output file path from entry. - String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName(); - if (BrutIO.detectPossibleDirectoryTraversal(outResPath)) { - LOGGER.warning("Potentially malicious file path: " + outResPath + ", using instead: " + inResPath); - outResPath = inResPath; - } else if (!ext.isEmpty()) { - outResPath += "." + ext; - } + String outResPath = entry.getTypeName() + entry.getConfig().getQualifiers() + "/" + entry.getName() + + (ext.isEmpty() ? "" : "." + ext); // Map output path to original path if it's different. String outFileName = "res/" + outResPath;
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/ResourcesDecoder.java+15 −7 modified@@ -24,6 +24,7 @@ import brut.androlib.meta.VersionInfo; import brut.androlib.res.decoder.*; import brut.androlib.res.table.*; +import brut.androlib.res.table.value.ResBag; import brut.androlib.res.table.value.ResFileReference; import brut.androlib.res.xml.ResXmlUtils; import brut.androlib.res.xml.ValuesXmlSerializable; @@ -86,15 +87,24 @@ public void decodeResources(File apkDir) throws AndrolibException { } ResPackage pkg = mTable.getMainPackage(); - Map<ResType, List<ResEntry>> valuesEntries = new HashMap<>(); - LOGGER.info("Decoding file-resources..."); - for (ResEntry entry : pkg.listEntries()) { + LOGGER.info("Decoding value resources..."); + for (ResEntry entry : new ArrayList<>(pkg.listEntries())) { + if (entry.getValue() instanceof ResBag) { + ((ResBag) entry.getValue()).resolveKeys(); + } + } + + LOGGER.info("Decoding file resources..."); + for (ResEntry entry : new ArrayList<>(pkg.listEntries())) { if (entry.getValue() instanceof ResFileReference) { fileDecoder.decode(entry, inDir, outDir, mResFileMapping); } - // ResFileDecoder may have replaced an invalid file reference, - // so we use "if" here rather than "else if". + } + + LOGGER.info("Generating values XMLs..."); + Map<ResType, List<ResEntry>> valuesEntries = new HashMap<>(); + for (ResEntry entry : pkg.listEntries()) { if (entry.getValue() instanceof ValuesXmlSerializable) { ResType type = entry.getType(); List<ResEntry> entries = valuesEntries.get(type); @@ -105,8 +115,6 @@ public void decodeResources(File apkDir) throws AndrolibException { entries.add(entry); } } - - LOGGER.info("Decoding values */* XMLs..."); for (Map.Entry<ResType, List<ResEntry>> entry : valuesEntries.entrySet()) { generateValuesXml(pkg, entry.getKey(), entry.getValue(), outDir, serial); }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/ResEntrySpec.java+26 −14 modified@@ -16,17 +16,9 @@ */ package brut.androlib.res.table; -import com.google.common.collect.Sets; - import java.util.Objects; -import java.util.Set; public class ResEntrySpec { - private static final Set<String> INVALID_ENTRY_NAMES = Sets.newHashSet( - "0_resource_name_obfuscated", // #3067 - "(name removed)" // #2940 - ); - public static final String DUMMY_PREFIX = "APKTOOL_DUMMY_"; public static final String MISSING_PREFIX = "APKTOOL_MISSING_"; public static final String RENAMED_PREFIX = "APKTOOL_RENAMED_"; @@ -40,13 +32,33 @@ public ResEntrySpec(ResTypeSpec typeSpec, ResId id, String name) { assert typeSpec.getId() == id.getTypeId(); mTypeSpec = typeSpec; mId = id; - // Some apps had their entry names collapsed to a single value in - // the key string pool. Rename to avoid duplicates. - if (name == null || name.isEmpty() || INVALID_ENTRY_NAMES.contains(name)) { - mName = RENAMED_PREFIX + id; - } else { - mName = name; + // Some apps had their entry names obfuscated or collapsed to + // a single value in the key string pool. + mName = isValidEntryName(name) ? name : RENAMED_PREFIX + id; + } + + private static boolean isValidEntryName(String name) { + // Must not be empty. + int len = name.length(); + if (len == 0) { + return false; + } + // Must start with a valid Java identifier start character. + if (!Character.isJavaIdentifierStart(name.charAt(0))) { + return false; + } + // The rest must be valid Java identifier part characters. + for (int i = 1; i < len; i++) { + char ch = name.charAt(i); + // Whitelisted special characters. + if (ch == '.' || ch == '-') { + continue; + } + if (!Character.isJavaIdentifierPart(ch)) { + return false; + } } + return true; } public ResTypeSpec getTypeSpec() {
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResAttribute.java+47 −1 modified@@ -16,6 +16,7 @@ */ package brut.androlib.res.table.value; +import android.util.TypedValue; import brut.androlib.exceptions.AndrolibException; import brut.androlib.res.table.ResEntry; import brut.androlib.res.table.ResId; @@ -62,7 +63,7 @@ public class ResAttribute extends ResBag implements ValuesXmlSerializable { public static final ResAttribute DEFAULT = new ResAttribute( null, TYPE_ANY, Integer.MIN_VALUE, Integer.MAX_VALUE, L10N_NOT_REQUIRED); - protected final int mType; + protected int mType; protected final int mMin; protected final int mMax; protected final int mL10n; @@ -149,6 +150,51 @@ public ResPrimitive getValue() { } } + public void addType(int type) { + if ((mType & TYPE_ANY) == TYPE_ANY) { + return; + } + switch (type) { + case TypedValue.TYPE_REFERENCE: + case TypedValue.TYPE_DYNAMIC_REFERENCE: + case TypedValue.TYPE_ATTRIBUTE: + case TypedValue.TYPE_DYNAMIC_ATTRIBUTE: + mType |= TYPE_REFERENCE; + return; + case TypedValue.TYPE_STRING: + mType |= TYPE_STRING; + return; + case TypedValue.TYPE_FLOAT: + mType |= TYPE_FLOAT; + return; + case TypedValue.TYPE_DIMENSION: + mType |= TYPE_DIMEN; + return; + case TypedValue.TYPE_FRACTION: + mType |= TYPE_FRACTION; + return; + case TypedValue.TYPE_INT_BOOLEAN: + mType |= TYPE_BOOL; + return; + default: + if (type >= TypedValue.TYPE_FIRST_COLOR_INT && type <= TypedValue.TYPE_LAST_COLOR_INT) { + mType |= TYPE_COLOR; + } else if (type >= TypedValue.TYPE_FIRST_INT && type <= TypedValue.TYPE_LAST_INT) { + mType |= TYPE_INT; + } + return; + } + } + + public boolean hasSymbolsForValue(ResItem value) throws AndrolibException { + return getSymbolsForValue(value) != null; + } + + protected Symbol[] getSymbolsForValue(ResItem value) throws AndrolibException { + // Stub for attribute types with symbols. + return null; + } + public String formatValue(ResItem value, boolean asTextNode) throws AndrolibException { if (!(value instanceof ResXmlEncodable)) { return null;
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResBag.java+6 −0 modified@@ -16,6 +16,8 @@ */ package brut.androlib.res.table.value; +import brut.androlib.exceptions.AndrolibException; + import java.util.logging.Logger; public abstract class ResBag extends ResValue { @@ -61,4 +63,8 @@ public ResItem getValue() { return mValue; } } + + public void resolveKeys() throws AndrolibException { + // Stub for bags with resolvable keys. + } }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResEnum.java+100 −35 modified@@ -16,13 +16,18 @@ */ package brut.androlib.res.table.value; +import android.util.TypedValue; import brut.androlib.Config; import brut.androlib.exceptions.AndrolibException; +import brut.androlib.res.table.ResConfig; import brut.androlib.res.table.ResEntry; import brut.androlib.res.table.ResEntrySpec; +import brut.androlib.res.table.ResId; +import brut.androlib.res.table.ResPackage; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -32,77 +37,137 @@ public class ResEnum extends ResAttribute { private static final Logger LOGGER = Logger.getLogger(ResEnum.class.getName()); private final Symbol[] mSymbols; - private final Map<Integer, String> mFormatCache; + private Map<Integer, Symbol[]> mSymbolsCache; + private Map<Integer, String> mFormatsCache; public ResEnum(ResReference parent, int type, int min, int max, int l10n, Symbol[] symbols) { super(parent, type, min, max, l10n); mSymbols = symbols; - mFormatCache = new HashMap<>(); } @Override - protected String formatValueToSymbols(ResItem value) throws AndrolibException { - if (!(value instanceof ResPrimitive)) { + public void resolveKeys() throws AndrolibException { + ResPackage pkg = mParent.getPackage(); + Config config = pkg.getTable().getConfig(); + boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; + + for (Symbol symbol : mSymbols) { + ResReference key = symbol.getKey(); + if (key.resolve() != null) { + continue; + } + + ResId entryId = key.getId(); + + // #2836 - Skip item if the resource cannot be identified. + if (removeUnresolved || entryId.getPackageId() != pkg.getId()) { + LOGGER.warning(String.format( + "null enum reference: key=%s, value=%s", key, symbol.getValue())); + continue; + } + + pkg.addEntrySpec(entryId, ResEntrySpec.MISSING_PREFIX + entryId); + pkg.addEntry(entryId, ResConfig.DEFAULT, new ResCustom("id")); + } + } + + @Override + protected Symbol[] getSymbolsForValue(ResItem value) throws AndrolibException { + if (!isSymbolValueType(value)) { return null; } int data = ((ResPrimitive) value).getData(); - String formatted = mFormatCache.get(data); - if (formatted != null) { - return formatted; + return getSymbols(data); + } + + private boolean isSymbolValueType(ResItem value) throws AndrolibException { + if (!(value instanceof ResPrimitive)) { + return false; } + int type = ((ResPrimitive) value).getType(); + return type == TypedValue.TYPE_INT_DEC || type == TypedValue.TYPE_INT_HEX; + } + + private Symbol[] getSymbols(int data) throws AndrolibException { + if (mSymbolsCache == null) { + // Lazily establish a symbols cache for performance. + mSymbolsCache = new HashMap<>(); + } else if (mSymbolsCache.containsKey(data)) { + return mSymbolsCache.get(data); + } + + Symbol[] symbols = new Symbol[mSymbols.length]; + int symbolsCount = 0; + for (Symbol symbol : mSymbols) { - if (symbol.getValue().getData() != data) { - continue; + if (symbol.getValue().getData() == data) { + symbols[symbolsCount++] = symbol; } + } + + if (symbolsCount == 0) { + symbols = null; + } else if (symbolsCount < symbols.length) { + symbols = Arrays.copyOf(symbols, symbolsCount); + } + + mSymbolsCache.put(data, symbols); + return symbols; + } + + @Override + protected String formatValueToSymbols(ResItem value) throws AndrolibException { + if (!isSymbolValueType(value)) { + return null; + } + + int data = ((ResPrimitive) value).getData(); + if (mFormatsCache == null) { + // Lazily establish a formats cache for performance. + mFormatsCache = new HashMap<>(); + } else if (mFormatsCache.containsKey(data)) { + return mFormatsCache.get(data); + } + + Symbol[] symbols = getSymbols(data); + String formatted = null; + + if (symbols != null) { + for (Symbol symbol : symbols) { + ResEntrySpec keySpec = symbol.getKey().resolve(); + if (keySpec == null) { + continue; + } - ResReference key = symbol.getKey(); - ResEntrySpec keySpec = key.resolve(); - if (keySpec != null) { formatted = keySpec.getName(); - mFormatCache.put(data, formatted); // fill_parent is deprecated since API 8 but appears first. // Keep looking for match_parent and use it instead if found. if (data == -1 && formatted.equals("fill_parent")) { continue; } + break; } - break; } + mFormatsCache.put(data, formatted); return formatted; } @Override protected void serializeSymbolsToValuesXml(XmlSerializer serial, ResEntry entry) throws AndrolibException, IOException { - Config config = mParent.getPackage().getTable().getConfig(); - boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; - for (Symbol symbol : mSymbols) { - ResReference key = symbol.getKey(); - ResEntrySpec keySpec = key.resolve(); - ResPrimitive value = symbol.getValue(); - - String name; - if (keySpec != null) { - name = keySpec.getName(); - } else { - // #2836 - Support skipping items if the resource cannot be identified. - if (removeUnresolved) { - LOGGER.warning(String.format( - "null enum reference: key=%s, value=%s", key, value)); - continue; - } - - name = ResEntrySpec.MISSING_PREFIX + key.getId(); + ResEntrySpec keySpec = symbol.getKey().resolve(); + if (keySpec == null) { + continue; } serial.startTag(null, "enum"); - serial.attribute(null, "name", name); - serial.attribute(null, "value", value.encodeAsResXmlAttrValue()); + serial.attribute(null, "name", keySpec.getName()); + serial.attribute(null, "value", symbol.getValue().encodeAsResXmlAttrValue()); serial.endTag(null, "enum"); } }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResFlags.java+136 −101 modified@@ -16,10 +16,14 @@ */ package brut.androlib.res.table.value; +import android.util.TypedValue; import brut.androlib.Config; import brut.androlib.exceptions.AndrolibException; +import brut.androlib.res.table.ResConfig; import brut.androlib.res.table.ResEntry; import brut.androlib.res.table.ResEntrySpec; +import brut.androlib.res.table.ResId; +import brut.androlib.res.table.ResPackage; import org.xmlpull.v1.XmlSerializer; import java.io.IOException; @@ -34,166 +38,197 @@ public class ResFlags extends ResAttribute { private static final Logger LOGGER = Logger.getLogger(ResFlags.class.getName()); private final Symbol[] mSymbols; - private final Symbol[] mZeroFlags; - private final Symbol[] mFlags; - private final Map<Integer, String> mFormatCache; + private Map<Integer, Symbol[]> mSymbolsCache; + private Map<Integer, String> mFormatsCache; + private Symbol[] mSortedSymbols; public ResFlags(ResReference parent, int type, int min, int max, int l10n, Symbol[] symbols) { super(parent, type, min, max, l10n); mSymbols = symbols; - mFormatCache = new HashMap<>(); + } + + @Override + public void resolveKeys() throws AndrolibException { + ResPackage pkg = mParent.getPackage(); + Config config = pkg.getTable().getConfig(); + boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; - Symbol[] zeroFlags = new Symbol[symbols.length]; - int zeroFlagsCount = 0; - Symbol[] flags = new Symbol[symbols.length]; - int flagsCount = 0; + for (Symbol symbol : mSymbols) { + ResReference key = symbol.getKey(); + if (key.resolve() != null) { + continue; + } - for (Symbol symbol : symbols) { - ResPrimitive value = symbol.getValue(); + ResId entryId = key.getId(); - if (value.getData() == 0) { - zeroFlags[zeroFlagsCount++] = symbol; - } else { - flags[flagsCount++] = symbol; + // #2836 - Skip item if the resource cannot be identified. + if (removeUnresolved || entryId.getPackageId() != pkg.getId()) { + LOGGER.warning(String.format( + "null flag reference: key=%s, value=%s", key, symbol.getValue())); + continue; } - } - mZeroFlags = zeroFlagsCount < zeroFlags.length - ? Arrays.copyOf(zeroFlags, zeroFlagsCount) : zeroFlags; - mFlags = flagsCount < flags.length ? Arrays.copyOf(flags, flagsCount) : flags; - - // We establish a priority list for the flags. This can never be completely - // accurate to the source, but it's a best-guess approach. - Comparator<Symbol> byBitCount = Comparator.comparingInt( - (Symbol symbol) -> Integer.bitCount(symbol.getValue().getData())); - Comparator<Symbol> byRawValue = Comparator.comparingInt( - (Symbol symbol) -> symbol.getValue().getData()); - Arrays.sort(mFlags, byBitCount.reversed().thenComparing(byRawValue)); + pkg.addEntrySpec(entryId, ResEntrySpec.MISSING_PREFIX + entryId); + pkg.addEntry(entryId, ResConfig.DEFAULT, new ResCustom("id")); + } } @Override - protected String formatValueToSymbols(ResItem value) throws AndrolibException { - if (!(value instanceof ResPrimitive)) { + protected Symbol[] getSymbolsForValue(ResItem value) throws AndrolibException { + if (!isSymbolValueType(value)) { return null; } int data = ((ResPrimitive) value).getData(); - String formatted = mFormatCache.get(data); - if (formatted != null) { - return formatted; + return getSymbols(data); + } + + private boolean isSymbolValueType(ResItem value) throws AndrolibException { + if (!(value instanceof ResPrimitive)) { + return false; } - Symbol[] symbols; - int count = 0; - if (data == 0) { - symbols = mZeroFlags; - count = symbols.length; + int type = ((ResPrimitive) value).getType(); + return type == TypedValue.TYPE_INT_DEC || type == TypedValue.TYPE_INT_HEX; + } - if (count == 0) { - return null; + private Symbol[] getSymbols(int data) throws AndrolibException { + if (mSymbolsCache == null) { + // Lazily establish a symbols cache for performance. + mSymbolsCache = new HashMap<>(); + } else if (mSymbolsCache.containsKey(data)) { + return mSymbolsCache.get(data); + } + + if (mSortedSymbols == null) { + // Lazily establish a priority list for the flags. This can never be + // completely accurate to the source, but it's a best-effort approach. + mSortedSymbols = mSymbols.clone(); + Comparator<Symbol> byBitCount = Comparator.comparingInt( + (Symbol symbol) -> Integer.bitCount(symbol.getValue().getData())); + Comparator<Symbol> byRawValue = Comparator.comparingInt( + (Symbol symbol) -> symbol.getValue().getData()); + Arrays.sort(mSortedSymbols, byBitCount.reversed().thenComparing(byRawValue)); + } + + Symbol[] symbols = new Symbol[mSortedSymbols.length]; + int symbolsCount = 0; + + if (data == 0) { + for (Symbol symbol : mSortedSymbols) { + if (symbol.getValue().getData() == 0) { + symbols[symbolsCount++] = symbol; + } } } else { - symbols = new Symbol[mFlags.length]; int mask = 0; - for (Symbol symbol : mFlags) { + for (Symbol symbol : mSortedSymbols) { int flag = symbol.getValue().getData(); - if ((data & flag) != flag || (mask & flag) == flag) { continue; } - symbols[count++] = symbol; + symbols[symbolsCount++] = symbol; mask |= flag; if (mask == data) { break; } } - if (count == 0) { - LOGGER.warning("Invalid flags value: " + value); - return null; - } - } + // Filter out redundant flags. + if (symbolsCount > 2) { + Symbol[] filtered = new Symbol[symbolsCount]; + int filteredCount = 0; - // Render the flags as a format. - Config config = mParent.getPackage().getTable().getConfig(); - boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < count; i++) { - Symbol symbol = symbols[i]; + for (int i = 0; i < symbolsCount; i++) { + Symbol symbol = symbols[i]; + mask = 0; - // Filter out redundant flags. - if (count > 2) { - int mask = 0; + // Combine the other flags. + for (int j = 0; j < symbolsCount; j++) { + Symbol other = symbols[j]; - // Combine the other flags. - for (int j = 0; j < count; j++) { - Symbol other = symbols[j]; + if (j != i) { + mask |= other.getValue().getData(); + } + } - if (j != i) { - mask |= other.getValue().getData(); + // Skip if it doesn't add at least one unique bit. + if ((symbol.getValue().getData() & ~mask) == 0) { + continue; } - } - // Skip if it doesn't add at least one unique bit. - if ((symbol.getValue().getData() & ~mask) == 0) { - continue; + filtered[filteredCount++] = symbol; } - } - // Append the flag to the format. - ResReference key = symbol.getKey(); - ResEntrySpec keySpec = key.resolve(); - if (sb.length() > 0) { - sb.append('|'); + symbols = filtered; + symbolsCount = filteredCount; } - if (keySpec != null) { - sb.append(keySpec.getName()); - } else { - // #2836 - Support skipping items if the resource cannot be identified. - if (removeUnresolved) { - return null; + } + + if (symbolsCount == 0) { + symbols = null; + } else if (symbolsCount < symbols.length) { + symbols = Arrays.copyOf(symbols, symbolsCount); + } + + mSymbolsCache.put(data, symbols); + return symbols; + } + + @Override + protected String formatValueToSymbols(ResItem value) throws AndrolibException { + if (!isSymbolValueType(value)) { + return null; + } + + int data = ((ResPrimitive) value).getData(); + if (mFormatsCache == null) { + // Lazily establish a formats cache for performance. + mFormatsCache = new HashMap<>(); + } else if (mFormatsCache.containsKey(data)) { + return mFormatsCache.get(data); + } + + Symbol[] symbols = getSymbols(data); + String formatted = null; + + if (symbols != null) { + StringBuilder sb = new StringBuilder(); + + for (Symbol symbol : symbols) { + ResEntrySpec keySpec = symbol.getKey().resolve(); + if (keySpec == null) { + continue; } - sb.append(ResEntrySpec.MISSING_PREFIX + key.getId()); + if (sb.length() > 0) { + sb.append('|'); + } + sb.append(keySpec.getName()); } + + formatted = sb.toString(); } - formatted = sb.toString(); - mFormatCache.put(data, formatted); + mFormatsCache.put(data, formatted); return formatted; } @Override protected void serializeSymbolsToValuesXml(XmlSerializer serial, ResEntry entry) throws AndrolibException, IOException { - Config config = mParent.getPackage().getTable().getConfig(); - boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; - for (Symbol symbol : mSymbols) { - ResReference key = symbol.getKey(); - ResEntrySpec keySpec = key.resolve(); - ResPrimitive value = symbol.getValue(); - - String name; - if (keySpec != null) { - name = keySpec.getName(); - } else { - // #2836 - Support skipping items if the resource cannot be identified. - if (removeUnresolved) { - LOGGER.warning(String.format( - "null flag reference: key=%s, value=%s", key, value)); - continue; - } - - name = ResEntrySpec.MISSING_PREFIX + key.getId(); + ResEntrySpec keySpec = symbol.getKey().resolve(); + if (keySpec == null) { + continue; } serial.startTag(null, "flag"); - serial.attribute(null, "name", name); - serial.attribute(null, "value", value.encodeAsResXmlAttrValue()); + serial.attribute(null, "name", keySpec.getName()); + serial.attribute(null, "value", symbol.getValue().encodeAsResXmlAttrValue()); serial.endTag(null, "flag"); } }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResItem.java+2 −2 modified@@ -41,8 +41,6 @@ public abstract class ResItem extends ResValue { STANDARD_TYPE_FORMATS.put("string", Sets.newHashSet("string")); } - public abstract String getFormat(); - public static ResItem parse(ResPackage pkg, int type, int data, String rawValue) { switch (type) { case TypedValue.TYPE_NULL: @@ -72,4 +70,6 @@ public static ResItem parse(ResPackage pkg, int type, int data, String rawValue) return null; } } + + public abstract String getFormat(); }
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResPlural.java+1 −2 modified@@ -56,8 +56,6 @@ public void serializeToValuesXml(XmlSerializer serial, ResEntry entry) for (RawItem item : mItems) { int key = item.getKey(); - ResItem value = item.getValue(); - String quantity; switch (key) { case ATTR_OTHER: @@ -83,6 +81,7 @@ public void serializeToValuesXml(XmlSerializer serial, ResEntry entry) continue; } + ResItem value = item.getValue(); String body; if (value instanceof ResString) { body = ResXmlEncoders.enumerateNonPositionalSubstitutionsIfRequired(
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/table/value/ResStyle.java+53 −26 modified@@ -19,6 +19,7 @@ import brut.androlib.Config; import brut.androlib.exceptions.AndrolibException; import brut.androlib.exceptions.UndefinedResObjectException; +import brut.androlib.res.table.ResConfig; import brut.androlib.res.table.ResEntry; import brut.androlib.res.table.ResEntrySpec; import brut.androlib.res.table.ResId; @@ -78,12 +79,42 @@ public ResItem getValue() { } } + @Override + public void resolveKeys() throws AndrolibException { + ResPackage pkg = mParent.getPackage(); + Config config = pkg.getTable().getConfig(); + boolean removeUnresolved = config.getDecodeResolve() == Config.DecodeResolve.REMOVE; + + for (Item item : mItems) { + ResReference key = item.getKey(); + ResEntrySpec keySpec = key.resolve(); + if (keySpec != null) { + try { + keySpec.getPackage().getDefaultEntry(keySpec.getId()); + continue; + } catch (UndefinedResObjectException ignored) { + } + } + + ResId entryId = key.getId(); + + // #2836 - Skip item if the resource cannot be identified. + if (removeUnresolved || entryId.getPackageId() != pkg.getId()) { + LOGGER.warning(String.format( + "null style reference: key=%s, value=%s", key, item.getValue())); + continue; + } + + if (keySpec == null) { + pkg.addEntrySpec(entryId, ResEntrySpec.MISSING_PREFIX + entryId); + } + pkg.addEntry(entryId, ResConfig.DEFAULT, ResAttribute.DEFAULT); + } + } + @Override public void serializeToValuesXml(XmlSerializer serial, ResEntry entry) throws AndrolibException, IOException { - Config config = mParent.getPackage().getTable().getConfig(); - boolean skipDuplicates = !config.isAnalysisMode(); - serial.startTag(null, "style"); serial.attribute(null, "name", entry.getName()); if (mParent.resolve() != null) { @@ -92,52 +123,48 @@ public void serializeToValuesXml(XmlSerializer serial, ResEntry entry) serial.attribute(null, "parent", ""); } + Config config = mParent.getPackage().getTable().getConfig(); + boolean skipDuplicates = !config.isAnalysisMode(); Set<String> processedNames = new HashSet<>(); for (Item item : mItems) { - ResReference key = item.getKey(); - ResEntrySpec keySpec = key.resolve(); - ResItem value = item.getValue(); - - // #2836 - Support skipping items if the resource cannot be identified. + ResEntrySpec keySpec = item.getKey().resolve(); if (keySpec == null) { - LOGGER.warning(String.format("null style reference: key=%s, value=%s", key, value)); continue; } - String name = keySpec.getFullName(entry.getPackage(), true); + String keyName = keySpec.getFullName(entry.getPackage(), true); // #3400 - Skip duplicate items in styles. - if (skipDuplicates && processedNames.contains(name)) { + if (skipDuplicates && processedNames.contains(keyName)) { continue; } - String body = null; + // We need the attribute entry's value to format the item's value. + ResValue keyValue; try { - // We need the attribute entry's value to format the item's value. - ResValue keyDefValue = - keySpec.getPackage().getDefaultEntry(keySpec.getId()).getValue(); - - if (keyDefValue instanceof ResAttribute) { - body = ((ResAttribute) keyDefValue).formatValue(value, true); - } else { - LOGGER.warning("Unexpected style item key: " + keySpec); - } + keyValue = keySpec.getPackage().getDefaultEntry(keySpec.getId()).getValue(); } catch (UndefinedResObjectException ignored) { + continue; + } + + ResItem value = item.getValue(); + String body = null; + if (keyValue instanceof ResAttribute) { + body = ((ResAttribute) keyValue).formatValue(value, true); + } else { + LOGGER.warning("Unexpected style item key: " + keySpec); } if (body == null) { // Fall back to default attribute. body = ResAttribute.DEFAULT.formatValue(value, true); } - if (body == null) { - continue; - } serial.startTag(null, "item"); - serial.attribute(null, "name", name); + serial.attribute(null, "name", keyName); serial.text(body); serial.endTag(null, "item"); - processedNames.add(name); + processedNames.add(keyName); } serial.endTag(null, "style");
brut.apktool/apktool-lib/src/main/java/brut/androlib/res/xml/ResXmlEncoders.java+13 −13 modified@@ -91,7 +91,8 @@ public static String encodeAsXmlValue(String str) { return str; } - StringBuilder sb = new StringBuilder(str.length() + 10); + int len = str.length(); + StringBuilder sb = new StringBuilder(len + 10); switch (str.charAt(0)) { case '#': @@ -105,7 +106,7 @@ public static String encodeAsXmlValue(String str) { int startPos = 0; boolean enclose = false; boolean wasSpace = true; - for (int i = 0; i < str.length(); i++) { + for (int i = 0; i < len; i++) { char ch = str.charAt(i); if (isInStyleTag) { if (ch == '>') { @@ -140,7 +141,7 @@ public static String encodeAsXmlValue(String str) { break; } // Skip writing trailing \u0000 if we are at end of string. - if ((sb.length() + 1) == str.length() && ch == '\u0000') { + if ((sb.length() + 1) == len && ch == '\u0000') { continue; } sb.append(String.format("\\u%04x", (int) ch)); @@ -161,7 +162,8 @@ public static String encodeAsResXmlAttrValue(String str) { return str; } - StringBuilder sb = new StringBuilder(str.length() + 10); + int len = str.length(); + StringBuilder sb = new StringBuilder(len + 10); switch (str.charAt(0)) { case '#': @@ -171,7 +173,7 @@ public static String encodeAsResXmlAttrValue(String str) { break; } - for (int i = 0; i < str.length(); i++) { + for (int i = 0; i < len; i++) { char ch = str.charAt(i); switch (ch) { case '\\': @@ -239,29 +241,27 @@ private static Pair<List<Integer>, List<Integer>> findSubstitutions(String str, if (nonPosMax == -1) { nonPosMax = Integer.MAX_VALUE; } - int pos; - int pos2 = 0; + List<Integer> nonPositional = new ArrayList<>(); List<Integer> positional = new ArrayList<>(); - if (str == null) { return Pair.of(nonPositional, positional); } - int length = str.length(); - + int len = str.length(); + int pos, pos2 = 0; while ((pos = str.indexOf('%', pos2)) != -1) { pos2 = pos + 1; - if (pos2 == length) { + if (pos2 == len) { nonPositional.add(pos); break; } char ch = str.charAt(pos2++); if (ch == '%') { continue; } - if (ch >= '0' && ch <= '9' && pos2 < length) { - while ((ch = str.charAt(pos2++)) >= '0' && ch <= '9' && pos2 < length); + if (ch >= '0' && ch <= '9' && pos2 < len) { + while ((ch = str.charAt(pos2++)) >= '0' && ch <= '9' && pos2 < len); if (ch == '$') { positional.add(pos); continue;
brut.apktool/apktool-lib/src/test/java/brut/androlib/BuildAndDecodeApkTest.java+1 −1 modified@@ -438,7 +438,7 @@ public void drawableXhdpiTest() throws BrutException { @Test public void ninePatchImageColorTest() throws IOException { - String fileName = "res/drawable-xhdpi/9patch.9.png"; + String fileName = "res/drawable-xhdpi/ninepatch.9.png"; File control = new File(sTestOrigDir, fileName); File test = new File(sTestNewDir, fileName);
brut.apktool/apktool-lib/src/test/java/brut/androlib/DecodeResolveTest.java+18 −18 modified@@ -58,47 +58,47 @@ public void decodeResolveRemoveTest() throws BrutException { } @Test - public void decodeResolveDummyTest() throws BrutException { - sConfig.setDecodeResolve(Config.DecodeResolve.DUMMY); + public void decodeResolveKeepTest() throws BrutException { + sConfig.setDecodeResolve(Config.DecodeResolve.KEEP); ExtFile testApk = new ExtFile(sTmpDir, TEST_APK); - ExtFile testDir = new ExtFile(testApk + ".out.dummies"); + ExtFile testDir = new ExtFile(testApk + ".out.leave"); new ApkDecoder(testApk, sConfig).decode(testDir); assertTrue(new File(testDir, "res/values/strings.xml").isFile()); - File attrXml = new File(testDir, "res/values/attrs.xml"); - Document attrDocument = loadDocument(attrXml); + Document attrDocument = loadDocument(new File(testDir, "res/values/attrs.xml")); assertEquals(4, attrDocument.getElementsByTagName("enum").getLength()); - File colorXml = new File(testDir, "res/values/colors.xml"); - Document colorDocument = loadDocument(colorXml); + Document colorDocument = loadDocument(new File(testDir, "res/values/colors.xml")); assertEquals(8, colorDocument.getElementsByTagName("color").getLength()); - assertEquals(1, colorDocument.getElementsByTagName("item").getLength()); + assertEquals(0, colorDocument.getElementsByTagName("item").getLength()); - File publicXml = new File(testDir, "res/values/public.xml"); - Document publicDocument = loadDocument(publicXml); + Document publicDocument = loadDocument(new File(testDir, "res/values/public.xml")); assertEquals(22, publicDocument.getElementsByTagName("public").getLength()); } @Test - public void decodeResolveKeepTest() throws BrutException { - sConfig.setDecodeResolve(Config.DecodeResolve.KEEP); + public void decodeResolveDummyTest() throws BrutException { + sConfig.setDecodeResolve(Config.DecodeResolve.DUMMY); ExtFile testApk = new ExtFile(sTmpDir, TEST_APK); - ExtFile testDir = new ExtFile(testApk + ".out.leave"); + ExtFile testDir = new ExtFile(testApk + ".out.dummies"); new ApkDecoder(testApk, sConfig).decode(testDir); assertTrue(new File(testDir, "res/values/strings.xml").isFile()); - Document attrDocument = loadDocument(new File(testDir, "res/values/attrs.xml")); + File attrXml = new File(testDir, "res/values/attrs.xml"); + Document attrDocument = loadDocument(attrXml); assertEquals(4, attrDocument.getElementsByTagName("enum").getLength()); - Document colorDocument = loadDocument(new File(testDir, "res/values/colors.xml")); + File colorXml = new File(testDir, "res/values/colors.xml"); + Document colorDocument = loadDocument(colorXml); assertEquals(8, colorDocument.getElementsByTagName("color").getLength()); - assertEquals(0, colorDocument.getElementsByTagName("item").getLength()); + assertEquals(1, colorDocument.getElementsByTagName("item").getLength()); - Document publicDocument = loadDocument(new File(testDir, "res/values/public.xml")); - assertEquals(21, publicDocument.getElementsByTagName("public").getLength()); + File publicXml = new File(testDir, "res/values/public.xml"); + Document publicDocument = loadDocument(publicXml); + assertEquals(23, publicDocument.getElementsByTagName("public").getLength()); } }
brut.apktool/apktool-lib/src/test/java/brut/androlib/ResourceDirectoryTraversalTest.java+3 −2 modified@@ -16,6 +16,7 @@ */ package brut.androlib; +import brut.androlib.res.table.ResEntrySpec; import brut.common.BrutException; import brut.directory.ExtFile; @@ -33,13 +34,13 @@ public static void beforeClass() throws Exception { } @Test - public void checkIfMaliciousRawFileIsDisassembledProperly() throws BrutException { + public void checkIfMaliciousRawFileRenamed() throws BrutException { sConfig.setForced(true); ExtFile testApk = new ExtFile(sTmpDir, TEST_APK); ExtFile testDir = new ExtFile(testApk + ".out"); new ApkDecoder(testApk, sConfig).decode(testDir); - assertTrue(new File(testDir, "res/raw/poc").exists()); + assertTrue(new File(testDir, "res/raw/" + ResEntrySpec.RENAMED_PREFIX + "0x7f040000").exists()); } }
brut.apktool/apktool-lib/src/test/resources/testapp/res/drawable-xhdpi/ninepatch.9.png+0 −0 renamedbrut.j.util/src/main/java/brut/util/BrutIO.java+0 −5 modified@@ -103,9 +103,4 @@ public static String sanitizePath(File baseDir, String path) throws InvalidPathE return basePath.relativize(resolvedPath).toString(); } - - public static boolean detectPossibleDirectoryTraversal(String path) { - return path.contains("../") || path.contains("/..") - || path.contains("..\\") || path.contains("\\.."); - } }
brut.j.xml/src/main/java/brut/xmlpull/MXSerializer.java+14 −11 modified@@ -266,13 +266,13 @@ private void rebuildIndentationBuf() { } int bufPos = 0; if (writeLineSeparator) { - for (int i = 0; i < lineSeparator.length(); i++) { + for (int i = 0, n = lineSeparator.length(); i < n; i++) { indentationBuf[bufPos++] = lineSeparator.charAt(i); } } if (writeIndentation) { for (int i = 0; i < maxIndentLevel; i++) { - for (int j = 0; j < indentationString.length(); j++) { + for (int j = 0, n = indentationString.length(); j < n; j++) { indentationBuf[bufPos++] = indentationString.charAt(j); } } @@ -894,8 +894,9 @@ private void writeAttributeValue(String value) throws IOException { return; } // .[&, < and " escaped], + int len = value.length(); int pos = 0; - for (int i = 0; i < value.length(); i++) { + for (int i = 0; i < len; i++) { char ch = value.charAt(i); if (ch == '&') { if (i > pos) { @@ -930,8 +931,8 @@ private void writeAttributeValue(String value) throws IOException { pos = i + 1; } else { if (TRACE_ESCAPING) { - System.err.println(getClass().getName() + " DEBUG ATTR value.len=" + value.length() - + " " + printable(value)); + System.err.println(getClass().getName() + " DEBUG ATTR value.len=" + len + " " + + printable(value)); } throw new IllegalStateException( "character " + printable(ch) + " (" + Integer.toString(ch) + ") is not allowed in output" @@ -951,8 +952,9 @@ private void writeElementContent(String text) throws IOException { } // escape '<', '&', ']]>', <32 if necessary + int len = text.length(); int pos = 0; - for (int i = 0; i < text.length(); i++) { + for (int i = 0; i < len; i++) { // TODO: check if doing char[] text.getChars() would be faster than // getCharAt(i) ... char ch = text.charAt(i); @@ -964,7 +966,7 @@ private void writeElementContent(String text) throws IOException { } } else { if (ch == '&') { - if (!(i < text.length() - 3 && text.charAt(i + 1) == 'l' + if (!(i < len - 3 && text.charAt(i + 1) == 'l' && text.charAt(i + 2) == 't' && text.charAt(i + 3) == ';')) { if (i > pos) { write(text.substring(pos, i)); @@ -990,8 +992,8 @@ private void writeElementContent(String text) throws IOException { // fallthrough } else { if (TRACE_ESCAPING) { - System.err.println(getClass().getName() + " DEBUG TEXT value.len=" + text.length() - + " " + printable(text)); + System.err.println(getClass().getName() + " DEBUG TEXT value.len=" + len + " " + + printable(text)); } throw new IllegalStateException("character " + Integer.toString(ch) + " is not allowed in output" + getLocation() @@ -1066,9 +1068,10 @@ private static String printable(String str) { if (str == null) { return "null"; } - StringBuffer retval = new StringBuffer(str.length() + 16); + int len = str.length(); + StringBuffer retval = new StringBuffer(len + 16); retval.append('\''); - for (int i = 0; i < str.length(); i++) { + for (int i = 0; i < len; i++) { addPrintable(retval, str.charAt(i)); } retval.append('\'');
brut.j.yaml/src/main/java/brut/yaml/YamlLine.java+2 −1 modified@@ -39,7 +39,8 @@ public YamlLine(String line) { return; } // count indent - space only - for (int i = 0; i < line.length(); i++) { + int len = line.length(); + for (int i = 0; i < len; i++) { if (line.charAt(i) == ' ') { indent++; } else {
brut.j.yaml/src/main/java/brut/yaml/YamlStringEscapeUtils.java+2 −3 modified@@ -63,9 +63,8 @@ private static void escapeJavaStyleString(Writer writer, String str) throws IOEx if (str == null) { return; } - int sz; - sz = str.length(); - for (int i = 0; i < sz; i++) { + int len = str.length(); + for (int i = 0; i < len; i++) { char ch = str.charAt(i); // "[^\t\n\r\u0020-\u007E\u0085\u00A0-\uD7FF\uE000-\uFFFD]" // handle unicode
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/iBotPeaches/Apktool/commit/e10a0450c7afcd9462c0b76bcbff0e7428b92bddnvdPatchWEB
- github.com/advisories/GHSA-m8mh-x359-vm8mghsaADVISORY
- github.com/iBotPeaches/Apktool/security/advisories/GHSA-m8mh-x359-vm8mnvdVendor AdvisoryMitigationWEB
- nvd.nist.gov/vuln/detail/CVE-2026-39973ghsaADVISORY
- github.com/iBotPeaches/Apktool/pull/4041nvdIssue TrackingWEB
- github.com/iBotPeaches/Apktool/releases/tag/v3.0.2nvdProductWEB
News mentions
0No linked articles in our index yet.