调试器实现原理 (ptrace) 和 Segmentation Fault 产生摘要

    本文总结以下 ptrace 的功能,从流程上讲解以下大致如何实现一个调试器的思路,以及介绍 初学编程时常常遇到的 Segmentation Fault 和 Stack Overflow 的问题的意义。这里注意栈溢出一般是只有 debugger 才能提示,直接运行程序时一样会出现 Segmentation Fault,本文也讲解 debugger 或者用户程序自己如何判断是栈溢出的方案(附相关的系统调用和内核中的原理)。


信号

    Linux 具备多种信号量,这里摘录以下常见的几个:

SIGILL

非法指令

SIGSEGV

segmentation fault 非法段(内存)访问 (since elf 是按段加载权限的)

SIGCHLD

子进程变成僵尸了

SIGTSTP

终端停止

SIGTTIN/SIGTITOU

后台进程读写中断

SIGINT

键盘中断

  • 这些信号都有默认行为, 默认么是终止或者 core dump (转存 image 到硬盘, 系统可以开启或关闭此功能, xv6 里面 p->kill 之后直接就回到 shell 了.)要么是忽略.
  • 这些信号记录由于在机器位上只有一位, 意味着连续的信号是不缓存的, 等于一个 FIFO 结构直接丢弃信号, 导致不可预料的后果. Linux 默认当 handler 在执行时阻塞其他的 SIG, 导致很多连续的阻塞发生后, 仅检测到两个阻塞(返回后缓存了一个阻塞, 实际等于没有缓存). 如通过信号来实现计数就是错误的信号编程.


调试器怎么实现(ptrace)

    如果要搞破解和开发外挂,可以学习调试器实现原理。

    以下内容总结自 man ptrace 以及网络。首先是图片,ptrace 如何转移子进程控制权到父进程。ptrace 是一个能够将子进程信号转接到父进程的系统调用,同时支援 pid 字节转接信号 handler 控制权和提供读取和修改程序内存数据的功能。

  • 基本功能:ptrace 提供 poke 的调用(即推数据注入)和 peek 的调用(查看数据)。(此时可以通过 man 查看相关函数列表)。
  • 转接信号:子进程调用 ptrace 注册则意味该进程被其父进程跟踪。任何传递给该进程的信号(除了 SIGKILL)都将通过 wait() 方法阻塞该进程并通知其父进程。此外,该进程的之后所有调用 exec() 动作都将导致 SIGTRAP 信号发送到此进程上,使得父进程在该发送这个请求。
  • 拦截系统调用:值得一提的是 ptrace 不仅支援信号,还支援拦截系统调用,而这些机制实现是用了很别扭的方法,即通过僵尸状态,我们知道 wait 当且仅当孩子变成僵尸快死了才能返回,但是这里的僵尸状态将充当一个睡眠状态。也不奇怪为什么会用到 SIGCHLD 信号了。
  • Attach 模式:除了直接运行的 fork 模式,ptrace 还支援直接换父亲,即常见 debugger 中的 attach 模式。
  • 读取寄存器:ptrace 支持获取寄存器状态,这一点很容易理解,只需要把 trapframe 传出来就行了。
  • 端点原理:断点的原理是软中断,通过插入和修改代码即可。例如 0xcc 是软中断指令,则在断点处把开头的指令替换为 0xcc,执行程序的时候到达 0xcc 控制权将转换到 debugger,从而可以暂停子程序运行,通过 ptrace 调用很容易在父进程(debugger)获取控制权候进行修改操作,随后返回运行,这也就是为什么调试时要程序断停下来才能够添加新的端点,因为实际上 debugger 和 程序 是顺序性交替运行的。
  • 条件断点:其思想实际还是 sigtrap 到 debugger,然后debugger 判断再决定,所以效率低下。为此 CPU 应当提供方案,即硬件断点通过硬件执行判断,这点具体实现比较专有。
  • 烫烫烫 和 屯屯屯 :我们知道在 xv6 里面内核 kalloc 和 free 管理存储空间的时候,都会填充特定的 byte 数据,这样就知道该内容是什么样的了方便调试。这个思路调试时也可以用,正常操作系统或 gcc 加载 ELF 文件的时候就不会进行这个处理常常就出现 segmentation fault。在 windows 下 vc 的 debug 程序会把栈内存全部初始化为 0xcc(对就是前面的软中断 int 3,中断3 的意思),这样做的好处是如果访问了野指针作为函数入口,就会进入 ide debugger 的中断中去,而堆则用 0xcd 填充,即屯屯屯,这样则是能够让识别野指针的内存访问。双方都支持的功能是非法访问到未初始化的地方,就能以 GBK 加载出烫烫和屯了。所以出现烫可以判断是局部变量数组越界,而出现屯则是访问了没有 malloc、realloc 的堆内存地址或者栈空间的数组越界。

    完整的思路是:一开始运行程序时就引发软中断 sigtrap,即上图中的那样,debugger 进程通过调用fork函数创建子进程并让子进程执行PTRACE_TRACEME,然后子进程再调execve()来运行我们要调试的程序,则 execve 把 elf 加载进来候马上就通过 sigtrap 转接给 debugger 进程。attach 模式则通过系统调用实现暂停运行。然后控制权转至 debugger。


 Segmentation Fault

    对于这个错误,实际上就是内存的越界访问,叫 segmentation 则是因为 elf 是段式虚拟内存,所以历史原因。先引用一段网络上总结地比较好的资料。

   我们在 shell 中启动一个进程,进程访问一个虚拟地址,MMU 将这个虚拟地址转化成物理地址的过程中,发现转化不了,于是产生一个中断。中断的过程即由 IDTR 找到 IDT,执行中断处理函数,最后调到 do_page_fault 函数。do_page_fault 在当前进程的 vma 中找这个地址(task_struct 维护一颗 vm_area 的红黑树),如果访问的虚拟地址没有落到任何一个 vma 中,那么 do_page_fault 会给进程发送一个 SIGSEGV 信号。

    SIGSEGV 信号的默认 handler 是 Core,终止这个进程,产生一个 core dump。终止的时候,这个进程把它的 status 给父进程 shell(segfault 的 status 是 139),shell 收到了之后,知道子进程是因 segfault 退出,就在屏幕上打出来一行字 Segmentation fault (core dumped)

链接:https://www.zhihu.com/question/338188059/answer/808073398

   涉及内容具体摘要:

  • 虚拟内存:MMU 负责的是 PTE 页表项的转换,但是 mmap(lab: mmap) 的时候常常涉及的是 文件 等内容,所以需要使用 lazy allocation,或者 lazy load,具体就是先不 map pte(因为文件还没有加载,如果 map 了就会)而加载到 proc PCB(进程控制块) 的记录 vm 区域的 vm_area 结构体去。这个结构体可以是线性链式的但是导致检索速度慢,所以一般采用红黑树方案。每当真正访问到这个 virtual address 的时候,在 trap handler 里面再处理 lazy load 和真正的 map pte,这样 trap 返回后就能正常访问。
  • 注册 handler:为了让 user space 能够从 kernel 里面获取信号,在 6.S081 里进行了 alarm 的编程练习 (lab: trap),具体实现是通过 register 的机制,user space 通过定义函数指针作为 signal handler 并 register 到 kernel 中,当 kernel trap 时候,查看 pointer 是否存在,从而 trap 跳转至 signal handler 指向的函数,所以这里 PCB 里肯定有一个函数指针表用于存储各种信号量的 handler 函数指针。
  • 默认情况: 如果 OS 接受到 interrupt 而进入 trap handler,得知当前 signal 之后他会检索 proc 的 handler 列表有没注册这个 signal 的handler,从而进入默认的处理:即 dump (coredump文件包含了程序运行时的内存,寄存器状态,堆栈指针,内存管理信息等)和 kill。

内核 do_page_fault 函数源码阅读

    以下附 Linux 内核 trap handler 转接的处理 page fault 的 do_page_fault 函数,可以发现 stack overflow 实际也会以 sigsegv 返回。


Stack Overflow

    了解 ptrace 后可以理解为什么 Stack Overflow 可以被 debugger 捕获。接下来理解为什么 Stack 空间需要设定。我们复习用户地址空间:

  为了限制 stack,可行的方案有 canary space (类似 guard page)让 trap handler 来决定还能不能 grow。请理解栈溢出的实际行为是 sp 指针增加后访问了非法地址空间,即 SIGSEGV 信号的行为。此时如果无法 grow stack 的话就 core dump 和 kill 了。Linux 下限制 stack grow 的是 ulimit -s SIZE 可调整的,由于栈顶是 VM_MAX, 所以实际上是可以做很大的栈空间的,我野不太清楚为什么要限制栈的大小。

    结合上述源码,我们知道有一个 first user address,这个东西是地址随机化的哪个,实际不是我们 limit 的部分,他是最底线的限制,不能超过为进程分布的虚拟空间。实际的涉及 ulimit 的部分则是由 expand_stack 函数内部判断的,如果超过限制将不进行分配从而返回错误。


如何在应用层捕获 SIGSEGV 判断栈溢出

    问题:为什么编写 SIGSEGV signal handler 无法捕获 stack overflow 的情况而还是 core dump 了呢?

    明明都是 segmentation fault?答案是这里涉及的是,由于 handler 运行需要 stack !那么解决方案是 Signal Stack (The GNU C Library) 原理也很简单,就是用 signalstack syscall register 一个 stack 空间即可,这个栈建议设计的是一个堆上的空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值