系统调用

Syscall简介

syscall 类似于一种全局可用的函数,由操作系统内核提供。系统调用会接收寄存器中的参数,并执行包含这些参数的函数。

Linux 内核提供了许多可用的系统调用。我们可以通过读取系统文件中的 unistd_64.h 字段来获取这些调用的列表以及每个调用的编号 syscall number

cat /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H 1

#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5

系统调用号

Linux x86-64 常见 syscall number:

syscall编号十六进制作用
read00x0从文件描述符读取数据
write10x1向文件描述符写入数据
open20x2打开文件
close30x3关闭文件描述符
execve590x3b执行程序
exit600x3c退出进程
mmap90x9映射内存
mprotect100xa修改内存权限
socket410x29创建 socket
connect420x2a连接远程主机
dup2330x21复制文件描述符

比如说调用一个新的程序(such as sh):

在C里面是这样子的

int execve(const char *filename, char *const argv[], char *const envp[]);

对应syscall参数

参数寄存器含义
rax59syscall number
rdifilename要执行的程序路径
rsiargv参数数组
rdxenvp环境变量数组

也就是近似这样:

mov rax, 59        ; execve
mov rdi, "/bin/sh" ; 第一个参数:程序路径
mov rsi, 0         ; 第二个参数:argv
mov rdx, 0         ; 第三个参数:envp
syscall

但是汇编里面不能直接把字符串直接赋值给指针,所以我们需要用push来压个栈;同时系统对系统调用时会以\x0为结尾,所以需要提前放0进入进去,构造一个/bin/sh\0的这么一个字符串 也就是这样

xor rax,rax
push rax

move rbx,0x68732f6e69622f
push rbx

# 此时rsp就是这样:
# rsp -> 2f 62 69 6e 2f 73 68 00  
#        00 00 00 00 00 00 00 00

mov rax, 59
mov rdi, rsp

xor rsi, rsi
xor rdx,rdx
syscall

外部函数调用

在汇编里调用 C 库函数,比如:printfputsscanfmallocstrlen这些函数不是我们自己在当前 .s 文件里实现的,而是在外部库里,比如 libc 里面。

所以需要用:

extern printf

告诉汇编器:

printf 这个符号不是我当前文件里定义的,后面链接的时候,你去外部库里找。

例如:

global main
extern printf

section .data
    fmt db "It's %s", 10, 0
    msg db "Aligned!", 0

section .text
main:
    sub rsp, 8

    mov rdi, fmt
    mov rsi, msg
    xor eax, eax
    call printf

    add rsp, 8
    xor eax, eax
    ret

这里:

extern printf

表示 printf 来自外部。

call printf

表示调用外部函数。

原则

Linux x64 外部函数调用参数

Linux x64 使用 System V ABI。

前 6 个参数通过寄存器传递:

第 1 个参数:rdi
第 2 个参数:rsi
第 3 个参数:rdx
第 4 个参数:rcx
第 5 个参数:r8
第 6 个参数:r9

比如 C 语言里:

printf("It's %s\n", "Aligned!");

汇编里就是:

mov rdi, fmt      ; 第 1 个参数,格式字符串
mov rsi, msg      ; 第 2 个参数,%s 对应的字符串
xor eax, eax      ; printf 是可变参数函数,需要清空 eax
call printf

这里的:

xor eax, eax

是因为 printf 属于可变参数函数。
在 Linux x64 ABI 里,调用可变参数函数时,al 要表示用了几个向量寄存器传浮点参数。没有浮点参数时,直接置 0:

xor eax, eax

注意:字符串结尾要有 0

如果使用 %s,字符串必须以 0x00 结尾。

正确写法:

fmt db "It's %s", 10, 0msg db "Aligned!", 0

不要写成:

msg db "Aligned!", 10

因为 %s 会一直读,直到遇到 0x00 才停止。
如果没有 0x00,可能会读到后面的脏数据,甚至崩溃。

栈对齐

Linux x64 下,调用函数前有一个重要规则:执行 call 指令之前,rsp 必须是 16 字节对齐的。

所以我们需要检查rsp的值,如果遇到rsp不等于0的情况,就需要保证堆栈平衡

也就是经常需要补8的原因:

print:
    sub rsp, 8

    call printf

    add rsp, 8
    ret

常见的Libc函数

printf 函数

printf 是 libc 中最常见的输出函数,用来按照指定格式打印内容。

在 C 语言中:

printf("It's %s\n", "Aligned!");

在 Linux x64 汇编中,前几个参数通过寄存器传递:

第 1 个参数:rdi
第 2 个参数:rsi
第 3 个参数:rdx
第 4 个参数:rcx
第 5 个参数:r8
第 6 个参数:r9

所以:

printf("It's %s\n", "Aligned!");

对应汇编:

extern printf
 
section .data
    fmt db "It's %s", 10, 0
    msg db "Aligned!", 0
 
section .text
    ; call printf 前要保证 rsp 16 字节对齐
    sub rsp, 8
 
    mov rdi, fmt      ; 第 1 个参数:格式字符串
    mov rsi, msg      ; 第 2 个参数:%s 对应的字符串
    xor eax, eax      ; printf 是可变参数函数,al 置 0
    call printf
 
    add rsp, 8

其中:

mov rdi, fmt

相当于传入第一个参数:

printf(fmt, msg);
mov rsi, msg

相当于传入第二个参数:

printf("It's %s\n", msg);

常见格式符

%d    十进制整数
%x    十六进制整数
%p    指针地址
%s    字符串
%c    字符

例如:

section .data
    fmt db "num = %d, str = %s", 10, 0
    msg db "hello", 0
 
section .text
    sub rsp, 8
 
    mov rdi, fmt
    mov rsi, 123
    mov rdx, msg
    xor eax, eax
    call printf
 
    add rsp, 8

对应 C 语言:

printf("num = %d, str = %s\n", 123, "hello");

输出:

num = 123, str = hello

printf 注意点

字符串必须以 0x00 结尾

错误写法:

msg db "hello"

正确写法:

msg db "hello", 0

因为 %s 会一直往后读,直到遇到 0x00 才停止。


printf 是可变参数函数

printf 的参数数量不固定,比如:

printf("%d\n", a);
printf("%d %d\n", a, b);
printf("%d %d %d\n", a, b, c);

所以在 Linux x64 下,调用前一般要写:

xor eax, eax

表示没有使用向量寄存器传递浮点参数。

初学阶段可以直接记:

调用 printf 前写 xor eax, eax

scanf 函数

scanf 是 libc 中常见的输入函数,用来从标准输入读取数据。

在 C 语言中:

scanf("%d", &num);

注意,scanf 需要的是变量地址,不是变量值。


读取整数

global main
extern printf
extern scanf
 
section .data
    inputFmt db "%d", 0
    outputFmt db "num = %d", 10, 0
 
section .bss
    num resd 1
 
section .text
main:
    sub rsp, 8
 
    ; scanf("%d", &num)
    mov rdi, inputFmt
    mov rsi, num
    xor eax, eax
    call scanf
 
    ; printf("num = %d\n", num)
    mov rdi, outputFmt
    mov esi, [num]
    xor eax, eax
    call printf
 
    add rsp, 8
 
    xor eax, eax
    ret

对应 C 语言:

int num;
scanf("%d", &num);
printf("num = %d\n", num);

重点:scanf 传的是地址

这个很重要。

读取整数时:

mov rsi, num

意思是把 num 的地址传给 scanf

不要写成:

mov rsi, [num]

因为:

mov rsi, num

表示:

rsi = num 的地址

而:

mov rsi, [num]

表示:

rsi = num 里面存的值

scanf 需要写入数据,所以必须给它一个可写地址。


scanf 读取字符串

C 语言:

char buf[64];
scanf("%63s", buf);

汇编:

global main
extern printf
extern scanf
 
section .data
    inputFmt db "%63s", 0
    outputFmt db "you input: %s", 10, 0
 
section .bss
    buf resb 64
 
section .text
main:
    sub rsp, 8
 
    ; scanf("%63s", buf)
    mov rdi, inputFmt
    mov rsi, buf
    xor eax, eax
    call scanf
 
    ; printf("you input: %s\n", buf)
    mov rdi, outputFmt
    mov rsi, buf
    xor eax, eax
    call printf
 
    add rsp, 8
 
    xor eax, eax
    ret

这里推荐写:

inputFmt db "%63s", 0

而不是:

inputFmt db "%s", 0

因为 %s 不限制长度,容易造成缓冲区溢出。

如果 buf 是 64 字节,那么最多读 63 个字符,最后 1 个字节留给 0x00 结尾。


scanf 常见格式符

%d    读取十进制整数,需要 int *
%ld   读取 long,需要 long *
%x    读取十六进制整数
%s    读取字符串,需要 char *
%c    读取单个字符,需要 char *

例如:

scanf("%d", &num);
scanf("%s", buf);
scanf("%c", &ch);

汇编里本质都是:

第 1 个参数:格式字符串
第 2 个参数:变量地址

scanf 返回值

scanf 的返回值在 rax / eax 中。

它返回成功读取了几个数据。

例如:

scanf("%d %d", &a, &b);

如果两个整数都读取成功,返回值是:

2

如果只成功读取一个,返回:

1

如果失败,可能返回:

0

所以汇编里可以这样判断:

call scanf
cmp eax, 1
jne input_error

scanf 注意点

1. 第二个参数一般是地址

读取整数:

mov rsi, num

读取字符串:

mov rsi, buf

不要把变量里面的值传过去。


2. %s 要限制长度

危险写法:

fmt db "%s", 0

推荐写法:

fmt db "%63s", 0

如果缓冲区是:

buf resb 64

那格式符应该限制为:

"%63s"

3. scanf 也是可变参数函数

所以调用前也建议写:

xor eax, eax

printf 一样。