ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

jocky实战:Eclipse老工程Java混淆接入与避坑

jocky实战:Eclipse老工程Java混淆接入与避坑

简介:这份面向Java开发者的Eclipse插件工具包,围绕Jocky源码级混淆编译器展开,适合需要在开发阶段直接完成代码混淆、减少后期额外处理环节的工程人员。资源共13个文件,以4个txt说明文档、3个jar插件包和2个xml配置文件为主,另含zip压缩包、url链接、jpg示意图及mf清单文件,整体约692KB,体积轻便,便于快速取用与部署。已有1000人学习下载,说明其在Eclipse混淆插件场景中具备一定参考价值。通过插件方式接入后,用户可在工程右键菜单中调用Jocky相关功能;其特点是在编译过程中直接对源码做混淆处理,而非仅针对字节码二次加工。包内说明文档与配置样例可用于理解插件目录组织、接入方式及基础使用流程,帮助开发者较快完成环境准备与功能验证。

1. 为什么还在用 jocky:一个 Eclipse 老工程的混淆选择

前阵子帮一个做工业手持设备的团队看打包脚本,工程还是 Eclipse 时代的 ADT 结构,ProGuard 的配置整段被注释掉了。诉求很具体:APK 要过甲方的静态扫描,类名和方法名不能裸奔,但 ProGuard 一开就报一堆can't find referenced class,关掉又等于没混淆。翻工具目录时发现一个 jocky 的 jar,这是早年安卓圈常用的 Java 代码混淆工具,走字节码改写路线,不依赖 ProGuard 那套规则体系,对 Eclipse 老工程尤其省心。它能干三件事:类/方法/字段重命名、字符串常量加密、调试信息清理,适合还在维护老工程、又不想重构构建链的人。下面按接入、配置、踩坑、验证的顺序拆一遍。

2. 拆开 jocky 的混淆链路:字节码改写到底改了什么

2.1 从 class 到 dex:jocky 介入的时机

Eclipse ADT 工程的打包链路并不复杂:src下的 Java 文件先被 Eclipse 内置编译器编成bin/classes下的 class 文件,然后 ADT 调用 dx 把这些 class 转成classes.dex,最后和资源一起打进 APK。jocky 的位置就在 class 之后、dx 之前,输入是 class 目录或者打好的 jar,输出是改写后的 class 或 jar。它和 ProGuard 抢的是同一个卡位,但两者的处理深度完全不同。

ProGuard 的流程是 shrink(删无用代码)、optimize(优化字节码)、obfuscate(改名)、preverify(预校验)四步,每一步都要读完整的依赖库和规则文件,所以配置重、报错多。jocky 只做 obfuscate 这一层,不删代码、不做数据流优化,因此它不需要完整的 classpath,也不需要-dontwarn那套东西。这个取舍带来的直接好处是配置轻、跑得快,代价是它不会帮你瘦身,方法数该多少还是多少。

提示:如果工程的痛点是 64K 方法数超限,jocky 解决不了,得上 multidex 或者换 R8;它的价值只在“让反编译出来的东西不可读”这一件事上。

还有一个容易被忽略的点:jocky 必须先扫全量类才能改名。因为改名要保证继承链一致——父类的getUser()改成a(),子类重写的getUser()也必须改成a(),否则多态直接失效;接口方法和实现类方法同理。这意味着它不能单文件处理,必须先构建一张类图,再统一决策。增量构建时类图不完整,改名决策就会出错,后面第 5 章会专门讲这个坑。

2.2 四类混淆动作:重命名、字符串加密、控制流、调试信息

第一类是标识符重命名,也是 jocky 的核心动作。包名会被压平,com.example.app.ui可能变成a.b;类名、方法名、字段名换成短名或者形近字符。这里有个细节:重载方法改名后不能撞车,两个同名不同参的方法会被加上不同后缀区分。字段改名还要考虑父类字段和子类字段同名的情况,处理不干净会出现“子类字段遮蔽父类字段”的语义变化,这类问题在编译期看不出来,运行时才炸。

第二类是字符串常量加密。class 常量池里的CONSTANT_String会被替换成密文,同时插入一段解密调用,运行时第一次用到才还原。接口地址、渠道号、硬编码的密钥这类东西,反编译出来就是明文,加上这层之后至少不是一眼可见。代价是启动时多几次解密调用,量级在毫秒级,老设备上能感知到一点,但通常可以接受。

第三类是控制流混淆,把基本块打散、插入条件跳转或者把if/else改写成switch。jocky 在这块强度一般,对抗手工逆向不够看,但能把自动化分析工具的产出搅乱。第四类是调试信息清理,删掉SourceFile、LineNumberTable、LocalVariableTable,崩溃堆栈里的行号会变成 Unknown,类名方法名也变成短名。这一步对减小包体有一点点帮助,但真正的代价是线上排查必须依赖 mapping 文件。

注意:字符串加密和反射是天然冲突的。反射里写的Class.forName("com.example.model.User")是字符串,加密之后运行时解出来还是原名,但类本身已经被改名了,照样找不到。这类字符串要么 keep 类名,要么单独做映射替换。

2.3 和 ProGuard 的边界:什么场景该选谁

把两者的差异摊在一张表里,选型就清楚了:

维度jockyProGuard / R8
处理内容重命名、字符串加密、调试信息清理压缩、优化、混淆、预校验
规则体系简单的 keep 列表完整的-keep/-dontwarn规则
依赖分析不需要完整 classpath需要,缺依赖就报错
处理速度快,不做数据流分析慢,开 optimize 更慢
对新版字节码支持有限,高版本 class 容易翻车持续更新
反射友好度差,全靠手动 keep同样差,但生态里有现成规则
维护状态基本停更活跃

结论不复杂:类数量可控、反射点少、构建链老、打包机内存紧张的工程,jocky 是能用的;新工程、依赖几十个 aar、用 Kotlin 和 Java 8 特性的,别碰它,直接上 R8。我一般把 jocky 当成“老工程的后悔药”,而不是通用方案。

2.4 混淆后的产物结构:包名压平带来的连锁反应

jocky 处理完之后,输出目录里的 class 文件路径会跟着包名一起变。原本com/example/app/ui/MainActivity.class可能变成a/b/c.class,这对 dx 没影响,但对构建脚本有影响——任何在混淆之后按原路径找 class 的步骤都会失败,比如自定义的资源处理脚本、按包名过滤的拷贝任务。

另一个连锁反应是R类。资源 ID 的引用在字节码里是常量,改名不影响;但如果有代码通过反射拿R.drawable.xxx的字段名,就会断。常见做法是把R类整个 keep 住,别让它参与改名。还有BuildConfig,里面有DEBUG、APPLICATION_ID这类常量,混淆后字段名变了,代码里如果按字段名反射取值同样会断。

产物里还会多出两个文件:mapping 和 log。mapping 是“原名 → 混淆名”的对照表,log 记录哪些类被跳过、哪些规则没命中。这两个文件别随手删,后面还原堆栈、排查 keep 规则全靠它们。

3. 在 Eclipse 工程里接上 jocky:插件与 Ant 两条路

3.1 插件方式:工程属性里的混淆开关

最省事的接法是 Eclipse 插件。把发行包解压,plugins目录下的 jar 复制到 Eclipse 安装目录的dropins文件夹,重启 IDE。然后在工程上右键 Properties,如果插件加载成功,会多出一个 jocky 的配置页,里面通常有启用开关、keep 文件路径、输出目录、日志路径这几项。

配置完之后,走Export Android Application导出 APK,控制台会打印混淆过程的日志。第一次跑建议把日志级别调高,确认它确实处理了你的类,而不是静默跳过。

注意:插件对 Eclipse 版本比较敏感,太新的 IDE 可能认不出老插件的扩展点;另外它一般只挂在导出向导上,Run As不会触发混淆,调试时别指望看到混淆后的行为。

插件方式适合单人维护的小工程,但它有两个硬伤:一是配置项少,很多命令行参数暴露不出来;二是构建过程不透明,出了问题只能看日志猜。团队协作或者要接 CI 的场景,还是得走 Ant 或命令行。

3.2 Ant 任务方式:把混淆挂到打包流程

Ant 方式可控性最好,适合把混淆和编译、打包串成一条固定流水线。核心是在build.xml里声明 taskdef,然后按顺序调 compile、jar、jocky、dx。

<?xml version="1.0" encoding="UTF-8"?> <project name="obfuscate-demo" default="obfuscate" basedir="."> <!-- jocky 的 jar 路径,按实际发行包位置改 --> <property name="jocky.jar" value="tools/jocky.jar" /> <property name="in.jar" value="build/classes.jar" /> <property name="out.jar" value="build/classes-obf.jar" /> <!-- classname 按发行包 README 里的 taskdef 说明填,这里只是示意 --> <taskdef name="jocky" classname="com.jocky.JockyTask"> <classpath> <pathelement location="${jocky.jar}" /> </classpath> </taskdef> <target name="obfuscate"> <!-- 输入用干净的编译产物,输出单独放,别覆盖输入 --> <jocky inputJar="${in.jar}" outputJar="${out.jar}" keepFile="config/keep.txt" mappingFile="build/mapping.txt" logFile="build/jocky.log" level="5" encryptString="false" /> </target> </project>

这段配置的逻辑很直白:taskdef把 jocky 的任务类注册进 Ant,jocky标签里的属性就是一轮混淆的全部输入输出。inputJar和outputJar必须分开,覆盖输入会导致第二次构建拿到已经被混淆过的 class,再混淆一遍直接坏掉。level先给 5,稳定之后再往上加。encryptString第一次先关,等主流程跑通了再开,不然字符串解密的问题会和混淆本身的问题混在一起,排查成本翻倍。

在 Eclipse 里挂 Ant Builder 的步骤:工程 Properties → Builders → New → Ant Builder,选中build.xml,把 target 设成obfuscate,触发时机勾在 Java Builder 之后。这样每次保存编译完,混淆会自动跑一遍,日志直接进 Console。

3.3 命令行方式:脱离 IDE 跑批量构建

CI 或者打包机上没有 Eclipse,就用命令行。参数形式和 Ant 属性基本对应,只是前缀不同:

# 先看帮助,确认当前发行版的参数名,不同版本前缀可能是 - 或 -- java -jar tools/jocky.jar --help # 执行混淆:输入 jar、输出 jar、keep 列表、映射表、日志 java -jar tools/jocky.jar \ --in build/classes.jar \ --out build/classes-obf.jar \ --keep config/keep.txt \ --mapping build/mapping.txt \ --log build/jocky.log \ --level 5

命令行的好处是退出码可判。混淆失败通常返回非 0,CI 里set -e或者&&链式判断就能拦住,不至于把半成品打进去。日志里重点看两类行:skip开头的说明某个类被跳过,keep开头的说明白名单命中。如果关键类出现在 skip 列表里,说明输入路径不对或者类没编译进去。

参数名以--help输出为准,不要照抄别处的脚本,版本之间改过名字是很常见的事。

4. 混淆配置的参数表:keep 规则、级别与输出

4.1 keep 白名单怎么写才不漏

keep 文件是 jocky 配置里最需要认真对待的部分,写少了运行时崩溃,写多了混淆等于没做。它的格式比 ProGuard 简单,通常是逐行声明,粒度到类全名或包前缀,不支持复杂的成员签名通配。下面是我常用的一个骨架:

# 安卓组件:系统按类名反射实例化,必须保留 com.example.app.MainActivity com.example.app.SyncService com.example.app.BootReceiver # 自定义 View:布局 XML 里按类名引用 com.example.app.widget.TrendChart # 反射入口:Class.forName / getMethod 用到的类 com.example.model.User com.example.model.Order # JNI 入口:类名和方法名决定 C 侧符号 com.example.jni.NativeBridge # 第三方 SDK:内部有反射,整包保留 com.thirdparty.sdk.

必须进白名单的清单,我一般按这几类过一遍:四大组件和 Application;自定义 View;Serializable / Parcelable 的模型类;所有Class.forName、getDeclaredMethod出现的位置;native 方法所在类;注解处理器生成的类;第三方 SDK 的包前缀。最后一项最容易被漏,很多统计、推送 SDK 内部用类名做 key,混淆后初始化直接崩。

提示:keep 文件要进版本库,和代码一起评审。我见过有人本地改完忘了提交,打包机拉下来是旧规则,问题复现不了,查了一下午。

4.2 混淆级别与字符串加密开关

参数不多,但每个都影响结果,列成表更清楚:

参数作用建议起步值
inputJar输入 class 或 jar干净的全量编译产物
outputJar输出路径单独目录,不与输入重叠
keepFile白名单版本化管理
mappingFile名字映射表每次构建归档
logFile过程日志保留最近若干次
level混淆强度先 5,稳定后加到 7 或 8
encryptString字符串加密先 false,跑通后开
removeLog删日志调用发布包开,调试包关

level的含义是改名激进程度,数字越大,包名压平越彻底、成员名越短。不要一上来拉满,先低级别跑通全流程,再逐级往上加,每加一级跑一遍冒烟用例。这样出问题时能定位到是哪一级引入的。

encryptString单独说一句:开了之后,反编译看到的字符串是密文,但解密逻辑本身也在字节码里,专业逆向还是能还原。它的价值是提高门槛,不是绝对安全。另外,加密后的字符串在 log 里打印出来是密文,调试时别被这个吓到。

4.3 mapping 文件与日志:出问题时的黑匣子

mapping 文件记录的是“原名 → 混淆名”的完整对照,线上崩溃堆栈还原全靠它。归档策略很简单:每次构建生成一份,按版本号或构建号命名,跟 APK 一起存,别覆盖。丢了 mapping 的混淆包,堆栈基本等于天书,只能靠猜。

日志文件主要看三件事:哪些类被 keep 命中、哪些类被 skip、有没有“找不到引用”的警告。skip 列表里出现业务类,说明输入路径或者编译产物有问题;警告里出现“referenced class not found”,说明依赖没进输入,这类警告攒多了,混淆结果可能不完整。

还原堆栈的做法不复杂,把崩溃日志里的短类名和方法名,按 mapping 逐条替换回原名,再对着源码看行号。工具不是必需的,mapping 格式就是纯文本,写个脚本做字符串替换也能凑合,关键是 mapping 得在。

5. 避坑记录:五类最容易翻车的场景

5.1 反射调用被改名:ClassNotFoundException 与 NoSuchMethodException

现象:混淆包启动就崩,堆栈里是ClassNotFoundException: com.example.model.User,或者NoSuchMethodException: init()。奇怪的是同一份代码不混淆跑得好好的。

原因:反射里的类名和方法名是字符串常量,jocky 改的是符号引用,不会去解析字符串内容。类被改成a.b.c之后,Class.forName("com.example.model.User")还在找原名,自然找不到。方法同理,getMethod("init")找的是原方法名,改名后对不上。

解决:把反射目标类整体加进 keep,类名和成员名都保留;如果反射点很多、keep 列表爆炸,就在混淆后做一轮字符串替换,把代码里的原类名映射成混淆名,这需要读 mapping 文件;更彻底的做法是把反射改成接口调用,从根上消除字符串依赖。我一般先 keep,等稳定了再逐步把热点反射改掉。

5.2 注解与序列化字段:JSON 解析出来全是 null

现象:接口返回的数据解析出来字段全是 null,或者 Parcelable 反序列化后字段丢失。日志里没有异常,就是数据不对。

原因:Gson、Jackson 这类框架默认按字段名匹配 JSON key,字段被改成a、b之后,user_name匹配不上任何字段,静默赋 null。Parcelable 稍微好一点,它按写入顺序读,改名不影响,但如果模型类里混用了反射赋值,同样会出问题。

解决:模型类整体 keep,字段名不动;或者给每个字段加@SerializedName("user_name")显式指定 key,这样字段名可以混淆,注解值不变。后一种做法混淆强度更高,但要确认混淆过程不会把注解抹掉,第一次用的时候拿一个类做验证。Parcelable 的类建议直接 keep,省事。

5.3 JNI 与 native 方法:UnsatisfiedLinkError 找上门

现象:调用 native 方法时报UnsatisfiedLinkError: No implementation found for native ...,附带的符号名是一串短名。

原因:静态注册的 JNI 靠命名约定绑定,C 侧符号是Java_包名_类名_方法名,Java 侧的类名或方法名一改,符号对不上,动态库加载得到但方法找不到。包名被压平之后,这个符号更是面目全非。

解决:把 JNI 类和所有 native 方法加进 keep,类名、方法名、包名都不能动。如果不想 keep,就改成动态注册:在JNI_OnLoad里用RegisterNatives手动绑定方法签名,这样 Java 侧类名可以混淆,但签名表要跟着维护,改方法参数时两边都得动,维护成本不低。

5.4 增量构建与 class 版本:本地能跑,打包机上报 VerifyError

现象:本地 Eclipse 里导出正常,打包机上报VerifyError或ClassFormatError,指向某个看起来毫无问题的类。

原因有两个。一是增量编译:Eclipse 只重编改动过的文件,bin/classes里混着新旧 class,jocky 拿到不完整的类图做改名决策,继承链对不上就产出非法字节码。二是 class 文件版本:高版本 JDK 编出来的 class 带invokedynamic等新指令,老版本 jocky 依赖的字节码库不认,处理时直接写坏。

解决:混淆前强制 clean,全量编译,输入目录只放这一次构建的产物;编译目标版本降到 jocky 能处理的级别,Java 8 的 lambda 如果报错就改成匿名内部类。打包脚本里加一步校验,混淆后跑一次 dx,dx 能过基本说明字节码没坏。

5.5 与第三方库的冲突:某个 SDK 初始化就崩

现象:接入的某个统计或推送 SDK 初始化崩溃,堆栈指向它内部的类,报错信息含糊,看不出所以然。

原因:这类 SDK 内部大量使用反射,用类名做 key、用字段名拼请求参数,甚至有自己的类加载逻辑。整包混淆之后,内部约定全断。

解决:把 SDK 的包前缀整包 keep,别心疼那点混淆强度;翻 SDK 文档看有没有官方 keep 规则,有就照抄;如果文档缺失,就逐个类试,从入口类开始 keep,崩了再加下一个。还有一种情况是 SDK 依赖的公共库被混淆导致冲突,这种只能把公共库也 keep 掉。

6. 验证与收尾:混淆后怎么确认没改坏

混淆这件事,配完只是开始,验证才是收尾。我习惯先做静态抽查,再跑冒烟用例,两步都过才算完成。

静态抽查看三样东西:包名有没有压平、成员名有没有变形、字符串还是不是明文。用unzip列出 jar 内容,再拿javap看一个类的字节码:

# 列出混淆后的 jar,确认包路径已经变短 unzip -l build/classes-obf.jar | head -n 30 # 抽查某个类的成员名和常量池,类名按上一步的实际输出替换 javap -p -c -classpath build/classes-obf.jar a.b.c | head -n 60

第一段输出里如果还能看到com/example/app这种完整路径,说明包名压平没生效,检查level是不是太低。第二段输出重点看方法名是不是短名、常量池里的字符串是不是密文;如果字符串仍是明文,要么encryptString没开,要么这个类被 keep 了。

冒烟用例不用多,覆盖三条链路就够:登录(走网络和序列化)、列表加载(走反射和模型解析)、一个回调入口(走系统反射实例化)。跑完抓 logcat 里的堆栈,拿 mapping 还原,确认崩溃点落在业务代码而不是混淆引入的怪问题。我还会对比混淆前后的方法数,方法数暴涨通常意味着某处生成了大量合成方法,得回头查。

从那以后我每次接老工程的混淆,都先把 keep 文件当代码一样评审一遍,再跑这三条链路,最后把 mapping 按版本归档。这套流程不新鲜,但能省掉大部分半夜被叫起来看堆栈的机会。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进