吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 237|回复: 2
上一主题 下一主题
收起左侧

[Android 原创] 某校园跑软件去广告尝试

[复制链接]
跳转到指定楼层
楼主
Stars313 发表于 2026-8-25 07:50 回帖奖励
本帖最后由 Stars313 于 2026-8-25 07:56 编辑

某步点去广告逆向全程记录

0. 目标与结局速览

目标:把某步点(包名 run.xbud.android,网易易盾加固,内置字节系广告组件)做成一个装上就没广告的独立 APK

路线 结局
hosts 屏蔽广告域名 + 外部注入补丁库 验证可用,但属运行时手段,不算交付
扔壳重建(拿内存 dump 的 dex 重打包) 实证死路:核心方法被易盾永久 native 化
在包内找明文签名期望值做替换 600 个目标 × 12 种形态,零命中
保留完整壳 + 包内自带“停车”补丁 最接近成功:装上、UI 起来、活过 30 秒,最终死于毫秒级内;修复代码已写、未验证

文中所有结论按证据强度标注:实测(有日志/报错/数据撑着)、推断(基于实证的最优解释)、未排除(还开着的口子)。
AI:ox-alpha+GLM-5.3


1. 环境与工具

按用途分层:

  • 静态解包/反编译:apktool(解包/重打)、jadx(看 Java)、baksmali(dex→smali)
  • native 分析:IDA(早期,略)、ELF 头检查(Python struct)、/proc/<pid>/maps
  • 动态:frida 17.16.2 + frida-java-bridge + esbuild 编译探针脚本;自写 ptrace 跟踪器 minitrace
  • 测试机:MuMu 模拟器,Android 15(x86_64 宿主 + houdini ARM 翻译层),KernelSU root,SELinux Permissive。亲手装的 ZygiskFrida 模块,会在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so 里的内容——这个特性后来反复搅局,后面细说
  • 补丁编译:zig 交叉编译(x86_64-linux-musl / aarch64-linux-musl)
  • 重打包链:apktool b → zipalign → apksigner;Python 干杂活(塞 dex、扫哈希、验截图)

2. 阶段一:初筛——壳、dex、广告

2.1 认壳

apktool 解开原包,manifest 里 application 指向 com.netease.nis.wrapper.MyApplicationcom.netease.nis.wrapper 是网易易盾加固壳的包名特征,但这不是唯一依据——后续运行时出现的 libnesec*.so、加密 dex 的加载方式、壳内的签名代理(见 §6),都指向同一结论。原始 APK 里的可见 dex 基本是壳的占位代码,业务代码以加密数据形式放在包内,由壳在运行时解密后通过内存方式加载,不落盘。

2.2 从内存里捞出业务 dex

方法概述(细节属早期工作):hook 类加载路径(InMemoryDexClassLoader 一类)配合内存扫描定位运行时解密出的 dex 结构,按 dex 格式导出。共 dump 出 7 个 payload dex,最大一个约 54 MB。baksmali 反汇编后得到 7 个 smali 目录(smali_0 ~ smali_6),与 dex 数量一一对应。

分拣结果:smali_0 全是壳的类;smali_1 基本是倍孜(Beizi)广告 SDK;smali_6 是业务主体代码。中间四份没有逐份做完整成分清单,只通过两次全量扫描(native 方法统计、壳类交叉引用搜索)间接画像过:里面摊着 androidx 全家桶、Flutter 引擎、高德地图引擎、腾讯 MMKV、字节视频组件等大体积依赖。关键结论(解耦、方法抽取)都是全量扫描得出的,不依赖中间几份的精确归类——但如果要精确到“某个 SDK 散在哪几份”,得回头逐份统计,现在只能说到这个粒度。

2.3 广告 SDK 盘点

  • 倍孜(Beizi):实锤。smali_1 整份是它,业务目录里还有 BeiZiNewRewardVideoActivity(激励视频)、BeiZiNewInterstitialActivity(插屏)、BeiZiNewLandingPageActivity(落地页)等一堆 Activity,以及它家的布局引擎 YogaNative。
  • 字节系组件:多条线拼起来的“在场”证据——com.bykv.vk.component.ttvideo(TTPlayer 视频播放、AVMDLDataLoader 视频下载,典型的广告物料播放组件)+ com.volcengine.mobsecBiz(字节移动安全组件,就是后面签名校验那套)。没有逐一验证广告展示实际走的哪条链路。
  • 广点通(GDT):只在 hosts 屏蔽列表里有它的域名,屏蔽后效果是合并算账的,没单独验证比重。疑似在场、未确证。

2.4 hosts 验证及其局限

往设备 /system/etc/hosts 加了约 40 行倍孜/GDT 域名,App 内广告位空了。实测有效,但这步的目的只是确认广告走网络层、锁定域名归属,不是去广告方案:hosts 需要 root、逐台设备改、广告 SDK 还可能有本地兜底或域名降级逻辑。交付物不能依赖它。


3. 阶段二:无声死亡与击杀桩

3.1 现象

附着 frida,或者重打包后安装,进程几秒内消失。退出码 208,没有 Java 崩溃栈、没有 tombstone。这不是 crash——crash 会走 ART 或 debuggerd 的上报路径留下现场;这里更像是进程主动调用了退出系统调用,把所有上报机制都绕开了。

3.2 定位

当时怀疑是壳直接调 exit_group 绕过 Java 异常和 native crash 上报。验证靠自写 ptrace 跟踪器 minitrace 盯系统调用,加上扫内存:观察触发退出时发起调用的指令位置,落在 libnesec-x86.so 的映射区间内。

先把两个数字说清,这是最容易读混的地方:

  • 231 是 x86_64 下 exit_group 的系统调用号,不是退出码。它被 mov eax, 231 装进 eax;
  • 208 是传给 exit_group 的 status 参数(走第一个参数寄存器 rdi)。ptrace 观测到该参数恒为 208(0xD0)——它大概率就是壳内部定义的一个特定错误码或状态标识,具体含义未深究。

所谓“击杀桩”,指壳在内存里解密释放出来的一小段直接发起退出系统调用的机器码。它不经过 libc 的 exit/_exit,也不走任何导出/导入表:

b8 e7 00 00 00    mov eax, 231    ; exit_group 系统调用号
0f 05             syscall

普通 hook(挂在库函数符号、PLT、GOT、导出函数入口上那一类)对它全部无效,因为调用链上根本没有可以下钩的“名字”。要处理只能:扫内存匹配字节特征后 inline patch、seccomp 过滤、或 ptrace 拦截。

3.3 对策:gotkill

写了个叫 gotkill 的小库,加载进进程后开后台线程反复扫内存,把命中的击杀桩改写成“停车”指令:

/* park shellcode: mov eax,34(SYS_pause); syscall; jmp $ */
static const unsigned char repl[16] = {
    0xB8, 0x22, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xEB,
    0xFE, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
};

几个设计选择的理由:

  • 为什么不 nop / ret:原桩是“必定不返回”的语义,直接 ret 会把控制流弹到未知位置;nop 掉 syscall 后线程会继续往下执行壳的后续代码,行为不可控。
  • 为什么是 pause 而不是纯用户态死循环:让线程停在一个系统调用里,状态可控、可观测;后面的 jmp $(跳回自己)是保险——万一 pause 被信号打断返回了,也不会往下执行任何东西。
  • 16 字节写入的安全性:原桩只有 7 字节,替换块按 16 字节写、尾部 nop 填满。这确实存在覆盖原桩后方 9 字节的理论风险——它的安全性是经验性的:实测命中的桩都位于壳解密释放的代码区里,桩后是 nop 填充或块边界,且外部注入验证期间进程被补丁后持续正常运行了数分钟,没有出现因补丁引发的崩溃。没有逐桩验证过后方 9 字节的内容,这是该方案的一个已接受的风险点。

匹配条件也很保守:只认 b8 ?? 00 00 00 / 0f 05 且装入 eax 的值 ∈ {231, 60}(exit_group / exit)、立即数高位字节全零的形态,避免误伤正常代码。

不改 APK、纯外部注入的条件下验证有效(外部注入指通过模拟器的注入机制把 so 塞进目标进程,先验证思路,再考虑内置进包):

[GOTKILL] NEUTERED nesec stub nr=231 @+0x5d4f0

nr=231 就是命中的系统调用号,@+0x5d4f0 是桩相对 so 加载基址的偏移。

3.4 两条死支线

  • seccomp 拦截:装上 seccomp 过滤器拦退出调用后,进程改被 SIGKILL(不是 208)。说明壳检测到了运行环境被过滤(具体怎么检测的——读 /proc/self/status 的 Seccomp 字段还是行为探测——没查),只挡退出调用不够,它还有别的杀法。弃。
  • ptrace 互斥:minitrace 和 frida 同时挂同一个目标会失败(一个 Linux 进程同一时刻通常只能被一个 ptrace tracer 附着,frida 的注入机制也占这个坑)。两者只能二选一,多数时候靠 frida 单独上。

4. 阶段三:扔壳重建——死因是“方法抽取”

4.1 依据与改动

依据:内存 dump 出的 7 个业务 dex 看起来是完整的,且全量搜索业务 smali 中对壳类(nis.wrapperms.bd.ccom.netease)的引用为零。注意这个“零引用”是静态层面的:没有直接类引用、没有明显的字符串加载痕迹;JNI、反射、ClassLoader 注入这类动态依赖不在它的覆盖范围里——后面的事实恰恰证明了这一点。

改动:manifest 的 application 从壳的 MyApplication 改成业务侧的 run.xbud.android.common.XBDApplication,7 个 dex 作为 classes.dexclasses7.dex 塞进 apktool 打出的资源壳(不含任何壳代码和 libnesec)。

4.2 三个工程坑

  1. 塞 dex 的脚本按文件名排序,一个杂散文件(hdr*.bin)排到了第一位占据了 classes.dex 的名字,7 个 dex 实际只进去 1 个。列包内条目才发现,修成显式配对 payload_N.dex → classes(N+1).dex
  2. resources.arsc 被重压缩。Python zipfile 默认对所有条目用 deflate,而 resources.arsc 是资源索引表,安装器要求它直接映射访问,Android 11+(API 30+)强制它未压缩(STORED)存储且 4 字节对齐,违反直接拒装,安装器报错原话:Targeting R+ ... requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary。修复是保留每个条目原有的压缩方式(传入原 ZipInfo 对象而不是只传文件名),对齐交给 zipalign:
for item in zin.infolist():
    if item.filename.startswith('classes'):
        continue
    # 保留原压缩方式;resources.arsc 必须 STORED,对齐由 zipalign -p 处理
    zout.writestr(item, zin.read(item.filename))
  1. 签名方案。jarsigner 只生成 v1(JAR 签名),较新的 Android 要求 v2 及以上的 APK 签名块,v1-only 装不上。换 apksigner 出 v2+v3,apksigner verify --verbose 确认两者都通过。

4.3 崩溃与真相

包装上了,进程起来了,业务类也找到了,然后崩在:

java.lang.UnsatisfiedLinkError: No implementation found for
void run.xbud.android.common.XBDApplication.attachBaseContext(android.content.Context)

先解释这个错误:它不是说某个 .so 没装上,而是说 Java 层声明为 native 的方法在运行时找不到对应 JNI 实现(JNI 绑定通常靠 RegisterNatives 或按符号名约定)。看 smali 才知道规模:

.method protected native attachBaseContext(Landroid/content/Context;)V
.method public native onCreate()V
.method private final native break()V
...(该类几十个方法全是 native)

全 App 53693 个类里 600 个含 native 方法,集中在业务 Activity(跑步记录、个人页等,每类 30+ 个方法被抽)。这是易盾的方法抽取:Java 方法体被整体搬进壳的 native 层,dex 里只剩 native 声明,运行时由壳注册/分发。

4.4 决定性实验

用 frida 反射检查一个已经成功执行过的方法:

const M = Java.use('java.lang.reflect.Modifier');
// App 已正常启动、onCreate 已执行完毕之后:
M.isNative(onCreateMethod.getModifiers())   // 仍是 true

如果是“懒恢复”式抽取(方法体运行到才回填 dex),执行过的方法应该能观察到非 native 状态或字节码回填。实测仍为 native,说明 Java 方法体从头到尾不在任何 dex 里,逻辑长期驻留 native 层。因此对本案这种形态,FART/BlackDex 一类主动脱壳工具帮不上忙——它们能 dump 内存 dex、能触发方法调用,但可恢复的 Java 方法体本身就不存在,没有对象可脱。

结论(限定范围):对当前样本被抽取的这批类,简单去壳重打包不可行;业务运行依赖壳提供的 native 实现。这不排除理论上逆向 native 层重建的可能,但那是以周计的工程,不在本次范围。


5. 阶段四:找签名期望值——零命中

思路:壳要判“签名不对就杀”,期望值总得存在某处;若以明文形态放在包里,找到就能换成我们证书的哈希,让重签通过。

扫描:原证书 DER 的 md5/sha1/sha256 × {原始字节、小写 hex、大写 hex} 共 12 种特征,覆盖 38 个 so、全部 assets、原始 APK,共 600 个目标。零命中。

这个结果的边界要说清:它只排除了“原始哈希以这 12 种常见明文形态直接放在包内”的情况。不排除 Base64/UTF-16 编码、截断、加盐、加密存储、运行时派生、甚至服务端下发——扫描前就声明,这些形态不在覆盖范围里。基于已覆盖范围内的零命中,放弃“静态匹配换哈希”思路,转向保留原壳、处理运行时行为。


6. 阶段五:壳自带的签名代理

读壳的 smali 时有个意外发现:com.netease.nis.wrapper.plugin.d 是个 InvocationHandler(Java 动态代理),代理应用内部的包管理接口。当被调用的方法名匹配(方法名本身是密文 "KQAANQAQDi8CESwPFQo=",经壳的字符串解密器解出)且 flags 带 0x40 时,把返回的签名换掉:

const/16 v2, 0x40                        ; 0x40 = 旧版 PackageManager.GET_SIGNATURES
if-ne v0, v2, :cond_47
new-instance v2, Landroid/content/pm/Signature;
iget-object v0, p0, ...->b:Ljava/lang/String;   ; 壳预存的签名串
invoke-direct {v2, v0}, ...Signature;-><init>(Ljava/lang/String;)V
...
aput-object v2, v3, v1                   ; signatures[0] = 预存值

被拦截方法推断为 getPackageInfo,依据有两条:解密后的字符串内容,加上行为特征的完全吻合——该代理检查调用 flags 是否带 0x40(GET_SIGNATURES),并且改写的是返回值里 PackageInfo.signatures[] 数组的首元素,这正是 getPackageInfo 独有的签名语义。0x40 对应旧版 GET_SIGNATURES(新版是 GET_SIGNING_CERTIFICATES,壳明显按旧标志位处理)。

推断(间接证据,非直测):这个代理的用途是让业务/广告 SDK 里走 PackageManager 查签名的校验(比如字节 ms.bz.bd.c.Pgl.e1 那套——用 MessageDigest 算证书哈希再送出去比对)在受保护环境里仍能读到原始签名。而 nesec 自己的 native 校验不走这条代理——证据是间接的:重签后即使 Java 层代理可能仍在喂伪签名,进程照样快速退出,说明还存在一条不经过该代理的真实签名/完整性检查路径。


7. 阶段六:守卫方案——保留壳,自带补丁

7.1 方案

架构原样保留:壳的 MyApplication、libnesec、加密 payload 一个不动(业务逻辑靠 native 分发,必须留着壳)。只加两样:

① KillGuardApp——继承壳的 MyApplication,静态块里先加载补丁库:

.class public Lcom/netease/nis/wrapper/KillGuardApp;
.super Lcom/netease/nis/wrapper/MyApplication;

.method static constructor <clinit>()V
    .registers 1
    :try_start_0
    const-string v0, "gotkill"
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
    :try_end_0
    .catch Ljava/lang/UnsatisfiedLinkError; {:try_start_0 .. :try_end_0} :catch_0
    goto :goto_0
    :catch_0
    move-exception v0
    :goto_0
    return-void
.end method

同时 manifest 的 application android:name 必须改成 KillGuardApp——不改的话系统实例化的仍是 MyApplication,这个子类永远不会跑。这一步做了。

时序说明(这里容易读反):Java 类初始化顺序是父类 <clinit> 先、子类 <clinit>。所以 loadLibrary("gotkill") 并不抢在 MyApplication.<clinit> 之前——它可行是因为读过 smali 确认 MyApplication.<clinit> 只做字符串解密、不加载任何库,而 nesec 的加载发生在更晚的 attachBaseContext 阶段。完整链条:父类静态块(解密字符串)→ 子类静态块(加载 gotkill,补丁就位)→ attachBaseContext(壳加载 nesec,nesec 解密并安装击杀桩)→ gotkill 的巡检已经在扫了。前提是静态分析没漏(<clinit> 里没看到 loadLibrary 调用路径;未做运行时复核——见 §7.4 未排除项)。

② 双架构 libgotkill.so 打进包里,摆脱外部注入。

7.2 ABI 布局——一个反直觉且踩了坑的环境

先说实测事实。看运行中进程的 /proc/<pid>/maps:业务 so 全部从 .../lib/arm64/ 目录加载,同时进程里有 /system/lib64/arm64/...、houdini 初始化日志——App 是 arm64 库 + houdini 翻译执行的形态(x86_64 宿主,ABI 按 arm64 走,安装器只抽 arm64-v8a 目录)。文件头检查进一步显示这对 nesec 是“混布”的:

libnesec-x86.so: ELF64 x86_64     ← 文件名叫 x86,实际是 x86_64 ELF
libnesec.so:     ELF64 aarch64    ← 名字干净的反而真身是 arm64

两个不同架构的 so 同放在 lib/arm64-v8a/ 目录里。x86_64 那份之所以能被加载,是 MuMu 的 native bridge loader 对该目录下的 ELF 做了兼容处理(推断,依据是它在 maps 里真实出现了)。这也解释了 gotkill 为什么用 x86 字节特征能扫到并改写“arm 库”里的桩——libnesec-x86.so 是 x86_64 代码原生执行,桩的字节本来就是 x86 的。

补丁库的放置我实际试过两种,都得交代:

  • 第一版守卫包:把 x86_64 版 libgotkill.so 放进 lib/arm64-v8a/(模仿 libnesec-x86.so 的布局)。结果启动 190ms 死于 208。这一版里包内补丁是否加载成功未知(日志写不出,见 §7.3)。
  • 第二版守卫包:aarch64 版放 lib/arm64-v8a/,x86_64 版放 lib/armeabi-v7a/后者是个失误:armeabi-v7a 是 32 位 ARM 目录,这台 64 位设备根本不会抽取它,那份 x86_64 拷贝大概率是从未生效的死重。实际有可能起作用的只有 arm64-v8a 里的 aarch64 版——它走标准 arm64 ABI 加载、由 houdini 翻译执行;而补丁器扫什么、写什么只取决于内存里的目标字节(x86 桩),与补丁器自身被编译成什么架构无关,所以 aarch64 版扫 x86 桩没有障碍。第二版守卫包活过了 30 秒。

顺带一个还没处理的对真机的含义:在真 arm64 手机上,nesec 加载的是 aarch64 的 libnesec.so,击杀桩将是 ARM64 机器码,现在的 x86 特征扫描不会命中——当前补丁只对这台模拟器环境有效,真机需要另配 ARM64 的桩特征。这超出了本次范围,但交付前必须知道。

7.3 测试结果:大起大落

第二版守卫包装机成功。第一次启动:8 秒存活、30 秒存活,截图解析出白底+金黄品牌色——重签包的 UI 真起来了。但在 30 秒~2 分钟之间的某次检查时已死(精确死亡时刻没测)。第二次冷启动 0.3 秒内死,退出码 208。

尸检时间线(logcat):

04.602  ZygiskFrida: App detected: run.xbud.android
04.713  ZygiskFrida: Injecting /data/local/tmp/.jcache/inline.so
04.716  ZygiskFrida: Remapped
04.901  Zygote: Process 16837 exited cleanly (208)

先解释这组日志:ZygiskFrida 是模拟器自带的注入模块,它在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so——这个路径上放的是我们早前部署的旧版 gotkill(当时为原版 App 动态分析备用的停车补丁)。也就是说守卫包测试时进程里实际有两份补丁来源:自动注入的旧版 x86_64、包内的 aarch64 版。exited cleanly (208) 是 Zygote 的措辞,指进程正常退出(非信号杀死),与“主动调 exit_group”完全一致。

7.4 竞速假说与未排除项

主假说(推断):注入后约 185 毫秒被处决。旧版 gotkill 的后台线程起跑前先 usleep(200000)——注入发生在 04.714,旧版首次扫描本应在 ~04.916 才发生,而死亡在 04.901,差了约 15 毫秒输掉竞速。时间上吻合。

但这个假说单独解释不了第一次为什么活了 30 秒以上。开着的口子:

  1. 包内 aarch64 版补丁两次到底加载成功没有——UnsatisfiedLinkError 被静态块 catch 吞了,而日志文件因 App 进程无权写 /data/local/tmp(早期外部注入阶段沿用下来的路径,普通应用进程本就写不了)一直缺失,无法佐证。一个能自洽解释两次差异的假设是:第一次包内版加载成功、它的巡检线程比注入版起跑更早(类初始化阶段就位)从而补上了窗口,第二次因某种原因没加载——未验证。
  2. 第一次 30s~2min 之间的死亡原因不明——可能是延迟触发的第二道检查(比如反 frida 检测走了别的路径),也可能是同一条 nesec 逻辑带定时器。未验证。
  3. 模拟器的自动注入本身就是个污染变量:在真机上没有 ZygiskFrida,inline.so 不会被注入,死亡时序可能完全不同。本环境测得的“第二次秒死”不排除是模拟器特有的注入触发了壳的反注入检测。

8. 修复代码——已写、未验证

针对竞速假说和日志盲区,gotkill.c 改了三处(只改完源码,编译、重打、装机验证全部没做):

① 构造函数里同步先扫一轮,零延迟起跑(constructor 在 dlopen 时执行)。说明一下这轮扫描的定位:此时 nesec 多半还没加载、内存里还没有桩,首轮同步扫描大概率扑空——它的意义是消除旧版 200ms 的起跑延迟,保证“如果桩已经在了”这种情况下零遗漏;真正接力的是随后创建的 hooker 巡检线程,nesec 加载并释放击杀桩后由它捕获

__attribute__((constructor)) static void on_load(void) {
    unlink(get_log_path());
    log_line("[GOTKILL] loaded\n");
    neuter_nesec_once();   /* 同步首轮:消除旧版 200ms 起跑延迟 */
    pthread_t th;
    pthread_create(&th, 0, hooker, 0);
}

neuter_nesec_once() 是立即执行一轮扫描替换;hooker 是持续巡检线程(名字起得随意,它就是反复扫 maps 处理后续延迟出现的桩的那个循环)。

② 轮询节奏前紧后松:

for (int round = 0; round < 3000; round++) {
    usleep(round < 30 ? 5000 : (round < 60 ? 50000 : 1000000));

算总账:前 30 轮 × 5ms ≈ 150ms(覆盖启动竞速窗口),30–60 轮 × 50ms ≈ 1.5s,之后 1s 一次直到约 49 分钟停止。设上限是为了不无限占线程;代价是 49 分钟后不再扫描——若壳有超长延迟的检查就会漏。

③ 日志路径改走应用可写目录:

static char log_path[128];
static const char* get_log_path(void) {
    if (!log_path[0]) {
        const char* cache = getenv("XBD_CACHE");
        if (cache && *cache) snprintf(log_path, sizeof log_path, "%s/nesec208.log", cache);
        else snprintf(log_path, sizeof log_path, "/data/local/tmp/nesec208.log");
    }
    return log_path;
}

这个修复是半成品XBD_CACHE 环境变量目前没有任何地方设置——还需要 KillGuardApp 的 Java/smali 侧把 context.getCacheDir() setenv 传进去,或 native 侧按包名硬拼 /data/user/0/run.xbud.android/cache/(多用户/多开场景后者会错)。这步没接完。另外 §7.2 说的 armeabi-v7a 死重问题也应在下一版重打时一并清掉(只留 arm64-v8a 里的 aarch64 版)。


9. 剩下的牌(按便宜程度排)

  1. 验证修复版竞速:编译 aarch64 → 重打(清掉 armeabi-v7a 死重、接通日志路径)→ 装机。若 nesec 只有一个约 200ms 的启动击杀窗口,零延迟首扫 + 5ms 快轮询大概率赢。日志接通后一并回答“包内补丁到底加载没有”。
  2. 排除模拟器污染:把守卫包装到无 ZygiskFrida 注入的环境(真机或关掉注入)测一轮,确认“第二次秒死”是不是模拟器特有的。
  3. 若竞速仍不稳:放弃停车补丁思路,转攻 nesec 读取真实签名的那条路径并伪造(§6 代理的 native 对应物)。需要先在运行时定位 nesec 取签名的调用点,工作量大一个量级。

10. 证据属性声明

  • 实测(有报错输出、日志行、测量数据):退出码 208 与无崩溃栈;击杀桩位于 libnesec-x86.so;停车补丁在外部注入下生效(NEUTERED 日志);7 dex 内容与 600 类 native 统计;onCreate 执行后仍为 native;哈希扫描零命中;libnesec-x86.so 为 x86_64 ELF 而进程走 arm64+houdini;守卫包装机成功、首次启动 UI 起来并存活超 30 秒;第二次启动注入后 ~185ms 退出 208。
  • 推断(基于实证的最优解释):签名代理拦截的就是 getPackageInfo 且喂饱对象是业务侧校验;nesec 另有不经代理的 native 校验;两次启动生死差异源于扫描竞速与包内补丁加载成功与否;MuMu 对 arm64 目录内 x86_64 ELF 的兼容加载。
  • 未排除:包内补丁(两版)是否曾加载成功;首次 30s–2min 间死亡的具体触发者;模拟器自动注入对检测时序的影响;壳是否存在 49 分钟窗口之外的延迟检查;16 字节补丁覆盖后方字节的理论风险在全部桩上是否成立。

实验过程与日志来自真实环境,部分代码片段与分析整理有 AI 辅助。

能力有限,分析于此,希望对大家有所帮助

[/md]

某步点去广告逆向全程记录

0. 目标与结局速览

目标:把某步点(包名 run.xbud.android,网易易盾加固,内置字节系广告组件)做成一个装上就没广告的独立 APK

路线 结局
hosts 屏蔽广告域名 + 外部注入补丁库 验证可用,但属运行时手段,不算交付
扔壳重建(拿内存 dump 的 dex 重打包) 实证死路:核心方法被易盾永久 native 化
在包内找明文签名期望值做替换 600 个目标 × 12 种形态,零命中
保留完整壳 + 包内自带“停车”补丁 最接近成功:装上、UI 起来、活过 30 秒,最终死于毫秒级内;修复代码已写、未验证

文中所有结论按证据强度标注:实测(有日志/报错/数据撑着)、推断(基于实证的最优解释)、未排除(还开着的口子)。


1. 环境与工具

按用途分层:

  • 静态解包/反编译:apktool(解包/重打)、jadx(看 Java)、baksmali(dex→smali)
  • native 分析:IDA(早期,略)、ELF 头检查(Python struct)、/proc/<pid>/maps
  • 动态:frida 17.16.2 + frida-java-bridge + esbuild 编译探针脚本;自写 ptrace 跟踪器 minitrace
  • 测试机:MuMu 模拟器,Android 15(x86_64 宿主 + houdini ARM 翻译层),KernelSU root,SELinux Permissive。亲手装的 ZygiskFrida 模块,会在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so 里的内容——这个特性后来反复搅局,后面细说
  • 补丁编译:zig 交叉编译(x86_64-linux-musl / aarch64-linux-musl)
  • 重打包链:apktool b → zipalign → apksigner;Python 干杂活(塞 dex、扫哈希、验截图)

2. 阶段一:初筛——壳、dex、广告

2.1 认壳

apktool 解开原包,manifest 里 application 指向 com.netease.nis.wrapper.MyApplicationcom.netease.nis.wrapper 是网易易盾加固壳的包名特征,但这不是唯一依据——后续运行时出现的 libnesec*.so、加密 dex 的加载方式、壳内的签名代理(见 §6),都指向同一结论。原始 APK 里的可见 dex 基本是壳的占位代码,业务代码以加密数据形式放在包内,由壳在运行时解密后通过内存方式加载,不落盘。

2.2 从内存里捞出业务 dex

方法概述(细节属早期工作):hook 类加载路径(InMemoryDexClassLoader 一类)配合内存扫描定位运行时解密出的 dex 结构,按 dex 格式导出。共 dump 出 7 个 payload dex,最大一个约 54 MB。baksmali 反汇编后得到 7 个 smali 目录(smali_0 ~ smali_6),与 dex 数量一一对应。

分拣结果:smali_0 全是壳的类;smali_1 基本是倍孜(Beizi)广告 SDK;smali_6 是业务主体代码。中间四份没有逐份做完整成分清单,只通过两次全量扫描(native 方法统计、壳类交叉引用搜索)间接画像过:里面摊着 androidx 全家桶、Flutter 引擎、高德地图引擎、腾讯 MMKV、字节视频组件等大体积依赖。关键结论(解耦、方法抽取)都是全量扫描得出的,不依赖中间几份的精确归类——但如果要精确到“某个 SDK 散在哪几份”,得回头逐份统计,现在只能说到这个粒度。

2.3 广告 SDK 盘点

  • 倍孜(Beizi):实锤。smali_1 整份是它,业务目录里还有 BeiZiNewRewardVideoActivity(激励视频)、BeiZiNewInterstitialActivity(插屏)、BeiZiNewLandingPageActivity(落地页)等一堆 Activity,以及它家的布局引擎 YogaNative。
  • 字节系组件:多条线拼起来的“在场”证据——com.bykv.vk.component.ttvideo(TTPlayer 视频播放、AVMDLDataLoader 视频下载,典型的广告物料播放组件)+ com.volcengine.mobsecBiz(字节移动安全组件,就是后面签名校验那套)。没有逐一验证广告展示实际走的哪条链路。
  • 广点通(GDT):只在 hosts 屏蔽列表里有它的域名,屏蔽后效果是合并算账的,没单独验证比重。疑似在场、未确证。

2.4 hosts 验证及其局限

往设备 /system/etc/hosts 加了约 40 行倍孜/GDT 域名,App 内广告位空了。实测有效,但这步的目的只是确认广告走网络层、锁定域名归属,不是去广告方案:hosts 需要 root、逐台设备改、广告 SDK 还可能有本地兜底或域名降级逻辑。交付物不能依赖它。


3. 阶段二:无声死亡与击杀桩

3.1 现象

附着 frida,或者重打包后安装,进程几秒内消失。退出码 208,没有 Java 崩溃栈、没有 tombstone。这不是 crash——crash 会走 ART 或 debuggerd 的上报路径留下现场;这里更像是进程主动调用了退出系统调用,把所有上报机制都绕开了。

3.2 定位

当时怀疑是壳直接调 exit_group 绕过 Java 异常和 native crash 上报。验证靠自写 ptrace 跟踪器 minitrace 盯系统调用,加上扫内存:观察触发退出时发起调用的指令位置,落在 libnesec-x86.so 的映射区间内。

先把两个数字说清,这是最容易读混的地方:

  • 231 是 x86_64 下 exit_group 的系统调用号,不是退出码。它被 mov eax, 231 装进 eax;
  • 208 是传给 exit_group 的 status 参数(走第一个参数寄存器 rdi)。ptrace 观测到该参数恒为 208(0xD0)——它大概率就是壳内部定义的一个特定错误码或状态标识,具体含义未深究。

所谓“击杀桩”,指壳在内存里解密释放出来的一小段直接发起退出系统调用的机器码。它不经过 libc 的 exit/_exit,也不走任何导出/导入表:

b8 e7 00 00 00    mov eax, 231    ; exit_group 系统调用号
0f 05             syscall

普通 hook(挂在库函数符号、PLT、GOT、导出函数入口上那一类)对它全部无效,因为调用链上根本没有可以下钩的“名字”。要处理只能:扫内存匹配字节特征后 inline patch、seccomp 过滤、或 ptrace 拦截。

3.3 对策:gotkill

写了个叫 gotkill 的小库,加载进进程后开后台线程反复扫内存,把命中的击杀桩改写成“停车”指令:

/* park shellcode: mov eax,34(SYS_pause); syscall; jmp $ */
static const unsigned char repl[16] = {
    0xB8, 0x22, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xEB,
    0xFE, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
};

几个设计选择的理由:

  • 为什么不 nop / ret:原桩是“必定不返回”的语义,直接 ret 会把控制流弹到未知位置;nop 掉 syscall 后线程会继续往下执行壳的后续代码,行为不可控。
  • 为什么是 pause 而不是纯用户态死循环:让线程停在一个系统调用里,状态可控、可观测;后面的 jmp $(跳回自己)是保险——万一 pause 被信号打断返回了,也不会往下执行任何东西。
  • 16 字节写入的安全性:原桩只有 7 字节,替换块按 16 字节写、尾部 nop 填满。这确实存在覆盖原桩后方 9 字节的理论风险——它的安全性是经验性的:实测命中的桩都位于壳解密释放的代码区里,桩后是 nop 填充或块边界,且外部注入验证期间进程被补丁后持续正常运行了数分钟,没有出现因补丁引发的崩溃。没有逐桩验证过后方 9 字节的内容,这是该方案的一个已接受的风险点。

匹配条件也很保守:只认 b8 ?? 00 00 00 / 0f 05 且装入 eax 的值 ∈ {231, 60}(exit_group / exit)、立即数高位字节全零的形态,避免误伤正常代码。

不改 APK、纯外部注入的条件下验证有效(外部注入指通过模拟器的注入机制把 so 塞进目标进程,先验证思路,再考虑内置进包):

[GOTKILL] NEUTERED nesec stub nr=231 @+0x5d4f0

nr=231 就是命中的系统调用号,@+0x5d4f0 是桩相对 so 加载基址的偏移。

3.4 两条死支线

  • seccomp 拦截:装上 seccomp 过滤器拦退出调用后,进程改被 SIGKILL(不是 208)。说明壳检测到了运行环境被过滤(具体怎么检测的——读 /proc/self/status 的 Seccomp 字段还是行为探测——没查),只挡退出调用不够,它还有别的杀法。弃。
  • ptrace 互斥:minitrace 和 frida 同时挂同一个目标会失败(一个 Linux 进程同一时刻通常只能被一个 ptrace tracer 附着,frida 的注入机制也占这个坑)。两者只能二选一,多数时候靠 frida 单独上。

4. 阶段三:扔壳重建——死因是“方法抽取”

4.1 依据与改动

依据:内存 dump 出的 7 个业务 dex 看起来是完整的,且全量搜索业务 smali 中对壳类(nis.wrapperms.bd.ccom.netease)的引用为零。注意这个“零引用”是静态层面的:没有直接类引用、没有明显的字符串加载痕迹;JNI、反射、ClassLoader 注入这类动态依赖不在它的覆盖范围里——后面的事实恰恰证明了这一点。

改动:manifest 的 application 从壳的 MyApplication 改成业务侧的 run.xbud.android.common.XBDApplication,7 个 dex 作为 classes.dexclasses7.dex 塞进 apktool 打出的资源壳(不含任何壳代码和 libnesec)。

4.2 三个工程坑

  1. 塞 dex 的脚本按文件名排序,一个杂散文件(hdr*.bin)排到了第一位占据了 classes.dex 的名字,7 个 dex 实际只进去 1 个。列包内条目才发现,修成显式配对 payload_N.dex → classes(N+1).dex
  2. resources.arsc 被重压缩。Python zipfile 默认对所有条目用 deflate,而 resources.arsc 是资源索引表,安装器要求它直接映射访问,Android 11+(API 30+)强制它未压缩(STORED)存储且 4 字节对齐,违反直接拒装,安装器报错原话:Targeting R+ ... requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary。修复是保留每个条目原有的压缩方式(传入原 ZipInfo 对象而不是只传文件名),对齐交给 zipalign:
for item in zin.infolist():
    if item.filename.startswith('classes'):
        continue
    # 保留原压缩方式;resources.arsc 必须 STORED,对齐由 zipalign -p 处理
    zout.writestr(item, zin.read(item.filename))
  1. 签名方案。jarsigner 只生成 v1(JAR 签名),较新的 Android 要求 v2 及以上的 APK 签名块,v1-only 装不上。换 apksigner 出 v2+v3,apksigner verify --verbose 确认两者都通过。

4.3 崩溃与真相

包装上了,进程起来了,业务类也找到了,然后崩在:

java.lang.UnsatisfiedLinkError: No implementation found for
void run.xbud.android.common.XBDApplication.attachBaseContext(android.content.Context)

先解释这个错误:它不是说某个 .so 没装上,而是说 Java 层声明为 native 的方法在运行时找不到对应 JNI 实现(JNI 绑定通常靠 RegisterNatives 或按符号名约定)。看 smali 才知道规模:

.method protected native attachBaseContext(Landroid/content/Context;)V
.method public native onCreate()V
.method private final native break()V
...(该类几十个方法全是 native)

全 App 53693 个类里 600 个含 native 方法,集中在业务 Activity(跑步记录、个人页等,每类 30+ 个方法被抽)。这是易盾的方法抽取:Java 方法体被整体搬进壳的 native 层,dex 里只剩 native 声明,运行时由壳注册/分发。

4.4 决定性实验

用 frida 反射检查一个已经成功执行过的方法:

const M = Java.use('java.lang.reflect.Modifier');
// App 已正常启动、onCreate 已执行完毕之后:
M.isNative(onCreateMethod.getModifiers())   // 仍是 true

如果是“懒恢复”式抽取(方法体运行到才回填 dex),执行过的方法应该能观察到非 native 状态或字节码回填。实测仍为 native,说明 Java 方法体从头到尾不在任何 dex 里,逻辑长期驻留 native 层。因此对本案这种形态,FART/BlackDex 一类主动脱壳工具帮不上忙——它们能 dump 内存 dex、能触发方法调用,但可恢复的 Java 方法体本身就不存在,没有对象可脱。

结论(限定范围):对当前样本被抽取的这批类,简单去壳重打包不可行;业务运行依赖壳提供的 native 实现。这不排除理论上逆向 native 层重建的可能,但那是以周计的工程,不在本次范围。


5. 阶段四:找签名期望值——零命中

思路:壳要判“签名不对就杀”,期望值总得存在某处;若以明文形态放在包里,找到就能换成我们证书的哈希,让重签通过。

扫描:原证书 DER 的 md5/sha1/sha256 × {原始字节、小写 hex、大写 hex} 共 12 种特征,覆盖 38 个 so、全部 assets、原始 APK,共 600 个目标。零命中。

这个结果的边界要说清:它只排除了“原始哈希以这 12 种常见明文形态直接放在包内”的情况。不排除 Base64/UTF-16 编码、截断、加盐、加密存储、运行时派生、甚至服务端下发——扫描前就声明,这些形态不在覆盖范围里。基于已覆盖范围内的零命中,放弃“静态匹配换哈希”思路,转向保留原壳、处理运行时行为。


6. 阶段五:壳自带的签名代理

读壳的 smali 时有个意外发现:com.netease.nis.wrapper.plugin.d 是个 InvocationHandler(Java 动态代理),代理应用内部的包管理接口。当被调用的方法名匹配(方法名本身是密文 "KQAANQAQDi8CESwPFQo=",经壳的字符串解密器解出)且 flags 带 0x40 时,把返回的签名换掉:

const/16 v2, 0x40                        ; 0x40 = 旧版 PackageManager.GET_SIGNATURES
if-ne v0, v2, :cond_47
new-instance v2, Landroid/content/pm/Signature;
iget-object v0, p0, ...->b:Ljava/lang/String;   ; 壳预存的签名串
invoke-direct {v2, v0}, ...Signature;-><init>(Ljava/lang/String;)V
...
aput-object v2, v3, v1                   ; signatures[0] = 预存值

被拦截方法推断为 getPackageInfo,依据有两条:解密后的字符串内容,加上行为特征的完全吻合——该代理检查调用 flags 是否带 0x40(GET_SIGNATURES),并且改写的是返回值里 PackageInfo.signatures[] 数组的首元素,这正是 getPackageInfo 独有的签名语义。0x40 对应旧版 GET_SIGNATURES(新版是 GET_SIGNING_CERTIFICATES,壳明显按旧标志位处理)。

推断(间接证据,非直测):这个代理的用途是让业务/广告 SDK 里走 PackageManager 查签名的校验(比如字节 ms.bz.bd.c.Pgl.e1 那套——用 MessageDigest 算证书哈希再送出去比对)在受保护环境里仍能读到原始签名。而 nesec 自己的 native 校验不走这条代理——证据是间接的:重签后即使 Java 层代理可能仍在喂伪签名,进程照样快速退出,说明还存在一条不经过该代理的真实签名/完整性检查路径。


7. 阶段六:守卫方案——保留壳,自带补丁

7.1 方案

架构原样保留:壳的 MyApplication、libnesec、加密 payload 一个不动(业务逻辑靠 native 分发,必须留着壳)。只加两样:

① KillGuardApp——继承壳的 MyApplication,静态块里先加载补丁库:

.class public Lcom/netease/nis/wrapper/KillGuardApp;
.super Lcom/netease/nis/wrapper/MyApplication;

.method static constructor <clinit>()V
    .registers 1
    :try_start_0
    const-string v0, "gotkill"
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
    :try_end_0
    .catch Ljava/lang/UnsatisfiedLinkError; {:try_start_0 .. :try_end_0} :catch_0
    goto :goto_0
    :catch_0
    move-exception v0
    :goto_0
    return-void
.end method

同时 manifest 的 application android:name 必须改成 KillGuardApp——不改的话系统实例化的仍是 MyApplication,这个子类永远不会跑。这一步做了。

时序说明(这里容易读反):Java 类初始化顺序是父类 <clinit> 先、子类 <clinit>。所以 loadLibrary("gotkill") 并不抢在 MyApplication.<clinit> 之前——它可行是因为读过 smali 确认 MyApplication.<clinit> 只做字符串解密、不加载任何库,而 nesec 的加载发生在更晚的 attachBaseContext 阶段。完整链条:父类静态块(解密字符串)→ 子类静态块(加载 gotkill,补丁就位)→ attachBaseContext(壳加载 nesec,nesec 解密并安装击杀桩)→ gotkill 的巡检已经在扫了。前提是静态分析没漏(<clinit> 里没看到 loadLibrary 调用路径;未做运行时复核——见 §7.4 未排除项)。

② 双架构 libgotkill.so 打进包里,摆脱外部注入。

7.2 ABI 布局——一个反直觉且踩了坑的环境

先说实测事实。看运行中进程的 /proc/<pid>/maps:业务 so 全部从 .../lib/arm64/ 目录加载,同时进程里有 /system/lib64/arm64/...、houdini 初始化日志——App 是 arm64 库 + houdini 翻译执行的形态(x86_64 宿主,ABI 按 arm64 走,安装器只抽 arm64-v8a 目录)。文件头检查进一步显示这对 nesec 是“混布”的:

libnesec-x86.so: ELF64 x86_64     ← 文件名叫 x86,实际是 x86_64 ELF
libnesec.so:     ELF64 aarch64    ← 名字干净的反而真身是 arm64

两个不同架构的 so 同放在 lib/arm64-v8a/ 目录里。x86_64 那份之所以能被加载,是 MuMu 的 native bridge loader 对该目录下的 ELF 做了兼容处理(推断,依据是它在 maps 里真实出现了)。这也解释了 gotkill 为什么用 x86 字节特征能扫到并改写“arm 库”里的桩——libnesec-x86.so 是 x86_64 代码原生执行,桩的字节本来就是 x86 的。

补丁库的放置我实际试过两种,都得交代:

  • 第一版守卫包:把 x86_64 版 libgotkill.so 放进 lib/arm64-v8a/(模仿 libnesec-x86.so 的布局)。结果启动 190ms 死于 208。这一版里包内补丁是否加载成功未知(日志写不出,见 §7.3)。
  • 第二版守卫包:aarch64 版放 lib/arm64-v8a/,x86_64 版放 lib/armeabi-v7a/后者是个失误:armeabi-v7a 是 32 位 ARM 目录,这台 64 位设备根本不会抽取它,那份 x86_64 拷贝大概率是从未生效的死重。实际有可能起作用的只有 arm64-v8a 里的 aarch64 版——它走标准 arm64 ABI 加载、由 houdini 翻译执行;而补丁器扫什么、写什么只取决于内存里的目标字节(x86 桩),与补丁器自身被编译成什么架构无关,所以 aarch64 版扫 x86 桩没有障碍。第二版守卫包活过了 30 秒。

顺带一个还没处理的对真机的含义:在真 arm64 手机上,nesec 加载的是 aarch64 的 libnesec.so,击杀桩将是 ARM64 机器码,现在的 x86 特征扫描不会命中——当前补丁只对这台模拟器环境有效,真机需要另配 ARM64 的桩特征。这超出了本次范围,但交付前必须知道。

7.3 测试结果:大起大落

第二版守卫包装机成功。第一次启动:8 秒存活、30 秒存活,截图解析出白底+金黄品牌色——重签包的 UI 真起来了。但在 30 秒~2 分钟之间的某次检查时已死(精确死亡时刻没测)。第二次冷启动 0.3 秒内死,退出码 208。

尸检时间线(logcat):

04.602  ZygiskFrida: App detected: run.xbud.android
04.713  ZygiskFrida: Injecting /data/local/tmp/.jcache/inline.so
04.716  ZygiskFrida: Remapped
04.901  Zygote: Process 16837 exited cleanly (208)

先解释这组日志:ZygiskFrida 是模拟器自带的注入模块,它在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so——这个路径上放的是我们早前部署的旧版 gotkill(当时为原版 App 动态分析备用的停车补丁)。也就是说守卫包测试时进程里实际有两份补丁来源:自动注入的旧版 x86_64、包内的 aarch64 版。exited cleanly (208) 是 Zygote 的措辞,指进程正常退出(非信号杀死),与“主动调 exit_group”完全一致。

7.4 竞速假说与未排除项

主假说(推断):注入后约 185 毫秒被处决。旧版 gotkill 的后台线程起跑前先 usleep(200000)——注入发生在 04.714,旧版首次扫描本应在 ~04.916 才发生,而死亡在 04.901,差了约 15 毫秒输掉竞速。时间上吻合。

但这个假说单独解释不了第一次为什么活了 30 秒以上。开着的口子:

  1. 包内 aarch64 版补丁两次到底加载成功没有——UnsatisfiedLinkError 被静态块 catch 吞了,而日志文件因 App 进程无权写 /data/local/tmp(早期外部注入阶段沿用下来的路径,普通应用进程本就写不了)一直缺失,无法佐证。一个能自洽解释两次差异的假设是:第一次包内版加载成功、它的巡检线程比注入版起跑更早(类初始化阶段就位)从而补上了窗口,第二次因某种原因没加载——未验证。
  2. 第一次 30s~2min 之间的死亡原因不明——可能是延迟触发的第二道检查(比如反 frida 检测走了别的路径),也可能是同一条 nesec 逻辑带定时器。未验证。
  3. 模拟器的自动注入本身就是个污染变量:在真机上没有 ZygiskFrida,inline.so 不会被注入,死亡时序可能完全不同。本环境测得的“第二次秒死”不排除是模拟器特有的注入触发了壳的反注入检测。

8. 修复代码——已写、未验证

针对竞速假说和日志盲区,gotkill.c 改了三处(只改完源码,编译、重打、装机验证全部没做):

① 构造函数里同步先扫一轮,零延迟起跑(constructor 在 dlopen 时执行)。说明一下这轮扫描的定位:此时 nesec 多半还没加载、内存里还没有桩,首轮同步扫描大概率扑空——它的意义是消除旧版 200ms 的起跑延迟,保证“如果桩已经在了”这种情况下零遗漏;真正接力的是随后创建的 hooker 巡检线程,nesec 加载并释放击杀桩后由它捕获

__attribute__((constructor)) static void on_load(void) {
    unlink(get_log_path());
    log_line("[GOTKILL] loaded\n");
    neuter_nesec_once();   /* 同步首轮:消除旧版 200ms 起跑延迟 */
    pthread_t th;
    pthread_create(&th, 0, hooker, 0);
}

neuter_nesec_once() 是立即执行一轮扫描替换;hooker 是持续巡检线程(名字起得随意,它就是反复扫 maps 处理后续延迟出现的桩的那个循环)。

② 轮询节奏前紧后松:

for (int round = 0; round < 3000; round++) {
    usleep(round < 30 ? 5000 : (round < 60 ? 50000 : 1000000));

算总账:前 30 轮 × 5ms ≈ 150ms(覆盖启动竞速窗口),30–60 轮 × 50ms ≈ 1.5s,之后 1s 一次直到约 49 分钟停止。设上限是为了不无限占线程;代价是 49 分钟后不再扫描——若壳有超长延迟的检查就会漏。

③ 日志路径改走应用可写目录:

static char log_path[128];
static const char* get_log_path(void) {
    if (!log_path[0]) {
        const char* cache = getenv("XBD_CACHE");
        if (cache && *cache) snprintf(log_path, sizeof log_path, "%s/nesec208.log", cache);
        else snprintf(log_path, sizeof log_path, "/data/local/tmp/nesec208.log");
    }
    return log_path;
}

这个修复是半成品XBD_CACHE 环境变量目前没有任何地方设置——还需要 KillGuardApp 的 Java/smali 侧把 context.getCacheDir() setenv 传进去,或 native 侧按包名硬拼 /data/user/0/run.xbud.android/cache/(多用户/多开场景后者会错)。这步没接完。另外 §7.2 说的 armeabi-v7a 死重问题也应在下一版重打时一并清掉(只留 arm64-v8a 里的 aarch64 版)。


9. 剩下的牌(按便宜程度排)

  1. 验证修复版竞速:编译 aarch64 → 重打(清掉 armeabi-v7a 死重、接通日志路径)→ 装机。若 nesec 只有一个约 200ms 的启动击杀窗口,零延迟首扫 + 5ms 快轮询大概率赢。日志接通后一并回答“包内补丁到底加载没有”。
  2. 排除模拟器污染:把守卫包装到无 ZygiskFrida 注入的环境(真机或关掉注入)测一轮,确认“第二次秒死”是不是模拟器特有的。
  3. 若竞速仍不稳:放弃停车补丁思路,转攻 nesec 读取真实签名的那条路径并伪造(§6 代理的 native 对应物)。需要先在运行时定位 nesec 取签名的调用点,工作量大一个量级。

10. 证据属性声明

  • 实测(有报错输出、日志行、测量数据):退出码 208 与无崩溃栈;击杀桩位于 libnesec-x86.so;停车补丁在外部注入下生效(NEUTERED 日志);7 dex 内容与 600 类 native 统计;onCreate 执行后仍为 native;哈希扫描零命中;libnesec-x86.so 为 x86_64 ELF 而进程走 arm64+houdini;守卫包装机成功、首次启动 UI 起来并存活超 30 秒;第二次启动注入后 ~185ms 退出 208。
  • 推断(基于实证的最优解释):签名代理拦截的就是 getPackageInfo 且喂饱对象是业务侧校验;nesec 另有不经代理的 native 校验;两次启动生死差异源于扫描竞速与包内补丁加载成功与否;MuMu 对 arm64 目录内 x86_64 ELF 的兼容加载。
  • 未排除:包内补丁(两版)是否曾加载成功;首次 30s–2min 间死亡的具体触发者;模拟器自动注入对检测时序的影响;壳是否存在 49 分钟窗口之外的延迟检查;16 字节补丁覆盖后方字节的理论风险在全部桩上是否成立。

实验过程与日志来自真实环境,部分代码片段与分析整理有 AI 辅助。

能力有限,分析于此,希望对大家有所帮助

[/md]

某步点去广告逆向全程记录

0. 目标与结局速览

目标:把某步点(包名 run.xbud.android,网易易盾加固,内置字节系广告组件)做成一个装上就没广告的独立 APK

路线 结局
hosts 屏蔽广告域名 + 外部注入补丁库 验证可用,但属运行时手段,不算交付
扔壳重建(拿内存 dump 的 dex 重打包) 实证死路:核心方法被易盾永久 native 化
在包内找明文签名期望值做替换 600 个目标 × 12 种形态,零命中
保留完整壳 + 包内自带“停车”补丁 最接近成功:装上、UI 起来、活过 30 秒,最终死于毫秒级内;修复代码已写、未验证

文中所有结论按证据强度标注:实测(有日志/报错/数据撑着)、推断(基于实证的最优解释)、未排除(还开着的口子)。


1. 环境与工具

按用途分层:

  • 静态解包/反编译:apktool(解包/重打)、jadx(看 Java)、baksmali(dex→smali)
  • native 分析:IDA(早期,略)、ELF 头检查(Python struct)、/proc/<pid>/maps
  • 动态:frida 17.16.2 + frida-java-bridge + esbuild 编译探针脚本;自写 ptrace 跟踪器 minitrace
  • 测试机:MuMu 模拟器,Android 15(x86_64 宿主 + houdini ARM 翻译层),KernelSU root,SELinux Permissive。亲手装的 ZygiskFrida 模块,会在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so 里的内容——这个特性后来反复搅局,后面细说
  • 补丁编译:zig 交叉编译(x86_64-linux-musl / aarch64-linux-musl)
  • 重打包链:apktool b → zipalign → apksigner;Python 干杂活(塞 dex、扫哈希、验截图)

2. 阶段一:初筛——壳、dex、广告

2.1 认壳

apktool 解开原包,manifest 里 application 指向 com.netease.nis.wrapper.MyApplicationcom.netease.nis.wrapper 是网易易盾加固壳的包名特征,但这不是唯一依据——后续运行时出现的 libnesec*.so、加密 dex 的加载方式、壳内的签名代理(见 §6),都指向同一结论。原始 APK 里的可见 dex 基本是壳的占位代码,业务代码以加密数据形式放在包内,由壳在运行时解密后通过内存方式加载,不落盘。

2.2 从内存里捞出业务 dex

方法概述(细节属早期工作):hook 类加载路径(InMemoryDexClassLoader 一类)配合内存扫描定位运行时解密出的 dex 结构,按 dex 格式导出。共 dump 出 7 个 payload dex,最大一个约 54 MB。baksmali 反汇编后得到 7 个 smali 目录(smali_0 ~ smali_6),与 dex 数量一一对应。

分拣结果:smali_0 全是壳的类;smali_1 基本是倍孜(Beizi)广告 SDK;smali_6 是业务主体代码。中间四份没有逐份做完整成分清单,只通过两次全量扫描(native 方法统计、壳类交叉引用搜索)间接画像过:里面摊着 androidx 全家桶、Flutter 引擎、高德地图引擎、腾讯 MMKV、字节视频组件等大体积依赖。关键结论(解耦、方法抽取)都是全量扫描得出的,不依赖中间几份的精确归类——但如果要精确到“某个 SDK 散在哪几份”,得回头逐份统计,现在只能说到这个粒度。

2.3 广告 SDK 盘点

  • 倍孜(Beizi):实锤。smali_1 整份是它,业务目录里还有 BeiZiNewRewardVideoActivity(激励视频)、BeiZiNewInterstitialActivity(插屏)、BeiZiNewLandingPageActivity(落地页)等一堆 Activity,以及它家的布局引擎 YogaNative。
  • 字节系组件:多条线拼起来的“在场”证据——com.bykv.vk.component.ttvideo(TTPlayer 视频播放、AVMDLDataLoader 视频下载,典型的广告物料播放组件)+ com.volcengine.mobsecBiz(字节移动安全组件,就是后面签名校验那套)。没有逐一验证广告展示实际走的哪条链路。
  • 广点通(GDT):只在 hosts 屏蔽列表里有它的域名,屏蔽后效果是合并算账的,没单独验证比重。疑似在场、未确证。

2.4 hosts 验证及其局限

往设备 /system/etc/hosts 加了约 40 行倍孜/GDT 域名,App 内广告位空了。实测有效,但这步的目的只是确认广告走网络层、锁定域名归属,不是去广告方案:hosts 需要 root、逐台设备改、广告 SDK 还可能有本地兜底或域名降级逻辑。交付物不能依赖它。


3. 阶段二:无声死亡与击杀桩

3.1 现象

附着 frida,或者重打包后安装,进程几秒内消失。退出码 208,没有 Java 崩溃栈、没有 tombstone。这不是 crash——crash 会走 ART 或 debuggerd 的上报路径留下现场;这里更像是进程主动调用了退出系统调用,把所有上报机制都绕开了。

3.2 定位

当时怀疑是壳直接调 exit_group 绕过 Java 异常和 native crash 上报。验证靠自写 ptrace 跟踪器 minitrace 盯系统调用,加上扫内存:观察触发退出时发起调用的指令位置,落在 libnesec-x86.so 的映射区间内。

先把两个数字说清,这是最容易读混的地方:

  • 231 是 x86_64 下 exit_group 的系统调用号,不是退出码。它被 mov eax, 231 装进 eax;
  • 208 是传给 exit_group 的 status 参数(走第一个参数寄存器 rdi)。ptrace 观测到该参数恒为 208(0xD0)——它大概率就是壳内部定义的一个特定错误码或状态标识,具体含义未深究。

所谓“击杀桩”,指壳在内存里解密释放出来的一小段直接发起退出系统调用的机器码。它不经过 libc 的 exit/_exit,也不走任何导出/导入表:

b8 e7 00 00 00    mov eax, 231    ; exit_group 系统调用号
0f 05             syscall

普通 hook(挂在库函数符号、PLT、GOT、导出函数入口上那一类)对它全部无效,因为调用链上根本没有可以下钩的“名字”。要处理只能:扫内存匹配字节特征后 inline patch、seccomp 过滤、或 ptrace 拦截。

3.3 对策:gotkill

写了个叫 gotkill 的小库,加载进进程后开后台线程反复扫内存,把命中的击杀桩改写成“停车”指令:

/* park shellcode: mov eax,34(SYS_pause); syscall; jmp $ */
static const unsigned char repl[16] = {
    0xB8, 0x22, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xEB,
    0xFE, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
};

几个设计选择的理由:

  • 为什么不 nop / ret:原桩是“必定不返回”的语义,直接 ret 会把控制流弹到未知位置;nop 掉 syscall 后线程会继续往下执行壳的后续代码,行为不可控。
  • 为什么是 pause 而不是纯用户态死循环:让线程停在一个系统调用里,状态可控、可观测;后面的 jmp $(跳回自己)是保险——万一 pause 被信号打断返回了,也不会往下执行任何东西。
  • 16 字节写入的安全性:原桩只有 7 字节,替换块按 16 字节写、尾部 nop 填满。这确实存在覆盖原桩后方 9 字节的理论风险——它的安全性是经验性的:实测命中的桩都位于壳解密释放的代码区里,桩后是 nop 填充或块边界,且外部注入验证期间进程被补丁后持续正常运行了数分钟,没有出现因补丁引发的崩溃。没有逐桩验证过后方 9 字节的内容,这是该方案的一个已接受的风险点。

匹配条件也很保守:只认 b8 ?? 00 00 00 / 0f 05 且装入 eax 的值 ∈ {231, 60}(exit_group / exit)、立即数高位字节全零的形态,避免误伤正常代码。

不改 APK、纯外部注入的条件下验证有效(外部注入指通过模拟器的注入机制把 so 塞进目标进程,先验证思路,再考虑内置进包):

[GOTKILL] NEUTERED nesec stub nr=231 @+0x5d4f0

nr=231 就是命中的系统调用号,@+0x5d4f0 是桩相对 so 加载基址的偏移。

3.4 两条死支线

  • seccomp 拦截:装上 seccomp 过滤器拦退出调用后,进程改被 SIGKILL(不是 208)。说明壳检测到了运行环境被过滤(具体怎么检测的——读 /proc/self/status 的 Seccomp 字段还是行为探测——没查),只挡退出调用不够,它还有别的杀法。弃。
  • ptrace 互斥:minitrace 和 frida 同时挂同一个目标会失败(一个 Linux 进程同一时刻通常只能被一个 ptrace tracer 附着,frida 的注入机制也占这个坑)。两者只能二选一,多数时候靠 frida 单独上。

4. 阶段三:扔壳重建——死因是“方法抽取”

4.1 依据与改动

依据:内存 dump 出的 7 个业务 dex 看起来是完整的,且全量搜索业务 smali 中对壳类(nis.wrapperms.bd.ccom.netease)的引用为零。注意这个“零引用”是静态层面的:没有直接类引用、没有明显的字符串加载痕迹;JNI、反射、ClassLoader 注入这类动态依赖不在它的覆盖范围里——后面的事实恰恰证明了这一点。

改动:manifest 的 application 从壳的 MyApplication 改成业务侧的 run.xbud.android.common.XBDApplication,7 个 dex 作为 classes.dexclasses7.dex 塞进 apktool 打出的资源壳(不含任何壳代码和 libnesec)。

4.2 三个工程坑

  1. 塞 dex 的脚本按文件名排序,一个杂散文件(hdr*.bin)排到了第一位占据了 classes.dex 的名字,7 个 dex 实际只进去 1 个。列包内条目才发现,修成显式配对 payload_N.dex → classes(N+1).dex
  2. resources.arsc 被重压缩。Python zipfile 默认对所有条目用 deflate,而 resources.arsc 是资源索引表,安装器要求它直接映射访问,Android 11+(API 30+)强制它未压缩(STORED)存储且 4 字节对齐,违反直接拒装,安装器报错原话:Targeting R+ ... requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary。修复是保留每个条目原有的压缩方式(传入原 ZipInfo 对象而不是只传文件名),对齐交给 zipalign:
for item in zin.infolist():
    if item.filename.startswith('classes'):
        continue
    # 保留原压缩方式;resources.arsc 必须 STORED,对齐由 zipalign -p 处理
    zout.writestr(item, zin.read(item.filename))
  1. 签名方案。jarsigner 只生成 v1(JAR 签名),较新的 Android 要求 v2 及以上的 APK 签名块,v1-only 装不上。换 apksigner 出 v2+v3,apksigner verify --verbose 确认两者都通过。

4.3 崩溃与真相

包装上了,进程起来了,业务类也找到了,然后崩在:

java.lang.UnsatisfiedLinkError: No implementation found for
void run.xbud.android.common.XBDApplication.attachBaseContext(android.content.Context)

先解释这个错误:它不是说某个 .so 没装上,而是说 Java 层声明为 native 的方法在运行时找不到对应 JNI 实现(JNI 绑定通常靠 RegisterNatives 或按符号名约定)。看 smali 才知道规模:

.method protected native attachBaseContext(Landroid/content/Context;)V
.method public native onCreate()V
.method private final native break()V
...(该类几十个方法全是 native)

全 App 53693 个类里 600 个含 native 方法,集中在业务 Activity(跑步记录、个人页等,每类 30+ 个方法被抽)。这是易盾的方法抽取:Java 方法体被整体搬进壳的 native 层,dex 里只剩 native 声明,运行时由壳注册/分发。

4.4 决定性实验

用 frida 反射检查一个已经成功执行过的方法:

const M = Java.use('java.lang.reflect.Modifier');
// App 已正常启动、onCreate 已执行完毕之后:
M.isNative(onCreateMethod.getModifiers())   // 仍是 true

如果是“懒恢复”式抽取(方法体运行到才回填 dex),执行过的方法应该能观察到非 native 状态或字节码回填。实测仍为 native,说明 Java 方法体从头到尾不在任何 dex 里,逻辑长期驻留 native 层。因此对本案这种形态,FART/BlackDex 一类主动脱壳工具帮不上忙——它们能 dump 内存 dex、能触发方法调用,但可恢复的 Java 方法体本身就不存在,没有对象可脱。

结论(限定范围):对当前样本被抽取的这批类,简单去壳重打包不可行;业务运行依赖壳提供的 native 实现。这不排除理论上逆向 native 层重建的可能,但那是以周计的工程,不在本次范围。


5. 阶段四:找签名期望值——零命中

思路:壳要判“签名不对就杀”,期望值总得存在某处;若以明文形态放在包里,找到就能换成我们证书的哈希,让重签通过。

扫描:原证书 DER 的 md5/sha1/sha256 × {原始字节、小写 hex、大写 hex} 共 12 种特征,覆盖 38 个 so、全部 assets、原始 APK,共 600 个目标。零命中。

这个结果的边界要说清:它只排除了“原始哈希以这 12 种常见明文形态直接放在包内”的情况。不排除 Base64/UTF-16 编码、截断、加盐、加密存储、运行时派生、甚至服务端下发——扫描前就声明,这些形态不在覆盖范围里。基于已覆盖范围内的零命中,放弃“静态匹配换哈希”思路,转向保留原壳、处理运行时行为。


6. 阶段五:壳自带的签名代理

读壳的 smali 时有个意外发现:com.netease.nis.wrapper.plugin.d 是个 InvocationHandler(Java 动态代理),代理应用内部的包管理接口。当被调用的方法名匹配(方法名本身是密文 "KQAANQAQDi8CESwPFQo=",经壳的字符串解密器解出)且 flags 带 0x40 时,把返回的签名换掉:

const/16 v2, 0x40                        ; 0x40 = 旧版 PackageManager.GET_SIGNATURES
if-ne v0, v2, :cond_47
new-instance v2, Landroid/content/pm/Signature;
iget-object v0, p0, ...->b:Ljava/lang/String;   ; 壳预存的签名串
invoke-direct {v2, v0}, ...Signature;-><init>(Ljava/lang/String;)V
...
aput-object v2, v3, v1                   ; signatures[0] = 预存值

被拦截方法推断为 getPackageInfo,依据有两条:解密后的字符串内容,加上行为特征的完全吻合——该代理检查调用 flags 是否带 0x40(GET_SIGNATURES),并且改写的是返回值里 PackageInfo.signatures[] 数组的首元素,这正是 getPackageInfo 独有的签名语义。0x40 对应旧版 GET_SIGNATURES(新版是 GET_SIGNING_CERTIFICATES,壳明显按旧标志位处理)。

推断(间接证据,非直测):这个代理的用途是让业务/广告 SDK 里走 PackageManager 查签名的校验(比如字节 ms.bz.bd.c.Pgl.e1 那套——用 MessageDigest 算证书哈希再送出去比对)在受保护环境里仍能读到原始签名。而 nesec 自己的 native 校验不走这条代理——证据是间接的:重签后即使 Java 层代理可能仍在喂伪签名,进程照样快速退出,说明还存在一条不经过该代理的真实签名/完整性检查路径。


7. 阶段六:守卫方案——保留壳,自带补丁

7.1 方案

架构原样保留:壳的 MyApplication、libnesec、加密 payload 一个不动(业务逻辑靠 native 分发,必须留着壳)。只加两样:

① KillGuardApp——继承壳的 MyApplication,静态块里先加载补丁库:

.class public Lcom/netease/nis/wrapper/KillGuardApp;
.super Lcom/netease/nis/wrapper/MyApplication;

.method static constructor <clinit>()V
    .registers 1
    :try_start_0
    const-string v0, "gotkill"
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
    :try_end_0
    .catch Ljava/lang/UnsatisfiedLinkError; {:try_start_0 .. :try_end_0} :catch_0
    goto :goto_0
    :catch_0
    move-exception v0
    :goto_0
    return-void
.end method

同时 manifest 的 application android:name 必须改成 KillGuardApp——不改的话系统实例化的仍是 MyApplication,这个子类永远不会跑。这一步做了。

时序说明(这里容易读反):Java 类初始化顺序是父类 <clinit> 先、子类 <clinit>。所以 loadLibrary("gotkill") 并不抢在 MyApplication.<clinit> 之前——它可行是因为读过 smali 确认 MyApplication.<clinit> 只做字符串解密、不加载任何库,而 nesec 的加载发生在更晚的 attachBaseContext 阶段。完整链条:父类静态块(解密字符串)→ 子类静态块(加载 gotkill,补丁就位)→ attachBaseContext(壳加载 nesec,nesec 解密并安装击杀桩)→ gotkill 的巡检已经在扫了。前提是静态分析没漏(<clinit> 里没看到 loadLibrary 调用路径;未做运行时复核——见 §7.4 未排除项)。

② 双架构 libgotkill.so 打进包里,摆脱外部注入。

7.2 ABI 布局——一个反直觉且踩了坑的环境

先说实测事实。看运行中进程的 /proc/<pid>/maps:业务 so 全部从 .../lib/arm64/ 目录加载,同时进程里有 /system/lib64/arm64/...、houdini 初始化日志——App 是 arm64 库 + houdini 翻译执行的形态(x86_64 宿主,ABI 按 arm64 走,安装器只抽 arm64-v8a 目录)。文件头检查进一步显示这对 nesec 是“混布”的:

libnesec-x86.so: ELF64 x86_64     ← 文件名叫 x86,实际是 x86_64 ELF
libnesec.so:     ELF64 aarch64    ← 名字干净的反而真身是 arm64

两个不同架构的 so 同放在 lib/arm64-v8a/ 目录里。x86_64 那份之所以能被加载,是 MuMu 的 native bridge loader 对该目录下的 ELF 做了兼容处理(推断,依据是它在 maps 里真实出现了)。这也解释了 gotkill 为什么用 x86 字节特征能扫到并改写“arm 库”里的桩——libnesec-x86.so 是 x86_64 代码原生执行,桩的字节本来就是 x86 的。

补丁库的放置我实际试过两种,都得交代:

  • 第一版守卫包:把 x86_64 版 libgotkill.so 放进 lib/arm64-v8a/(模仿 libnesec-x86.so 的布局)。结果启动 190ms 死于 208。这一版里包内补丁是否加载成功未知(日志写不出,见 §7.3)。
  • 第二版守卫包:aarch64 版放 lib/arm64-v8a/,x86_64 版放 lib/armeabi-v7a/后者是个失误:armeabi-v7a 是 32 位 ARM 目录,这台 64 位设备根本不会抽取它,那份 x86_64 拷贝大概率是从未生效的死重。实际有可能起作用的只有 arm64-v8a 里的 aarch64 版——它走标准 arm64 ABI 加载、由 houdini 翻译执行;而补丁器扫什么、写什么只取决于内存里的目标字节(x86 桩),与补丁器自身被编译成什么架构无关,所以 aarch64 版扫 x86 桩没有障碍。第二版守卫包活过了 30 秒。

顺带一个还没处理的对真机的含义:在真 arm64 手机上,nesec 加载的是 aarch64 的 libnesec.so,击杀桩将是 ARM64 机器码,现在的 x86 特征扫描不会命中——当前补丁只对这台模拟器环境有效,真机需要另配 ARM64 的桩特征。这超出了本次范围,但交付前必须知道。

7.3 测试结果:大起大落

第二版守卫包装机成功。第一次启动:8 秒存活、30 秒存活,截图解析出白底+金黄品牌色——重签包的 UI 真起来了。但在 30 秒~2 分钟之间的某次检查时已死(精确死亡时刻没测)。第二次冷启动 0.3 秒内死,退出码 208。

尸检时间线(logcat):

04.602  ZygiskFrida: App detected: run.xbud.android
04.713  ZygiskFrida: Injecting /data/local/tmp/.jcache/inline.so
04.716  ZygiskFrida: Remapped
04.901  Zygote: Process 16837 exited cleanly (208)

先解释这组日志:ZygiskFrida 是模拟器自带的注入模块,它在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so——这个路径上放的是我们早前部署的旧版 gotkill(当时为原版 App 动态分析备用的停车补丁)。也就是说守卫包测试时进程里实际有两份补丁来源:自动注入的旧版 x86_64、包内的 aarch64 版。exited cleanly (208) 是 Zygote 的措辞,指进程正常退出(非信号杀死),与“主动调 exit_group”完全一致。

7.4 竞速假说与未排除项

主假说(推断):注入后约 185 毫秒被处决。旧版 gotkill 的后台线程起跑前先 usleep(200000)——注入发生在 04.714,旧版首次扫描本应在 ~04.916 才发生,而死亡在 04.901,差了约 15 毫秒输掉竞速。时间上吻合。

但这个假说单独解释不了第一次为什么活了 30 秒以上。开着的口子:

  1. 包内 aarch64 版补丁两次到底加载成功没有——UnsatisfiedLinkError 被静态块 catch 吞了,而日志文件因 App 进程无权写 /data/local/tmp(早期外部注入阶段沿用下来的路径,普通应用进程本就写不了)一直缺失,无法佐证。一个能自洽解释两次差异的假设是:第一次包内版加载成功、它的巡检线程比注入版起跑更早(类初始化阶段就位)从而补上了窗口,第二次因某种原因没加载——未验证。
  2. 第一次 30s~2min 之间的死亡原因不明——可能是延迟触发的第二道检查(比如反 frida 检测走了别的路径),也可能是同一条 nesec 逻辑带定时器。未验证。
  3. 模拟器的自动注入本身就是个污染变量:在真机上没有 ZygiskFrida,inline.so 不会被注入,死亡时序可能完全不同。本环境测得的“第二次秒死”不排除是模拟器特有的注入触发了壳的反注入检测。

8. 修复代码——已写、未验证

针对竞速假说和日志盲区,gotkill.c 改了三处(只改完源码,编译、重打、装机验证全部没做):

① 构造函数里同步先扫一轮,零延迟起跑(constructor 在 dlopen 时执行)。说明一下这轮扫描的定位:此时 nesec 多半还没加载、内存里还没有桩,首轮同步扫描大概率扑空——它的意义是消除旧版 200ms 的起跑延迟,保证“如果桩已经在了”这种情况下零遗漏;真正接力的是随后创建的 hooker 巡检线程,nesec 加载并释放击杀桩后由它捕获

__attribute__((constructor)) static void on_load(void) {
    unlink(get_log_path());
    log_line("[GOTKILL] loaded\n");
    neuter_nesec_once();   /* 同步首轮:消除旧版 200ms 起跑延迟 */
    pthread_t th;
    pthread_create(&th, 0, hooker, 0);
}

neuter_nesec_once() 是立即执行一轮扫描替换;hooker 是持续巡检线程(名字起得随意,它就是反复扫 maps 处理后续延迟出现的桩的那个循环)。

② 轮询节奏前紧后松:

for (int round = 0; round < 3000; round++) {
    usleep(round < 30 ? 5000 : (round < 60 ? 50000 : 1000000));

算总账:前 30 轮 × 5ms ≈ 150ms(覆盖启动竞速窗口),30–60 轮 × 50ms ≈ 1.5s,之后 1s 一次直到约 49 分钟停止。设上限是为了不无限占线程;代价是 49 分钟后不再扫描——若壳有超长延迟的检查就会漏。

③ 日志路径改走应用可写目录:

static char log_path[128];
static const char* get_log_path(void) {
    if (!log_path[0]) {
        const char* cache = getenv("XBD_CACHE");
        if (cache && *cache) snprintf(log_path, sizeof log_path, "%s/nesec208.log", cache);
        else snprintf(log_path, sizeof log_path, "/data/local/tmp/nesec208.log");
    }
    return log_path;
}

这个修复是半成品XBD_CACHE 环境变量目前没有任何地方设置——还需要 KillGuardApp 的 Java/smali 侧把 context.getCacheDir() setenv 传进去,或 native 侧按包名硬拼 /data/user/0/run.xbud.android/cache/(多用户/多开场景后者会错)。这步没接完。另外 §7.2 说的 armeabi-v7a 死重问题也应在下一版重打时一并清掉(只留 arm64-v8a 里的 aarch64 版)。


9. 剩下的牌(按便宜程度排)

  1. 验证修复版竞速:编译 aarch64 → 重打(清掉 armeabi-v7a 死重、接通日志路径)→ 装机。若 nesec 只有一个约 200ms 的启动击杀窗口,零延迟首扫 + 5ms 快轮询大概率赢。日志接通后一并回答“包内补丁到底加载没有”。
  2. 排除模拟器污染:把守卫包装到无 ZygiskFrida 注入的环境(真机或关掉注入)测一轮,确认“第二次秒死”是不是模拟器特有的。
  3. 若竞速仍不稳:放弃停车补丁思路,转攻 nesec 读取真实签名的那条路径并伪造(§6 代理的 native 对应物)。需要先在运行时定位 nesec 取签名的调用点,工作量大一个量级。

10. 证据属性声明

  • 实测(有报错输出、日志行、测量数据):退出码 208 与无崩溃栈;击杀桩位于 libnesec-x86.so;停车补丁在外部注入下生效(NEUTERED 日志);7 dex 内容与 600 类 native 统计;onCreate 执行后仍为 native;哈希扫描零命中;libnesec-x86.so 为 x86_64 ELF 而进程走 arm64+houdini;守卫包装机成功、首次启动 UI 起来并存活超 30 秒;第二次启动注入后 ~185ms 退出 208。
  • 推断(基于实证的最优解释):签名代理拦截的就是 getPackageInfo 且喂饱对象是业务侧校验;nesec 另有不经代理的 native 校验;两次启动生死差异源于扫描竞速与包内补丁加载成功与否;MuMu 对 arm64 目录内 x86_64 ELF 的兼容加载。
  • 未排除:包内补丁(两版)是否曾加载成功;首次 30s–2min 间死亡的具体触发者;模拟器自动注入对检测时序的影响;壳是否存在 49 分钟窗口之外的延迟检查;16 字节补丁覆盖后方字节的理论风险在全部桩上是否成立。

实验过程与日志来自真实环境,部分代码片段与分析整理有 AI 辅助。

能力有限,分析于此,希望对大家有所帮助

[/md]

[b]# 某步点去广告逆向全程记录

0. 目标与结局速览

目标:把某步点(包名 run.xbud.android,网易易盾加固,内置字节系广告组件)做成一个装上就没广告的独立 APK

路线 结局
hosts 屏蔽广告域名 + 外部注入补丁库 验证可用,但属运行时手段,不算交付
扔壳重建(拿内存 dump 的 dex 重打包) 实证死路:核心方法被易盾永久 native 化
在包内找明文签名期望值做替换 600 个目标 × 12 种形态,零命中
保留完整壳 + 包内自带“停车”补丁 最接近成功:装上、UI 起来、活过 30 秒,最终死于毫秒级内;修复代码已写、未验证

文中所有结论按证据强度标注:实测(有日志/报错/数据撑着)、推断(基于实证的最优解释)、未排除(还开着的口子)。


1. 环境与工具

按用途分层:

  • 静态解包/反编译:apktool(解包/重打)、jadx(看 Java)、baksmali(dex→smali)
  • native 分析:IDA(早期,略)、ELF 头检查(Python struct)、/proc/<pid>/maps
  • 动态:frida 17.16.2 + frida-java-bridge + esbuild 编译探针脚本;自写 ptrace 跟踪器 minitrace
  • 测试机:MuMu 模拟器,Android 15(x86_64 宿主 + houdini ARM 翻译层),KernelSU root,SELinux Permissive。亲手装的 ZygiskFrida 模块,会在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so 里的内容——这个特性后来反复搅局,后面细说
  • 补丁编译:zig 交叉编译(x86_64-linux-musl / aarch64-linux-musl)
  • 重打包链:apktool b → zipalign → apksigner;Python 干杂活(塞 dex、扫哈希、验截图)

2. 阶段一:初筛——壳、dex、广告

2.1 认壳

apktool 解开原包,manifest 里 application 指向 com.netease.nis.wrapper.MyApplicationcom.netease.nis.wrapper 是网易易盾加固壳的包名特征,但这不是唯一依据——后续运行时出现的 libnesec*.so、加密 dex 的加载方式、壳内的签名代理(见 §6),都指向同一结论。原始 APK 里的可见 dex 基本是壳的占位代码,业务代码以加密数据形式放在包内,由壳在运行时解密后通过内存方式加载,不落盘。

2.2 从内存里捞出业务 dex

方法概述(细节属早期工作):hook 类加载路径(InMemoryDexClassLoader 一类)配合内存扫描定位运行时解密出的 dex 结构,按 dex 格式导出。共 dump 出 7 个 payload dex,最大一个约 54 MB。baksmali 反汇编后得到 7 个 smali 目录(smali_0 ~ smali_6),与 dex 数量一一对应。

分拣结果:smali_0 全是壳的类;smali_1 基本是倍孜(Beizi)广告 SDK;smali_6 是业务主体代码。中间四份没有逐份做完整成分清单,只通过两次全量扫描(native 方法统计、壳类交叉引用搜索)间接画像过:里面摊着 androidx 全家桶、Flutter 引擎、高德地图引擎、腾讯 MMKV、字节视频组件等大体积依赖。关键结论(解耦、方法抽取)都是全量扫描得出的,不依赖中间几份的精确归类——但如果要精确到“某个 SDK 散在哪几份”,得回头逐份统计,现在只能说到这个粒度。

2.3 广告 SDK 盘点

  • 倍孜(Beizi):实锤。smali_1 整份是它,业务目录里还有 BeiZiNewRewardVideoActivity(激励视频)、BeiZiNewInterstitialActivity(插屏)、BeiZiNewLandingPageActivity(落地页)等一堆 Activity,以及它家的布局引擎 YogaNative。
  • 字节系组件:多条线拼起来的“在场”证据——com.bykv.vk.component.ttvideo(TTPlayer 视频播放、AVMDLDataLoader 视频下载,典型的广告物料播放组件)+ com.volcengine.mobsecBiz(字节移动安全组件,就是后面签名校验那套)。没有逐一验证广告展示实际走的哪条链路。
  • 广点通(GDT):只在 hosts 屏蔽列表里有它的域名,屏蔽后效果是合并算账的,没单独验证比重。疑似在场、未确证。

2.4 hosts 验证及其局限

往设备 /system/etc/hosts 加了约 40 行倍孜/GDT 域名,App 内广告位空了。实测有效,但这步的目的只是确认广告走网络层、锁定域名归属,不是去广告方案:hosts 需要 root、逐台设备改、广告 SDK 还可能有本地兜底或域名降级逻辑。交付物不能依赖它。


3. 阶段二:无声死亡与击杀桩

3.1 现象

附着 frida,或者重打包后安装,进程几秒内消失。退出码 208,没有 Java 崩溃栈、没有 tombstone。这不是 crash——crash 会走 ART 或 debuggerd 的上报路径留下现场;这里更像是进程主动调用了退出系统调用,把所有上报机制都绕开了。

3.2 定位

当时怀疑是壳直接调 exit_group 绕过 Java 异常和 native crash 上报。验证靠自写 ptrace 跟踪器 minitrace 盯系统调用,加上扫内存:观察触发退出时发起调用的指令位置,落在 libnesec-x86.so 的映射区间内。

先把两个数字说清,这是最容易读混的地方:

  • 231 是 x86_64 下 exit_group 的系统调用号,不是退出码。它被 mov eax, 231 装进 eax;
  • 208 是传给 exit_group 的 status 参数(走第一个参数寄存器 rdi)。ptrace 观测到该参数恒为 208(0xD0)——它大概率就是壳内部定义的一个特定错误码或状态标识,具体含义未深究。

所谓“击杀桩”,指壳在内存里解密释放出来的一小段直接发起退出系统调用的机器码。它不经过 libc 的 exit/_exit,也不走任何导出/导入表:

b8 e7 00 00 00    mov eax, 231    ; exit_group 系统调用号
0f 05             syscall

普通 hook(挂在库函数符号、PLT、GOT、导出函数入口上那一类)对它全部无效,因为调用链上根本没有可以下钩的“名字”。要处理只能:扫内存匹配字节特征后 inline patch、seccomp 过滤、或 ptrace 拦截。

3.3 对策:gotkill

写了个叫 gotkill 的小库,加载进进程后开后台线程反复扫内存,把命中的击杀桩改写成“停车”指令:

/* park shellcode: mov eax,34(SYS_pause); syscall; jmp $ */
static const unsigned char repl[16] = {
    0xB8, 0x22, 0x00, 0x00, 0x00, 0x0F, 0x05, 0xEB,
    0xFE, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
};

几个设计选择的理由:

  • 为什么不 nop / ret:原桩是“必定不返回”的语义,直接 ret 会把控制流弹到未知位置;nop 掉 syscall 后线程会继续往下执行壳的后续代码,行为不可控。
  • 为什么是 pause 而不是纯用户态死循环:让线程停在一个系统调用里,状态可控、可观测;后面的 jmp $(跳回自己)是保险——万一 pause 被信号打断返回了,也不会往下执行任何东西。
  • 16 字节写入的安全性:原桩只有 7 字节,替换块按 16 字节写、尾部 nop 填满。这确实存在覆盖原桩后方 9 字节的理论风险——它的安全性是经验性的:实测命中的桩都位于壳解密释放的代码区里,桩后是 nop 填充或块边界,且外部注入验证期间进程被补丁后持续正常运行了数分钟,没有出现因补丁引发的崩溃。没有逐桩验证过后方 9 字节的内容,这是该方案的一个已接受的风险点。

匹配条件也很保守:只认 b8 ?? 00 00 00 / 0f 05 且装入 eax 的值 ∈ {231, 60}(exit_group / exit)、立即数高位字节全零的形态,避免误伤正常代码。

不改 APK、纯外部注入的条件下验证有效(外部注入指通过模拟器的注入机制把 so 塞进目标进程,先验证思路,再考虑内置进包):

[GOTKILL] NEUTERED nesec stub nr=231 @+0x5d4f0

nr=231 就是命中的系统调用号,@+0x5d4f0 是桩相对 so 加载基址的偏移。

3.4 两条死支线

  • seccomp 拦截:装上 seccomp 过滤器拦退出调用后,进程改被 SIGKILL(不是 208)。说明壳检测到了运行环境被过滤(具体怎么检测的——读 /proc/self/status 的 Seccomp 字段还是行为探测——没查),只挡退出调用不够,它还有别的杀法。弃。
  • ptrace 互斥:minitrace 和 frida 同时挂同一个目标会失败(一个 Linux 进程同一时刻通常只能被一个 ptrace tracer 附着,frida 的注入机制也占这个坑)。两者只能二选一,多数时候靠 frida 单独上。

4. 阶段三:扔壳重建——死因是“方法抽取”

4.1 依据与改动

依据:内存 dump 出的 7 个业务 dex 看起来是完整的,且全量搜索业务 smali 中对壳类(nis.wrapperms.bd.ccom.netease)的引用为零。注意这个“零引用”是静态层面的:没有直接类引用、没有明显的字符串加载痕迹;JNI、反射、ClassLoader 注入这类动态依赖不在它的覆盖范围里——后面的事实恰恰证明了这一点。

改动:manifest 的 application 从壳的 MyApplication 改成业务侧的 run.xbud.android.common.XBDApplication,7 个 dex 作为 classes.dexclasses7.dex 塞进 apktool 打出的资源壳(不含任何壳代码和 libnesec)。

4.2 三个工程坑

  1. 塞 dex 的脚本按文件名排序,一个杂散文件(hdr*.bin)排到了第一位占据了 classes.dex 的名字,7 个 dex 实际只进去 1 个。列包内条目才发现,修成显式配对 payload_N.dex → classes(N+1).dex
  2. resources.arsc 被重压缩。Python zipfile 默认对所有条目用 deflate,而 resources.arsc 是资源索引表,安装器要求它直接映射访问,Android 11+(API 30+)强制它未压缩(STORED)存储且 4 字节对齐,违反直接拒装,安装器报错原话:Targeting R+ ... requires the resources.arsc of installed APKs to be stored uncompressed and aligned on a 4-byte boundary。修复是保留每个条目原有的压缩方式(传入原 ZipInfo 对象而不是只传文件名),对齐交给 zipalign:
for item in zin.infolist():
    if item.filename.startswith('classes'):
        continue
    # 保留原压缩方式;resources.arsc 必须 STORED,对齐由 zipalign -p 处理
    zout.writestr(item, zin.read(item.filename))
  1. 签名方案。jarsigner 只生成 v1(JAR 签名),较新的 Android 要求 v2 及以上的 APK 签名块,v1-only 装不上。换 apksigner 出 v2+v3,apksigner verify --verbose 确认两者都通过。

4.3 崩溃与真相

包装上了,进程起来了,业务类也找到了,然后崩在:

java.lang.UnsatisfiedLinkError: No implementation found for
void run.xbud.android.common.XBDApplication.attachBaseContext(android.content.Context)

先解释这个错误:它不是说某个 .so 没装上,而是说 Java 层声明为 native 的方法在运行时找不到对应 JNI 实现(JNI 绑定通常靠 RegisterNatives 或按符号名约定)。看 smali 才知道规模:

.method protected native attachBaseContext(Landroid/content/Context;)V
.method public native onCreate()V
.method private final native break()V
...(该类几十个方法全是 native)

全 App 53693 个类里 600 个含 native 方法,集中在业务 Activity(跑步记录、个人页等,每类 30+ 个方法被抽)。这是易盾的方法抽取:Java 方法体被整体搬进壳的 native 层,dex 里只剩 native 声明,运行时由壳注册/分发。

4.4 决定性实验

用 frida 反射检查一个已经成功执行过的方法:

const M = Java.use('java.lang.reflect.Modifier');
// App 已正常启动、onCreate 已执行完毕之后:
M.isNative(onCreateMethod.getModifiers())   // 仍是 true

如果是“懒恢复”式抽取(方法体运行到才回填 dex),执行过的方法应该能观察到非 native 状态或字节码回填。实测仍为 native,说明 Java 方法体从头到尾不在任何 dex 里,逻辑长期驻留 native 层。因此对本案这种形态,FART/BlackDex 一类主动脱壳工具帮不上忙——它们能 dump 内存 dex、能触发方法调用,但可恢复的 Java 方法体本身就不存在,没有对象可脱。

结论(限定范围):对当前样本被抽取的这批类,简单去壳重打包不可行;业务运行依赖壳提供的 native 实现。这不排除理论上逆向 native 层重建的可能,但那是以周计的工程,不在本次范围。


5. 阶段四:找签名期望值——零命中

思路:壳要判“签名不对就杀”,期望值总得存在某处;若以明文形态放在包里,找到就能换成我们证书的哈希,让重签通过。

扫描:原证书 DER 的 md5/sha1/sha256 × {原始字节、小写 hex、大写 hex} 共 12 种特征,覆盖 38 个 so、全部 assets、原始 APK,共 600 个目标。零命中。

这个结果的边界要说清:它只排除了“原始哈希以这 12 种常见明文形态直接放在包内”的情况。不排除 Base64/UTF-16 编码、截断、加盐、加密存储、运行时派生、甚至服务端下发——扫描前就声明,这些形态不在覆盖范围里。基于已覆盖范围内的零命中,放弃“静态匹配换哈希”思路,转向保留原壳、处理运行时行为。


6. 阶段五:壳自带的签名代理

读壳的 smali 时有个意外发现:com.netease.nis.wrapper.plugin.d 是个 InvocationHandler(Java 动态代理),代理应用内部的包管理接口。当被调用的方法名匹配(方法名本身是密文 "KQAANQAQDi8CESwPFQo=",经壳的字符串解密器解出)且 flags 带 0x40 时,把返回的签名换掉:

const/16 v2, 0x40                        ; 0x40 = 旧版 PackageManager.GET_SIGNATURES
if-ne v0, v2, :cond_47
new-instance v2, Landroid/content/pm/Signature;
iget-object v0, p0, ...->b:Ljava/lang/String;   ; 壳预存的签名串
invoke-direct {v2, v0}, ...Signature;-><init>(Ljava/lang/String;)V
...
aput-object v2, v3, v1                   ; signatures[0] = 预存值

被拦截方法推断为 getPackageInfo,依据有两条:解密后的字符串内容,加上行为特征的完全吻合——该代理检查调用 flags 是否带 0x40(GET_SIGNATURES),并且改写的是返回值里 PackageInfo.signatures[] 数组的首元素,这正是 getPackageInfo 独有的签名语义。0x40 对应旧版 GET_SIGNATURES(新版是 GET_SIGNING_CERTIFICATES,壳明显按旧标志位处理)。

推断(间接证据,非直测):这个代理的用途是让业务/广告 SDK 里走 PackageManager 查签名的校验(比如字节 ms.bz.bd.c.Pgl.e1 那套——用 MessageDigest 算证书哈希再送出去比对)在受保护环境里仍能读到原始签名。而 nesec 自己的 native 校验不走这条代理——证据是间接的:重签后即使 Java 层代理可能仍在喂伪签名,进程照样快速退出,说明还存在一条不经过该代理的真实签名/完整性检查路径。


7. 阶段六:守卫方案——保留壳,自带补丁

7.1 方案

架构原样保留:壳的 MyApplication、libnesec、加密 payload 一个不动(业务逻辑靠 native 分发,必须留着壳)。只加两样:

① KillGuardApp——继承壳的 MyApplication,静态块里先加载补丁库:

.class public Lcom/netease/nis/wrapper/KillGuardApp;
.super Lcom/netease/nis/wrapper/MyApplication;

.method static constructor <clinit>()V
    .registers 1
    :try_start_0
    const-string v0, "gotkill"
    invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
    :try_end_0
    .catch Ljava/lang/UnsatisfiedLinkError; {:try_start_0 .. :try_end_0} :catch_0
    goto :goto_0
    :catch_0
    move-exception v0
    :goto_0
    return-void
.end method

同时 manifest 的 application android:name 必须改成 KillGuardApp——不改的话系统实例化的仍是 MyApplication,这个子类永远不会跑。这一步做了。

时序说明(这里容易读反):Java 类初始化顺序是父类 <clinit> 先、子类 <clinit>。所以 loadLibrary("gotkill") 并不抢在 MyApplication.<clinit> 之前——它可行是因为读过 smali 确认 MyApplication.<clinit> 只做字符串解密、不加载任何库,而 nesec 的加载发生在更晚的 attachBaseContext 阶段。完整链条:父类静态块(解密字符串)→ 子类静态块(加载 gotkill,补丁就位)→ attachBaseContext(壳加载 nesec,nesec 解密并安装击杀桩)→ gotkill 的巡检已经在扫了。前提是静态分析没漏(<clinit> 里没看到 loadLibrary 调用路径;未做运行时复核——见 §7.4 未排除项)。

② 双架构 libgotkill.so 打进包里,摆脱外部注入。

7.2 ABI 布局——一个反直觉且踩了坑的环境

先说实测事实。看运行中进程的 /proc/<pid>/maps:业务 so 全部从 .../lib/arm64/ 目录加载,同时进程里有 /system/lib64/arm64/...、houdini 初始化日志——App 是 arm64 库 + houdini 翻译执行的形态(x86_64 宿主,ABI 按 arm64 走,安装器只抽 arm64-v8a 目录)。文件头检查进一步显示这对 nesec 是“混布”的:

libnesec-x86.so: ELF64 x86_64     ← 文件名叫 x86,实际是 x86_64 ELF
libnesec.so:     ELF64 aarch64    ← 名字干净的反而真身是 arm64

两个不同架构的 so 同放在 lib/arm64-v8a/ 目录里。x86_64 那份之所以能被加载,是 MuMu 的 native bridge loader 对该目录下的 ELF 做了兼容处理(推断,依据是它在 maps 里真实出现了)。这也解释了 gotkill 为什么用 x86 字节特征能扫到并改写“arm 库”里的桩——libnesec-x86.so 是 x86_64 代码原生执行,桩的字节本来就是 x86 的。

补丁库的放置我实际试过两种,都得交代:

  • 第一版守卫包:把 x86_64 版 libgotkill.so 放进 lib/arm64-v8a/(模仿 libnesec-x86.so 的布局)。结果启动 190ms 死于 208。这一版里包内补丁是否加载成功未知(日志写不出,见 §7.3)。
  • 第二版守卫包:aarch64 版放 lib/arm64-v8a/,x86_64 版放 lib/armeabi-v7a/后者是个失误:armeabi-v7a 是 32 位 ARM 目录,这台 64 位设备根本不会抽取它,那份 x86_64 拷贝大概率是从未生效的死重。实际有可能起作用的只有 arm64-v8a 里的 aarch64 版——它走标准 arm64 ABI 加载、由 houdini 翻译执行;而补丁器扫什么、写什么只取决于内存里的目标字节(x86 桩),与补丁器自身被编译成什么架构无关,所以 aarch64 版扫 x86 桩没有障碍。第二版守卫包活过了 30 秒。

顺带一个还没处理的对真机的含义:在真 arm64 手机上,nesec 加载的是 aarch64 的 libnesec.so,击杀桩将是 ARM64 机器码,现在的 x86 特征扫描不会命中——当前补丁只对这台模拟器环境有效,真机需要另配 ARM64 的桩特征。这超出了本次范围,但交付前必须知道。

7.3 测试结果:大起大落

第二版守卫包装机成功。第一次启动:8 秒存活、30 秒存活,截图解析出白底+金黄品牌色——重签包的 UI 真起来了。但在 30 秒~2 分钟之间的某次检查时已死(精确死亡时刻没测)。第二次冷启动 0.3 秒内死,退出码 208。

尸检时间线(logcat):

04.602  ZygiskFrida: App detected: run.xbud.android
04.713  ZygiskFrida: Injecting /data/local/tmp/.jcache/inline.so
04.716  ZygiskFrida: Remapped
04.901  Zygote: Process 16837 exited cleanly (208)

先解释这组日志:ZygiskFrida 是模拟器自带的注入模块,它在每个 App 进程启动时自动注入 /data/local/tmp/.jcache/inline.so——这个路径上放的是我们早前部署的旧版 gotkill(当时为原版 App 动态分析备用的停车补丁)。也就是说守卫包测试时进程里实际有两份补丁来源:自动注入的旧版 x86_64、包内的 aarch64 版。exited cleanly (208) 是 Zygote 的措辞,指进程正常退出(非信号杀死),与“主动调 exit_group”完全一致。

7.4 竞速假说与未排除项

主假说(推断):注入后约 185 毫秒被处决。旧版 gotkill 的后台线程起跑前先 usleep(200000)——注入发生在 04.714,旧版首次扫描本应在 ~04.916 才发生,而死亡在 04.901,差了约 15 毫秒输掉竞速。时间上吻合。

但这个假说单独解释不了第一次为什么活了 30 秒以上。开着的口子:

  1. 包内 aarch64 版补丁两次到底加载成功没有——UnsatisfiedLinkError 被静态块 catch 吞了,而日志文件因 App 进程无权写 /data/local/tmp(早期外部注入阶段沿用下来的路径,普通应用进程本就写不了)一直缺失,无法佐证。一个能自洽解释两次差异的假设是:第一次包内版加载成功、它的巡检线程比注入版起跑更早(类初始化阶段就位)从而补上了窗口,第二次因某种原因没加载——未验证。
  2. 第一次 30s~2min 之间的死亡原因不明——可能是延迟触发的第二道检查(比如反 frida 检测走了别的路径),也可能是同一条 nesec 逻辑带定时器。未验证。
  3. 模拟器的自动注入本身就是个污染变量:在真机上没有 ZygiskFrida,inline.so 不会被注入,死亡时序可能完全不同。本环境测得的“第二次秒死”不排除是模拟器特有的注入触发了壳的反注入检测。

8. 修复代码——已写、未验证

针对竞速假说和日志盲区,gotkill.c 改了三处(只改完源码,编译、重打、装机验证全部没做):

① 构造函数里同步先扫一轮,零延迟起跑(constructor 在 dlopen 时执行)。说明一下这轮扫描的定位:此时 nesec 多半还没加载、内存里还没有桩,首轮同步扫描大概率扑空——它的意义是消除旧版 200ms 的起跑延迟,保证“如果桩已经在了”这种情况下零遗漏;真正接力的是随后创建的 hooker 巡检线程,nesec 加载并释放击杀桩后由它捕获

__attribute__((constructor)) static void on_load(void) {
    unlink(get_log_path());
    log_line("[GOTKILL] loaded\n");
    neuter_nesec_once();   /* 同步首轮:消除旧版 200ms 起跑延迟 */
    pthread_t th;
    pthread_create(&th, 0, hooker, 0);
}

neuter_nesec_once() 是立即执行一轮扫描替换;hooker 是持续巡检线程(名字起得随意,它就是反复扫 maps 处理后续延迟出现的桩的那个循环)。

② 轮询节奏前紧后松:

for (int round = 0; round < 3000; round++) {
    usleep(round < 30 ? 5000 : (round < 60 ? 50000 : 1000000));

算总账:前 30 轮 × 5ms ≈ 150ms(覆盖启动竞速窗口),30–60 轮 × 50ms ≈ 1.5s,之后 1s 一次直到约 49 分钟停止。设上限是为了不无限占线程;代价是 49 分钟后不再扫描——若壳有超长延迟的检查就会漏。

③ 日志路径改走应用可写目录:

static char log_path[128];
static const char* get_log_path(void) {
    if (!log_path[0]) {
        const char* cache = getenv("XBD_CACHE");
        if (cache && *cache) snprintf(log_path, sizeof log_path, "%s/nesec208.log", cache);
        else snprintf(log_path, sizeof log_path, "/data/local/tmp/nesec208.log");
    }
    return log_path;
}

这个修复是半成品XBD_CACHE 环境变量目前没有任何地方设置——还需要 KillGuardApp 的 Java/smali 侧把 context.getCacheDir() setenv 传进去,或 native 侧按包名硬拼 /data/user/0/run.xbud.android/cache/(多用户/多开场景后者会错)。这步没接完。另外 §7.2 说的 armeabi-v7a 死重问题也应在下一版重打时一并清掉(只留 arm64-v8a 里的 aarch64 版)。


9. 剩下的牌(按便宜程度排)

  1. 验证修复版竞速:编译 aarch64 → 重打(清掉 armeabi-v7a 死重、接通日志路径)→ 装机。若 nesec 只有一个约 200ms 的启动击杀窗口,零延迟首扫 + 5ms 快轮询大概率赢。日志接通后一并回答“包内补丁到底加载没有”。
  2. 排除模拟器污染:把守卫包装到无 ZygiskFrida 注入的环境(真机或关掉注入)测一轮,确认“第二次秒死”是不是模拟器特有的。
  3. 若竞速仍不稳:放弃停车补丁思路,转攻 nesec 读取真实签名的那条路径并伪造(§6 代理的 native 对应物)。需要先在运行时定位 nesec 取签名的调用点,工作量大一个量级。

10. 证据属性声明

  • 实测(有报错输出、日志行、测量数据):退出码 208 与无崩溃栈;击杀桩位于 libnesec-x86.so;停车补丁在外部注入下生效(NEUTERED 日志);7 dex 内容与 600 类 native 统计;onCreate 执行后仍为 native;哈希扫描零命中;libnesec-x86.so 为 x86_64 ELF 而进程走 arm64+houdini;守卫包装机成功、首次启动 UI 起来并存活超 30 秒;第二次启动注入后 ~185ms 退出 208。
  • 推断(基于实证的最优解释):签名代理拦截的就是 getPackageInfo 且喂饱对象是业务侧校验;nesec 另有不经代理的 native 校验;两次启动生死差异源于扫描竞速与包内补丁加载成功与否;MuMu 对 arm64 目录内 x86_64 ELF 的兼容加载。
  • 未排除:包内补丁(两版)是否曾加载成功;首次 30s–2min 间死亡的具体触发者;模拟器自动注入对检测时序的影响;壳是否存在 49 分钟窗口之外的延迟检查;16 字节补丁覆盖后方字节的理论风险在全部桩上是否成立。

实验过程与日志来自真实环境,部分代码片段与分析整理有 AI 辅助。

能力有限,分析于此,希望对大家有所帮助

免费评分

参与人数 1热心值 +1 收起 理由
hbygogogo + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
Hmily 发表于 2026-8-26 09:50
这种AI文章真是没欲望看下去。
3#
fub8 发表于 2026-8-26 10:37
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-26 14:50

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表