Go-main linux内核细节收尾

文摘   2024-11-22 14:09   美国  

点击上方蓝字 江湖评谈设为关注/星标




前言

上一篇:Go-main内核态细节 最后提到了一个问题,Go-main是由Linuxkernel两次调用用户态EA(Entrypoint Address)之后运行的,为啥第一次调用EA的时候内核态不会跳转到用户态,而是报了页错误异常。第二次则不会报页错误异常,从内核态切到了用户态,进行了正常运行。同一个地址调用,为啥运行结果就不同呢?本篇看下这个问题。

概念

要解决这个问题,首先了解x64里面的几个特殊寄存器

CR0 寄存器

  1. CR0 是控制寄存器之一,用于控制操作系统如何使用 CPU 的不同特性,尤其是与保护模式、分页和浮点单元等相关的功能。

  2. 作用

  3. CR0 寄存器的位用于启用或禁用特定的 CPU 特性,以下是一些主要的控制位:

  4. 名称描述
    0PE (Protection Enable)启用保护模式。保护模式允许操作系统使用虚拟内存、权限控制等。
    1MP (Monitor Coprocessor)控制浮点单元监视。影响处理器如何处理浮点异常。
    2EM (Emulation)如果设置,禁用浮点单元的操作,转而模拟浮点操作。
    3TS (Task Switched)当设置时,表明发生了任务切换,控制浮点上下文的保存与恢复。
    4ET (Extension Type)在早期处理器中,控制扩展类型。现代系统中通常不使用。
    5NE (Numeric Error)启用或禁用数字错误异常(例如浮点异常)。
    16WP (Write Protect)启用写保护,防止写入只读页面。
    31PG (Paging)启用分页模式。启用时,CPU 使用虚拟内存和分页机制。
  5. 功能

  6. 保护模式(PE):启用保护模式是进入 32 位或 64 位操作系统的基础,它启用分页机制、虚拟内存、段保护等功能。

  7. 分页(PG):当设置此位时,CPU 进入分页模式,允许使用分页机制进行虚拟内存管理。

  8. 写保护(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调用:

(lldb) b 0x469600(lldb) cProcess 1 resumingProcess 1 stopped* thread #1, stop reason = breakpoint 1.1    frame #0: 0x0000000000469600error: memory read failed for 0x469600(lldb)re 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 -c5vmlinux`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 0x0000000000558dc00x00558dc0: 0x0000000000000000 0x00000000000000000x00558dd0: 0x0000000000000000 0x00000000000000000x00558de0: 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) cProcess 1 resumingProcess 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) fframe #0: 0x0000000000469600error: 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) cProcess 1 resumingWatchpoint 1 hit:old value: 0new value: 1578648346Process 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) cProcess 1 resumingWatchpoint 1 hit:old value: 1578648346new value: 0This 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 -c5vmlinux`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 0x0000000000558dc00x00558dc0: 0x0000000000000000 0x0000000000000000

第一次调用是被读取(rsi)的状态,第二次则是被写入(rdi)的状态,且向他写入的rax值为0,这是在第一次调用EA前0x558dc0最后一次被读写(因为再Continue就会来到EA入口)。这也就是为什么后面导致了页异常,因为0是无法被执行读取的。看下第一次EA:

(lldb) cProcess 1 resumingProcess 1 stopped* thread #1, stop reason = breakpoint 2.1    frame #0: 0x0000000000469600error: memory read failed for 0x469600

继续运行下面就是第二次执行EA了,后面继续运行地址0x558dc0依旧会被读写赋值,但此时对于分析go-main已经不重要了。不过它自带一个有个较为重要的细节:

(lldb) cProcess 1 resumingWatchpoint 1 hit:old value: 0new value: 131112Process 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) fframe #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 0x4884f3Breakpoint 1: address = 0x00000000004884f3(lldb) cProcess 1 resumingProcess 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要快很多。

以上就是本篇内容了。感谢关注,点赞,转发,收藏。

往期精彩回顾

Go-main内核态细节

Go-main如何被Linux内核加载执行的


江湖评谈
记录,分享,自由。
 最新文章