第2章:Linux系统调用基础——系统调用表、int 0x80与syscall指令

系统调用,说白了就是用户态程序请求内核帮忙干活的唯一通道。你在CTF里做Pwn题,无论是写shellcode还是绕过沙箱,都绕不开这玩意儿。我刚开始搞逆向的时候,总觉得系统调用是内核的事,跟我没关系——直到第一次手写shellcode,发现连个read都调不明白,才老老实实回来补课。

2.1 系统调用表:内核的“服务菜单”

内核维护着一张全局表,叫系统调用表(sys_call_table)。每个表项是一个函数指针,指向具体的系统调用处理函数。用户程序通过系统调用号来索引这张表,告诉内核“我要第X号服务”。

这张表在内存中的布局大致如下:

// x86-64 架构下的 sys_call_table 示意
void *sys_call_table[] = {
    [0]  = sys_read,        // 从文件描述符读取
    [1]  = sys_write,       // 写入文件描述符
    [2]  = sys_open,        // 打开文件
    [3]  = sys_close,       // 关闭文件描述符
    // ... 中间省略几百项
    [59] = sys_execve,      // 执行程序
    [60] = sys_exit,        // 退出进程
    [61] = sys_wait4,       // 等待子进程
    // ...
};

这张表是内核的“命根子”。沙箱机制,比如seccomp,本质上就是在你发起系统调用时,拦截检查这张表的索引——你调了哪个号,参数是什么,该不该放行。

关键点:系统调用号在不同架构下不一样。x86(32位)和x86-64(64位)的编号体系完全不同。比如execve在x86上是11号,在x86-64上是59号。写shellcode时搞混了,程序直接崩。

2.2 int 0x80 与 syscall 指令:两条不同的路

Linux提供了两种触发系统调用的指令,分别对应不同的架构和调用约定。

2.2.1 int 0x80(传统32位方式)

这是x86架构的老牌方式。通过软中断int 0x80,CPU会跳转到内核预置的中断处理函数。调用时:

  • 系统调用号放在 eax 寄存器
  • 参数依次放在 ebx、ecx、edx、esi、edi、ebp
  • 返回值在 eax 中

一个典型的32位shellcode片段:

; 32位 Linux execve("/bin/sh", NULL, NULL)
xor eax, eax
push eax
push 0x68732f2f      ; "//sh"
push 0x6e69622f      ; "/bin"
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov al, 11           ; syscall number for execve (x86)
int 0x80

我在一次CTF比赛中遇到过一道题,题目环境是64位内核,但shellcode只能用32位指令。当时我直接用int 0x80调execve,结果发现返回值一直是-38(ENOSYS)。后来查资料才明白——64位内核虽然兼容int 0x80,但它的系统调用表是64位的,32位调用号根本对不上。

避坑指南:在64位进程中使用int 0x80,系统调用号会被解释为32位编号,但内核实际查的是64位表。比如你传eax=11(32位的execve),内核会把它当成64位的munmap(11号),结果完全不是你想要的。

2.2.2 syscall(64位原生方式)

从x86-64架构开始,Linux引入了syscall指令。它比int 0x80更快,因为不走中断描述符表,而是通过MSR寄存器直接跳转。调用约定也变了:

  • 系统调用号放在 rax 寄存器
  • 参数依次放在 rdi、rsi、rdx、r10、r8、r9
  • 返回值在 rax 中
  • rcx 和 r11 会被内核破坏(保存了返回地址和RFLAGS)

64位shellcode示例:

; 64位 Linux execve("/bin/sh", NULL, NULL)
xor rdx, rdx
xor rsi, rsi
mov rdi, 0x68732f6e69622f2f  ; "/bin//sh"
push rdi
mov rdi, rsp
mov rax, 59                  ; syscall number for execve (x86-64)
syscall

2.3 系统调用号与参数传递:一张表说清楚

我把两种方式的差异整理成了一张表,方便你对照:

项目 int 0x80(32位) syscall(64位)
触发指令 int 0x80 syscall
调用号寄存器 eax rax
参数1 ebx rdi
参数2 ecx rsi
参数3 edx rdx
参数4 esi r10
参数5 edi r8
参数6 ebp r9
返回值 eax rax
被破坏的寄存器 无(保存所有) rcx、r11

你想想看,为什么64位要把参数寄存器从ebx系列换成rdi系列?其实是为了和函数调用约定(x86-64 ABI)保持一致。这样内核在处理系统调用时,可以直接复用用户态的寄存器布局,少做一次搬运。

个人习惯:我写shellcode时,优先用syscall指令。除非题目明确限制了只能用32位指令,否则64位方式更稳定、更不容易踩坑。而且沙箱规则通常只拦截syscall,用int 0x80有时候反而能绕过——但这属于后几章的内容了。

2.4 常见系统调用号速查

CTF里常用的系统调用就那么几个。我建议你把这些背下来,至少看到编号能立刻反应出是哪个函数:

系统调用 x86(32位)编号 x86-64(64位)编号 功能简述
read 3 0 从文件描述符读取数据
write 4 1 向文件描述符写入数据
open 5 2 打开文件
close 6 3 关闭文件描述符
mmap 90 9 内存映射
mprotect 125 10 修改内存页保护属性
execve 11 59 执行新程序
exit 1 60 退出进程

我曾经在分析一个沙箱规则时,发现它只拦截了execve(59号),但没拦截open和read。这意味着我虽然不能执行新程序,但可以打开文件读取flag。嗯,这就是系统调用号知识在实际绕过中的价值。

2.5 系统调用流程全景图

下面这张图展示了从用户态发起系统调用到内核处理完成的完整路径:

Linux系统调用完整流程 用户态(User Space) 1. 设置系统调用号 → rax/eax 2. 设置参数 → rdi/rdx/... 或 ebx/ecx/... 3. 执行 syscall 或 int 0x80 syscall / int 0x80 内核态(Kernel Space) 4. 保存用户态上下文 5. 根据系统调用号查 sys_call_table 6. 调用对应的内核函数 沙箱拦截点(seccomp等) sys_call_table [0] → sys_read [1] → sys_write [2] → sys_open [3] → sys_close [4] → sys_stat [5] → sys_fstat [6] → sys_lstat [7] → sys_poll [8] → sys_lseek [9] → sys_mmap 查表索引 💡 说明: 沙箱(如seccomp)在步骤5和6之间插入检查逻辑。如果系统调用号或参数被规则禁止,内核直接返回错误,不会执行真正的处理函数。

这张图把整个流程串起来了。你从用户态发起调用,经过syscall或int 0x80陷入内核,内核查表找到对应的处理函数。沙箱的拦截点就在查表之后、执行之前——这也是我们后续章节要重点突破的地方。

2.6 本章小结

系统调用是用户态和内核态之间的唯一桥梁。理解int 0x80和syscall的区别、系统调用号的分配规则、参数传递的寄存器约定,是后续编写shellcode和分析沙箱规则的基础。我个人建议你动手写几个简单的shellcode,分别用32位和64位方式调write输出一行文字,跑通了再往下走——纸上得来终觉浅。