ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

APK修改工具实战:apktool/jadx定位与smali回编译重签名

APK修改工具实战:apktool/jadx定位与smali回编译重签名

简介:面向安卓应用开发与逆向学习者的APK修改主题资料,围绕安装包结构认知、资源编辑、反编译重打包、签名验证与权限调整等场景展开,兼顾入门操作与进阶研究。内容涉及清单文件、资源目录、字节码、本地库与签名信息的组成关系,并串联反编译、代码查看、图形化编辑、重新签名等常用工具的用途,帮助读者建立从解包、修改到重打包的基本思路,同时提示版权、安全、兼容性与隐私风险,强调修改后应用的稳定性与设备适配问题。资源包大小约28.58MB,文件总数标记为0,文件类型明细暂未提供,使用前需以实际压缩包内容为准。已有1320人学习,适合具备基础安卓知识、希望系统理解修改流程与工具选型的读者参考。

1. 线上渠道包的弹窗文案改错一个字,apk修改工具就是那颗后悔药

线上渠道包的弹窗里有一句文案写错了,产品催得急,发版流程又走不完,这种时候 apk修改工具 就是那颗后悔药。它做的事并不神秘:把已经编译打包好的 APK 拆回资源目录、smali 汇编和明文清单,改完按原样回编译、对齐、重签名,产出一个能直接装机的包。整条链路的核心是三件工具——apktool 负责拆包和回编译,jadx 负责把 dex 还原成可读的 Java 用来定位,apksigner 负责把改完的包重新签成系统认的样子。

适合谁?做兼容适配和多渠道定制的客户端工程师、需要核查第三方 SDK 落地行为的安全同学、以及想真正弄懂 Android 构建链路的人。不适合谁?想找按钮点两下的傻瓜软件的人,以及打算拿它绕开授权校验的人——后者既不长久也踩线,这里只讨论自有应用和已获授权范围内的修改。先把这条边界立住,后面的参数和命令才有意义。

2. 工具链选型:apktool、jadx、dex2jar 各自守哪一段

真正上手之前最容易犯的错,是把「能看」和「能改」混为一谈。很多人第一次拆包下的是 jadx,看到的 Java 代码漂亮得像源码,改完发现根本编译不回去;也有人拿着 apktool 反出来的 smali 硬啃,读了三小时还没定位到那个字符串。这两件事需要两套工具,各干各的,不要指望一个工具全包。

2.1 三类工具的分工边界

按「能不能反向编译」这条线切分,边界非常清楚:

工具输入输出可回编译典型用途
apktoolAPKres/、smali/、明文 AndroidManifest.xml是改资源、改清单、改 smali
jadxAPK / dexJava 源码否,纯只读全局搜索字符串、定位业务逻辑
dex2jar + jd-guidexjar否jadx 反编译失败时的兜底方案
baksmali / smali单个 dexsmali是,dex 级别只动 dex、完全不碰资源
aapt2res/编译后的资源表—apktool 回编译时内部调用
zipalign / apksignerAPK对齐并签名后的 APK—出包收尾

选型的第一原则就一句话:要改就必须走 apktool 或 baksmali,要读就优先 jadx。apktool 反出来的 smali 可读性一般,但它是唯一能做「往返」的——拆开、改、再合回去,产物仍然是一个结构完整的 APK。jadx 反编译出来的 Java 质量高得多,控制流、泛型、lambda 都还原得比较像样,但它本质上是单向的反编译器,没有对应的回编译路径。

第二原则是:定位阶段永远先用 jadx 找目标,再回到 apktool 的目录树里找对应的 smali 文件。直接对着 smali 找逻辑,等于放弃了栈上变量的名字、放弃了泛型信息、放弃了 switch 的原始结构,纯给自己加难度。jadx 里搜到一个字符串常量,记下它所在的类和行号,然后把类名转成 smali 路径,这个过程是确定性的,不需要猜。

至于 dex2jar,这几年用得越来越少。jadx 对高版本 dex、对 Kotlin 编译产物、对混淆过的代码处理得都还不错,dex2jar 主要是在某些老版本 dex 格式或者 jadx 直接崩掉的场景下兜底。手里备着就行,别当主力。

2.2 环境准备与版本对齐

工具的版本对齐比想象中重要。apktool 从较新的版本开始默认走 aapt2 编译资源,而 aapt2 对资源文件的语法检查比老的 aapt 严格得多:颜色值写成#AARRGGBB之外的形式会报错,XML 里的&没转义会报错,dimen里单位缺失也会报错。同一个 APK,用旧版 apktool 能编过去,用新版直接失败,这种「玄学」问题九成都是版本差异造成的。

# 环境自检:三个东西的版本必须能对上 java -version # 建议 JDK 17,apktool 2.9.x 对 JDK 8 支持已收紧 apktool --version # 记录版本号,回编译失败时第一个要核对的就是它 # build-tools 里的三个二进制要能直接调用 export PATH="$PATH:$ANDROID_HOME/build-tools/34.0.0" which aapt2 zipalign apksigner

这段自检的逻辑是:java -version确认 JVM 满足 apktool 的运行要求,版本过低会在启动阶段直接抛UnsupportedClassVersionError;apktool --version把版本号记下来,因为后面每一条编译报错的排查都要以它为前提;export PATH把 build-tools 目录挂上去,让aapt2、zipalign、apksigner三个命令能被 apktool 和脚本直接调到。

参数上唯一需要留意的是 build-tools 的版本号。34.0.0 只是举例,实际应该挑和目标 APK 的 targetSdk 接近的版本。差得太远时,aapt2 对资源属性的理解会有出入,表现为回编译时一堆attribute not found。遇到这种情况,换一个更接近的 build-tools 版本重试,比逐条改资源文件快得多。

2.3 一次完整的解包—定位—回编译闭环

先跑通一个最小闭环,再谈细节。下面五条命令覆盖了从拆包到产出一个可安装包的完整路径,每条后面都有解释。

# 1. 拆包,-f 表示覆盖已有目录,避免残留旧文件干扰 apktool d app-release.apk -o work -f # 2. 用 jadx 单独出一份 Java 源码,只用于阅读,不参与回编译 jadx -d jadx-out --no-res app-release.apk # 3. 在 work/ 里改完东西后回编译 apktool b work -o unsigned.apk # 4. 字节对齐,必须在签名之前做 zipalign -p -f -v 4 unsigned.apk aligned.apk # 5. 重签名并验证 apksigner sign --ks release.keystore --ks-key-alias demo \ --ks-pass pass:123456 --key-pass pass:123456 \ --v1-signing-enabled true --v2-signing-enabled true \ --out signed.apk aligned.apk apksigner verify --verbose --print-certs signed.apk

逐步拆开看。第 1 步的-o work指定输出目录,-f是强制覆盖——不加这个参数,第二次拆同一个包会直接报目录已存在然后退出,反复调试时会很烦。第 2 步的--no-res让 jadx 跳过资源解码,速度快很多,反正资源部分有 apktool 的产物,不需要 jadx 再来一遍。

第 3 步的-o unsigned.apk是输出文件名,不指定的话默认输出到work/dist/下面,名字跟目录名一致,脚本里容易找不到。第 4 步的-p参数是 page-align,把 so 文件按页对齐,4是固定的四字节对齐值,这个数字不要改。第 5 步的几个参数里,--v1-signing-enabled true是给 minSdk 低于 24 的设备兜底的,--v2-signing-enabled true覆盖 Android 7.0 及以上;--ks-pass和--key-pass分别对应 keystore 密码和 key 密码,两个都写死在命令里只适合本地调试,正式脚本里应该走环境变量。

跑完这五步,signed.apk就是可以adb install的产物了。这条命令链的顺序不能乱,尤其是 zipalign 和 apksigner 的先后关系,后面会专门讲。

3. 反编译与资源定位:把 APK 拆成能读的目录树

拆包本身不复杂,复杂的是拆完之后知道该动哪个文件。apktool 的产物目录结构是固定的,但每个目录的「可改程度」差别很大,改错了位置,回编译能过、装上去却不生效,排查起来非常耗时间。

3.1 apktool d 的参数与产物结构

# 完整拆包:资源和 dex 都反编译 apktool d app-release.apk -o work -f # 只拆 dex,资源保持原样不反编译 apktool d app-release.apk -o work_dex -r -f # 只拆资源,dex 不动 apktool d app-release.apk -o work_res -s -f

这三个命令对应三种完全不同的调试场景。第一个是常规操作,资源、清单、smali 全部还原成可编辑状态。第二个-r是 no-resources,只解 dex,适合「我只想改一行 smali,完全不想碰资源」的情况——好处是资源部分保持二进制原样,回编译时不会因为资源反编译再编译的往返丢信息。第三个-s是 no-sources,只解资源,适合改个图标、改个字符串这种纯资源改动。

产物结构长这样:

路径内容能不能改
AndroidManifest.xml解码后的明文清单能,注意属性格式
apktool.yml版本号、minSdk、doNotCompress能,改动会影响回编译
res/资源文件与 values 目录能,注意资源 ID
smali/、smali_classes2/dex 反汇编结果能,注意寄存器
assets/原样资源,不解码能,一般整文件替换
lib/so 文件一般不动
original/原始清单与 META-INF不动,回编译要用
unknown/未识别文件不动

original/这个目录容易被忽略,但它是回编译能成功的关键之一——里面存着原始 APK 的签名文件副本和二进制清单,apktool 回编译时会拿它做参照。手贱删掉的话,回编译大概率会报奇怪的错。unknown/同理,放的是 apktool 不认识的压缩包条目,删了会导致包体结构变化。

3.2 AndroidManifest 与 resources.arsc 的修改点

清单文件是改动风险最低的地方。改versionCode、versionName、加一个meta-data、调整android:extractNativeLibs,这些都直接改 XML 就行。但有个细节:版本号在两个地方都有,AndroidManifest.xml和apktool.yml的versionInfo段,两边要保持一致,否则回编译后的包在dumpsys package里读出来的版本号可能对不上。

# apktool.yml 片段,改版本号时两处同步 versionInfo: versionCode: '1024' versionName: 3.2.1

注意versionCode外面带单引号,versionName不带。这是 YAML 的语法差异,versionCode在某些 apktool 版本里会被解析成整型,不加引号在某些边界值上会出问题。改成字符串形式最保险。

资源这块要重点说一个坑:smali 里引用资源用的是硬编码的十六进制 ID,不是资源名。比如某段代码里出现const v0, 0x7f0b0042,这个0x7f0b0042就是R.id.xxx编译后的值。这意味着你在res/values/strings.xml里把字符串改了,只要这段代码读的是这个 ID 对应的资源,改动就生效;但如果这个字符串是被内联成const-string直接写死在 smali 里的,改strings.xml完全没用。判断方法很简单:jadx 里搜原文案,如果搜到的结果是const-string或者ldc,那就是内联的,得去 smali 里改。

3.3 jadx 做 Java 层交叉验证

# 输出到目录,跳过资源,保留反编译失败的类 jadx -d jadx-out --no-res --show-bad-code app-release.apk

--no-res跳过资源,--show-bad-code让那些 jadx 不能完全还原的类仍然输出内容(可能带语法错误,但结构可读),这在遇到混淆过的代码时很有用——哪怕方法体还原得不对,类名、方法签名、字段名还是能提供大量线索。

从 Java 定位到 smali 靠的是一张固定的映射表:

Java 写法smali 描述符
void foo()foo()V
String bar(int)bar(I)Ljava/lang/String;
boolean check(String, int)check(Ljava/lang/String;I)Z
com.a.b.C类Lcom/a/b/C;
类路径com.a.b.C文件smali/com/a/b/C.smali

举个实际路径:jadx 里看到一个方法com.demo.channel.ChannelHelper.getChannel()返回"official",那对应的文件就是work/smali/com/demo/channel/ChannelHelper.smali,方法签名是.method public static getChannel()Ljava/lang/String;。如果work/smali下找不到,就去smali_classes2、smali_classes3里找,这是多 dex 的产物。

4. smali 层修改与回编译:改一行字节码的正确姿势

smali 是 dalvik 字节码的汇编表示,语法不复杂但很啰嗦。真正需要改的地方通常只有两种:改一个常量、改一个方法的返回值。前者最简单,后者要碰寄存器。

4.1 smali 语法最小集

.method public static getChannel()Ljava/lang/String; .registers 2 const-string v0, "official" return-object v0 .end method

改成:

.method public static getChannel()Ljava/lang/String; .registers 2 const-string v0, "beta" return-object v0 .end method

这段改动的逻辑很直白:.registers 2声明这个方法总共用两个寄存器(v0 和 v1,其中 v1 空闲),const-string v0, "official"把字符串常量的引用塞进 v0,return-object v0返回它。只把字符串换掉、寄存器数量不变,是最安全的改法,不会触发任何校验错误。

需要理解的两个概念:.registers和.locals的区别,以及p寄存器。.registers N声明的是寄存器总数,包含参数占用的部分;.locals N声明的是局部变量数,不包含参数。一个实例方法foo(int)用.registers 3的话,v0 是局部变量,v1 是this(也就是 p0),v2 是那个 int 参数(p1)。改代码时如果新增了一个invoke调用需要额外的寄存器,.registers的数字必须同步加,否则运行时直接VerifyError。

另一个高频改动是改方法的返回值。比如某个方法原本返回false,想让它返回true:

.method public isEnabled()Z .registers 2 const/4 v0, 0x0 # 原值:false return v0 .end method

const/4 v0, 0x0改成const/4 v0, 0x1就是 true。const/4是四位常量指令,能表示 -8 到 7,改布尔值够用。改浮点数或大整数要用const或const-wide,指令选错了会编译失败。

4.2 回编译 apktool b 与常见报错

# 基础回编译 apktool b work -o unsigned.apk # 加详细输出,排查失败原因时用 apktool b work -o unsigned.apk --verbose

回编译的报错信息经常藏得很深,apktool 只抛一个could not exec (exit code = 1),真正的错误在它上面几行。下面是几个反复出现的问题:

报错片段真实原因处理方式
W: Could not decode attr value清单里用了 apktool 不认识的私有属性改用-r只解 dex,资源保持二进制不动
resource ... not found资源被误删,或 public.xml 缺失恢复被删文件,或从original/里找回参照
could not exec (exit code = 1)aapt2 编译失败,真实错误在上方往上翻五行,找 aapt2 自己抛的那条
Invalid resource dimenvalues 里维度值格式不对检查dimens.xml的单位和数值
Unterminated elementXML 标签没闭合,手改漏了用 xmllint 校验改过的每个 XML

第一条值得展开。Could not decode attr value这个警告在老版本 apktool 上很常见,原因是原始 APK 里用了某个厂商自定义的属性,apktool 手里没有对应的框架定义,反编译时就解不出来,回编译自然也编不回去。遇到这种情况,正确做法不是硬改,而是换策略:用apktool d -r只解 dex,资源部分完全不碰,改完 smali 直接回编译,aapt2 就不需要重新处理那些解不出来的属性了。

4.3 重签名与 zipalign 的先后顺序

这两步的顺序是硬约束,反了会出问题,而且出问题的方式很隐蔽——apksigner verify报签名不匹配,但签名命令本身是成功的,很容易误判成密钥问题。

# 第一步:生成一个自用的 keystore,有效期给足 keytool -genkeypair -v -keystore release.keystore \ -storepass 123456 -keypass 123456 \ -alias demo -keyalg RSA -keysize 2048 -validity 10000 \ -dname "CN=demo, OU=demo, O=demo, L=demo, ST=demo, C=CN" # 第二步:对齐,必须在签名前 zipalign -p -f -v 4 unsigned.apk aligned.apk # 第三步:签名,v1 和 v2 都开 apksigner sign --ks release.keystore --ks-key-alias demo \ --ks-pass pass:123456 --key-pass pass:123456 \ --v1-signing-enabled true --v2-signing-enabled true \ --out signed.apk aligned.apk # 第四步:验证,看 scheme 列表和证书指纹 apksigner verify --verbose --print-certs signed.apk

keytool那条命令里,-validity 10000是天数,别设成默认的 90 天,否则三个月后自己签的包全部失效,还得重新签一遍。-dname里的字段随便填,重签名只要求证书本身有效,不校验内容。zipalign的-f是覆盖已存在的输出文件,-v打详细日志,4是四字节对齐的固定值。

为什么必须 zipalign 在前?因为 apksigner 的 v2 签名覆盖的是整个 APK 文件的字节区间,签完之后文件任何一个字节变动都会让签名失效。zipalign 会重排 zip 条目、调整偏移量,本质上是重写整个文件。先签后对齐,等于签完再改文件,verify必然失败。

签名方案的选择取决于目标设备的 minSdk:

方案生效版本覆盖范围什么时候必须开
v1 (JAR)全版本逐文件摘要minSdk 低于 24
v2Android 7.0+整包大多数现代设备
v3Android 9+支持密钥轮换可选
v4Android 11+增量安装一般不用手写

实际出包时 v1 和 v2 同时开是最稳的做法,apksigner会自动按 minSdk 决定哪些设备用哪个方案校验。

5. 避坑与排查:五个让回编译直接翻车的点

前面几步跑通之后,真正耗时的是出错时的定位。下面五条都是反复踩过的,按「现象 → 原因 → 解决」写清楚。

5.1 装上去秒退,日志里只有一行 SecurityException

现象:adb install成功,点开图标闪一下就退出,adb logcat里能看到java.lang.SecurityException或者干脆只有一行Process died。

原因:应用内置了签名校验,启动时读PackageInfo.signatures跟预置的摘要做比对,重签名之后摘要变了,校验失败就直接退出。这类校验有的在 Application 的onCreate,有的藏在 native 层,还有的放在attachBaseContext里,越早执行越难绕过。

解决:这属于明确的边界问题,只对自己开发或已获授权的应用做修改。自研项目可以直接在 debug 变体里关掉校验开关;第三方应用遇到签名校验,正确做法是放弃,不要在这上面耗时间。

5.2 INSTALL_FAILED_UPDATE_INCOMPATIBLE

现象:安装时报INSTALL_FAILED_UPDATE_INCOMPATIBLE,提示签名不一致。

原因:设备上已经装了同包名的旧版本,两个包的签名证书不同,系统拒绝覆盖安装。

解决:adb uninstall 包名先卸载再装。注意-r参数只能替换签名相同的包,签名变了就必须先卸载。调试阶段更省事的做法是自始至终用同一个 keystore,别每次重新生成。

5.3 回编译只报 could not exec,往上翻也看不出所以然

现象:apktool b直接甩一行brut.androlib.AndrolibException: could not exec (exit code = 1),看不出任何有用信息。

原因:这条是 apktool 包了一层的错误,真正失败的是它调起的 aapt2 进程,aapt2 自己的输出被吞了一部分。常见触发点是 values 目录下的 XML 里出现了未转义的&、<,或者颜色值写成了老 aapt 才接受的形式。

解决:加--verbose重跑,把输出重定向到文件,往上翻找 aapt2 自己抛的那一行;实在找不到就手动执行aapt2 compile和aapt2 link复现一次,错误信息会完整很多。

5.4 资源改了但界面没变

现象:strings.xml里明明改了文案,装上去还是旧的。

原因:三种可能。一是这段文案在 smali 里被内联成const-string了,压根不读资源;二是资源 ID 在代码里硬编码,改的资源和代码引用的 ID 不是同一个;三是走了多语言目录,改的是values/strings.xml,设备语言命中的是values-en/strings.xml。

解决:jadx 里全局搜原文案,确认来源是资源引用还是字符串常量;再grep -rn一遍res/values*下所有同名条目,把所有语言目录一起改掉。

5.5 多 dex 应用改了没生效

现象:改的类看起来没错,回编译也过了,运行行为却没变。

原因:类不在主 dex 里。多 dex 应用拆包后会生成smali/、smali_classes2/、smali_classes3/多个目录,同名类在多个 dex 里出现时,加载顺序决定哪个生效。

解决:全量搜一遍,别只盯smali/:

grep -rn "Lcom/demo/channel/ChannelHelper;" work/smali*/

输出里出现多处就说明有重复类,需要确认运行时实际加载的是哪一个,通常按 dex 序号从小到大优先。

6. 验证与进阶:改完的包怎么确认真的生效

改完签完不等于结束,还得确认三件事:包本身是合法的、装上去版本对、运行时行为符合预期。这三层验证分别对应不同的命令,缺一层就可能出现「本地看着好好的,发出去一堆人闪退」。

6.1 三层验证,从静态到运行

第一层是静态检查,不装机就能发现大部分问题:

# 读包的基本信息,确认版本号、包名、权限 aapt2 dump badging signed.apk | head -20 # 确认签名方案和证书指纹 apksigner verify --verbose --print-certs signed.apk

aapt2 dump badging输出的第一行就是包名和版本信息,能直接对照改动确认有没有生效;apksigner verify的--verbose会列出 v1、v2、v3 各自是否通过,--print-certs打印证书指纹,跟 keystore 对一下就知道签的是不是同一把钥匙。

第二层是装机验证:

adb install -r -d signed.apk adb shell dumpsys package com.demo.app | grep -E "versionName|versionCode|signatures"

-r是覆盖安装,-d允许降级安装——调试阶段版本号改来改去,没有-d会频繁被系统拦住。装完用dumpsys package读出实际的版本号和签名摘要,这一步能抓到「apktool.yml 和清单版本号不一致」这类隐蔽问题。

第三层是运行验证,清一次日志再启动,只关注崩溃和权限相关的行:

adb logcat -c && adb logcat | grep -E "AndroidRuntime|SecurityException|PackageManager"

logcat -c先清空缓冲区,避免被历史日志干扰;grep 的三个关键词分别对应崩溃、签名或权限异常、包管理相关告警。

6.2 把闭环固化成脚本

手工敲五条命令改一个包还行,一天改十个包就必须脚本化。我现在的习惯是把整条链路写成一个带严格模式的 bash 脚本,任何一步失败立即中断:

#!/usr/bin/env bash set -euo pipefail APK="${1:?用法: ./repack.sh <apk> [输出目录]}" OUT="${2:-work}" apktool d "$APK" -o "$OUT" -f # 改动逻辑放在这里,或单独拆成 patch 脚本 apktool b "$OUT" -o unsigned.apk zipalign -p -f 4 unsigned.apk aligned.apk apksigner sign --ks release.keystore --ks-key-alias demo \ --ks-pass "pass:${KS_PASS:?缺少 KS_PASS}" \ --v1-signing-enabled true --v2-signing-enabled true \ --out signed.apk aligned.apk apksigner verify signed.apk && echo "OK: signed.apk"

set -euo pipefail这三个开关是关键:-e让任何命令非零退出就中断,-u让引用未定义变量直接报错,-o pipefail让管道中任一环节失败都能被捕获。密码走环境变量KS_PASS,不写死在脚本里,${KS_PASS:?...}的写法在变量缺失时会直接中断并打印提示,比等到apksigner报错再回头找原因省事得多。

从那以后我每次改包,不管是改一行文案还是改一段 smali,都强制走一遍「拆包 → 改动 → 回编译 → 对齐 → 签名 → verify → 装机 → logcat」这八步,一步不跳。跳过 verify 省下的三十秒,往往要在后面用半小时的排查还回去。希望帮到你。

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

返回列表