Android改造者之路 06 · 魔高一丈
第 06 章 魔高一丈
续写章节|沿用「原木」系列的武侠讲解体,非原作者(dayeve)作品
前情提要
师傅把茶杯放下,看着我:“你知道这六道关,最大的共同点是什么吗?”
“……都在 app 里?”
“对。它们都在 app 里,而 app 在你手机上。”
“只要代码要在这台机器上跑,它就一定要现形——要么现形在磁盘上,要么现形在内存里,要么现形在调用那一刻。”
“你要做的,就是在它现形的那一瞬间,抓住它。”
我好像懂了什么,又好像没完全懂。
“那具体怎么做?”
师傅站起来,往屋里走:“明天再说。今晚先把那个天气 app 的验身君找出来——就当是作业。”
我:???
剧情片段
导演: 道高一尺,后面那句是啥来着?
助理: 魔高一丈。
剧情片段
前情提要
一、一夜
灯芯烧到第三遍的时候,我终于承认——我找不到。
那个天气 app 的 smali,我翻了整整一夜。
jadx 里搜 signature,四十七个结果。搜 sign,三百多个。搜 篡改、修改、verify,零个。
我一个一个点开看。看到第三十个的时候,眼睛开始发花;看到第四十个的时候,我已经分不清 sign 到底是"签名"还是"正负号"了。
窗外的天从黑变成灰,又从灰变成白。
我趴在桌上,第一次认真怀疑:师傅是故意的。
他明知道我一夜找不到,还是让我找。
“找到了?”
师傅的声音从背后传来。我不知道他站了多久。
“……没有。”
“找了几处?”
“四十七处 signature,三百多个 sign。我一个个点开看,看到现在。”
师傅笑了,在我对面坐下,给我倒了杯茶。
“你知道你笨在哪儿吗?”
“……哪儿?”
"你在找’验身君’这三个字。"他把茶杯推过来,“可验身君不会在自己脑门上写’我是验身君’。”
我愣住了。
"第五章我是不是说过——混淆能藏住名字,藏不住行为?"他敲了敲桌子,“这句话反过来也成立:你要找一个人,别找他叫什么,看他干了什么。”
“验身君会干什么?”
“他会去问系统要自己的签名。所以他一定会出现在同一个地方——”
我猛地抬起头。
"对。"师傅点点头,“去搜他调用了什么,别搜他叫什么。”
我打开 jadx,删掉搜索框里那个该死的 signature,重新输入:
getPackageInfo
回车。
三个结果。
第一个,就是它。
二、师傅的往事
我盯着那行代码,手有点抖。熬了一整夜,答案就在三个字符的距离之外。
师傅看着我的表情,忽然不说话了。
他端起茶,喝了一口,目光落在窗外那棵老树上。
"我像你这么大的时候,"他开口了,“也是个改造者。”
我差点把茶喷出来。
“你?”
"那时候我比你现在狠。"师傅笑了笑,“接活儿,改包,什么来钱改什么。去广告、破会员、改游戏数据——一天能出十几个包。”
“那后来呢?”
“后来有一次,我接了个单子,改一个挂号 app。”
他停了一下。
“我改得挺漂亮。界面干净,功能齐全,客户很满意。直到有天他告诉我,那个 app 是给乡镇卫生所用来排班的。我改掉了它的一个校验,结果排班数据错了三天。”
屋子里安静下来。
“那三天里,有个村子的疫苗接种通知全发错了。”
我张了张嘴,没说出话。
“从那以后我就不接活了。我去做了保卫者——给 app 加壳、加校验、加反调试。我想,既然有人拿这个害人,那我就把门守严点。”
“守了几年?”
“五年。”
“然后呢?”
师傅把茶杯放下,看着我。
“然后我发现,我加的每一道锁,最后都被人打开了。”
“有的被我当年用的招打开,有的被我没见过的招打开。我守得越严,对面的人长得越快。”
他叹了口气。
“到最后我才明白一件事——最锋利的矛,和最厚的盾,是同一块铁打的。”
“所以我谁也不做了。我就在这儿,教技术。至于拿它去做什么——那是你们自己的事。”
他站起来,往屋里走,走到门口停了一下。
“这就是我今天要教你的东西。但我先说清楚:我教你破关,不代表我赞成你去破。”
“记住第二章那句话——改造者是唯一中立的存在。”
“中立的意思不是’两边都不得罪’,是你得自己选,然后自己承担。”
三、破关六法
师傅在桌上摆了六个茶杯,从近到远排开。
“六道关,六种破法。我把它们讲透——讲透的意思,是让你知道每一步为什么有效,不是给你一个咒语让你念。”
3.1 破第一关:签名校验
核心思路:它要"比对",那你要么让它比不出来,要么让它比对的结果永远是"通过"。
招一 · 改判断(smali 层,最简单)
找到调用校验的地方,把结果钉死。
# 改之前
invoke-static {p0}, Lcom/demo/weather/SignCheck;->checkSign(Landroid/content/Context;)Z
move-result v0
if-eqz v0, :cond_pass # v0==0(校验失败)就跳走
# 改之后:在 move-result 后插入一行,强制为真
invoke-static {p0}, Lcom/demo/weather/SignCheck;->checkSign(Landroid/content/Context;)Z
move-result v0
const/4 v0, 0x1 # ← 不管算出来是什么,强制通过
if-eqz v0, :cond_pass
为什么有效:校验函数本身照常运行,只是它的返回值被你接管了。这是最省事的一招,因为你完全不用去理解它怎么算的。
招二 · Hook 签名来源(Frida 层,不改包)
不改 apk,直接在运行时把"签名"这个数据源换掉。
Java.perform(function () {
var Signature = Java.use("android.content.pm.Signature");
// 让任何取签名字符串的地方,都返回原版签名
Signature.toCharsString.implementation = function () {
var real = this.toCharsString();
console.log("[*] 原始签名: " + real);
return "308201dd30820146a0030201..."; // ← 填原版签名串
};
});
为什么有效:签名校验的流程是「取当前签名 → 与预埋值比对」。你不动后半段的比对,只把前半段取出来的值换成原版,比对自然就通过了。
怎么拿到"原版签名串"?先别填,直接跑一遍脚本,用
console.log打出现在的值——那是你重签后的签名。然后去装一个官方原版 apk,再跑一次,打出来的就是原版签名。
招三 · 工具一键(最省事)
- MT管理器:Dex 编辑器 → 搜索
getPackageInfo→ 或用其内置的「去除签名校验」 - NP管理器:同类功能
- 核心破解(XP 模块):全局禁用签名校验,需 root
避坑
| 现象 | 原因 |
|---|---|
| 改完还是检测到 | 校验不止一处,jadx 全局搜 getPackageInfo 看命中几处 |
| 过了启动页,用一会儿又崩 | 有延迟校验,常见于进入某个功能时才触发 |
直接 System.exit(0) 找不到判断点 |
搜字符串「已被修改」,从弹窗/退出的代码往上反推谁调用了它 |
3.2 破第二关:完整性校验(CRC)
核心思路:它称的是"重量",那你要么把秤拨回原值,要么把秤砸了。
招一 · 改判断
同 3.1 招一——找到 if 判断,把结果钉死。
招二 · Hook CRC 来源
Java.perform(function () {
var ZipEntry = Java.use("java.util.zip.ZipEntry");
ZipEntry.getCrc.implementation = function () {
var crc = this.getCrc();
var name = this.getName();
if (name.indexOf("classes.dex") >= 0) {
console.log("[*] 真实 CRC: " + crc + " -> 返回原值");
return 1234567890; // ← 填原版 CRC(从原版 apk 读出来)
}
return crc;
};
});
为什么有效:跟签名校验一模一样的逻辑——不改比对,改数据源。它问"多重",你就回答原本的重量。
招三 · 让校验方法空手而归
找到那个校验方法,第一行直接返回(第 04 章招式 B):
.method private checkIntegrity()Z
.registers 5
const/4 v0, 0x1 # ← 直接返回 true
return v0 # 原逻辑全在下面,永远执行不到
...
.end method
注意返回类型:方法签名是
Z(boolean),就得return v0;如果是V(void),就得return-void。类型不匹配会直接崩溃——这是新手最常见的闪退原因之一。
避坑
- CRC 值怎么拿到原版?装原版 apk 跑一遍脚本打印出来;或者直接解压原版 apk 看
classes.dex的 CRC - 有些 app 校验的是整个 apk 的 MD5,那就去 Hook
MessageDigest.digest() - 本质是同一招:找到它"测量"的那个函数,Hook 掉
3.3 破第三关:代码混淆
核心思路:混淆只改名字,不改行为。所以——顺着行为找,别顺着名字找。
(就是我熬了一夜才懂的那件事。)
招一 · 优先搜 API,不搜业务词
这是最实用的一招,优先级从高到低:
| 要找什么 | 搜这个(系统 API 不会被混淆) |
|---|---|
| 签名校验 | getPackageInfo |
| 完整性校验 | getCrc / getPackageCodePath / MessageDigest |
| 字符串判等 | equals |
| 弹窗提示 | Toast / setText / show |
| 网络请求 | okhttp / HttpURLConnection / JSONObject |
| 日志 | Log.d / Log.e |
系统 API 的名字是乱不了的吗?对,因为它们不在 app 里,在 Android 框架里。
招二 · jadx 反混淆 + 手动重命名
jadx --deobf app.apk -d out # --deobf 开启反混淆
jadx 里右键类名/方法名可以 Rename(快捷键 N)。你读懂一个 a() 是干什么的,就把它改成 checkVip()——读一个,改一个,慢慢就把地图拼出来了。
招三 · 用资源 id 反查
代码里出现 const v0, 0x7f0d0012 这种 id 时,去 res/values/public.xml 或 resources.arsc 里反查它对应哪个字符串/布局。
一个 id 旁边写着"支付成功",那段代码大概率就在处理支付结果。
招四 · 动态追踪(最省力)
静态推不出来就别推了,直接看它运行时干了什么:
Java.perform(function () {
var Target = Java.use("com.demo.a.a"); // 就算叫 a.a 也无所谓
Target.a.overload("java.lang.String").implementation = function (arg) {
console.log("[*] a() 参数: " + arg);
var ret = this.a(arg);
console.log("[*] a() 返回: " + ret);
return ret;
};
});
为什么有效:你不需要知道它叫什么,你只需要知道它进出的是什么数据。参数是加密前的字符串,返回值是判定结果——一看就懂。
3.4 破第四关:加固加壳
核心思路:壳能藏住磁盘上的 dex,但藏不住内存里的 dex。
原理一句话
app 运行时,真 dex 必须被加载进内存才能执行。所以——等它加载完,从内存里拷出来。
磁盘上的 classes.dex ──(壳,加密)──> 你看不懂
↓ 运行时解密加载
内存中的 dex ──(明文)──> dump 出来,可读
实操
# 1. 手机上运行 frida-server
# 2. 启动目标 app(壳会在这时解密真 dex)
# 3. dump
frida-dexdump -U -f com.example.app -o dex_dump/
# 产物:一堆 dex,拿去 jadx 看
jadx-gui dex_dump/classes_0x1234.dex
同类工具:FDex2、dumpDex(Xposed 模块)、BlackDex(免 root)。
三代壳怎么办
| 代际 | 能不能脱 | 建议 |
|---|---|---|
| 一代(整体加密) | 能,dump 效果很好 | 直接上 frida-dexdump |
| 二代(方法抽取) | 能,但要回填方法体 | 用专业脱壳机(如 FART) |
| 三代(Dex2C / VMP) | 基本不能 | 建议放弃或换方向 |
三代壳把 Java 逻辑转成了 native 或自定义虚拟机指令。这时候你面对的已经不是 smali 问题,是二进制逆向问题——那是 IDA 的领域,不是新手该碰的。
最实用的一句话
看到加固,先问自己:这个 app 值得我花这个力气吗?
大多数情况下,答案是"不值得"。
3.5 破第五关:环境检测
核心思路:它在"观察你",那你就别让它看见,或者让它看见它想看的。
招一 · 隐藏 root(对付 root 检测)
- Magisk → 设置 → 隐藏 Magisk 应用(改包名)
- 开启 DenyList,把目标 app 勾上
- 装 Shamiko 模块(比 DenyList 更彻底的隐藏)
招二 · 改 Frida 的"样子"(对付 frida 检测)
默认 frida-server 监听 27042 端口,且进程里有 frida-agent 字样——这是它的两个招牌。
# 换端口启动
./frida-server-16.0.0-android-arm64 -l 0.0.0.0:8080
或者改用 frida-gadget:把 so 打包进 apk 里,没有独立进程、没有默认端口,很难被检测。
招三 · Hook 掉检测函数
Java.perform(function () {
// 1. 反调试:让"是否被调试"永远返回 false
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
return false;
};
// 2. 反 root:让 su 文件"不存在"
var File = Java.use("java.io.File");
File.exists.implementation = function () {
var p = this.getPath();
if (p.indexOf("/su") >= 0 || p.indexOf("magisk") >= 0) {
console.log("[*] 屏蔽 root 检测: " + p);
return false;
}
return this.exists();
};
});
注意:Hook
File.exists是全局的,会影响所有文件判断,可能引发奇怪的副作用。实战中尽量只 Hook 具体的检测函数,这个例子只是演示思路。
招四 · 用现成工具
Objection(基于 Frida 的命令行工具)内置了一堆开关:
objection -g com.example.app explore
android root disable # 一键禁用 root 检测
android sslpinning disable # 一键绕过 SSL Pinning
android emulator detect # 看看它检测了哪些模拟器特征
一个重要的判断
如果你的改造在真机上正常、模拟器上崩溃,或开着 Frida 就崩、关掉就好——先别怀疑自己的代码。
先怀疑照妖君。
3.6 破第六关:服务端校验
核心思路:本地改不了服务器,那就改你们之间的对话。
招一 · 抓包改响应(最常用)
以 Charles 为例:
- 手机配代理,装 Charles 根证书(Android 7+ 需装进系统证书区)
- 找到 app 请求会员状态的那个接口
- 右键 → Breakpoints,下次请求会暂停,你手动把响应里的
"isVip": false改成true - 或者用 Map Local,把某个接口永久指向你写好的本地 JSON
{
"code": 0,
"data": {
"isVip": true,
"expireDate": "2099-12-31"
}
}
招二 · 改请求(改你发出的东西)
把请求里的参数改掉,比如把 userId 换掉、把 amount 改小。
招三 · 认清它的边界
这一关跟前五关有个本质区别:
- 前五关:东西在你手机上,你总有办法
- 第六关:东西在别人服务器上,你能改的只有"你看到的"和"你发出的"
所以服务端校验理论上无法从本地真正绕过——抓包改响应,改的只是你自己这一台手机的显示。
换个手机、换个账号,一切照旧。
该收手的时候
师傅说到这儿,停了一下。
“这一关我跟你说清楚——如果对方的关键价值在服务器上,那就不是你该改的东西。”
“改通信跟改本地,是两回事。前者只影响你自己,后者可能动到别人的东西。”
破关速查表
| 关卡 | 最快的破法 | 备选 | 难度 |
|---|---|---|---|
| 一 · 签名校验 | smali 里 const/4 v0, 0x1 钉死结果 |
Hook Signature.toCharsString;MT 一键 |
★☆☆☆☆ |
| 二 · 完整性校验 | 同上,或整个方法 return |
Hook ZipEntry.getCrc |
★★☆☆☆ |
| 三 · 混淆 | 搜系统 API 而非业务词 | jadx --deobf + 重命名;动态追踪 |
★★★☆☆ |
| 四 · 加固 | frida-dexdump 内存 dump |
FDex2 / BlackDex;三代壳建议放弃 | ★★★★☆ |
| 五 · 环境检测 | Magisk DenyList + Shamiko | 改 frida 端口;Objection 一键 | ★★★☆☆ |
| 六 · 服务端 | 抓包 + Breakpoints 改响应 | Map Local;改请求参数 | ★★★★★ |
贯穿六关的一条总纲:
不要去理解它怎么算的,去接管它算出来的结果。
签名、CRC、root 检测、会员判定——全都一样。找到那个"产出结论"的函数,把结论换成你想要的,你甚至不需要读懂中间那几十行代码。
四、小剧场模式:矛与盾
小剧场模式
导演: 最后一场,矛与盾的对手戏。灯光暗一点。
助理: 3 2 1。。。action
验身君: 我验它身上的印章。
我: 我就让印章变成原来那枚。
秤重君: 我称它的分量。
我: 我就报给你原本的分量。
迷魂君: 我让它看不懂。
我: 那我就不看名字,我看它干什么。
铁甲君: 我让它连看都看不到。
我: 那我就等它跑起来,从内存里把它揪出来。
照妖君: 我看它是不是改造者。
我: 那我就……不是。
云端君: (沉默良久)我把东西放在它够不着的地方。
我: ……
我: 这个,我确实够不着。
云端君: 那你打算怎么办?
我: 我想想。
我: 如果我够不着,那说明——那东西本来就不该我拿。
云端君: (微微点头)算你识相。
保卫者众: ……
我: 不过话说回来——你们六位,守了这么多年,有没有守住过?
验身君: ……没有。
铁甲君: 也没有。
照妖君: 一次都没有。
我: 那你们还守?
铁甲君: 守。
我: 为什么?
铁甲君: 因为守得住一次,就少一个人受害一次。 不需要永远守住。
我: ……(沉默)
我: 那我呢?我学这些,是为了什么?
师傅: (画外音)你说呢?
导演: cut!! 完美,收工。
小剧场模式
五、尾声
第二天天黑的时候,我把那个天气 app 装回了手机。
开屏广告没了,界面清爽。启动,运行,一切正常。
那个「检测到应用已被修改」的弹窗,再也没出现过。
师傅坐在藤椅上,看我折腾完,问了一句:
“所以,谁赢了?”
我愣了一下。
“你是说,我和那个 app?”
"我是说,"他慢慢地说,“道高一尺,魔高一丈——你觉得这是在说谁压过谁?”
我想了想:“……是说魔比道厉害?”
师傅笑了。
“是说这事儿没有终点。”
“道长一尺,魔就长一丈;魔长一丈,道又长一尺。两边都在长,谁也吃不掉谁。”
“所以你今天破的这道关,明天就会长出新的。你明天学会的这招,后天就不灵了。”
我有点沮丧:“那我学这些有什么用?”
"有用。"师傅站起来,走到窗边,“因为你在长。”
“这行里真正值钱的,从来不是某一招某一式,是你在一次次失效里,练出来的那双眼睛。”
"洞察、推算、分析、辨别……"他回头看我,“第二章那七个字,你现在能数出第七个了吗?”
我掰着手指头:洞察、推算、知识、分析、辨别、联想……
"领悟。"我说。
师傅点点头,笑了。
“领悟不是学会了,是明白了正确的使用姿势,并能在未知的领域发挥效果。”
“记住这句话。兵器会旧,招式会过时,但这个不会。”
我点点头,收起手机。
走到门口的时候,师傅忽然叫住我。
“小子。”
“嗯?”
“那个 app——你只改了广告,别的什么都没动,对吧?”
“对。”
“很好。”
他转过身去,摆摆手。
“明天早点来。该讲藏经阁了。”
我:“???”
(又来……)
剧情片段
导演: 收工。
助理: 导演,藏经阁是什么?
导演: 兵器有了,招式有了,破关的法子也有了。
导演: 那接下来该问的是——这些东西,是从哪儿来的?
助理: 您的意思是……
导演: 从看得懂别人写的东西开始。
导演: 下一章,就叫——万法归宗。
剧情片段


