Skip to content

Commit 87328de

Browse files
committed
adding support for decoding multiple b64 strings in one line
1 parent 248f57b commit 87328de

6 files changed

Lines changed: 659 additions & 37 deletions

File tree

src/main/java/jadx/plugins/stringdecoder/B64DeobfuscateOptions.java

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ public class B64DeobfuscateOptions extends BasePluginOptionsBuilder {
1212
private int minPrintablePercent;
1313
private int minAlphanumericPercent;
1414
private boolean requirePadding;
15-
private boolean skipIdentifiers;
1615

1716
@Override
1817
public void registerOptions() {
@@ -44,10 +43,6 @@ public void registerOptions() {
4443
.description("Only flag strings that end with '=' padding; reduces false positives from identifiers and short words")
4544
.defaultValue(false)
4645
.setter(v -> requirePadding = v);
47-
boolOption(JadxStringDecoderPlugin.PLUGIN_ID + ".skipIdentifiers")
48-
.description("Skip strings that look like Java identifiers (letters/digits only, starts with a letter); avoids flagging symbol names")
49-
.defaultValue(false)
50-
.setter(v -> skipIdentifiers = v);
5146
intOption(JadxStringDecoderPlugin.PLUGIN_ID + ".minDecodedLength")
5247
.description("Minimum decoded string length to add a comment (0 = disabled); rejects very short decoded outputs")
5348
.defaultValue(0)
@@ -82,10 +77,6 @@ public boolean isRequirePadding() {
8277
return requirePadding;
8378
}
8479

85-
public boolean isSkipIdentifiers() {
86-
return skipIdentifiers;
87-
}
88-
8980
public int getMinDecodedLength() {
9081
return minDecodedLength;
9182
}

src/main/java/jadx/plugins/stringdecoder/B64DeobfuscatePass.java

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import java.util.Collections;
44
import java.util.HashSet;
5+
import java.util.LinkedHashMap;
6+
import java.util.LinkedHashSet;
57
import java.util.List;
8+
import java.util.Map;
69
import java.util.Set;
10+
import java.util.TreeMap;
711

812
import jadx.api.data.CommentStyle;
913
import jadx.api.plugins.input.data.annotations.EncodedType;
@@ -15,14 +19,16 @@
1519
import jadx.core.codegen.utils.CodeComment;
1620
import jadx.core.dex.attributes.AType;
1721
import jadx.core.dex.instructions.ConstStringNode;
22+
import jadx.core.dex.instructions.FilledNewArrayNode;
23+
import jadx.core.dex.instructions.args.InsnArg;
1824
import jadx.core.dex.instructions.args.RegisterArg;
25+
import jadx.core.dex.instructions.args.SSAVar;
1926
import jadx.core.dex.nodes.BlockNode;
2027
import jadx.core.dex.nodes.ClassNode;
2128
import jadx.core.dex.nodes.FieldNode;
2229
import jadx.core.dex.nodes.InsnNode;
2330
import jadx.core.dex.nodes.MethodNode;
2431
import jadx.core.dex.nodes.RootNode;
25-
import jadx.core.dex.instructions.args.SSAVar;
2632

2733
public class B64DeobfuscatePass implements JadxDecompilePass {
2834

@@ -61,6 +67,11 @@ public void visit(MethodNode mth) {
6167
}
6268
// Lazily built on the first B64 hit — avoids field scan for methods with no B64 strings
6369
Set<String> fieldConstants = null;
70+
// Arrays where ≥1 string passed normal detect() — required anchor for contextual decoding
71+
Set<InsnNode> arrayAnchors = null;
72+
// All valid-B64+UTF-8 strings in arrays (superset of anchors; used for the final comment)
73+
Map<InsnNode, TreeMap<Integer, String>> arrayCandidates = null;
74+
6475
for (BlockNode block : blocks) {
6576
for (InsnNode insn : block.getInstructions()) {
6677
if (!(insn instanceof ConstStringNode)) {
@@ -75,18 +86,109 @@ public void visit(MethodNode mth) {
7586
String decoded = forced
7687
? B64Detector.decodeForced(str, options.getMaxCommentLength())
7788
: B64Detector.detect(str, options);
89+
90+
// If this string feeds a FilledNewArrayNode, use contextual detection:
91+
// a string that fails heuristics (e.g. skipIdentifiers) is still included as a
92+
// candidate if at least one sibling in the array passes normal detection.
93+
FilledNewArrayNode arrayParent = findFilledNewArrayParent(csn);
94+
if (arrayParent != null) {
95+
int idx = argIndexOf(arrayParent, csn.getResult().getSVar());
96+
if (idx >= 0) {
97+
// Determine the best available decoded value for this slot
98+
String candidate = decoded != null ? decoded
99+
: B64Detector.decodeIfValid(str, options.getMaxCommentLength());
100+
if (candidate != null) {
101+
if (fieldConstants == null) {
102+
fieldConstants = collectConstantValueFieldStrings(mth.getParentClass());
103+
}
104+
if (!fieldConstants.contains(str)) {
105+
if (arrayCandidates == null) {
106+
arrayCandidates = new LinkedHashMap<>();
107+
}
108+
arrayCandidates.computeIfAbsent(arrayParent, k -> new TreeMap<>()).put(idx, candidate);
109+
if (decoded != null) {
110+
if (arrayAnchors == null) {
111+
arrayAnchors = new LinkedHashSet<>();
112+
}
113+
arrayAnchors.add(arrayParent);
114+
}
115+
}
116+
}
117+
continue;
118+
}
119+
}
120+
78121
if (decoded == null) {
79122
continue;
80123
}
81124
if (fieldConstants == null) {
82125
fieldConstants = collectConstantValueFieldStrings(mth.getParentClass());
83126
}
84127
// Skip strings that are static final CONSTANT_VALUE fields — B64FieldInitPass handles those
85-
if (!fieldConstants.contains(str)) {
86-
csn.addAttr(AType.CODE_COMMENTS, new CodeComment("b64: " + decoded, CommentStyle.LINE));
128+
if (fieldConstants.contains(str)) {
129+
continue;
87130
}
131+
csn.addAttr(AType.CODE_COMMENTS, new CodeComment("b64: " + decoded, CommentStyle.LINE));
132+
}
133+
}
134+
135+
// Only emit array comments for arrays with at least one confirmed detection (the anchor).
136+
// This prevents annotating arrays that happen to contain valid-looking Base64 by coincidence.
137+
if (arrayAnchors != null) {
138+
for (InsnNode arrayInsn : arrayAnchors) {
139+
String comment = buildArrayComment(arrayCandidates.get(arrayInsn));
140+
arrayInsn.addAttr(AType.CODE_COMMENTS, new CodeComment(comment, CommentStyle.LINE));
141+
}
142+
}
143+
}
144+
145+
/** Returns the FilledNewArrayNode that directly uses the result of {@code csn}, or null. */
146+
private static FilledNewArrayNode findFilledNewArrayParent(ConstStringNode csn) {
147+
RegisterArg result = csn.getResult();
148+
if (result == null) {
149+
return null;
150+
}
151+
SSAVar ssaVar = result.getSVar();
152+
if (ssaVar == null) {
153+
return null;
154+
}
155+
for (RegisterArg use : ssaVar.getUseList()) {
156+
InsnNode parent = use.getParentInsn();
157+
if (parent instanceof FilledNewArrayNode) {
158+
return (FilledNewArrayNode) parent;
159+
}
160+
}
161+
return null;
162+
}
163+
164+
/** Returns the index of the arg in {@code insn} whose SSAVar matches {@code ssaVar}, or -1. */
165+
private static int argIndexOf(InsnNode insn, SSAVar ssaVar) {
166+
for (int i = 0; i < insn.getArgsCount(); i++) {
167+
InsnArg arg = insn.getArg(i);
168+
if (arg instanceof RegisterArg && ((RegisterArg) arg).getSVar() == ssaVar) {
169+
return i;
170+
}
171+
}
172+
return -1;
173+
}
174+
175+
/**
176+
* Builds a multi-line comment text for all decoded strings in a FilledNewArrayNode.
177+
* Each entry is formatted as "b64[N]: decoded" on its own line, sorted by index.
178+
* The renderer (appendMultiLineString) splits on \n and starts each continuation
179+
* line with "// ", producing one "// b64[N]: ..." line per decoded string.
180+
*/
181+
private static String buildArrayComment(TreeMap<Integer, String> decodings) {
182+
StringBuilder sb = new StringBuilder();
183+
boolean first = true;
184+
for (Map.Entry<Integer, String> e : decodings.entrySet()) {
185+
if (!first) {
186+
sb.append('\n');
88187
}
188+
sb.append("b64[").append(e.getKey()).append("]: ").append(e.getValue());
189+
first = false;
89190
}
191+
return sb.toString();
90192
}
91193

92194
/**

src/main/java/jadx/plugins/stringdecoder/B64Detector.java

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ public final class B64Detector {
1313
// Allow embedded \n/\r (PEM/MIME line-wrapped Base64); = padding and trailing newline are optional
1414
private static final Pattern BASE64_STANDARD = Pattern.compile("^[A-Za-z0-9+/\\n\\r]*=*[\\n\\r]*$");
1515
private static final Pattern BASE64_URL_SAFE = Pattern.compile("^[A-Za-z0-9_\\-\\n\\r]*=*[\\n\\r]*$");
16-
private static final Pattern IDENTIFIER_LIKE = Pattern.compile("^[A-Za-z][A-Za-z0-9]*$");
17-
18-
private B64Detector() {
16+
private B64Detector() {
1917
}
2018

2119
/**
@@ -29,9 +27,6 @@ public static String detect(String str, B64DeobfuscateOptions options) {
2927
if (options.isRequirePadding() && !str.endsWith("=")) {
3028
return null;
3129
}
32-
if (options.isSkipIdentifiers() && IDENTIFIER_LIKE.matcher(str).matches()) {
33-
return null;
34-
}
3530
if (!BASE64_STANDARD.matcher(str).matches() && !BASE64_URL_SAFE.matcher(str).matches()) {
3631
return null;
3732
}
@@ -109,6 +104,30 @@ private static boolean isAlphanumeric(String str, double minRatio) {
109104
return (double) alnumCount / str.length() >= minRatio;
110105
}
111106

107+
/**
108+
* Returns the decoded string if {@code str} is valid Base64 that decodes to valid UTF-8,
109+
* without applying heuristic filters (no length, printable-ratio, or alphanumeric-ratio checks).
110+
* Used for array-element candidates when at least one sibling passes normal detection.
111+
* Returns null if the charset check fails, Base64 decode throws, or the result is not valid UTF-8.
112+
*/
113+
public static String decodeIfValid(String str, int maxCommentLength) {
114+
if (!BASE64_STANDARD.matcher(str).matches() && !BASE64_URL_SAFE.matcher(str).matches()) {
115+
return null;
116+
}
117+
for (Base64.Decoder decoder : new Base64.Decoder[] { Base64.getDecoder(), Base64.getUrlDecoder() }) {
118+
try {
119+
byte[] bytes = decoder.decode(str);
120+
CharsetDecoder utf8 = StandardCharsets.UTF_8.newDecoder()
121+
.onMalformedInput(CodingErrorAction.REPORT)
122+
.onUnmappableCharacter(CodingErrorAction.REPORT);
123+
String decoded = utf8.decode(ByteBuffer.wrap(bytes)).toString();
124+
return truncate(decoded, maxCommentLength);
125+
} catch (Exception ignored) {
126+
}
127+
}
128+
return null;
129+
}
130+
112131
/**
113132
* Decodes {@code str} as Base64 without applying any false-positive heuristics.
114133
* Use when the string is explicitly passed to a Base64.decode call — the call itself

src/test/java/jadx/plugins/stringdecoder/JadxStringDecoderPluginTest.java

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -183,25 +183,6 @@ public void decodedFieldB64Test() throws Exception {
183183
assertThat(code).contains("b64: room://cloud.tencent.com/rtc");
184184
}
185185

186-
@Test
187-
public void skipIdentifiersFiltersIdentifierLikeTest() throws Exception {
188-
// "fillItem" looks like a Java identifier; with skipIdentifiers=true it must not be flagged
189-
// (printable threshold lowered so only the identifier check is responsible for filtering)
190-
String code = decompileSmali("b64/identifier_like_b64.smali",
191-
Map.of(opt("skipIdentifiers"), "yes", opt("minPrintablePercent"), "75"));
192-
System.out.println(code);
193-
assertThat(code).doesNotContain("b64:");
194-
}
195-
196-
@Test
197-
public void skipIdentifiersAllowsPaddedStringTest() throws Exception {
198-
// "aGVsbG8=" contains '=' so it is NOT identifier-like; skipIdentifiers=true must not suppress it
199-
String code = decompileSmali("b64/b64_decodable.smali",
200-
Map.of(opt("skipIdentifiers"), "yes"));
201-
System.out.println(code);
202-
assertThat(code).contains("b64: hello");
203-
}
204-
205186
@Test
206187
public void minDecodedLengthFiltersShortDecodeTest() throws Exception {
207188
// "aGVsbG8=" decodes to "hello" (5 chars); minDecodedLength=10 must suppress it
@@ -245,6 +226,39 @@ public void pemB64FieldTest() throws Exception {
245226
assertThat(code).contains("b64:");
246227
}
247228

229+
@Test
230+
public void antifridas8kTest() throws Exception {
231+
// Real-world anti-frida/xposed detection class (s8.k) with filled-new-array/range.
232+
// All 9 strings in the array should get indexed comments. Padded strings (frida,
233+
// libAndHook, liblsposed) anchor contextual decoding of the unpadded siblings that
234+
// would otherwise be filtered by skipIdentifiers or requirePadding. Index 2 ("Z3Vt")
235+
// is below minInputLength=8 individually, but the array context includes it too.
236+
String code = decompileSmali("b64/antifrida_s8k.smali");
237+
System.out.println(code);
238+
assertThat(code).contains("b64[0]: xposed");
239+
assertThat(code).contains("b64[1]: frida");
240+
assertThat(code).contains("b64[2]: gum");
241+
assertThat(code).contains("b64[3]: linjector");
242+
assertThat(code).contains("b64[4]: magisk");
243+
assertThat(code).contains("b64[5]: substrate");
244+
assertThat(code).contains("b64[6]: gdbserver");
245+
assertThat(code).contains("b64[7]: libAndHook");
246+
assertThat(code).contains("b64[8]: liblsposed");
247+
}
248+
249+
@Test
250+
public void filledArrayB64Test() throws Exception {
251+
// String[] initialised with two B64 strings via filled-new-array.
252+
// Both decoded values should appear as indexed comments on the array instruction.
253+
String code = decompileSmali("b64/filled_array_b64.smali");
254+
System.out.println(code);
255+
assertThat(code).contains("b64[0]: Hello, World!");
256+
assertThat(code).contains("b64[1]: hello");
257+
assertThat(code).contains("b64[2]: foobar");
258+
assertThat(code).contains("b64[3]: base64");
259+
assertThat(code).contains("b64[4]: testing");
260+
}
261+
248262
@Test
249263
public void byteArrayStringPassDisabledTest() throws Exception {
250264
// with enableByteArrayStringPass=false the bytes: comment must not appear

0 commit comments

Comments
 (0)