首页
社区
课程
招聘
[原创]APatch解析一、内核修补入口
发表于: 2天前 901

[原创]APatch解析一、内核修补入口

2天前
901

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

这是 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 内存,物理地址范围就是 0x000000000x1FFFFFFFF

操作系统启动后会开 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 是跑在电脑上的用户态工具,它的工作是:

  1. 编译出 kpimg——一个独立的裸机二进制 blob
  2. 分析原始内核 Image,找到所有需要的函数偏移
  3. 把这些偏移填到 kpimg 里的 setup_preset 结构体
  4. 把 kpimg 嵌入到内核镜像的某个偏移处
  5. 把内核入口指令改成跳转到 setup_entry

kpimg 的结构

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

_link_base = 0xD000(虚拟链接基址)

┌──────────────────────────┐ _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 (< 0xa00 B)   │ ← _paging_init, map_data, get_myva 等
├──────────────────────────┤ ALIGN(64K)
│ _kp_start                │
│ .kp.text                 │ ← start(), hook 跳板, patch 代码, 所有 .rodata
├──────────────────────────┤ ALIGN(64K)
│ .kp.data                 │ ← start_preset, 全局变量, .bss, 符号表
├──────────────────────────┤ ALIGN(64K)
│ _kp_end                  │
├──────────────────────────┤
│ extra data               │ ← KPM 模块等附加数据(不在链接脚本内)
└──────────────────────────┘

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

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

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

typedef struct _setup_preset_t {
    version_t kernel_version;           // 目标内核版本
    int64_t kimg_size;                  // 内核镜像大小
    int64_t kpimg_size;                 // kpimg 总大小
    int64_t kernel_size;                // 内核代码段大小
    int64_t page_shift;                 // 页大小(12=4K, 14=16K, 16=64K)
    int64_t setup_offset;               // kpimg 被塞在内核镜像的哪个偏移
    int64_t start_offset;               // KP 核心代码要搬到哪
    int64_t extra_size;                 // 附加数据大小
    int64_t map_offset;                 // map 代码要搬到哪
    int64_t kallsyms_lookup_name_offset; // kallsyms_lookup_name 的函数偏移
    int64_t paging_init_offset;         // paging_init 的函数偏移
    int64_t printk_offset;             // printk 的函数偏移
    int64_t sprintf_offset;            // sprintf 的函数偏移
    int64_t symbol_lookup_anchor_offset; // 符号查找锚点偏移
    map_symbol_t map_symbol;            // memblock 系列函数的偏移
    uint8_t header_backup[8];          // 原始内核入口的 8 字节备份
    uint8_t superkey[64];             // 超级密钥
    uint8_t root_superkey[32];        // root 密钥 hash
    patch_config_t patch_config;       // 预设的符号地址表
    // ...
} 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:
    mov x9, sp                    // 保存引导加载器给的原始 sp
    adrp x11, stack               // 加载 KP 自己的栈地址
    add x11, x11, :lo12:stack
    add x11, x11, STACK_SIZE      // 栈顶 = stack + 2KB
    mov sp, x11                    // 切换到 KP 的栈
    stp x9, x10, [sp, -16]!       // 把原始 sp 压栈(后面恢复用)
    b setup                        // 跳到 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:
    // 把所有寄存器压栈(相当于做一个完整的现场保存)
    stp x29, x30, [sp, -16]!
    stp x0, x1, [sp, -16]!
    stp x2, x3, [sp, -16]!
    // ... 共 24 条 stp,保存了几乎所有寄存器

    // 计算 kernel_pa(内核物理基址)
    adrp x9, _link_base
    add x9, x9, :lo12:_link_base       // x9 = _link_base 的当前物理地址
    adrp x10, setup_preset
    add x10, x10, :lo12:setup_preset    // x10 = setup_preset 的当前物理地址
    ldr x11, [x10, #setup_setup_offset_offset]  // x11 = setup_offset
    sub x19, x9, x11                           // x19 = kernel_pa

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

kernel_pa = _link_base 的物理地址 - setup_offset

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

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

接着:

    mov x0, x19           // 参数:kernel_pa
    bl start_prepare       // 阶段 1a:准备参数,搬运 KP 核心代码
    mov x0, x19
    bl map_prepare         // 阶段 1b:hook paging_init,搬运 map 代码

    // 恢复内核入口原始指令(把备份的 8 字节写回去)
    mov x0, x19
    add x1, x20, #setup_header_backup_offset
    mov x2, #8
    bl memcpy8

    // 刷 I-cache(CPU 对指令的缓存),确保 CPU 看到刚恢复的指令
    dsb ish
    ic iallu
    dsb ish
    isb

    // 恢复所有寄存器,跳回内核入口
    mov x16, x19
    // ... 24 条 ldp 恢复所有寄存器 ...
    br x16        // 跳到 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:
    // 保存寄存器
    stp x29, x30, [sp, -16]!
    stp x19, x20, [sp, -16]!
    stp x21, x22, [sp, -16]!
    stp x23, x24, [sp, -16]!
    mov x19, x0          // x19 = kernel_pa

    // 加载 4 个关键地址
    adrp x9, map_data          // x9  = map_data(map.c 用的控制块)
    add x9, x9, :lo12:map_data
    adrp x10, setup_preset     // x10 = setup_preset(源数据,kptools 填好的)
    add x10, x10, :lo12:setup_preset
    adrp x11, start_preset     // x11 = start_preset(目标数据,要搬过来的)
    add x11, x11, :lo12:start_preset
    adrp x12, header           // x12 = KP header
    add x12, x12, :lo12:header

第一步:逐字段拷贝参数

    // 把 setup_preset 里的参数一个一个拷贝到 start_preset
    ldr w13, [x10, #setup_kernel_version_offset]
    str w13, [x11, #start_kernel_version_offset]

    ldr x13, [x10, #setup_kallsyms_lookup_name_offset_offset]
    str x13, [x11, #start_kallsyms_lookup_name_offset_offset]

    ldr x13, [x10, #setup_kernel_size_offset]
    str x13, [x11, #start_kernel_size_offset]

    ldr x13, [x10, #setup_start_offset_offset]
    str x13, [x11, #start_start_offset_offset]
    mov x21, x13                      // x21 = start_offset(后面搬家用)

    ldr x13, [x10, #setup_extra_size_offset]
    str x13, [x11, #start_extra_size_offset]

    str x19, [x11, #start_kernel_pa_offset]  // kernel_pa(自己算的,不是从 preset 读的)

    ldr x13, [x10, #setup_map_offset_offset]
    str x13, [x11, #start_map_offset_offset]
    mov x20, x13                      // x20 = map_offset(后面备份用)

    // sprintf_offset, symbol_lookup_anchor_offset 同理...

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

第二步:拷贝大块数据

    // 拷贝 KP header(64 字节)
    add x0, x11, #start_header_offset
    add x1, x12, #0
    mov x2, #KP_HEADER_SIZE
    bl memcpy8

    // 拷贝 superkey(64 字节)
    add x0, x11, #start_superkey_offset
    add x1, x10, #setup_superkey_offset
    mov x2, #SUPER_KEY_LEN
    bl memcpy8

    // 拷贝 root_superkey hash(32 字节)
    ...

    // 拷贝 patch_config(512 字节)
    ...

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

第三步:备份 map 区域

    // 计算 map 区域大小 = _map_end - _map_start
    adrp x13, _map_end
    add x13, x13, :lo12:_map_end
    adrp x14, _map_start
    add x14, x14, :lo12:_map_start
    sub x2, x13, x14

    // 存大小
    str x2, [x11, #start_map_backup_len_offset]

    // 把 kernel_pa + map_offset 处的原始数据备份到 start_preset.map_backup
    add x0, x11, #start_map_backup_offset
    add x1, x19, x20    // src = kernel_pa + map_offset
    bl memcpy8

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

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

    // start_img_size = kpimg_size - (_kp_start - _link_base)
    ldr x22, [x10, #setup_kpimg_size_offset]  // 整个 kpimg 大小
    adrp x23, _kp_start
    add x23, x23, :lo12:_kp_start
    adrp x24, _link_base
    add x24, x24, :lo12:_link_base
    sub x23, x23, x24                          // _kp_start - _link_base(setup+map 部分)
    sub x22, x22, x23                          // x22 = 核心代码大小

    // 存到 map_data(_paging_init 阶段需要知道要搬多大)
    str x22, [x9, #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
    add x0, x19, x21                 // dst = kernel_pa + start_offset
    adrp x1, _kp_start
    add x1, x1, :lo12:_kp_start      // src = _kp_start(当前位置)
    ldr x2, [x10, #setup_extra_size_offset]
    add x2, x2, x22                   // len = start_img_size + extra_size
    bl rmemcpy32                       // 反向拷贝(从末尾开始)

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

画个图总结 start_prepare 做了什么:

原状:
    内核镜像 │.......│  setup  │  map  │kp核心代码│ 原始内核数据  │
              kernel_pa    ↑        ↑       ↑          ↑
                       kpimg 被塞在这里    _kp_start   kernel_pa+start_offset
                                                  (这块原始数据要被覆盖)

搬运后:
    内核镜像 │.......│  setup  │  map  │kp核心代码│kp核心代码(备份)│extra│
              kernel_pa    ↑        ↑       ↑          ↑
                       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:
    stp x29, x30, [sp, -16]!
    stp x19, x20, [sp, -16]!
    mov x19, x0                 // x19 = kernel_pa

    // 加载关键地址
    adrp x9, map_data
    add x9, x9, :lo12:map_data
    adrp x10, setup_preset
    add x10, x10, :lo12:setup_preset

    // 存 kernel_pa 到 map_data
    str x19, [x9, #map_kernel_pa_offset]

    // 获取 paging_init_offset,存到 map_data 和 x15
    ldr x11, [x10, #setup_paging_init_offset_offset]
    str x11, [x9, #map_paging_init_relo_offset]
    mov x15, x11                // x15 = paging_init_offset

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

然后是核心的 hook 操作:

    // paging_init_pa = paging_init_offset + kernel_pa
    add x13, x15, x19           // x13 = paging_init 在当前物理内存中的地址

    // 备份 paging_init 的第一条指令(4 字节)
    ldr w12, [x13]

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

处理 PAC/BTI 指令:

    // 构造一个 NOP 指令和一个比较用的 mask
    mov w3, #0x201F
    movk w3, #0xD503, lsl#16   // w3 = 0xD503201F(NOP 指令)
    orr w1, w3, #0x100         // w1 = 0xD503211F(PACIASP 指令)
    mov w2, #0xFFFFFD1F        // w2 = mask,屏蔽 bit5 和 bit8

    and w0, w12, w2            // 读到的指令 & mask
    cmp w0, w1                 // == PACIASP?(bit5+bit8 被 mask 掉了)
    b.ne .backup               // 不是 PAC,直接跳到备份

    // 是 PACIASP 指令,用 NOP 替换它(我们不需要做 PAC 签名)
    mov w12, w3                // w12 = NOP

    // 往前找跟在后面的 AUTIASP 指令,也换成 NOP
    add x11, x13, #4
.cmp_auti:
    ldr w0, [x11], #4
    and w0, w0, w2
    cmp w0, w1                 // 找到 AUTIASP?
    b.ne .cmp_auti              // 不是就继续找
    stur w3, [x11, #-4]        // 把 AUTIASP 也换成 NOP

.backup:
    str w12, [x9, #map_paging_init_backup_offset]  // 存备份
    dsb ish

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

然后 写入跳转指令

    // 计算 _paging_init 在目标位置(kernel_pa + map_offset)的偏移
    adrp x11, _paging_init
    add x11, x11, :lo12:_paging_init   // x11 = _paging_init 当前地址
    adrp x12, _map_start
    add x12, x12, :lo12:_map_start      // x12 = _map_start 当前地址
    sub x11, x11, x12                    // x11 = _paging_init 在 map 段内的偏移
    add x11, x11, x14                    // x11 = map_offset + 段内偏移
                                         //    = _paging_init 在目标内核中的偏移

    // 构造 ARM64 B 指令(无条件跳转)
    // B 指令格式:0x14000000 | ((offset >> 2) & 0x03FFFFFF)
    sub x15, x11, x15        // 跳转距离 = 目标偏移 - paging_init 偏移
    ubfx w15, w15, #2, #26   // 右移 2 位,取低 26 位
    mov w12, #0x14000000
    orr w15, w15, w12        // 组合成完整 B 指令
    str w15, [x13]            // 写入 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
    adrp x2, _map_end
    add x2, x2, :lo12:_map_end
    adrp x1, _map_start
    add x1, x1, :lo12:_map_start
    sub x2, x2, x1                      // x2 = map 段大小
    add x0, x19, x14                     // dst = kernel_pa + map_offset
    bl memcpy8

    dsb ish
    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 _paging_init() {
    map_data_t buf;
    map_data_t *data = &buf;
    mem_proc(data);  // 解析 MMU 参数

mem_proc:做地址重定位

static void mem_proc(map_data_t *data) {
    *data = *get_data();           // 把 map_data 从全局区拷到栈上
    uint64_t kernel_va = get_kva();

    // 计算 kimage_voffset(内核虚拟地址 - 物理地址)
    data->kimage_voffset = kernel_va - data->kernel_pa;

    // 把 memblock 等函数指针从偏移量重定位为真实地址
    data->paging_init_relo += kernel_va;
    data->map_symbol.memblock_reserve_relo += kernel_va;
    data->map_symbol.memblock_free_relo += kernel_va;
    data->map_symbol.memblock_phys_alloc_relo += kernel_va;
    // ...

    // 读 TCR_EL1 寄存器解析页表参数
    uint64_t tcr_el1;
    asm volatile("mrs %0, tcr_el1" : "=r"(tcr_el1));
    // 解析 T1SZ(虚拟地址位数)、TG1(页粒度:4K/16K/64K)
    data->va1_bits = 64 - t1sz;
    data->page_shift = 12/14/16;

    // 通过 memblock 检测 linear map 和 physic 的偏移
    uint64_t detect_phys = memblock_phys_alloc(0, 0x10, NUMA_NO_NODE);
    uint64_t detect_virt = memblock_virt_alloc(0, 0x10, detect_phys, detect_phys, NUMA_NO_NODE);
    data->linear_voffset = detect_virt - detect_phys;
}

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

关键操作:把 kptools 填的函数偏移 + kernel_va = 真实的函数虚拟地址。比如 paging_init_offset = 0x1234000kernel_va = 0xFFFF000040080000,那么 paging_init_relo = 0xFFFF000041A40000,就是一个可以直接 ((func_ptr)addr)() 调用的函数指针了。

核心流程

    uint64_t page_size = 1 << data->page_shift;

    // 1. 保留旧的 start 区域(防止 memblock 把它分配出去)
    uint64_t old_start_pa = data->start_offset + data->kernel_pa;
    memblock_reserve(old_start_pa, reserve_size);

    // 2. 分配新物理内存 = start + extra + hook + rox + rw
    uint64_t start_pa = map_phys_alloc(data, all_size, page_size);
    memblock_mark_nomap(start_pa, all_size);  // 标注不要映射到线性区

    // 3. 恢复 paging_init 原始指令,调用真正的 paging_init()
    *(uint32_t *)(paging_init_va) = data->paging_init_backup;
    flush_icache_all();
    ((paging_init_f)(paging_init_va))();

    // 4. 页表已完全建立,为新分配的内存创建映射
    for (off = 0; off < all_size; off += page_size) {
        get_or_create_pte(data, start_va + off, start_pa + off, attr_indx);
    }

    // 5. 清除 WXN(Writeable XOR eXecutable Never),否则后面没法设置 RWX 内存
    sctlr_el1 &= ~(1 << 19);
    asm volatile("msr sctlr_el1, %0" : : "r"(sctlr_el1));

    // 6. 把 start image + extra 从旧位置搬到新内存
    for (i = 0; i < start_img_size; i += 8)
        *(uint64_t *)(start_va + i) = *(uint64_t *)(old_start_va + i);
    for (i = 0; i < extra_size; i += 8)
        *(uint64_t *)(start_va + data->start_size + i) =
            *(uint64_t *)(old_start_va + start_img_size + i);

    // 7. 释放旧位置内存,flush cache
    memblock_free(old_start_pa, reserve_size);
    flush_icache_all();

    // 8. 调用 start()!
    ((start_f)start_va)(data->kimage_voffset, data->linear_voffset);
}

这张图更直观:

操作前(物理内存):              操作后(新分配的物理内存):

┌──────────────┐ kernel_pa        ┌──────────────┐ kernel_pa
│ 原始内核      │                  │ 原始内核       │
│              │                  │              │
│              │                  │              │
├──────────────┤ old_start_pa     │              │
│ KP核心代码    │ ← 从这里读         │              │
│ start_img    │                  │              │
│ extra data   │                  │              │
└──────────────┘                  ├──────────────┤ start_pa(新分配的)
                                  │ KP核心代码    │ ← 搬到这里
                                  │ extra data   │
                                  │ HOOK_ALLOC   │
                                  │ MEMORY_ROX   │
                                  │ MEMORY_RW    │
                                  └──────────────┘

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


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

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

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

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

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

static int start_init(uint64_t kimage_voff, uint64_t linear_voff) {
    // 从 start_preset 拿参数
    kernel_pa = start_preset.kernel_pa;
    kernel_va = kimage_voff + kernel_pa;  // 计算内核虚拟基址
    kernel_size = start_preset.kernel_size;

    // 解析 kallsyms_lookup_name 的地址(优先用 symbol_lookup_anchor 方法,
    // 不行就用 kptools 预设的偏移量)
    kallsym_offset = resolve_kallsyms_lookup_name_by_symbol_lookup_anchor();
    if (!kallsym_offset) kallsym_offset = start_preset.kallsyms_lookup_name_offset;

    // 内核函数地址 = kernel_va + 偏移
    kallsyms_lookup_name = (typeof(kallsyms_lookup_name))(kernel_va + kallsym_offset);

    // 拿到了 kallsyms_lookup_name,就可以查找任意内核符号!
    kernel_stext_va = kallsyms_lookup_name("_stext");
    printk     = (typeof(printk))kallsyms_lookup_name("printk");
    vsnprintf  = (typeof(vsnprintf))kallsyms_lookup_name("vsnprintf");
    kallsyms_on_each_symbol = kallsyms_lookup_name("kallsyms_on_each_symbol");
    // ... 等等

    // 读 TCR_EL1 解析页表参数(页粒度、虚拟地址位数等)
    // 读 TTBR1_EL1 获取内核页表基址
}

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

prot_myself:设置内存权限

static void prot_myself() {
    // text 段 → RX(可读可执行,不可写)
    for (i = _kp_text_start; i < _kp_text_end; i += page_size) {
        pte = pgtable_entry_kernel(i);
        *pte = (*pte | PTE_SHARED) & ~PTE_PXN;  // 清除 PXN,允许执行
        if (has_vmalloc_area()) *pte = (*pte | PTE_RDONLY) & ~PTE_DBM;
    }

    // data 段 → RW(可读可写,不可执行)
    for (i = _kp_data_start; i < _kp_data_end; i += page_size) {
        pte = pgtable_entry_kernel(i);
        *pte = (*pte | PTE_DBM | PTE_SHARED) & ~PTE_RDONLY;
        if (has_vmalloc_area()) *pte |= PTE_PXN;  // 设置 PXN,禁止执行
    }

    // hook 区域 → RWX(可读可写可执行,给 hook 跳板用)
    // ROX 内存 → RX
    // RW 内存 → RW
    // 并注册到 tlsf 内存分配器

    // 注册到内核 vmalloc 区域(让内核知道这块内存在用)
    vm_area_add_early(&kp_vm);
}

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

restore_map:擦除脚印

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

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


完整流程图

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

总结

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

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

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

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

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

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


[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!

最后于 2天前 被一只鸭子编辑 ,原因: 没写标题
收藏
免费 9
打赏
分享
最新回复 (2)
雪    币: 87
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
nice
2天前
0
雪    币: 6317
活跃值: (11352)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
大内高手啊,学习了,谢谢分享
16小时前
0
游客
登录 | 注册 方可回帖
返回