点击上方蓝字 江湖评谈设为关注/星标
前言
上一篇: 最后提到了一个问题,Go-main是由Linuxkernel两次调用用户态EA(Entrypoint Address)之后运行的,为啥第一次调用EA的时候内核态不会跳转到用户态,而是报了页错误异常。第二次则不会报页错误异常,从内核态切到了用户态,进行了正常运行。同一个地址调用,为啥运行结果就不同呢?本篇看下这个问题。
概念
要解决这个问题,首先了解x64里面的几个特殊寄存器
CR0 寄存器
CR0
是控制寄存器之一,用于控制操作系统如何使用 CPU 的不同特性,尤其是与保护模式、分页和浮点单元等相关的功能。作用
CR0
寄存器的位用于启用或禁用特定的 CPU 特性,以下是一些主要的控制位:位 名称 描述 0 PE (Protection Enable) 启用保护模式。保护模式允许操作系统使用虚拟内存、权限控制等。 1 MP (Monitor Coprocessor) 控制浮点单元监视。影响处理器如何处理浮点异常。 2 EM (Emulation) 如果设置,禁用浮点单元的操作,转而模拟浮点操作。 3 TS (Task Switched) 当设置时,表明发生了任务切换,控制浮点上下文的保存与恢复。 4 ET (Extension Type) 在早期处理器中,控制扩展类型。现代系统中通常不使用。 5 NE (Numeric Error) 启用或禁用数字错误异常(例如浮点异常)。 16 WP (Write Protect) 启用写保护,防止写入只读页面。 31 PG (Paging) 启用分页模式。启用时,CPU 使用虚拟内存和分页机制。 功能
保护模式(PE):启用保护模式是进入 32 位或 64 位操作系统的基础,它启用分页机制、虚拟内存、段保护等功能。
分页(PG):当设置此位时,CPU 进入分页模式,允许使用分页机制进行虚拟内存管理。
写保护(WP):启用此功能时,只有特定的内存页面可以被写入,防止在用户空间对内存进行非法写操作。
CR2 寄存器
CR2
寄存器通常用于存储 页面错误(Page Fault) 时访问的 虚拟地址。当操作系统捕获到一个页面错误时,CR2
会保存导致错误的线性地址,操作系统可以根据这个信息来处理页面缺失。
作用
页面错误地址:当 CPU 在分页模式下遇到页面错误时,
CR2
保存了导致错误的虚拟地址。这对操作系统来说非常重要,因为它帮助操作系统确定哪个页面无法访问,通常是因为页面未加载或权限错误。
使用场景
操作系统的页面错误处理程序读取
CR2
,获取发生错误的虚拟地址,并根据该地址决定是加载页面、分配新页面还是终止进程。
CR3 寄存器
CR3
寄存器用于存储 页目录基地址(Page Directory Base Address,PDBA),即当前活动页表(页目录)在内存中的物理地址。在分页模式下,CPU 通过 CR3
来查找页面映射的表格,负责虚拟地址到物理地址的转换。
作用
页目录基地址:
CR3
存储了当前进程使用的页目录的物理地址,页目录用于管理页表的层次结构。页表负责将虚拟地址映射到物理内存中的页面。上下文切换:每当发生进程切换时,操作系统会更新
CR3
寄存器,指向新进程的页目录基地址。
使用场景
页面翻译:在分页模式下,
CR3
寄存器指向活动页目录,从而为虚拟地址到物理地址的转换提供基础。上下文切换:在进程切换时,操作系统会保存当前进程的
CR3
寄存器值并加载新进程的CR3
,确保新进程能够使用其独立的虚拟地址空间。
以上是ChatGPT给出的概念,经过本人测试,基本上符合Linuxkernel6.11.7版本的代码规范。
内核态运行顺序确认
我们根据上一篇的代码和分析结果继续。用户态EA第一次被Linuxkernel调用:
0x469600
c
Process 1 resuming
Process 1 stopped
thread #1, stop reason = breakpoint 1.1
frame #0: 0x0000000000469600
error: memory read failed for 0x469600
r cr0 cr2 cr3
cr0 = 0x0000000080050033
cr2 = 0x0000000000558dc0
cr3 = 0x000000000451e000
cr2保存的是导致页错误的地址,这里的页错误地址是0x558dc0。根据上一篇的分析,当前的EA(第一次断点)所在的位置是中断例程快要结束时的函数vmlinux`common_interrupt_return调用的,关于这点可以通过Qemu的输出的ASM指令地址进行确认(这里有个误区,通过断点运行多少次确认是哪个函数调用,是比较麻烦的,所以Qemud ASM确认是比较明智的选择):
(lldb) di -s 0xffffffff820016c3 -c5
vmlinux`common_interrupt_return:
0xffffffff820016c3 <+243>: test byte ptr [rsp + 0x20], 0x4
0xffffffff820016c8 <+248>: jne 0xffffffff820016cc ; <+252>
0xffffffff820016ca <+250>: iretq
0xffffffff820016cc <+252>: push rdi
0xffffffff820016cd <+253>: swapgs
基于当前EA所在的函数以及cr2的值,可以确定当前调用EA地址的堆栈进行了一个中断,而这个中断是因为地址0x558dc0导致的页异常从而进行了中断。为啥能够确认是页异常呢,因为0x558dc0地址所在的值为0,无法读取。如下确认:
(lldb) x/8gx 0x0000000000558dc0
0x00558dc0: 0x0000000000000000 0x0000000000000000
0x00558dd0: 0x0000000000000000 0x0000000000000000
0x00558de0: 0x0000000000000000 0x0000000000000000
如此我们就理顺了整个Go-main的调用过程,execve函数加载了二进制可执行elf文件,并且载入了相关的信息,比如用户态EA。当Linuxkernel需要访问用户态EA地址的时候,通过0x558dc0地址报了页异常,进行了页异常处理和中断,中断之后会设置RIP为用户态EA,然后切换(swapgs等)内核态寄存器等信息到用户态,最后直接通过RIP跳转到用户态执行。
linuxkernle为什么第一次调用EA的时候会报页异常,因为它是通过内核态地址0x558dc0进入的页异常,在里面调用的EA。EA的地址跟CR2所在的地址不同,导致了页异常,而第二次修正了CR2的地址与EA相同,所以第二次可以跳转到用户态,这点以下代码可以确认。我们继续运行到linuxkernel第二次调用EA地址的地方
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #1, stop reason = breakpoint 2.1
frame #0: 0x0000000000469600
-> 0x469600: jmp 0x465ec0
0x469605: int3
0x469606: int3
0x469607: int3
(lldb) re r cr0 cr2 cr3
cr0 = 0x0000000080050033
cr2 = 0x0000000000469600
cr3 = 0x000000000451e000
我们把此时的CR0,CR2,CR3跟第一次运行的对比:
当前:
cr0 = 0x0000000080050033
cr2 = 0x0000000000469600
cr3 = 0x000000000451e000
第一次:
cr0 = 0x0000000080050033
cr2 = 0x0000000000558dc0
cr3 = 0x000000000451e000
不同者在与CR2的值,第一次kernel调用EA的时候CR2地址是0x558dc0,而第二次就变成了EA自己本身的地址。
以上分析完,这里看下在EA第一次被调用的时候CR0的状态
(lldb) f
frame #0: 0x0000000000469600
error: memory read failed for 0x469600
(lldb) p/t $cr0
(unsigned long) $1 = 0b0000000000000000000000000000000010000000000001010000000000110011
我们看到当前系统处在保护模式下,且监控了寄存器处理浮点单元,带有控制扩展类型,启用或禁用数字错误异常,启用写保护,防止写入只读页面
但这里依旧有个问题0x558dc0这个地址在内核里面到底是什么?我们下面继续看下。
内核态0x558dc0
通过软硬(注意硬件断点是读写)断点同时跟踪这个地址,软件断点似乎不起作用:
(lldb) b 0x469600
(lldb) b 0x0000000000558dc0
(lldb) watchpoint set expression -w read_write -- 0x0000000000558dc0
硬件断点分别跟踪到了如下地方:
(lldb) c
Process 1 resuming
Watchpoint 1 hit:
old value: 0
new value: 1578648346
Process 1 stopped
* thread #1, stop reason = watchpoint 1
frame #0: 0x00000000001002a2
-> 0x1002a2: rep movsq qword ptr es:[rdi], qword ptr [rsi]
0x1002a5: cld
(lldb) re r rdi rsi
rdi = 0x00000000031a3db8
rsi = 0x0000000000558db8
--------------------------------------------------------------
--------------------------------------------------------------
(lldb) c
Process 1 resuming
Watchpoint 1 hit:
old value: 1578648346
new value: 0
This version of LLDB has no plugin for the language "assembler". Inspection of frame variables will be limited.
Process 1 stopped
* thread #1, stop reason = watchpoint 1
frame #0: 0xffffffff81f44603 vmlinux`rep_stos_alternative at clear_page_64.S:97
94 .p2align 4
95 .Lunrolled:
96 10: movq %rax,(%rdi)
-> 97 11: movq %rax,8(%rdi)
98 12: movq %rax,16(%rdi)
(lldb) di -s $rip -c5
vmlinux`rep_stos_alternative:
-> 0xffffffff81f44603 <+67>: mov qword ptr [rdi + 0x8], rax
0xffffffff81f44607 <+71>: mov qword ptr [rdi + 0x10], rax
0xffffffff81f4460b <+75>: mov qword ptr [rdi + 0x18], rax
0xffffffff81f4460f <+79>: mov qword ptr [rdi + 0x20], rax
0xffffffff81f44613 <+83>: mov qword ptr [rdi + 0x28], rax
(lldb) re r rdi rax
rdi = 0x0000000000558dc0
rax = 0x0000000000000000 vmlinux.PT_LOAD[2]..data..percpu + 0
(lldb) x/8gx 0x0000000000558dc0
0x00558dc0: 0x0000000000000000 0x0000000000000000
第一次调用是被读取(rsi)的状态,第二次则是被写入(rdi)的状态,且向他写入的rax值为0,这是在第一次调用EA前0x558dc0最后一次被读写(因为再Continue就会来到EA入口)。这也就是为什么后面导致了页异常,因为0是无法被执行读取的。看下第一次EA:
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #1, stop reason = breakpoint 2.1
frame #0: 0x0000000000469600
error: memory read failed for 0x469600
继续运行下面就是第二次执行EA了,后面继续运行地址0x558dc0依旧会被读写赋值,但此时对于分析go-main已经不重要了。不过它自带一个有个较为重要的细节:
(lldb) c
Process 1 resuming
Watchpoint 1 hit:
old value: 0
new value: 131112
Process 1 stopped
* thread #1, stop reason = watchpoint 1
frame #0: 0x00000000004884fa
-> 0x4884fa: mov rax, qword ptr [rip + 0xc6d8f]
0x488501: lea rbx, [rip + 0x2a312]
0x488508: mov ecx, 0xb
0x48850d: call 0x488e20
(lldb) f
frame #0: 0x00000000004884fa
-> 0x4884fa: mov rax, qword ptr [rip + 0xc6d8f]
0x488501: lea rbx, [rip + 0x2a312]
0x488508: mov ecx, 0xb
0x48850d: call 0x488e20
(lldb) re r rax
rax = 0x000000c000020028
(lldb) p/x $rip+0xc6d8f
(unsigned long) $1 = 0x000000000054f289
看到此处的并没有操作0x558dc0,向上推两三个或者三四个字节(这里推5个)试试,看到了eax加上了0xd08c6依旧没有操作0x558dc0,继续向上或者向下推,依旧没有看到。
(lldb) di -s $rip-5 -c5
0x4884f5: add eax, 0xd08c6
-> 0x4884fa: mov rax, qword ptr [rip + 0xc6d8f]
0x488501: lea rbx, [rip + 0x2a312]
0x488508: mov ecx, 0xb
0x48850d: call 0x488e20
上面又进入了一个误区,其实看下Qemu的ASM很容易推测到0x558dc0在哪里操作了。当Qemu-asm如下:
IN:
0x004884f3:
OBJD-T: 488905c6080d00
---------------------------------------------------------
(lldb) di -s 0x004884f3 -c5 -b
0x4884f3: 48 89 05 c6 08 0d 00 mov qword ptr [rip + 0xd08c6], rax
-> 0x4884fa: 48 8b 05 8f 6d 0c 00 mov rax, qword ptr [rip + 0xc6d8f]
0x488501: 48 8d 1d 12 a3 02 00 lea rbx, [rip + 0x2a312]
0x488508: b9 0b 00 00 00 mov ecx, 0xb
0x48850d: e8 0e 09 00 00 call 0x488e20
原来是要向上推7个字节才能看到0x558dc0被操作的地方。为了验证下这个说法,重启下qemu和lldb,在0x4884f3下断。此时就看到了如所说
(lldb) b 0x4884f3
Breakpoint 1: address = 0x00000000004884f3
(lldb) c
Process 1 resuming
Process 1 stopped
* thread #1, stop reason = breakpoint 1.1
frame #0: 0x00000000004884f3
-> 0x4884f3: mov qword ptr [rip + 0xd08c6], rax
0x4884fa: mov rax, qword ptr [rip + 0xc6d8f]
0x488501: lea rbx, [rip + 0x2a312]
0x488508: mov ecx, 0xb
(lldb) p/x $rip+0xd08c6
(unsigned long) $0 = 0x0000000000558db9
结尾
linux内核端通过某个固定地址(0x558dc0)指向的值为0这一特征,进入到了页异常和中断,通过中断函数设置了iretq的RIP为用户态EA地址,此时跳转到EA导致异常,因为CR2实际上还是内核某个固定地址。然后继续页异常,中断,切换内核态状态(寄存器,堆,栈,设置RIP为EA,切换CR2值等等),最后通过iretq跳转到用户态EA执行用户态代码。
这里有两个误区都是没有通过Qemu-asm确认,所以需要注意。以上就是整个Go-main在linuxkernel里面运行的过程,基本上做到了大部分地方的覆盖。
个人感觉,Go对于内核的操作,比之Rust/.NET是要多的多的。不过可能功力提升,分析的速度比之Rust/.NET要快很多。
以上就是本篇内容了。感谢关注,点赞,转发,收藏。
往期精彩回顾