我记得第一次接触smali/baksmali的时候,还是被"DEX注入"这几个字吓住了。总觉得那应该是搞底层逆向的大佬才能碰的东西,普通人就算把工具下载下来,面对满屏的寄存器指令也不知道从哪儿下手。但实际上跑完一个完整的"DEX逻辑注入"流程之后,你会发现这东西并没有想象中神秘——它更像是一套固定的"拆解、修改、拼装"流程,每个环节都有非常明确的动作和工具。唯一的门槛,是你愿不愿意把smali指令当成一门可以阅读的语言去对待。
这篇文章就是围绕这个完整流程写的。从一个普通的Android App入手,先讲清楚smali和DEX之间的对应关系,再带你定位目标方法、动手修改指令、重新编译签名、最后到真机上验证效果。整个链路跑通一遍,你对smali的陌生感基本就会消除大半。这篇文章适合已经开始接触Android逆向但还没做过DEX级别修改的人,也适合那些只会用Hook框架、想往更底层走一步的朋友参考。
1. smali/baksmali的门道:不只是一条反汇编命令
网上很多教程上来就让你执行baksmali d classes.dex,然后打开smali文件一顿改。但很少有人解释清楚smali到底是什么、为什么要用smali而不是直接改DEX。搞清楚这两件事,你后面的操作才不是"照着抄",而是真正知道自己在干什么。
1.1 smali与DEX的关系
DEX是Android系统真正执行的文件格式,Dalvik虚拟机可以理解它,但人类很难直接阅读。smali就是介于人机之间的那层"汇编语言",它把DEX里的每一条字节码指令翻译成可读的文本形式。就像Windows下用IDA看到的汇编代码一样,smali保留了DEX里所有的类、方法、字段、指令和调试信息,但表达方式更贴近人的阅读习惯。
baksmali负责把DEX反汇编成smali文件,smali则负责把smali文件重新汇编成DEX。这两个工具是一对配套,名字虽然长,但做的事情就跟"解压"和"压缩"一样对称。它们的核心价值在于:允许你用文本编辑器去修改原本不可读的二进制逻辑。
这里要澄清一个常见的认知误区:smali并不是唯一的反汇编产物格式。有些工具(比如jadx)会把DEX还原成Java伪代码,看起来更接近源码,但那只是"还原出来的近似结果",不是真正能在虚拟机上执行的指令。smali才是跟原始DEX一一对应的东西,所以当你需要精确修改某一条指令,而不是仅仅看看逻辑的时候,smali就是唯一的选择。
还有一个经常被忽略的点:baksmali反汇编出的smali目录结构,跟DEX里的类定义是严格对应的。每个类对应一个.smali文件,文件头部会声明这个类的access标志、父类、接口。方法内部是寄存器操作和指令流。这套结构的完整性,决定了你修改后能否顺利重新汇编回DEX。
1.2 smali指令基础:寄存器、类型描述与指令格式
smali指令的基础是寄存器模型。Dalvik虚拟机不是基于栈的架构,而是基于寄存器的。每条指令都直接操作寄存器,寄存器不用入栈出栈,所以指令执行效率更高,但对应的代价是:写smali时你脑子里要时刻清楚每个寄存器里存的是什么。
寄存器命名有两套体系。方法参数用p0、p1这样的名字表示,局部变量用v0、v1这样的名字表示。一个方法的.registers N声明了这个方法总共需要多少个寄存器,N足够大,系统才会给你分配这么多。很多新手第一次修改smali时踩的坑,就是在方法里引用了超出.registers数量的寄存器,导致重新汇编时直接报register v3 out of range。
类型描述符的体系也跟JVM一致。I表示int,J表示long,Z表示boolean,Ljava/lang/String;表示字符串类型。方法签名则写成参数类型和返回值的组合,比如(Ljava/lang/String;)Ljava/lang/String;表示接收一个String参数、返回一个String类型的方法。这个体系对理解方法调用指令至关重要,因为smali里没有重载区分的额外提示,完全靠签名精确匹配。
实际修改smali时,你最常用到的指令就那么几类:const系列(加载常量)、iget/iput系列(读写实例字段)、invoke-*系列(方法调用)、move-result系列(获取方法返回值)、return-*系列(返回结果)。这些指令组合起来,基本能覆盖绝大多数注入逻辑的需求。
1.3 baksmali反汇编DEX的完整命令与产物结构
baksmali的使用很简单,但有一些参数值得说明。日常最常用的反汇编命令是:
java -jar baksmali.jar d classes.dex -o smali/这里的-o指定输出目录。如果你还想保留DEX里原有的调试信息、行号信息,可以加上-l参数。对注入逻辑来说,保留调试信息通常不是必要的,有时候反而会让smali文件变得臃肿。
反汇编出来的目录结构通常是这样的:
smali/com/example/MainActivity.smalismali/com/example/InjectHelper.smalismali/android/...
每个包路径对应一个目录,每个类对应一个文件。如果你要修改某个具体的业务逻辑,直接用编辑器打开对应的smali文件即可。
这里有一个实用技巧:在正式修改之前,先对原APK做一次备份,并且把DEX的哈希值记录下来。一旦修改之后出了问题,可以快速判断是不是你改动导致的变化。另外,反汇编时建议先看一个完整的smali文件,了解方法声明的整体结构,再去动手改目标方法。很多初学者一上来就找目标方法,改完发现方法体里的寄存器分配完全不对,就是因为对整体结构没概念。
2. 定位注入点:从调用链到小片段的逆推
要注入逻辑进DEX,第一步是要找到在哪里注。而"在哪里"这个问题,往往比"怎么注"更花时间。静态逆向的难点在于:你拿到的不是源码,而是smali的"汇编级别"代码——函数名、类名、字段名可能都在,但内部的逻辑关系需要靠读指令流来重建。
2.1 目标方法定位的三种路径
定位目标方法的方式,按效率从高到低排列,通常有三种:
**路径一:从字符串引用反查。**很多App在运行日志、Toast提示、网络请求参数中都会出现固定的字符串。把这些字符串在smali目录里搜索一下,就能快速定位到使用它的方法。比如你想在某个弹窗触发之前插入逻辑,那就在strings里找这个弹窗的标题文案。
**路径二:从AndroidManifest定位组件入口。**如果目标是某个Activity的onCreate、某个Service的onStartCommand,直接从清单文件找到对应类名,然后打开它的smali文件挨个方法找。
**路径三:从Java层框架调用链逆推。**利用JDWP调试、日志堆栈(Log backtrace)、或者Hook框架打印方法调用栈,拿到目标方法名之后回到smali里搜类。这条路适合处理那种没有任何特征字符串、只有逻辑运算的场景。
实际操作中,我很少只用一种方式。通常是先用字符串反查锁定大范围,再用调用链确认具体方法,最后用smali指令的语义逐行确认。这么做的好处是每一层都能过滤掉一部分噪音,不至于一上来就被上千个smali文件淹没。
2.2 用调用链倒推方法入口
举个具体例子。假设目标App在用户点击某个"分享"按钮之后会请求一个分享链接,我想在这个链接生成之后、使用之前插入一段自己的加密逻辑。
第一步,在smali目录里全局搜索分享链接相关的域名或路径关键字。如果找到了,就能定位到拼接链接的那个方法——通常是某个字符串build过程,里面能看到一连串的const-string和invoke-virtual。第二步,查看这个方法的调用者。在smali里往上翻,看谁调用了它,这就要靠阅读方法签名的逻辑了:上一个方法的invoke-direct/invoke-virtual调的正是当前方法,说明调用关系成立。第三步,在调用点确认方法参数。因为smali方法调用必须按寄存器顺序压参,参数个数和类型不匹配会在运行时报NoSuchMethodError,很多人在这里翻车。第四步,确定插入逻辑的位置。既然目标是"链接生成之后",那插入点就该是生成链接的方法返回之前,或者在调用这个生成方法之后的那一行。两个位置各有优劣,前者能修改返回值,后者能完全替换流程。
这种"先锁定、再倒推、后修改"的思路,其实跟Windows逆向里"下断点、看调用栈、改函数头"的逻辑一模一样。换到Android平台,只是断点换成了smali阅读,调用栈换成了方法签名和寄存器流转。
2.3 在smali代码中辨认特征指令
smali代码虽然啰嗦,但特征指令很鲜明。如果一段方法体里出现大量的const-string拼接,这多半是日志、URL构建;如果出现大量iget/iput操作,说明这是字段读写密集型逻辑;如果方法体很短、没有局部变量、一进方法就有参数检查,这很可能是接口回调或者事件派发。读smali的时候,优先关注几个关键指令族:
invoke-*系列:确定方法调用的目标、参数、返回值用途。这是理解方法行为最核心的指令。iget/iput系列:定位字段读写,对拦截敏感数据特别有用。const-string/const系列:定位常量字符串和数字常量。return-*系列:确定方法出口,也是注入逻辑最常见的位置。if-*/goto系列:分支和跳转,修改逻辑分支时要留意。
有一个经验之谈:如果想修改返回值,优先看方法末尾的return指令,在return之前加一段自己的逻辑,然后把返回值寄存器重新赋值。若直接改方法签名和返回值类型,牵动的面太大,动不动就会引发调用方的类型校验失败,这种方案风险太高,不推荐。
3. 动态注入DEX逻辑的实操链路
这一章节是整个博文的核心操作部分。说"动态注入",其实不是说在运行时用某种内存hook去做,而是指把一段新增的smali/DEX逻辑插入到原有的DEX里,让App在正常执行流程中调用到新逻辑。这个过程可以概括为"源码编译成dex — baksmali拆开 — 改smali — smali重新拼回dex — 替换/合并进apk — 签名安装"。
3.1 构造dex源文件与编译环境
首先要准备一个可以被smali工具识别的dex文件。这有两种做法:直接在smali层面写逻辑(工作量较大),或者写好Java代码再编译成dex(常用做法)。
做法一:Java源码编译。
我一般用Android Studio或者命令行工具链。把要注入的逻辑写成一个独立的Java类,然后用javac编译成.class文件,再用d8或dx工具转成.dex。举个例子:
javac -source 8 -target 8 -cp android.jar -d out/classes com/example/Inject.java d8 --release --output out/dex/ out/classes/com/example/Inject.class生成的classes.dex再用baksmali拆:
java -jar baksmali.jar d out/dex/classes.dex -o out/smali/这样拆出来的就是干净的smali目录,结构清晰,方便后续人工修改和插入。
做法二:直接写smali。
如果逻辑足够简单(比如做一个空方法、加一个日志、改一个返回值),完全可以直接在smali文件里新增一个类。比如:
.class public Lcom/example/InjectHelper; .super Ljava/lang/Object; .method public static log(Ljava/lang/String;)V .registers 1 sget-object v0, Ljava/lang/System;->out:Ljava/io/PrintStream; invoke-virtual {v0, p0}, Ljava/io/PrintStream;->println(Ljava/lang/String;)V return-void .end method这个类编译成之类再用smali打包即可。需要说明的是,我这里是简化写法,实际还得补上.source、.annotation这些元信息,否则某些严格校验的App会启动失败。一般建议还是用javac + d8的方式生成dex再拆成smali,这样元信息完整,不容易踩坑。
3.2 修改smali代码的具体写法
回到场景:在分享链接生成方法中注入一段逻辑。反编译出的smali方法大概是这样的:
.method private generateShareUrl(Ljava/lang/String;)Ljava/lang/String; .locals 3 const-string v0, "https://api.example.com/share?token=" invoke-virtual {p0, p1}, Lcom/example/MainActivity;->getToken(Ljava/lang/String;)Ljava/lang/String; move-result-object v1 invoke-virtual {v0, v1}, Ljava/lang/String;->concat(Ljava/lang/String;)Ljava/lang/String; move-result-object v2 return-object v2 .end method现在我要在 return 之前把v2里的结果拼接一个自定义参数&from=inject:
const-string v0, "&from=inject" invoke-virtual {v2, v0}, Ljava/lang/String;->concat(Ljava/lang/String;)Ljava/lang/String; move-result-object v2 return-object v2这里有个容易踩的坑:.locals 3表示只有三个寄存器,v0~v2。如果新增的代码要额外寄存器,必须把.locals的数值调大,否则汇编时报register v3 out of range。我一般预留1-2个寄存器空间,避免反复调整。
3.3 回编dex与替换/合并进APK
修改完成之后,用smali重新打包成dex:
java -jar smali.jar a out/smali/ -o classes.dex拿到新dex之后,替换进APK。如果是一个dex的情况,直接在apk里替换classes.dex即可;如果是多dex(classes2.dex、classes3.dex),取决于你注入到哪个dex。这里需要注意兼容性问题。有些壳和多dex加载机制对dex的编译版本、multidex标志有要求,替换错了位置会直接报ClassNotFoundException。
替换的实际操作有几种:
- 命令方式:用zip工具(zip、aapt)把新dex塞回APK。需要保留原APK的压缩方式。很多App对资源文件和DEX的压缩方式有校验,可不必要地使用不同压缩工具会破坏校验。
- 命令行方式:
zip -d app.apk classes.dex && zip -a app.apk classes.dex - 工具方式:用Apktool的框架目录操作,把smali目录改好之后直接
apktool b重新打包,一次完成apk的打包和res资源重建。只是这种重打包方式对资源对齐处理不好,还得多一步zipalign。
我自己的偏好是:如果只改DEX逻辑、不碰资源和Manifest,就优先用zip替换,改动面最小;如果还需要改动资源和配置,就用Apktool重打包,最后统一做对齐和签名。
3.4 签名与安装验证
替换DEX之后,原签名就失效了。需要重新签名。这里有两个选择:
- debug签名:用Android SDK的
debug.keystore签名,安装方便,但目标App如果校验签名或校验包名,直接闪退。 - 自签正式签名:用自生成的keystore签名,签名校验上比debug签名稍微好一些,但仍然与原签名不同。
签名的命令:
apksigner sign --ks my.keystore --out signed.apk app.apk apksigner verify --verbose signed.apk安装验证:
adb install -r signed.apk跑起来之后,在真机上验证注入逻辑是否生效。日志往往比直接看界面更快定位问题。adb logcat -s Inject可以过滤自定义的日志输出。
4. 实测翻车现场与排查思路
这部分是这篇文章里我最想详细写的,因为我的注入流程里,真正耗时间的反而不是"写代码",而是"改完之后为什么会挂"。把这些问题和排查思路讲清楚,你能省下大量对着报错发呆的时间。
4.1 注入后闪退的常见原因与排查方法
注入后闪退,最常见的几个原因:
- 寄存器不足:新增代码用到了未声明的寄存器,方法执行时直接抛VerifyError。解决办法是调大
.locals。 - 方法引用不存在:调用了一个目标dex里没有的方法或字段,抛NoSuchMethodError。常见于混淆App,类名方法名被混淆后,反编译出来的符号表跟代码实际的不一致。
- 类型不匹配:方法的返回值或参数类型不对,常见于调用私有方法时没用
invoke-direct而用了invoke-virtual。 - 签名被校验:很多加固App或金融App在启动时会校验签名,替换签名后闪退。这是最绝望的情况,基本只能考虑处理加固。
排查流程我有一套固定的打法:
先用adb logcat抓取崩溃日志,看崩溃发生在哪个类、哪个方法。然后打开这个类的smali,仔细核对方法签名、字段引用、寄存器分配。大多数问题一眼就能看出来。如果看不到明显问题,就用dexdump或者apkanalyzer检查DEX的引用一致性。还有一种很好用的办法:把这个方法单独拎出来,写个最小的测试App复现,判断是smali本身写错了还是目标环境不兼容。
4.2 字段和方法引用检查
smali修改之后,引用检查是最容易出错又最容易忽略的环节。反编译时,混淆规则可能让同一个类名在多个dex里出现,或者类名相似度极高。我在注入之前会做一次全量引用扫描:
grep -rn "Lcom/example/InjectHelper" out/smali/把引用了这个新类的所有位置列出来,逐一确认类名、字段名、方法名是否都匹配。校验字段和方法还有个办法:用baksmali反编译后直接看.field定义和.method签名,再把调用方的iget/invoke-*指令与定义处的签名做对照。
这里补充一个小技巧:smali方法引用invoke-static和invoke-direct的选择,决定了虚拟方法的调用行为。如果你新增的逻辑是静态方法,用invoke-static就没问题;如果是实例方法,要确认接收者是p0(this),并且用invoke-virtual或invoke-direct时符合方法的解析规则。混淆App里尤其容易反过来。
4.3 多dex场景下的方法数限制与兼容性
Android的DEX有一个经典的64K方法数限制问题。如果你注入的逻辑很大,或者目标App本身已经接近方法数上限,往原来的classes.dex里塞新代码,很可能超过65536个方法引用,导致运行时报Too many methods: 70000; max is 65536或者构建工具直接拒绝。
解决办法是新增一个dex,而不是往现有dex里塞。用smali编译一个新dex,然后把原APK的classes.dex、classes2.dex之外再添加classes3.dex,同时依赖Android 5.0以上系统对multidex的原生支持。如果目标App支持到Android 4.x,还需要在Application里处理installSecondaryDexes,这类改动会比较繁琐。
实际操作中,我更多是把注入逻辑直接放进classes.dex里——毕竟注入的逻辑通常不大。只有在确实无法访问原dex的情况下才考虑新增dex方案,但那种情况下,更多要用到类加载器或者动态加载技术,就不是一个smali修改能搞定的了。
4.4 加固App下的注入策略变化
遇到加固App,情况就完全不同了。加固的本质是把原始DEX加密或压缩后藏起来,运行时在Application里解密并加载。这种情况下,你解包看到的classes.dex只是一层壳,真正的业务逻辑不在里面。直接在壳DEX里注入,等到真正的类加载时,你写的类根本不会被调用。
处理思路有几条路:
- 分析壳的加载时机,Hook脱壳后的类加载,在业务逻辑执行前注入。
- 拆掉壳的验证,让壳解密后加载你的DEX。
- 直接处理脱壳后的DEX,先用脱壳工具把原始DEX捞出来,再在原始DEX上做smali注入,最后重新加固或打包。
这条路比普通App复杂得多,而且脱壳工具的兼容性、壳的签名校验、资源保护等都会成为新的坑点。如果你刚接触Android逆向,建议先别碰加固App,先把普通App的smali注入流程做熟练。
5. 实际案例复盘:一个分享链接注入的完整过程
这一章来一个完整的案例复盘,从目标分析到最终验证,把前面讲的所有环节串起来。我以之前提到的"在分享链接生成方法中注入参数"为例。
第一步,明确需求:目标App在生成分享链接时,我想在链接末尾追加一个自定义参数&from=inject。影响面越小越好,只改业务逻辑,不碰资源和Manifest。
第二步,定位方法。用jadx先把APK反编译出Java伪代码,搜索"shareUrl"相关字符串,定位到com/example/MainActivity.smali里的generateShareUrl方法。jadx的好处是能直接看到Java层面的调用链,省掉大量逐行读smali的时间。
第三步,Baksmali反编译目标DEX。用baksmali把classes.dex反编译成smali目录,直接找到generateShareUrl.smali,对照jadx的伪代码确认逻辑,确定注入点是在return-object之前。
第四步,修改smali。按照前面说的,把v0复用为string常量,追加concat调用,然后move-result-object写回v2。这一步要确认.locals足够,不够就加。
第五步,重新编译dex并替换。用smali.jar a把smali目录重新编译成classes.dex,用zip命令替换进APK。
第六步,重新签名并安装验证。生成一个自签名keystore,用apksigner签名,adb安装后打开App,点击分享按钮,观察生成的链接是否带上了&from=inject。为了确认注入确实生效,我在注入逻辑里加了一个Log输出,方便logcat观察。
整个流程走下来,顺利的情况下五到十分钟就能完成。但实际做的时候,我在"重新编译dex之后替换进APK"这一步卡了一次——发现替换后App一启动就闪退,logcat显示VerifyError,原因就是我在新增的smali片段里用了一个未声明的寄存器v3,而方法.locals声明为3。这个问题排查了差不多二十分钟,因为一开始完全没往寄存器分配的方向想,一直在怀疑是替换方式的问题。
这里还有一个细节值得单独说:zip命令替换dex时,建议使用-Z store保持无压缩存放,因为Android对于DEX的读取和校验,在部分系统版本上要求有对齐规则。不对齐的话,虽然有时候也能跑,但大概率会在安装或运行时报错。这个坑非常隐蔽,如果你用zip命令替换DEX后屡次启动失败,先检查对齐。
6. 从smali注入到动态加载的边界与延展
做完上面的案例,你会发现smali注入的本质根本不是"写汇编",而是"在合适的时机、合适的位置,插入一段可执行的逻辑,并保证目标环境能正常加载它"。理解了这一点,就可以把smali注入的经验延伸到更复杂的技术方案里。
动态加载和DEX注入之间有很自然的承接关系。比如,用PathClassLoader加载外部的dex文件,配合反射调用注入类的方法,就可以实现不修改原APK的运行时注入效果。这种方案不需要改smali,只需要一个辅助dex和一套调用框架,但在类加载时机、ClassLoader隔离等方面有更多问题要处理。
smali层面的修改则适合"需要修改原方法的返回值、参数、调用逻辑"的场景,因为smali能精确控制到指令级别,可以把return-object v2改成return-object v3,或者在一串invoke之间插入一条const/4 v0, 0x0。这种精细度是Hook框架很难达到的。
两条路线的选择依据,我总结成一句话:如果只需在目标方法前后做事情,动态加载和Hook框架更省事;如果要精确改变方法内部的指令流程,smali注入是唯一选择。
最后再分享一个我在多次尝试后形成的小习惯:每次动手之前,先把原始DEX和APK完整备份,并把修改内容和期望效果写成一个简短的清单。这个习惯救过我很多次,因为逆向过程中的试错往往是连续的,改着改着很容易忘记最初的目标是什么。有了备份和清单,心态会稳很多,排查问题也有明确的参照。