简介:面向需要在 macOS 上进行 APK 逆向与资源提取的开发者、安全学习者及移动应用分析人员,这份工具集合围绕 apktool 的获取、配置与使用场景整理,可帮助降低在 Mac 环境中解包 APK、查看资源文件和理解工具参数的门槛。压缩包共 3 个文件,包含可执行的 jar 工具、便捷启动的 sh 脚本以及一份 md 使用说明,整体约 10.25MB,体量轻便,适合随取随用。说明文档对工具用途与操作方式作了交代,配合脚本可减少手动配置环节;jar 主体则承担 APK 解包与资源还原等核心工作,便于后续查看布局、图片、清单等文件。已有 2832 人学习或下载,适合希望快速搭建 apktool 使用环境、补齐逆向分析基础流程的读者参考。
1. Apktool 装不上的真相:JDK 版本比命令更先决定成败
apk逆向 获取apk资源 这类需求落到手上,往往就是一条apktool d的事。上周我只想从一份自研的安装包里把图标、strings.xml和清单里的渠道号抠出来做对照,结果在一台新机器上折腾了两个多小时:先撞UnsupportedClassVersionError,换完 JDK 又卡在回编译阶段找不到 aapt。apktool 本体只是一个几百 KB 的 jar,真正决定它能不能跑起来的,是背后那套 JDK、wrapper 脚本和 aapt2 二进制。
这套环境的坑有个特点——它不在安装那一步爆,而在你 decode 到一半、build 到结尾的时候才爆,报错信息还经常是 Java 的类版本号、二进制路径这种看不出因果的东西。手上只有一份 apk、想拿到里面资源的人,照着下面这条链路走一遍能跑通;已经装过但总在某个版本上翻车的,也能找到对号入座的那一段。
2. JDK 环境搭建:apktool 报 UnsupportedClassVersionError 的根因在这
2.1 JDK 8 还是 JDK 17:class 文件版本对照表
apktool 的 jar 是用 Java 编译出来的,编译目标和运行环境之间有一条硬约束:运行它的 JVM 版本不能低于编译目标。apktool 2.6 到 2.9 这一档,jar 的字节码目标大致在 Java 8,机器上有 JDK 8 就能起来;2.10 之后往上抬了一档,只有 JDK 8 的机器会直接报错,报错长这样:
Exception in thread "main" java.lang.UnsupportedClassVersionError: brut/androlib/Androlib has been compiled by a more recent version of the Java Runtime (class file version 55.0), this version of the Java Runtime only recognizes class file versions up to 52.0这个报错里唯一有用的信息是括号里那两个版本号。它说的是「这个 class 文件需要 55.0 的运行时,而你只有 52.0」。把这张对照表记住,以后看到任何 Java 的版本报错都能一眼判断差了几档:
| class 文件版本 | 对应 JDK 大版本 |
|---|---|
| 52.0 | JDK 8 |
| 55.0 | JDK 11 |
| 61.0 | JDK 17 |
| 65.0 | JDK 21 |
我的做法是机器上只留一份 JDK 17,apktool 用 2.9.x 之后的版本,省得两套环境互相干扰。但有些老包只有 apktool 2.4、2.5 那个年代能解,新版本反而报资源解析失败,这时候才需要单独装一份 JDK 8 放在/opt下,用的时候临时切JAVA_HOME,不动全局的默认版本。这一点很关键:全局默认 JDK 一旦被改低,机器上其他构建工具会跟着出问题。
还有一种更隐蔽的情况:java -version显示的是 17,但javac -version显示的是 8,或者反过来。这说明 PATH 里排在前面的 java 和 javac 不是同一份 JDK。apktool 是纯运行期程序,只用 java,所以它本身不会因为这个出事;但你在同一台机器上做回编译、跑 gradle 的时候就会撞上玄学问题。装完 JDK 的第一件事就是把这两条命令的输出对齐。
2.2 Linux 与 macOS:把 JAVA_HOME 写到不带版本号的目录
Linux 上我习惯把 JDK 解压到一个固定路径,目录名不带版本号,这样所有脚本里的路径永远不用改,将来升级只换目录内容:
# 解压到 /opt/jdk,--strip-components=1 剥掉压缩包自带的那层版本目录 sudo mkdir -p /opt/jdk sudo tar -zxvf ./jdk-17_linux-x64_bin.tar.gz -C /opt/jdk --strip-components=1--strip-components=1是这里的关键参数。下载回来的压缩包解开后通常自带一层jdk-17.x.x的目录,不加这个参数,最后会变成/opt/jdk/jdk-17.x.x/bin/java,路径里带版本号,脚本又得写死。剥掉这一层,/opt/jdk/bin/java就是稳定入口。
接着写环境变量。我不用~/.bashrc,因为那只对交互式 shell 生效,cron 和某些 CI 脚本跑起来是另一套环境。写进/etc/profile.d/更稳:
# /etc/profile.d/java.sh # JAVA_HOME 指向 JDK 根目录,末尾不要带 /bin export JAVA_HOME=/opt/jdk # 放在 PATH 最前面,压过系统自带的 java export PATH="$JAVA_HOME/bin:$PATH"JAVA_HOME末尾带/bin是最常见的写法错误,很多工具会自己拼$JAVA_HOME/bin/java,带上去就变成/opt/jdk/bin/bin/java。PATH 追加位置也有讲究,写在后面会被/usr/bin/java抢先命中,那就等于白配。改完执行source /etc/profile.d/java.sh让当前会话生效,然后开一个新终端再验一次。
macOS 上不要自己拼路径,系统自带一个定位工具,用它返回的路径最准:
# 用系统工具按大版本匹配,避免多个 JDK 并存时选错 export JAVA_HOME=$(/usr/libexec/java_home -v 17) export PATH="$JAVA_HOME/bin:$PATH"-v 17匹配的是大版本号,装了几个 17.x 的补丁版本时它会挑一个可用的返回。这条命令如果报「Unable to find any JVMs matching version」,说明 JDK 根本没装上,或者装的位置不被系统索引,先解决安装再谈配置。
2.3 Windows:别用 setx 写 PATH
Windows 上最容易翻车的是setx。它确实能把变量写进注册表,但有两个坑:一是对当前命令行窗口不生效,必须新开一个;二是 PATH 超过 1024 个字符会被截断,截断之后是静默的,你只会发现某些命令莫名其妙找不到了。
# 用 .NET 接口写用户级变量,避开 setx 的 1024 字符截断 [Environment]::SetEnvironmentVariable("JAVA_HOME", "C:\Java\jdk-17", "User") $old = [Environment]::GetEnvironmentVariable("Path", "User") [Environment]::SetEnvironmentVariable("Path", "C:\Java\jdk-17\bin;$old", "User")第三个参数"User"表示只写当前用户,不动系统级变量,出问题影响面小。改完关掉所有命令行窗口重开,Windows 的进程环境是启动时快照的,老窗口读不到新值。
还有一处 Windows 特有的陷阱:系统里可能已经存在一个C:\Program Files\Common Files\Java\javapath目录,里面放的是 java、javac 的软链,而且它默认排在 PATH 靠前的位置。你新加的 JDK 路径如果排在它后面,java -version依然会显示旧版本。遇到「明明配了却还是老版本」,先where java看命中的是哪个。
2.4 装完先做一致性自检
三条命令的输出必须互相对得上,任何一条对不上都别往下走:
#!/usr/bin/env bash # JDK 一致性自检:JAVA_HOME、java、javac 三处必须指向同一份 set -u echo "JAVA_HOME = ${JAVA_HOME:-<未设置>}" echo "which java = $(command -v java)" echo "which javac= $(command -v javac)" java -version 2>&1 | sed -n '1p' javac -version 2>&1java -version的输出走的是 stderr,所以后面要2>&1才能被管道接住,这一点很多人第一次写校验脚本时会漏。sed -n '1p'只取第一行版本信息,后面那些运行时参数跟排查无关。
如果which java指向/usr/bin/java,而JAVA_HOME是/opt/jdk,说明 PATH 顺序反了;如果 java 是 17 而 javac 是 8,说明机器上装了两份,PATH 里混着走。Windows 上对应的写法是where java、where javac、echo %JAVA_HOME%,判断逻辑一样。
这里有个容易误解的地方:apktool 的 wrapper 脚本通常直接调 PATH 里的java,并不读JAVA_HOME。所以真正决定 apktool 用哪个 JDK 的是 PATH,不是JAVA_HOME。但JAVA_HOME配错会影响同机的其他构建链,两者都要对。
3. Apktool 本体安装:jar、wrapper 脚本和 PATH 三件套
3.1 发布包里到底有哪两个东西
apktool 的发布包通常包含两部分:apktool.jar是核心,所有逻辑都在里面;另一个是几行的 wrapper 脚本,Linux 上叫apktool,Windows 上叫apktool.bat。很多教程只让你下 jar,然后每次敲java -jar apktool_x.y.z.jar d xxx.apk。这样能跑,但版本号要手敲,换台机器就忘,参数一多打字还容易错。
我习惯的目录规划是把 jar 和脚本分开放:jar 集中在/opt/apktool/,文件名带上版本号;wrapper 放/usr/local/bin/apktool,走 PATH 直接调用。Windows 上就都塞进C:\tools\apktool\,再加进用户 PATH。
下载完之后先验一下包完整性,别等到 decode 的时候才发现 jar 只下了一半:
# 检查 jar 里有没有入口类,能排除下载中断的半个包 unzip -l apktool-2.9.3.jar | grep -m1 'brut/androlib/Androlib.class'grep -m1命中第一条就退出,不用把整个 jar 的几千个文件名刷满屏幕。如果这条命令没有任何输出,说明 jar 结构不对,重新下,别在坏包上浪费时间——这种情况下java -jar只会给你一句Invalid or corrupt jarfile,看不出是下载问题还是版本问题。
3.2 Linux 与 macOS 的 wrapper:"$@"的引号不能省
wrapper 脚本我只让它做两件事:定位 jar、把参数原样透传。
#!/usr/bin/env bash # /usr/local/bin/apktool set -euo pipefail APKTOOL_JAR="/opt/apktool/apktool.jar" # jar 不在就直接报错退出,别让 java 抛一堆栈 if [ ! -f "$APKTOOL_JAR" ]; then echo "apktool.jar 不存在: $APKTOOL_JAR" >&2 exit 127 fi # exec 替换当前进程,Ctrl+C 时信号能直接传给 JVM exec java ${APKTOOL_OPTS:-} -jar "$APKTOOL_JAR" "$@"逐行说下取舍。set -euo pipefail里-u让未定义变量直接报错,pipefail让管道中任一环节失败都算失败,这行脚本短,加不加区别不大,但养成习惯之后排查会快很多。exec是把当前 shell 进程替换成 java 进程,而不是 fork 一个子进程等它结束,好处是 Ctrl+C 的信号能直接送到 JVM,不会留下僵尸进程。"$@"的引号绝对不能省,省掉之后,路径里带空格的参数会被 shell 重新拆成多个——这就是「文档目录带空格导致命令诡异失败」的根源。
${APKTOOL_OPTS:-}是留给临时加大堆内存的口子,默认展开成空串。碰上几十 MB 的大包,decode 阶段 OOM 的时候可以这样临时救一次:
# 只对这一次调用生效,不改脚本 APKTOOL_OPTS="-Xmx2g" apktool d big-app.apk -o out写完给执行权限,这一步经常被忘:sudo chmod +x /usr/local/bin/apktool。
3.3 Windows 的 bat:%~dp0和%*
Windows 上没有 exec 这套语义,脚本要简单得多,但两个符号必须搞明白:
@echo off rem C:\tools\apktool\apktool.bat rem %~dp0 是脚本自身所在目录,末尾自带反斜杠 set "APKTOOL_JAR=%~dp0apktool.jar" if not exist "%APKTOOL_JAR%" ( echo apktool.jar 不存在: %APKTOOL_JAR% exit /b 127 ) java -jar "%APKTOOL_JAR%" %*%~dp0展开成 bat 文件所在的目录,好处是这个目录整个搬到别的盘也能用,不用改脚本里的绝对路径。%*是把用户传进来的所有参数原样展开,这里不加引号反而更对——如果用户自己写了引号,加了外层引号会变成双重引号,java 收到的参数就带上了字面引号字符。
set "VAR=value"这种写法把整个赋值包在引号里,能容忍值里带空格和特殊字符,比set VAR=value稳。exit /b 127只退出当前批处理,不关整个命令行窗口。
3.4 版本切换:只动软链,不覆盖文件
装好之后第一件事是冒烟测试,确认能调起来:
apktool --version # 期望输出类似 2.9.3版本号能打出来,说明 wrapper、jar、PATH 这条链路通了。无参数执行apktool应该打印 usage,如果这时候报的是 Java 异常,回到第 2 章查 JDK。
版本管理上我不覆盖文件,而是保留多个 jar,用软链切换。这是装这类工具唯一值得留的「后悔药」:
# 新版本另存一份,切版本只动软链 sudo cp apktool-2.10.0.jar /opt/apktool/ sudo ln -sfn /opt/apktool/apktool-2.10.0.jar /opt/apktool/apktool.jar apktool --versionln -sfn的三个参数各有作用:-s建软链,-f目标存在时强制覆盖,-n防止目标本身是个目录时把链接建到目录里面去。少了-n,某些情况下会在/opt/apktool/apktool.jar/下面再建一个同名链接,排查起来很费时间。切换完立刻打一次--version确认,别等用到的时候才发现链到了旧文件。
4. 反编译参数配置:decode 与 build 的关键取舍
4.1 默认 decode 出来的目录长什么样
先跑一次不带任何取舍参数的完整反编译,看清楚产物结构,再决定砍哪部分:
# 最基础的 decode:-o 指定输出目录,-f 允许覆盖已存在的目录 apktool d app-release.apk -o out -f-f不加的话,输出目录已存在时它会停下来问你,脚本里跑就会卡住。产物大致是这么几块:
| 目录或文件 | 内容 | 什么时候会看 |
|---|---|---|
AndroidManifest.xml | 已解码成可读 XML 的清单 | 查权限、组件、渠道号 |
apktool.yml | 版本号、SDK 区间、压缩规则 | 回编译报错时第一个看它 |
res/ | 图片、布局、values/strings.xml | 提取文案和图标 |
smali/、smali_classes2/ | 多 dex 反汇编出的 smali | 只需要看代码逻辑时 |
lib/ | 按 ABI 分目录的 so | 核对架构支持 |
original/ | 原始清单和签名信息 | 对比解码前后差异 |
unknown/ | 没识别出来的文件 | 一般不用管 |
apktool.yml值得单独说。它是回编译的输入清单,里面记着这个包是用哪个 apktool 版本解的、minSdk 和 targetSdk 是多少、哪些后缀的文件不能压缩。回编译失败的时候,第一件事就是打开它看sdkInfo那一段,很多资源冲突是因为 SDK 区间和资源类型对不上。
4.2-s和-r:按需求砍掉一半工作量
只要资源、不看代码,用-s:
# -s 等价于 --no-src:跳过 dex 反编译,classes.dex 原样拷进输出目录 apktool d app-release.apk -o out-res -s -f这个参数省掉的是最耗时的那一步。一个中等体积的包,完整 decode 可能要几十秒到一两分钟,加了-s通常几秒就完事,因为 smali 反汇编是整个流程里最重的一环。产物里依然有res/、assets/、清单,只是没有smali/目录,classes.dex以原始二进制形式躺在输出目录里。
反过来只要代码、不要资源,用-r:
# -r 等价于 --no-res:不解码资源,只出 smali 和清单 apktool d app-release.apk -o out-smali -r -f这里有个容易误解的点:-r之后res/目录依然可能存在,但里面是原始的二进制资源,values/strings.xml这种解码产物不会有。如果你只是想看某个方法怎么写的,-r能让输出目录小一个数量级。
两个参数都能同时用,但那样基本等于只把 apk 解压了一遍,没什么场景需要。
4.3-p:框架目录隔离,多项目并行的必需品
apktool 解码时需要一份系统资源框架来解析@android:style/...这类引用,默认存在用户目录下(Linux 一般是~/.local/share/apktool,Windows 在%LOCALAPPDATA%\apktool)。单项目无所谓,同时处理几个不同系统版本的包时就会串——上次解一个老包报了一堆 style 找不到,查了半天才发现是前一个项目留下的框架覆盖了默认目录。
解决办法是给每个项目单独指定框架目录:
# 先把系统框架装进独立目录 apktool if framework-res.apk -p /opt/apktool/framework # 之后所有操作都显式指定同一个目录 apktool d app.apk -p /opt/apktool/framework -o out-p也就是--frame-path,decode 和 build 两个子命令都支持,两边必须指向同一个目录,否则回编译时解析出的资源 ID 会对不上,表现是装上去之后界面错乱或者直接崩。这是血泪经验:框架目录前后不一致导致的资源 ID 错位,从报错信息上完全看不出来。
4.4 回编译、对齐、签名:顺序错一步全废
改完资源之后回编译:
# 先出未签名包,--use-aapt2 在资源引用报错时优先尝试 apktool b out -o dist/app-unsigned.apk --use-aapt2--use-aapt2决定用哪一代资源编译器。aapt2 的检查更严格,也更接近现在的构建链;aapt1 对老包的容忍度更高。碰上style attribute not found或者资源 ID 相关的报错,把这两个选项来回切一次,能快速定位是不是编译器差异引起的。
回编译产物还不能直接装,必须对齐再签名,而且顺序不能反:
# 1) 4 字节对齐,-p 让 so 按页对齐,必须在签名之前做 zipalign -p -f 4 dist/app-unsigned.apk dist/app-aligned.apk # 2) 同时出 v1 和 v2 签名 apksigner sign \ --ks ~/.android/debug.keystore \ --ks-pass pass:android \ --key-pass pass:android \ --ks-key-alias androiddebugkey \ --out dist/app-signed.apk \ dist/app-aligned.apk # 3) 验签,别等装到设备上才发现 apksigner verify --print-certs dist/app-signed.apkzipalign必须排在apksigner前面。原因是 v2 签名会覆盖整个 zip 的文件布局,先签名再对齐等于把签名破坏掉,产物的表现是「装不上」或者「装上闪退」,而apksigner verify那一步如果不跑,你根本不知道是哪一环出的问题。-p参数是让 so 文件按页边界对齐,对性能有实际影响,不是可选项。
调试用的 keystore 没有的话现生成一个,参数照抄即可:
keytool -genkeypair -v \ -keystore ~/.android/debug.keystore \ -storepass android -keypass android \ -alias androiddebugkey \ -keyalg RSA -keysize 2048 -validity 10000 \ -dname "CN=Debug,O=Demo,C=CN"-validity 10000是天数,差不多二十七年,调试证书不用考虑过期。-dname里随便填,反正是本地验证用。
5. 常见问题排查:apktool 五个高频翻车现场
5.1 现象:一执行就抛 UnsupportedClassVersionError
原因:jar 的字节码版本高于本机 JVM。apktool 2.10 之后的包在只有 JDK 8 的机器上必然触发,报错里的class file version数字就是线索。
解决:先java -version确认本机版本,对照第 2 章那张表算出差几档。要么升级 JDK,要么把 apktool 降到和现有 JDK 匹配的版本。不要试图改 jar 的字节码,那套工具链比装个 JDK 麻烦得多。升降级之后记得重新跑一次apktool --version,避免 PATH 里还挂着旧 jar。
5.2 现象:decode 中途报 Could not decode arsc,或者资源解析到一半中断
原因:三个可能。apktool 版本比目标包老,认不出新的资源格式;aapt2 二进制缺失或者没有执行权限;框架目录里的系统资源和目标包不匹配。
解决:按顺序排查。先换用 2.9.x 之后的版本重试;再确认--use-aapt2与不用它两种模式各跑一次,看报错是否变化;最后检查框架目录,用-p指定一个干净的目录重新apktool if。Linux 上如果是 aapt 相关报错,顺手ls -l看一眼二进制有没有执行位,从压缩包里解出来的文件经常是 644。
5.3 现象:回编译成功,但装到设备上报解析包错误或直接闪退
原因:九成是签名链路的问题——没签名、签完又对齐、或者只出了 v1 签名但目标系统要求 v2。少数情况是apktool.yml里的 SDK 区间被改坏了,回编译时资源引用越界。
解决:严格执行「对齐 → 签名 → 验签」三步,中间不插别的操作。apksigner verify --print-certs有输出且证书信息正常,说明签名这一环没问题。如果验签通过还是装不上,把apktool.yml里的minSdkVersion和targetSdkVersion跟原包对比一下,看看是不是解码过程中被改写。
5.4 现象:路径带空格或中文时,命令行为完全不符合预期
原因:wrapper 脚本里参数展开没加引号,shell 把带空格的路径拆成了多个参数。Windows 上还有一种情况是 bat 里用了%*但外层多包了一层引号,导致双重引号。
解决:Linux/macOS 的脚本里,"$@"和"$APKTOOL_JAR"的引号一个都不能少,这是硬性要求。Windows 上把%*改成不加外层引号的写法。另外输出目录尽量别用中文名,虽然现代工具链大多支持,但aapt系列在部分版本上处理非 ASCII 路径时会出问题,把工作目录换到纯英文路径能排除一整类干扰。
5.5 现象:解出来的 res 目录里全是a/b/c这种短名,找不到原文件名
原因:这不是装坏了,是原包做了资源混淆,资源名被重写成短路径,映射表在打包时被剥离或者加密了。apktool 只能按现有的资源表还原,还原不出来就是还原不出来。
解决:接受这个现实,通过resources.arsc里的资源 ID 反查用途,或者从布局文件里的引用关系倒推。这种情况对「提取文案」影响不大,values/strings.xml通常还是完整的;对「替换图片」确实麻烦,需要结合布局文件逐个比对尺寸和引用位置。
6. 装完怎么验证:三条命令确认 apktool 真的能跑通
装完不验证,等于没装。我验收的顺序是固定的:先证明工具能起来,再证明它能把一个包解开,最后证明它能把包再打回去并且签名有效。第三步最容易被跳过,但恰恰是它能把「decode 正常、build 崩」这类半残状态暴露出来。
第一步只看工具本身:
apktool --version java -version两条输出对得上,说明 wrapper 找到的 jar 和 PATH 里的 java 是配套的。如果apktool --version打出来的版本和/opt/apktool/下软链指向的不一致,说明 PATH 里还有另一个 apktool,command -v apktool查一下位置。
第二步和第三步我写成一个脚本,用一个自己的小包跑一遍,三十秒出结论:
#!/usr/bin/env bash # apktool 装完验收:解码 -> 回编译 -> 产物结构检查 set -euo pipefail APK="${1:?用法: ./verify_apktool.sh <your.apk>}" WORK=$(mktemp -d) # 1) 只解资源,跑得快,够验证主链路 apktool d "$APK" -o "$WORK/out" -f -s # 2) 关键产物必须在,缺一个就说明解码不完整 test -f "$WORK/out/AndroidManifest.xml" || { echo "清单没解出来"; exit 1; } test -f "$WORK/out/apktool.yml" || { echo "apktool.yml 缺失"; exit 1; } test -d "$WORK/out/res" || { echo "res 目录缺失"; exit 1; } # 3) 原样回编译,不做任何修改 apktool b "$WORK/out" -o "$WORK/unsigned.apk" apksigner verify --print-certs "$WORK/unsigned.apk" >/dev/null 2>&1 \ && echo "注意: 产物自带原签名" \ || echo "OK: 回编译通过,未签名符合预期" echo "验收完成,工作目录: $WORK"脚本里几个细节值得说。${1:?...}是参数校验的简写,没传参时直接把用法打出来并退出,比if [ $# -lt 1 ]短。-s只是为了让验收跑得快,它不覆盖 smali 那条链路,如果日常主要工作是看代码,把-s去掉再跑一次。最后那段验签判断用的是「回编译产物默认不带签名」这个事实——如果它反而验签通过,说明apktool.yml或者original/里有东西被带进了产物,值得看一眼。
这套验收我建议固定成一个基线包,每次换 apktool 版本、换 JDK、换机器都拿它跑一遍。有了基线,出问题的时候能立刻判断是环境变了还是目标包变了,不用在两个变量之间来回猜。
从那以后我每次装完 apktool,都会先拿这个基线包完整走一遍 decode 加 build,再动真实项目——省下来的不是装工具那十分钟,是后面排查「到底是包的问题还是环境的问题」的两个小时。希望帮到你。
本文还有配套的精品资源,点击获取