rea-reverse

用 R8 mapping.txt 和 retrace 还原混淆后的 Android 崩溃堆栈

发布者 rea-reverse更新于 约 8 分钟读完
本页目录

开了 minifyEnabled true 的 release 包崩了,堆栈长这样:at a.c.b(r8-map-id-c701195c…:3)。类叫 a.c,方法叫 b,文件名是一串哈希,源码里一个都搜不到。构建时 R8 其实已经把这些名字和行号的对应关系写进了 mapping.txt,用 retrace 套上去就能还原。

这篇写给发混淆包、需要看懂自家崩溃报告的 Android / Java 开发者。我们专门写了一个小程序并用 R8 压缩,然后一步步看:mapping 文件从哪来,里面每一行怎么读(包括一个混淆帧展开成三帧的内联组),Android SDK 自带的 retrace 怎么用,以及本站的 ProGuard / R8 堆栈还原工具 在同样输入下的结果。

实测环境:macOS 27.0.1(Apple silicon),Temurin JDK 17.0.18,Android SDK Command-line Tools 20.0(里面的 r8、retrace 都报 R8 9.0.32);Gradle 部分用的是 Android Gradle 插件 9.4.1 + Gradle 9.6.1,内置 R8 9.4.24。下文所有堆栈和 mapping 片段都是这几次运行的原样输出。

示例程序和混淆后的崩溃

程序只有一个 App.java,60 行左右,解析 "apple,3,0.50" 这种订单行,第三行的数量故意写成 x:

final class LineParser {
  static Item parse(String line) {
    String[] parts = line.split(",");
    return new Item(parts[0], quantity(parts[1]), Double.parseDouble(parts[2]));   // line 31
  }

  private static int quantity(String field) {
    int q = toInt(field);                                                          // line 35
    if (q <= 0) throw new IllegalArgumentException("quantity must be positive: " + q);
    return q;
  }

  private static int toInt(String field) {
    try {
      return Integer.parseInt(field.trim());                                       // line 42
    } catch (NumberFormatException e) {
      throw new IllegalStateException("bad quantity field '" + field + "'", e);    // line 44
    }
  }
}

javac --release 11 编译、不压缩直接跑,堆栈很正常:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at org.sample.shop.LineParser.toInt(App.java:44)
	at org.sample.shop.LineParser.quantity(App.java:35)
	at org.sample.shop.LineParser.parse(App.java:31)
	at org.sample.shop.App.main(App.java:10)
Caused by: java.lang.NumberFormatException: For input string: "x"
	...
	at org.sample.shop.LineParser.toInt(App.java:42)
	... 3 more

接着用 R8 压缩这个 jar。用 class 文件模式(--classfile),产物还能在桌面 JVM 上跑,规则里只保留 main:

printf '%s\n' '-keep class org.sample.shop.App { public static void main(java.lang.String[]); }' > keep.pro
r8 --release --classfile --lib "$(/usr/libexec/java_home)" \
   --pg-conf keep.pro --pg-map-output out/mapping.txt \
   --output out/app-r8.jar out/in.jar
java -cp out/app-r8.jar org.sample.shop.App

R8 用时 1.4 秒,jar 从 3,860 字节变成 3,621 字节,mapping 文件 1,494 字节、31 行。同一个崩溃变成了:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at a.c.b(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:3)
	at a.c.a(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:1)
	at org.sample.shop.App.main(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:5)
Caused by: java.lang.NumberFormatException: For input string: "x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:668)
	at java.base/java.lang.Integer.parseInt(Integer.java:786)
	at a.c.b(r8-map-id-c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733:1)
	... 2 more

变化有三处:类名被改了(LineParser 成了 a.c);行号换成了 R8 自己编的小数字;应用帧从 4 个变成 3 个,因为 parse() 被 R8 内联进了 main,已经不是一个独立的方法了,... 3 more 也因此成了 ... 2 more。

源文件名被替换成 r8-map-id- 加一串哈希,这串哈希就是 mapping 文件头里的 pg_map_id。后面会看到,它是整段堆栈里最有用的一个字符串。

mapping.txt 是从哪来的

命令行里由 --pg-map-output 指定路径。Android Gradle 工程里,插件会为每个开了混淆的变体自动生成。我们建了一个只有一个 Activity 的测试应用(不依赖 AndroidX,直接继承 android.app.Activity),跑完 ./gradlew :app:assembleRelease 后目录是这样的:

app/build/outputs/mapping/release/configanalyzer.html
app/build/outputs/mapping/release/configuration.txt
app/build/outputs/mapping/release/configanalyzer.pb
app/build/outputs/mapping/release/seeds.txt
app/build/outputs/mapping/release/mapping.txt
app/build/outputs/mapping/release/mapping.prt
app/build/outputs/mapping/release/usage.txt

retrace 要的就是 mapping.txt。configuration.txt 是 R8 实际用到的合并后的 keep 规则,seeds.txt 是 keep 规则命中的内容,usage.txt 是被删掉的内容。AGP 9.4.1 还多写了一个 mapping.prt,file 认出来是个 ZIP 包,retrace 用不到它。

打 AAB 时,同一份 mapping 会被塞进 .aab。我们跑了 bundleRelease 对比了一下:

unzip -p app/build/outputs/bundle/release/app-release.aab \
  BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map \
  | cmp - app/build/outputs/mapping/release/mapping.txt && echo SAME
SAME

也就是说,就算手里只剩当初上传的 .aab,那次构建的 mapping 也能从里面拿出来。

搭这个构建时踩了两个坑。第一,AGP 9.4.1 在我们先用的 Gradle 9.4.1 上直接报错:Minimum supported Gradle version is 9.6.0. Current version is 9.4.1.,换 Gradle 9.6.1 才过。第二,我们是 --offline 构建,lintVitalAnalyzeRelease 报 No cached version of com.android.tools.lint:lint-gradle:32.4.1 available for offline mode。用 -x 跳过三个 lintVital* 任务后构建成功,mapping 文件跟这几个任务没有关系。

mapping 里每一行是什么意思

下面是 class 文件模式那份 mapping 里 App 和 LineParser 的部分:

# compiler: R8
# compiler_version: 9.0.32
# {"id":"com.android.tools.r8.mapping","version":"2.2"}
# pg_map_id: c701195cdf4e3ff721f2bcebca323794ae5b89d15c9c2c3eee87b3ffc0600733
org.sample.shop.App -> org.sample.shop.App:
# {"id":"sourceFile","fileName":"App.java"}
    1:3:void main(java.lang.String[]):7:9 -> main
    4:5:org.sample.shop.Item org.sample.shop.LineParser.parse(java.lang.String):30:31 -> main
    4:5:void main(java.lang.String[]):10 -> main
    6:6:void org.sample.shop.Cart.add(org.sample.shop.Item):53:53 -> main
    6:6:void main(java.lang.String[]):10 -> main
    7:7:void main(java.lang.String[]):12:12 -> main
...
org.sample.shop.LineParser -> a.c:
# {"id":"sourceFile","fileName":"App.java"}
    1:2:int quantity(java.lang.String):35:36 -> a
    1:3:int toInt(java.lang.String):42:44 -> b

类那一行是 原名 -> 混淆名:,下面的 # {...} 注释记录了原来的源文件名,堆栈里被 map id 顶掉的就是它。

成员行的格式是 混淆起始行:混淆结束行:签名:原起始行:原结束行 -> 混淆名。拿 1:3:int toInt(java.lang.String):42:44 -> b 来说:a.c 里方法 b 的混淆行 1 到 3,对应 toInt 的原始行 42 到 44。堆栈里的 a.c.b(...:3) 是这个区间的第 3 行,也就是源码第 44 行那个 throw;Caused by 里的 a.c.b(...:1) 是第 42 行,parseInt 调用。

内联的表现是:混淆区间相同的几行连在一起。main 的 4 到 5 行出现了两次,一次是 LineParser.parse(...):30:31,一次是 main(...):10。这一组从上往下读,就是从最内层到最外层:混淆行 5 是 parse 的第 31 行,这段代码被内联到了 main 的第 10 行。所以 App.main(...:5) 这一帧会展开成两帧。注意外层那行只有一个行号(:10),不是区间,因为它是调用点,不管落在被内联代码的哪一行,调用点都不变。

Android 构建里有个更深的例子。那个应用里 LineParser 是在 MainActivity.onCreate 里调用的,结果整个类都被 R8 删了:

org.sample.shop.LineParser -> R8$$REMOVED$$CLASS$$0:
org.sample.shop.MainActivity -> org.sample.shop.MainActivity:
    1:2:void onCreate(android.os.Bundle):9:10 -> onCreate
    3:3:int org.sample.shop.LineParser.toInt(java.lang.String):24:24 -> onCreate
    3:3:int org.sample.shop.LineParser.quantity(java.lang.String):17 -> onCreate
    3:3:void onCreate(android.os.Bundle):10 -> onCreate
...
    6:6:int org.sample.shop.LineParser.toInt(java.lang.String):26:26 -> onCreate
    6:6:int org.sample.shop.LineParser.quantity(java.lang.String):17 -> onCreate
    6:6:void onCreate(android.os.Bundle):10 -> onCreate

两个辅助方法都被搬进了 onCreate,onCreate 的第 6 行是一个三层的内联组:toInt 第 26 行,被 quantity 第 17 行调用,quantity 又被 onCreate 第 10 行调用。R8$$REMOVED$$CLASS$$0 的意思是 APK 里根本没有这个类,在反编译器里搜 LineParser 是搜不到的。

用 Android SDK 的 retrace

Command-line Tools 里 retrace 和 r8 放在一起:

~/Library/Android/sdk/cmdline-tools/latest/bin/retrace --version
Retrace 9.0.32 (build c3ce613f2fe60ca6edc3c2103508e6c966d7fc51 from go/r8bot ...)

retrace out/mapping.txt out/trace.txt

输出:

Exception in thread "main" java.lang.IllegalStateException: bad quantity field 'x'
	at org.sample.shop.LineParser.toInt(App.java:44)
	at org.sample.shop.LineParser.quantity(App.java:35)
	at org.sample.shop.LineParser.parse(App.java:31)
	at org.sample.shop.App.main(App.java:10)
Caused by: java.lang.NumberFormatException: For input string: "x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:668)
	at java.base/java.lang.Integer.parseInt(Integer.java:786)
	at org.sample.shop.LineParser.toInt(App.java:42)
	... 2 more

输入 10 行,输出 11 行。4 个应用帧和不压缩时完全一致,连被内联掉的 parse 也回来了。JDK 的帧原样保留,因为这些类不在 mapping 里。唯一没还原的是 ... 2 more 这个计数,retrace 直接照抄输入。

这次没有把 Android 包装到设备上跑,所以为了测那个三层内联组,我们按 Android mapping 的 map id 和混淆行 6 手写了一帧,带上 logcat 前缀:

E AndroidRuntime: java.lang.IllegalStateException: bad quantity field 'x'
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(r8-map-id-72621bc4...:6)
E AndroidRuntime: 	at android.app.Activity.performCreate(Activity.java:9002)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(Unknown Source)

输入是人工构造的,不是抓到的真实崩溃,但 retrace 的输出是实际跑出来的:

E AndroidRuntime: 	at org.sample.shop.LineParser.toInt(MainActivity.java:26)
E AndroidRuntime: 	at org.sample.shop.LineParser.quantity(MainActivity.java:17)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(MainActivity.java:10)
E AndroidRuntime: 	at android.app.Activity.performCreate(Activity.java:9002)
E AndroidRuntime: 	at org.sample.shop.MainActivity.onCreate(MainActivity.java)

logcat 前缀保留,一帧变三帧。没有行号的 Unknown Source 那帧补上了文件名,但补不出行号,因为堆栈里没有信息说明它落在哪个区间。

同样的输入放进浏览器工具

本站的 retrace 工具 是把 R8 的 retrace 移植到 Web Worker 里跑。为了保证这里说的都属实,我们把站点仓库里的 src/tools/retrace/analyze.ts 放到 Node.js 22 下,喂上面两组输入,再和 SDK 的 retrace 9.0.32 的输出做 diff。两组结果逐行一致,唯一的字节差异是末尾换行:工具的文本输出最后不带换行符。

工具对 class 文件那段堆栈算出的摘要:

{"lines":10,"frames":7,"mapped":4,"retraced":4,"ambiguous":0,"inlined":1,"removed":0,"exceptions":0}

mapping 部分:31 行、4 个类;堆栈里的 6 个类名找到 2 个,编译器 R8 9.0.32。另外 4 个是 java.lang.IllegalStateException、java.lang.NumberFormatException、java.lang.Integer,还有一个有点意外的 "x":retrace 默认的正则会把 For input string: "x" 冒号后面那段当成类名,R8 自己的解析器也是这么处理的。这些都不可能出现在应用的 mapping 里,所以这里 “6 个找到 2 个” 是正常的。整个过程 6 ms。mapping 在浏览器本地读取,不上传,工具页上写了怎么用 Network 面板自己验证。

实际用下来,脚本或 CI 里批量还原,命令行更合适;有人在群里甩来一段堆栈,你手边有 mapping 但这台机器没装 SDK,打开网页更快。

结果看着对,其实是错的

准备这篇时最费时间的问题,恰恰没有任何报错。我们把源码改了一点(在 toInt 上面加了一个 4 行的 label() 方法),重新构建,然后拿新 mapping 去还原旧堆栈:

	at org.sample.shop.LineParser.toInt(App.java:48)
	...
	at org.sample.shop.LineParser.toInt(App.java:46)

第一帧成了第 48 行,正确答案是 44;Caused by 那帧成了 46,正确答案是 42。两个行号都落在正确的方法里,看起来很合理,retrace 9.0.32 和浏览器工具都没有给出任何警告。方法名之所以还对,只是因为这次改动没有引起重命名;两次构建差别大一些,方法名也会一样理直气壮地错掉。

检查办法就是对 map id。旧堆栈里是 r8-map-id-c701195c…,新 mapping 文件头写的是 pg_map_id: 8e5ed241…。还原之前先对一下:

grep -o 'r8-map-id-[0-9a-f]*' trace.txt | head -1
grep '^# pg_map_id' mapping.txt

不一致就去找对的那份 mapping。反过来,mapping 完全选错的情况在浏览器工具里很好发现:我们把 Android 的 mapping 配上 JVM 那段堆栈,摘要直接显示 6 个类名 找到 0 个,映射上的帧也是 0。

keep 规则改不了行号

我们原本以为加上 -keepattributes SourceFile,LineNumberTable 就能拿到真实源码行号,老 ProGuard 时代是这么配的。在 R8 9.0.32 的 class 文件模式下并没有用。同一个 jar 我们构建了四次:

额外规则 堆栈第一帧
无 a.c.b(r8-map-id-c701195c…:3)
-keepattributes SourceFile,LineNumberTable a.c.b(r8-map-id-c701195c…:3)
-keepattributes LineNumberTable + -renamesourcefileattribute SourceFile a.c.b(r8-map-id-c701195c…:3)
-dontobfuscate org.sample.shop.LineParser.toInt(r8-map-id-f1c042af…:3)

就算 -dontobfuscate,类名是看得懂了,行号还是 :3,parse 也还是不见了。内联和行号重排属于优化,不属于混淆。所以 “关掉混淆” 并不能让你摆脱 mapping 文件,只要 R8 优化过,就得留着它。另外,AGP 默认的 proguard-android-optimize.txt(在 configuration.txt 里能看到展开后的内容)只保留了 Signature、InnerClasses 和注解相关的属性,没有 LineNumberTable 和 SourceFile,而 Android 构建的 mapping 里照样有完整的行号区间。

结合 REA

REA(morluto 的开源项目)通过无界面的 JADX 分析 APK。已发布的 [email protected] 包里,Android 相关的工具是 inspect_android_package、search_android_classes、inspect_android_class、inspect_android_method 和 trace_android_references,这些工具的契约里都没有提到 mapping 文件,也就是说 REA 看到的是 APK 里实际存在的名字。正确的顺序是:先 retrace,再用 mapping 把原名换回混淆名(org.sample.shop.LineParser -> a.c),然后让 REA 或 JADX 打开这个类。如果一个类在 mapping 里是 R8$$REMOVED$$CLASS$$0,就去看它被内联进的那个方法。配置方法见 用 REA 分析 Android APK 和 JADX MCP Server 配置。只分析自己构建或已获授权的应用,详见 免责声明。

本文的局限

  • JVM 那段堆栈是真实运行得到的;Android 那一帧是根据 Android mapping 手写的,因为没有在设备上跑 APK。两者的 retrace 输出都是真实结果。
  • 示例里没有出现带 <OR> 的歧义帧、outline 和 rewriteFrame 规则,这些在工具页里有说明。
  • .so 原生库的帧要用 ndk-stack 加未 strip 的库来符号化,retrace 管不了。怎么先把崩溃抓下来,见 用 adb logcat 和 bugreport 排查崩溃。

下一步