无痕注入内核!拆解APatchKernelPatch的四段式启动链路

admin 2026-07-19 04:36:57 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细拆解了APatchKernelPatch的四段式启动链路,包括kptools打补丁、物理地址阶段的setupentry与setup、hookpaginginit以及start正式初始化。文章深入分析了内核启动早期如何无痕注入代码,通过修改内核入口、搬运核心代码和恢复现场实现持久化。技术细节丰富,涉及ARM64汇编、内存管理和内核启动流程,适合高级安全研究人员参考。 综合评分: 83 文章分类: 红队,渗透测试,安全工具,逆向分析


cover_image

无痕注入内核!拆解 APatch KernelPatch 的四段式启动链路

原创

人生导师 人生导师

OnePanda-Sec

2026年7月15日 10:00 浙江

在小说阅读器读本章

去阅读

OnePanada-Sec 招新啦

招新要求

  • 热爱网络安全,喜欢 CTF;
  • 拥有 CTF 比赛经验,有较好比赛成绩的;
  • 乐于奉献、热爱分享,愿意提升自己同时帮助他人;
  • 时间允许参加各类赛事,服从战队管理与安排;
  • 各类比赛获奖者、能力出众者视情况考量;
  • 未参与其他高校联队:
  • 大一同学视情况放宽资历要求。

联系方式

请将个人简历发送至以下邮箱:

简历邮箱:[email protected]

一定是练功的时候总是差不多、差不多,到了关键时刻,就总是差一点。

这是 APatch 的第一篇,聊聊 KernelPatch 是怎么在开机最早期切入内核、在物理地址阶段完成准备工作、hook paging_init 完成内存搬迁,最后跑到 start.c 里真正开始干活的。

整个流程按时间顺序分成四个阶段来讲:打补丁 → 物理地址阶段 → paging_init hook → start 正式初始化。

开始之前,先搞清楚几个基本概念。


前置知识

CPU 怎么执行代码

CPU 内部有一个寄存器叫PC(Program Counter,ARM64 里也叫 IP)。执行循环就三件事:

循环: 1. 从 PC 指向的地址读 4 字节(一条指令) 2. 执行这条指令 3. PC += 4,回到 1

就这么简单。CPU 是个无脑的”读指令 → 执行 → 下一条”机器。

所以”函数的地址”本质上就是函数第一条指令在内存中的位置。比如内核编译完后,paging_init 函数的第一条指令在离内核开头 0x1234000 字节的位置,那 paging_init 的偏移就是 0x1234000。等内核加载到物理地址 0x40080000 后,paging_init 的真实物理地址就是 0x40080000 + 0x1234000 = 0x412B4000。

物理地址 vs 虚拟地址

开机时,CPU 直接拿裸的地址访问内存,这就是物理地址。物理地址就是一个巨大的字节数组的下标,你的手机有 8GB 内存,物理地址范围就是 0x00000000 到 0x1FFFFFFFF。

操作系统启动后会开 MMU(Memory Management Unit),在 CPU 和物理内存之间建一道翻译层:

CPU 发出的地址 MMU 翻译 物理内存(虚拟地址) ──────────→进程A: 0x1000 ── MMU ──→ 0x80001000进程B: 0x1000 ── MMU ──→ 0x90005000

两个进程都以为自己在用 0x1000,MMU 把它们翻译到不同的物理地址,互相不干扰。翻译规则存在页表(Page Table)里。paging_init 就是内核里负责建立完整页表的函数。

开机时的时间线

电源打开 │ ▼ 引导加载器把 kernel Image 加载到物理内存 │ (比如物理地址 0x40080000) │ ▼ 跳到内核入口(head.S) │ 此时 MMU 还没开,用的是物理地址 │ ▼ head.S 建一个临时的小页表,开启 MMU │ 现在 CPU 用虚拟地址了,但页表很小 │ ▼ start_kernel() → setup_arch() → paging_init() │ 建立完整页表,全量物理内存都有虚拟地址了 │ ▼ 内核继续初始化...

KernelPatch 就是在这个时间线里巧妙地插入了自己的代码。


阶段 0:kptools 打补丁(刷入前)

kptools 是跑在电脑上的用户态工具,它的工作是:

kpimg 的结构

kpimg 是 KernelPatch 自己编译出来的独立二进制,链接脚本 kpimg.lds 定义了它的布局:

_link_base =&nbsp;0xD000(虚拟链接基址)&nbsp;┌──────────────────────────┐ _link_base│ .setup.data(4K) │ ← setup_header (64B) + setup_preset + stack (2KB)├──────────────────────────┤│ .setup.text │ ← setup_entry, setup, map_prepare, start_prepare├──────────────────────────┤ ALIGN(16)│ .setup.map (<&nbsp;0xa00&nbsp;B) │ ← _paging_init, map_data, get_myva 等├──────────────────────────┤ ALIGN(64K)│ _kp_start ││ .kp.text │ ← start(), hook 跳板, patch 代码, 所有 .rodata├──────────────────────────┤ ALIGN(64K)│ .kp.data&nbsp;│ ← start_preset, 全局变量, .bss, 符号表├──────────────────────────┤ ALIGN(64K)│ _kp_end │├──────────────────────────┤│ extra&nbsp;data&nbsp;│ ← KPM 模块等附加数据(不在链接脚本内)└──────────────────────────┘

关键点:link_base 之前的区域(setup.xxx 和 setup.map)只在开机最早期临时使用,之后就没用了。kp_start 之后的区域是 KP 的核心代码和数据,会被搬家到一个更安全的地方长期驻留。

setup_preset——kptools 和 setup1.S 之间的通信协议

setup_preset 是一个很大的结构体,定义在 preset.h 里:

typedef&nbsp;struct&nbsp;_setup_preset_t&nbsp;{&nbsp;version_t&nbsp;kernel_version;&nbsp;// 目标内核版本&nbsp;int64_t&nbsp;kimg_size;&nbsp;// 内核镜像大小&nbsp;int64_t&nbsp;kpimg_size;&nbsp;// kpimg 总大小&nbsp;int64_t&nbsp;kernel_size;&nbsp;// 内核代码段大小&nbsp;int64_t&nbsp;page_shift;&nbsp;// 页大小(12=4K, 14=16K, 16=64K)&nbsp;int64_t&nbsp;setup_offset;&nbsp;// kpimg 被塞在内核镜像的哪个偏移&nbsp;int64_t&nbsp;start_offset;&nbsp;// KP 核心代码要搬到哪&nbsp;int64_t&nbsp;extra_size;&nbsp;// 附加数据大小&nbsp;int64_t&nbsp;map_offset;&nbsp;// map 代码要搬到哪&nbsp;int64_t&nbsp;kallsyms_lookup_name_offset;&nbsp;// kallsyms_lookup_name 的函数偏移&nbsp;int64_t&nbsp;paging_init_offset;&nbsp;// paging_init 的函数偏移&nbsp;int64_t&nbsp;printk_offset;&nbsp;// printk 的函数偏移&nbsp;int64_t&nbsp;sprintf_offset;&nbsp;// sprintf 的函数偏移&nbsp;int64_t&nbsp;symbol_lookup_anchor_offset;&nbsp;// 符号查找锚点偏移&nbsp;map_symbol_t&nbsp;map_symbol;&nbsp;// memblock 系列函数的偏移&nbsp;uint8_t&nbsp;header_backup[8];&nbsp;// 原始内核入口的 8 字节备份&nbsp;uint8_t&nbsp;superkey[64];&nbsp;// 超级密钥&nbsp;uint8_t&nbsp;root_superkey[32];&nbsp;// root 密钥 hash&nbsp;patch_config_t&nbsp;patch_config;&nbsp;// 预设的符号地址表&nbsp;// ...}&nbsp;setup_preset_t;

kptools 编译好 kpimg 后(此时 setup_preset 全是 0),分析目标内核找到所有偏移,直接修改 kpimg 二进制文件里对应字段,填好数据后一起塞进内核镜像。

setup_preset 编译时放在 kpimg 的 .setup.preset section 里,位置是固定的。kptools 知道它相对 kpimg 开头的偏移,所以可以直接定位修改。


阶段 1:setup_entry → setup → start_prepare & map_prepare(物理地址阶段)

修补后的内核镜像被引导加载器加载到物理内存。内核入口的第一条指令已经被改成跳到 setup_entry,所以 CPU 上来就执行它。

setup_entry

setup_entry:&nbsp;mov x9, sp&nbsp;// 保存引导加载器给的原始 sp&nbsp;adrp x11, stack&nbsp;// 加载 KP 自己的栈地址&nbsp;add&nbsp;x11, x11, :lo12:stack&nbsp;add&nbsp;x11, x11, STACK_SIZE&nbsp;// 栈顶 = stack + 2KB&nbsp;mov sp, x11&nbsp;// 切换到 KP 的栈&nbsp;stp x9, x10, [sp,&nbsp;-16]!&nbsp;// 把原始 sp 压栈(后面恢复用)&nbsp;b setup&nbsp;// 跳到 setup

这里干的事情很简单:把引导加载器传进来的 x0-x3(FDT 地址等参数)保留在寄存器里,切换到 KP 自己的栈(因为这时还没有操作系统,没有栈分配器,KP 自己在 .setup.data 里预留了一块 char stack[0x800]),然后跳到 setup。

:lo12: 是什么? ARM64 的地址是 64 位,没办法一条指令加载。所以拆成两步:adrp x11, stack // 加载 stack 所在的 4K 页的页地址(PC 相对)add x11, x11, :lo12:stack // 加上页内偏移(低 12 位)adrp 是 PC 相对寻址,算出来的是代码实际被加载到哪个物理地址,而不是链接时的虚拟地址。所以只要 kpimg 内部相对位置不变(整体是一块搬来的),adrp + :lo12: 就能算出正确的物理地址。

setup

setup:&nbsp;// 把所有寄存器压栈(相当于做一个完整的现场保存)&nbsp;stp x29, x30, [sp,&nbsp;-16]!&nbsp;stp x0, x1, [sp,&nbsp;-16]!&nbsp;stp x2, x3, [sp,&nbsp;-16]!&nbsp;// ... 共 24 条 stp,保存了几乎所有寄存器&nbsp;&nbsp;// 计算 kernel_pa(内核物理基址)&nbsp;adrp x9, _link_base&nbsp;add&nbsp;x9, x9, :lo12:_link_base&nbsp;// x9 = _link_base 的当前物理地址&nbsp;adrp x10, setup_preset&nbsp;add&nbsp;x10, x10, :lo12:setup_preset&nbsp;// x10 = setup_preset 的当前物理地址&nbsp;ldr x11, [x10,&nbsp;#setup_setup_offset_offset] // x11 = setup_offset&nbsp;sub x19, x9, x11&nbsp;// x19 = kernel_pa

这一步是最核心的地址计算。_link_base 是 kpimg 的起始位置(链接脚本写死 0xD000),kpimg 被塞在距离内核开头 setup_offset 的地方。所以:

kernel_pa&nbsp;= _link_base 的物理地址 - setup_offset

举个例子:内核被引导加载器加载到物理地址 0x40080000,kpimg 被塞在偏移 0x00F80000 处,那么 _link_base 的物理地址就是 0x40F80000 + 0xD000 = 0x40F8D000。减掉 0x00F80000,就得到 kernel_pa = 0x40080000。

有了 kernel_pa,后面所有需要定位内核内部函数的地方,统一用kernel_pa + 偏移量来算出物理地址。

接着:

mov x0, x19&nbsp;// 参数:kernel_pa&nbsp;bl start_prepare&nbsp;// 阶段 1a:准备参数,搬运 KP 核心代码&nbsp;mov x0, x19&nbsp;bl map_prepare&nbsp;// 阶段 1b:hook paging_init,搬运 map 代码&nbsp;&nbsp;// 恢复内核入口原始指令(把备份的 8 字节写回去)&nbsp;mov x0, x19&nbsp;add&nbsp;x1, x20,&nbsp;#setup_header_backup_offset&nbsp;mov x2,&nbsp;#8&nbsp;bl memcpy8&nbsp;&nbsp;// 刷 I-cache(CPU 对指令的缓存),确保 CPU 看到刚恢复的指令&nbsp;dsb ish&nbsp;ic iallu&nbsp;dsb ish&nbsp;isb&nbsp;&nbsp;// 恢复所有寄存器,跳回内核入口&nbsp;mov x16, x19&nbsp;// ... 24 条 ldp 恢复所有寄存器 ...&nbsp;br x16&nbsp;// 跳到 kernel_pa,内核正常启动

做完这一切后,setup 让 CPU 跳回内核原始入口,内核从自己正常的入口开始初始化——它完全不知道刚才有人来过了。

start_prepare:搬运 KP 核心代码

start_prepare 的核心任务:把_kp_start之后的 KP 核心代码搬运到内核镜像内部的一个位置,同时把参数从setup_preset拷贝到start_preset。

为什么要拷贝而不直接用 setup_preset?因为 setup_preset 在 kpimg 的 setup 段里,这一段只是临时存在的(入口恢复后就没有意义了)。start_preset 在 .start.data 段里,会跟着 KP 核心代码一起搬到最终目的地,长期存活。

逐段来看:

start_prepare:&nbsp;// 保存寄存器&nbsp;stp x29, x30, [sp,&nbsp;-16]!&nbsp;stp x19, x20, [sp,&nbsp;-16]!&nbsp;stp x21, x22, [sp,&nbsp;-16]!&nbsp;stp x23, x24, [sp,&nbsp;-16]!&nbsp;mov x19, x0&nbsp;// x19 = kernel_pa&nbsp;&nbsp;// 加载 4 个关键地址&nbsp;adrp x9, map_data&nbsp;// x9 = map_data(map.c 用的控制块)&nbsp;add&nbsp;x9, x9, :lo12:map_data&nbsp;adrp x10, setup_preset&nbsp;// x10 = setup_preset(源数据,kptools 填好的)&nbsp;add&nbsp;x10, x10, :lo12:setup_preset&nbsp;adrp x11, start_preset&nbsp;// x11 = start_preset(目标数据,要搬过来的)&nbsp;add&nbsp;x11, x11, :lo12:start_preset&nbsp;adrp x12, header&nbsp;// x12 = KP header&nbsp;add&nbsp;x12, x12, :lo12:header

第一步:逐字段拷贝参数

// 把 setup_preset 里的参数一个一个拷贝到 start_preset&nbsp;ldr w13, [x10,&nbsp;#setup_kernel_version_offset]&nbsp;str&nbsp;w13, [x11,&nbsp;#start_kernel_version_offset]&nbsp;&nbsp;ldr x13, [x10,&nbsp;#setup_kallsyms_lookup_name_offset_offset]&nbsp;str&nbsp;x13, [x11,&nbsp;#start_kallsyms_lookup_name_offset_offset]&nbsp;&nbsp;ldr x13, [x10,&nbsp;#setup_kernel_size_offset]&nbsp;str&nbsp;x13, [x11,&nbsp;#start_kernel_size_offset]&nbsp;&nbsp;ldr x13, [x10,&nbsp;#setup_start_offset_offset]&nbsp;str&nbsp;x13, [x11,&nbsp;#start_start_offset_offset]&nbsp;mov x21, x13 // x21 = start_offset(后面搬家用)&nbsp;&nbsp;ldr x13, [x10,&nbsp;#setup_extra_size_offset]&nbsp;str&nbsp;x13, [x11,&nbsp;#start_extra_size_offset]&nbsp;&nbsp;str&nbsp;x19, [x11,&nbsp;#start_kernel_pa_offset] // kernel_pa(自己算的,不是从 preset 读的)&nbsp;&nbsp;ldr x13, [x10,&nbsp;#setup_map_offset_offset]&nbsp;str&nbsp;x13, [x11,&nbsp;#start_map_offset_offset]&nbsp;mov x20, x13 // x20 = map_offset(后面备份用)&nbsp;&nbsp;// sprintf_offset, symbol_lookup_anchor_offset 同理...

为什么不用循环而是手写这么多 ldr/str?因为在汇编里写循环反而麻烦,而且不是所有字段都需要拷贝(比如 paging_init_offset,start.c 用不到)。手写虽然啰嗦,但清晰可控。

第二步:拷贝大块数据

// 拷贝 KP header(64 字节)&nbsp;add&nbsp;x0, x11,&nbsp;#start_header_offset&nbsp;add&nbsp;x1, x12,&nbsp;#0&nbsp;mov x2,&nbsp;#KP_HEADER_SIZE&nbsp;bl memcpy8&nbsp;&nbsp;// 拷贝 superkey(64 字节)&nbsp;add&nbsp;x0, x11,&nbsp;#start_superkey_offset&nbsp;add&nbsp;x1, x10,&nbsp;#setup_superkey_offset&nbsp;mov x2,&nbsp;#SUPER_KEY_LEN&nbsp;bl memcpy8&nbsp;&nbsp;// 拷贝 root_superkey hash(32 字节)&nbsp;...&nbsp;&nbsp;// 拷贝 patch_config(512 字节)&nbsp;...

字段太大不能用一条 ldr/str 搞定的,就调 memcpy8——KP 自己在文件开头写的逐字节拷贝函数(因为此时没有 libc 可用)。

第三步:备份 map 区域

// 计算 map 区域大小 = _map_end - _map_start&nbsp;adrp x13, _map_end&nbsp;add&nbsp;x13, x13, :lo12:_map_end&nbsp;adrp x14, _map_start&nbsp;add&nbsp;x14, x14, :lo12:_map_start&nbsp;sub x2, x13, x14&nbsp;&nbsp;// 存大小&nbsp;str x2, [x11,&nbsp;#start_map_backup_len_offset]&nbsp;&nbsp;// 把 kernel_pa + map_offset 处的原始数据备份到 start_preset.map_backup&nbsp;add&nbsp;x0, x11,&nbsp;#start_map_backup_offset&nbsp;add&nbsp;x1, x19, x20&nbsp;// src = kernel_pa + map_offset&nbsp;bl memcpy8

因为 map_prepare 马上会把 KP 的 map 代码覆盖写到 kernel_pa + map_offset 这个位置。这里先把那里的原始内核数据存起来,KP 初始化完成后由 restore_map()(在 start.c 里)写回去,消除痕迹。

第四步:计算核心代码大小

// start_img_size = kpimg_size - (_kp_start - _link_base)&nbsp;ldr x22, [x10,&nbsp;#setup_kpimg_size_offset] // 整个 kpimg 大小&nbsp;adrp x23, _kp_start&nbsp;add&nbsp;x23, x23, :lo12:_kp_start&nbsp;adrp x24, _link_base&nbsp;add&nbsp;x24, x24, :lo12:_link_base&nbsp;sub x23, x23, x24&nbsp;// _kp_start - _link_base(setup+map 部分)&nbsp;sub x22, x22, x23&nbsp;// x22 = 核心代码大小&nbsp;&nbsp;// 存到 map_data(_paging_init 阶段需要知道要搬多大)&nbsp;str x22, [x9,&nbsp;#map_start_img_size_offset]

kpimg_size 是整个 kpimg blob 的大小。_kp_start – _link_base 是 setup 段 + map 段的大小。一减,就是核心代码(.kp.text + .kp.data)的大小。后面 _paging_init 只搬这部分。

第五步:搬运核心代码

// 把 _kp_start 开始的代码拷贝到 kernel_pa + start_offset&nbsp;add&nbsp;x0, x19, x21&nbsp;// dst = kernel_pa + start_offset&nbsp;adrp x1, _kp_start&nbsp;add&nbsp;x1, x1, :lo12:_kp_start&nbsp;// src = _kp_start(当前位置)&nbsp;ldr x2, [x10,&nbsp;#setup_extra_size_offset]&nbsp;add&nbsp;x2, x2, x22&nbsp;// len = start_img_size + extra_size&nbsp;bl rmemcpy32&nbsp;// 反向拷贝(从末尾开始)

用 rmemcpy32(反向 32 位拷贝)而不是正向的 memcpy8,是因为 src 和 dst 可能有重叠——start_offset 可能指向 kpimg 内部附近的位置,正向拷贝有可能把还没读的数据覆盖掉。反向拷贝从末尾开始,不存在这个问题。

画个图总结 start_prepare 做了什么:

原状:&nbsp;内核镜像 │.......│ setup │&nbsp;map&nbsp;│kp核心代码│ 原始内核数据 │&nbsp;kernel_pa ↑ ↑ ↑ ↑&nbsp;kpimg 被塞在这里 _kp_start kernel_pa+start_offset&nbsp;(这块原始数据要被覆盖)&nbsp;搬运后:&nbsp;内核镜像 │.......│ setup │&nbsp;map&nbsp;│kp核心代码│kp核心代码(备份)│extra│&nbsp;kernel_pa ↑ ↑ ↑ ↑&nbsp;kpimg 在这里 _kp_start kernel_pa+start_offset

核心代码在同一个内核镜像里有了两份:一份在 kpimg 原始位置,一份在新位置。后面 _paging_init 会把新位置的那份搬到 malloc 分配的新内存里。

map_prepare:hook paging_init

map_prepare 的核心任务:修改paging_init函数的第一条指令,让它跳到 KP 的_paging_init。

map_prepare:&nbsp;stp x29, x30, [sp,&nbsp;-16]!&nbsp;stp x19, x20, [sp,&nbsp;-16]!&nbsp;mov x19, x0&nbsp;// x19 = kernel_pa&nbsp;&nbsp;// 加载关键地址&nbsp;adrp x9, map_data&nbsp;add&nbsp;x9, x9, :lo12:map_data&nbsp;adrp x10, setup_preset&nbsp;add&nbsp;x10, x10, :lo12:setup_preset&nbsp;&nbsp;// 存 kernel_pa 到 map_data&nbsp;str x19, [x9,&nbsp;#map_kernel_pa_offset]&nbsp;&nbsp;// 获取 paging_init_offset,存到 map_data 和 x15&nbsp;ldr x11, [x10,&nbsp;#setup_paging_init_offset_offset]&nbsp;str x11, [x9,&nbsp;#map_paging_init_relo_offset]&nbsp;mov x15, x11&nbsp;// x15 = paging_init_offset

paging_init_offset 是 kptools 在修补时分析内核二进制找到的,存进 setup_preset。这里读出来,x15 就拿到了 paging_init 相对于内核基址的偏移量。

然后是核心的 hook 操作:

// paging_init_pa = paging_init_offset + kernel_pa&nbsp;add&nbsp;x13, x15, x19&nbsp;// x13 = paging_init 在当前物理内存中的地址&nbsp;&nbsp;// 备份 paging_init 的第一条指令(4 字节)&nbsp;ldr w12, [x13]

等于读出 paging_init 在物理内存里的第一条指令。比如读到 0xD503201F(PACIBSP)或别的什么。

处理 PAC/BTI 指令:

// 构造一个 NOP 指令和一个比较用的 mask&nbsp;mov w3,&nbsp;#0x201F&nbsp;movk w3,&nbsp;#0xD503, lsl#16 // w3 = 0xD503201F(NOP 指令)&nbsp;orr w1, w3,&nbsp;#0x100 // w1 = 0xD503211F(PACIASP 指令)&nbsp;mov w2,&nbsp;#0xFFFFFD1F // w2 = mask,屏蔽 bit5 和 bit8&nbsp;&nbsp;and&nbsp;w0, w12, w2&nbsp;// 读到的指令 & mask&nbsp;cmp w0, w1&nbsp;// == PACIASP?(bit5+bit8 被 mask 掉了)&nbsp;b.ne .backup&nbsp;// 不是 PAC,直接跳到备份&nbsp;&nbsp;// 是 PACIASP 指令,用 NOP 替换它(我们不需要做 PAC 签名)&nbsp;mov w12, w3&nbsp;// w12 = NOP&nbsp;&nbsp;// 往前找跟在后面的 AUTIASP 指令,也换成 NOP&nbsp;add&nbsp;x11, x13,&nbsp;#4.cmp_auti:&nbsp;ldr w0, [x11],&nbsp;#4&nbsp;and&nbsp;w0, w0, w2&nbsp;cmp w0, w1&nbsp;// 找到 AUTIASP?&nbsp;b.ne .cmp_auti&nbsp;// 不是就继续找&nbsp;stur w3, [x11,&nbsp;#-4] // 把 AUTIASP 也换成 NOP&nbsp;.backup:&nbsp;str w12, [x9,&nbsp;#map_paging_init_backup_offset] // 存备份&nbsp;dsb ish

PAC(Pointer Authentication)是 ARMv8.3 引入的安全特性,用 PACIASP 和 AUTIASP 对一个函数的返回地址签名/验签。KP 要在这个位置插入跳转,这两个指令碍事(因为它们会修改 x30/lr 寄存器),所以换成 NOP。

然后写入跳转指令:

// 计算 _paging_init 在目标位置(kernel_pa + map_offset)的偏移&nbsp;adrp x11, _paging_init&nbsp;add&nbsp;x11, x11, :lo12:_paging_init&nbsp;// x11 = _paging_init 当前地址&nbsp;adrp x12, _map_start&nbsp;add&nbsp;x12, x12, :lo12:_map_start&nbsp;// x12 = _map_start 当前地址&nbsp;sub x11, x11, x12&nbsp;// x11 = _paging_init 在 map 段内的偏移&nbsp;add&nbsp;x11, x11, x14&nbsp;// x11 = map_offset + 段内偏移&nbsp;// = _paging_init 在目标内核中的偏移&nbsp;&nbsp;// 构造 ARM64 B 指令(无条件跳转)&nbsp;// B 指令格式:0x14000000 | ((offset >> 2) & 0x03FFFFFF)&nbsp;sub x15, x11, x15&nbsp;// 跳转距离 = 目标偏移 - paging_init 偏移&nbsp;ubfx w15, w15,&nbsp;#2, #26 // 右移 2 位,取低 26 位&nbsp;mov w12,&nbsp;#0x14000000&nbsp;orr w15, w15, w12&nbsp;// 组合成完整 B 指令&nbsp;str w15, [x13]&nbsp;// 写入 paging_init 入口!

这就是 hook 的关键:往 paging_init 的第一条指令位置写了一条B _paging_init指令。从此内核调用 paging_init() 时,CPU 执行的第一条指令就会跳到 KP 的代码。

注意这里用了 adrp + add 两次后一减的技巧:两个 adrp 算出来的都是物理地址,相减后绝对地址互相抵消,得到的是链接时常量 _paging_init – _map_start。这个偏移加上 map_offset,就是 map 代码被搬进内核镜像后 _paging_init 在内核中的新偏移。然后用这个偏移构造 B 指令——B 指令用的是 PC 相对偏移,和绝对地址无关,所以这段汇编正确性不依赖任何运行时状态,只依赖链接时算好的相对偏移。

最后搬运 map 代码:

// 把 map 代码拷贝到 kernel_pa + map_offset&nbsp;adrp x2, _map_end&nbsp;add&nbsp;x2, x2, :lo12:_map_end&nbsp;adrp x1, _map_start&nbsp;add&nbsp;x1, x1, :lo12:_map_start&nbsp;sub x2, x2, x1&nbsp;// x2 = map 段大小&nbsp;add&nbsp;x0, x19, x14&nbsp;// dst = kernel_pa + map_offset&nbsp;bl memcpy8&nbsp;&nbsp;dsb ish&nbsp;ret

Map 代码必须搬过去,因为 _paging_init 里会调内核函数,必须在内核的虚拟地址空间里才能正常工作。


阶段 2:_paging_init(map.c,内核初始化早期)

CPU 跳到内核入口,内核开始正常初始化。走到 start_kernel() → setup_arch() → paging_init() 的时候,命中了那条 B 指令,跳进 KP 的 _paging_init。

_paging_init 要完成的任务是:等内核建好页表后,给 KP 分配一块干净的独立内存,把核心代码搬过去,然后调到 start()。

void&nbsp;_paging_init() {&nbsp;map_data_t buf;&nbsp;map_data_t *data = &buf;&nbsp;mem_proc(data);&nbsp;// 解析 MMU 参数

mem_proc:做地址重定位

static&nbsp;void&nbsp;mem_proc(map_data_t&nbsp;*data)&nbsp;{&nbsp;*data = *get_data();&nbsp;// 把 map_data 从全局区拷到栈上&nbsp;uint64_t&nbsp;kernel_va =&nbsp;get_kva();&nbsp;&nbsp;// 计算 kimage_voffset(内核虚拟地址 - 物理地址)&nbsp;data->kimage_voffset = kernel_va - data->kernel_pa;&nbsp;&nbsp;// 把 memblock 等函数指针从偏移量重定位为真实地址&nbsp;data->paging_init_relo += kernel_va;&nbsp;data->map_symbol.memblock_reserve_relo += kernel_va;&nbsp;data->map_symbol.memblock_free_relo += kernel_va;&nbsp;data->map_symbol.memblock_phys_alloc_relo += kernel_va;&nbsp;// ...&nbsp;&nbsp;// 读 TCR_EL1 寄存器解析页表参数&nbsp;uint64_t&nbsp;tcr_el1;&nbsp;asm&nbsp;volatile(”mrs %0, tcr_el1” : ”=r”(tcr_el1));&nbsp;// 解析 T1SZ(虚拟地址位数)、TG1(页粒度:4K/16K/64K)&nbsp;data->va1_bits =&nbsp;64&nbsp;- t1sz;&nbsp;data->page_shift =&nbsp;12/14/16;&nbsp;&nbsp;// 通过 memblock 检测 linear map 和 physic 的偏移&nbsp;uint64_t&nbsp;detect_phys =&nbsp;memblock_phys_alloc(0,&nbsp;0x10, NUMA_NO_NODE);&nbsp;uint64_t&nbsp;detect_virt =&nbsp;memblock_virt_alloc(0,&nbsp;0x10, detect_phys, detect_phys, NUMA_NO_NODE);&nbsp;data->linear_voffset = detect_virt - detect_phys;}

这里 get_data() 能工作是因为 map_data 紧挨着 _paging_init 的代码,可以通过 adr 相对寻址拿到自己所在的地址,然后偏移回去。

关键操作:把 kptools 填的函数偏移 + kernel_va = 真实的函数虚拟地址。

一个具体的例子 :paging_init_offset = 0x1234000,kernel_va = 0xFFFF000040080000,那么paging_init_relo = 0xFFFF000041A40000

就是一个可以直接 ((func_ptr)addr)() 调用的函数指针了。

核心流程

uint64_t&nbsp;page_size =&nbsp;1&nbsp;<< data->page_shift;&nbsp;&nbsp;// 1. 保留旧的 start 区域(防止 memblock 把它分配出去)&nbsp;uint64_t&nbsp;old_start_pa = data->start_offset + data->kernel_pa;&nbsp;memblock_reserve(old_start_pa, reserve_size);&nbsp;&nbsp;// 2. 分配新物理内存 = start + extra + hook + rox + rw&nbsp;uint64_t&nbsp;start_pa =&nbsp;map_phys_alloc(data, all_size, page_size);&nbsp;memblock_mark_nomap(start_pa, all_size);&nbsp;// 标注不要映射到线性区&nbsp;&nbsp;// 3. 恢复 paging_init 原始指令,调用真正的 paging_init()&nbsp;*(uint32_t&nbsp;*)(paging_init_va) = data->paging_init_backup;&nbsp;flush_icache_all();&nbsp;((paging_init_f)(paging_init_va))();&nbsp;&nbsp;// 4. 页表已完全建立,为新分配的内存创建映射&nbsp;for&nbsp;(off =&nbsp;0; off < all_size; off += page_size) {&nbsp;get_or_create_pte(data, start_va + off, start_pa + off, attr_indx);&nbsp;}&nbsp;&nbsp;// 5. 清除 WXN(Writeable XOR eXecutable Never),否则后面没法设置 RWX 内存&nbsp;sctlr_el1 &= ~(1&nbsp;<<&nbsp;19);&nbsp;asm&nbsp;volatile(”msr sctlr_el1, %0” : : ”r”(sctlr_el1));&nbsp;&nbsp;// 6. 把 start image + extra 从旧位置搬到新内存&nbsp;for&nbsp;(i =&nbsp;0; i < start_img_size; i +=&nbsp;8)&nbsp;*(uint64_t&nbsp;*)(start_va + i) = *(uint64_t&nbsp;*)(old_start_va + i);&nbsp;for&nbsp;(i =&nbsp;0; i < extra_size; i +=&nbsp;8)&nbsp;*(uint64_t&nbsp;*)(start_va + data->start_size + i) =&nbsp;*(uint64_t&nbsp;*)(old_start_va + start_img_size + i);&nbsp;&nbsp;// 7. 释放旧位置内存,flush cache&nbsp;memblock_free(old_start_pa, reserve_size);&nbsp;flush_icache_all();&nbsp;&nbsp;// 8. 调用 start()!&nbsp;((start_f)start_va)(data->kimage_voffset, data->linear_voffset);}

这张图更直观:

操作前(物理内存): 操作后(新分配的物理内存):&nbsp;┌──────────────┐ kernel_pa ┌──────────────┐ kernel_pa│ 原始内核 │ │ 原始内核 ││ │ │ ││ │ │ │├──────────────┤ old_start_pa │ ││ KP核心代码 │ ← 从这里读 │ ││ start_img │ │ ││ extra&nbsp;data&nbsp;│ │ │└──────────────┘ ├──────────────┤ start_pa(新分配的)&nbsp;│ KP核心代码 │ ← 搬到这里&nbsp;│ extra&nbsp;data&nbsp;│&nbsp;│ HOOK_ALLOC │&nbsp;│ MEMORY_ROX │&nbsp;│ MEMORY_RW │&nbsp;└──────────────┘

注意第 3 步的时序:先恢复 paging_init 第一条指令,再调 paging_init。这样 paging_init 跑完后就没有任何 hook 痕迹了,后续代码再来看 paging_init 的代码,看到的就是原始指令。


阶段 3:start(start.c,正式初始化)

_paging_init 最后通过函数指针调用 start(),把控制权交给了 KP 的 C 代码入口:

int&nbsp;start(uint64_t&nbsp;kimage_voff,&nbsp;uint64_t&nbsp;linear_voff)&nbsp;{&nbsp;start_init(kimage_voff, linear_voff);&nbsp;// 基础初始化,解析 kallsyms&nbsp;prot_myself();&nbsp;// 设置自身页表权限(text=RX, data=RW, hook=RWX)&nbsp;restore_map();&nbsp;// 恢复被 hook 的 map 区域&nbsp;log_regs();&nbsp;// dump CPU 寄存器信息(调试用)&nbsp;predata_init();&nbsp;// 初始化预设数据&nbsp;symbol_init();&nbsp;// 符号系统初始化&nbsp;patch();&nbsp;// 安装各种内核 hook}

start_init:获取所有内核函数地址

这是最关键的一步——通过 kallsyms_lookup_name 获取内核中所有需要的函数地址:

static&nbsp;int&nbsp;start_init(uint64_t kimage_voff, uint64_t linear_voff)&nbsp;{&nbsp;// 从 start_preset 拿参数&nbsp;kernel_pa = start_preset.kernel_pa;&nbsp;kernel_va = kimage_voff + kernel_pa;&nbsp;// 计算内核虚拟基址&nbsp;kernel_size = start_preset.kernel_size;&nbsp;&nbsp;// 解析 kallsyms_lookup_name 的地址(优先用 symbol_lookup_anchor 方法,&nbsp;// 不行就用 kptools 预设的偏移量)&nbsp;kallsym_offset = resolve_kallsyms_lookup_name_by_symbol_lookup_anchor();&nbsp;if&nbsp;(!kallsym_offset) kallsym_offset = start_preset.kallsyms_lookup_name_offset;&nbsp;&nbsp;// 内核函数地址 = kernel_va + 偏移&nbsp;kallsyms_lookup_name = (typeof(kallsyms_lookup_name))(kernel_va + kallsym_offset);&nbsp;&nbsp;// 拿到了 kallsyms_lookup_name,就可以查找任意内核符号!&nbsp;kernel_stext_va = kallsyms_lookup_name(”_stext”);&nbsp;printk = (typeof(printk))kallsyms_lookup_name(”printk”);&nbsp;vsnprintf = (typeof(vsnprintf))kallsyms_lookup_name(”vsnprintf”);&nbsp;kallsyms_on_each_symbol = kallsyms_lookup_name(”kallsyms_on_each_symbol”);&nbsp;// ... 等等&nbsp;&nbsp;// 读 TCR_EL1 解析页表参数(页粒度、虚拟地址位数等)&nbsp;// 读 TTBR1_EL1 获取内核页表基址}

kallsyms_lookup_name 是内核里按函数名查找地址的函数。拿到了它,KP 就能在运行时找到任意内核函数的地址。这是整个 KP 体系的基础。

prot_myself:设置内存权限

static&nbsp;void&nbsp;prot_myself() {&nbsp;// text 段 → RX(可读可执行,不可写)&nbsp;for&nbsp;(i = _kp_text_start; i < _kp_text_end; i += page_size) {&nbsp;pte =&nbsp;pgtable_entry_kernel(i);&nbsp;*pte = (*pte |&nbsp;PTE_SHARED) & ~PTE_PXN;&nbsp;// 清除 PXN,允许执行&nbsp;if&nbsp;(has_vmalloc_area()) *pte = (*pte |&nbsp;PTE_RDONLY) & ~PTE_DBM;&nbsp;}&nbsp;&nbsp;// data 段 → RW(可读可写,不可执行)&nbsp;for&nbsp;(i = _kp_data_start; i < _kp_data_end; i += page_size) {&nbsp;pte =&nbsp;pgtable_entry_kernel(i);&nbsp;*pte = (*pte |&nbsp;PTE_DBM&nbsp;|&nbsp;PTE_SHARED) & ~PTE_RDONLY;&nbsp;if&nbsp;(has_vmalloc_area()) *pte |=&nbsp;PTE_PXN;&nbsp;// 设置 PXN,禁止执行&nbsp;}&nbsp;&nbsp;// hook 区域 → RWX(可读可写可执行,给 hook 跳板用)&nbsp;// ROX 内存 → RX&nbsp;// RW 内存 → RW&nbsp;// 并注册到 tlsf 内存分配器&nbsp;&nbsp;// 注册到内核 vmalloc 区域(让内核知道这块内存在用)&nbsp;vm_area_add_early(&kp_vm);}

通过直接操作页表项(PTE),KP 给自己的内存设置了和内核其他区域一样的访问权限,同时预留了 hook 跳板需要的 RWX 区域。

restore_map:擦除脚印

static&nbsp;void&nbsp;restore_map()&nbsp;{&nbsp;uint64_t&nbsp;start = kernel_va + start_preset.map_offset;&nbsp;// 把 map_backup 里备份的原始数据写回 map_offset 位置&nbsp;for&nbsp;(i = start; i < end; i += page_size) {&nbsp;pte =&nbsp;pgtable_entry_kernel(i);&nbsp;*pte |= PTE_DBM;&nbsp;// 临时允许写入&nbsp;flush_tlb_kernel_page(i);&nbsp;memcpy((void&nbsp;*)i, map_backup, page_size);&nbsp;*pte = orig;&nbsp;// 恢复原权限&nbsp;}&nbsp;flush_icache_all();}

map_offset 那块区域之前被 map_prepare 写入了 KP 的 map 代码。现在工作完成了,把备份的原始数据写回去,内核完全看不出这里被动过。


完整流程图

kptools(电脑上)&nbsp;├─ 分析内核二进制,找到所有关键偏移&nbsp;├─ 填 setup_preset 结构体&nbsp;├─ 把 kpimg 嵌入内核镜像&nbsp;└─ 改内核入口 → setup_entry&nbsp;│&nbsp;▼ 刷入手机,开机&nbsp;│setup_entry (setup1.S) ← MMU 未开,物理地址&nbsp;│ 切换栈&nbsp;▼setup (setup1.S)&nbsp;│ 算出 kernel_pa&nbsp;├─ start_prepare&nbsp;│ ├─ setup_preset → start_preset(搬参数)&nbsp;│ ├─ 备份&nbsp;map&nbsp;区域原始数据&nbsp;│ └─ 搬运 KP 核心代码到 kernel_pa + start_offset&nbsp;│&nbsp;├─ map_prepare&nbsp;│ ├─ 备份 paging_init 第一条指令&nbsp;│ ├─ 写 B _paging_init 覆盖 paging_init 入口&nbsp;│ └─ 搬运&nbsp;map&nbsp;代码到 kernel_pa + map_offset&nbsp;│&nbsp;├─ 恢复内核入口原始指令&nbsp;└─ br x16 → 跳到内核入口&nbsp;│&nbsp;▼ 内核正常启动&nbsp;│ head.S → start_kernel() → setup_arch()&nbsp;│&nbsp;▼paging_init() 被调用 ← 命中 B 指令,hook 触发&nbsp;│&nbsp;▼_paging_init (map.c) ← MMU 已开启,虚拟地址&nbsp;├─ 解析 MMU 参数(page_shift, va_bits)&nbsp;├─ 做地址重定位(偏移 → 真实函数指针)&nbsp;├─ memblock 分配新物理内存&nbsp;├─ 恢复 paging_init 原始指令,调用真正的 paging_init()&nbsp;├─ 创建页表映射新内存&nbsp;├─ 搬运 KP 核心代码到新内存&nbsp;├─ 释放旧位置&nbsp;└─ 调用 start(kimage_voffset, linear_voffset)&nbsp;│&nbsp;▼start (start.c) ← KP 正式入口&nbsp;├─ start_init: 通过 kallsyms_lookup_name 取所有内核符号&nbsp;├─ prot_myself: 设置自身页表权限&nbsp;├─ restore_map: 恢复被 hack 的&nbsp;map&nbsp;区域&nbsp;├─ predata_init&nbsp;├─ symbol_init: 初始化导出符号表&nbsp;└─ patch(): 安装各种内核 hook&nbsp;│&nbsp;▼内核继续正常初始化(KernelPatch 已就绪)

总结

KernelPatch 的入口修补方案有几个精妙的设计:

时机选择:在 MMU 开之前做最少的事(搬数据 + 改一条指令),等页表建好才做复杂初始化。太早没有 memblock 分配器,太晚有安全机制(CFI、SELinux)干扰。

adrp 自定位:利用 ARM64 的 adrp PC 相对寻址来获知自己的物理加载地址,不依赖任何绝对地址,这也是 “position-independent” 思想。

插队而非替换:hook paging_init 只改一条指令 → 进来后第一件事就是恢复原始指令 → 然后调用真正的 paging_init → 完全不受影响。是”插队”而不是”替换”。

数据传递:setup_preset 是 kptools(用户态修补工具)和 setup1.S(开机最早期汇编)之间的通信协议。编译时全零,修补时填入偏移,开机时读出使用。start_preset 是从 setup_preset 拷贝过来的,跟着 KP 核心代码一起搬家,在 start.c 阶段读取。

痕迹清理:入口指令被恢复、paging_init 指令被恢复、map 区域数据被恢复——内核完全不知道刚才发生了什么。

微信QQ交流群

欢迎大家加入[ OnePanda-Sec ] 群聊一起交流 ^ ^

END


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:OnePanda-Sec 人生导师 人生导师《无痕注入内核!拆解 APatch KernelPatch 的四段式启动链路》

评论:0   参与:  0