汇编的基本背景我就不提了,然后本篇笔记也是记得比较潦草。这篇笔记主要是用来给我在忘了的时候快速复习用的。

另外由于教程使用的是 GCC asm,所以我基本上会用 GCC asm 的语法。

汇编

寄存器

没啥好说的,最基本的是注意 rip 不能改,而且存放了下一条指令地址。rsp 是栈指针寄存器,指向的是栈的内存地址,这个可以改,但是要小心修改。

赋值

最常见的基本就是 mov.

.intel_syntax noprefix
.global _start
.text
_start:
 
mov rax, 5 ;# 立即数赋值寄存器.
mov rbx, rax ;# 用寄存器赋值
;# mov rip, 00401000 ;# 不能 mov rip 寄存器

简单运算

这没什么好说的。

inc rbx ;# 递增 1
dec rax ;# 递减 1
add rbx, rax ;# rbx 累加 rax
sub rbx, rax ;# rbx 减去 rax

位运算

访问内存与操作内存地址

内存的东西一般用中括号包裹,比如:

mov rax, [something] ;# 把符号 something 地址处的东西存入 rax.

其中读取的字节大小根据寄存器大小而定,比如说 rax 是 64 位也就是 8 字节,那么这里 mov 就会把 地址 something 开始往后偏移八字节的内容存入 rax.

可以通过一些名称来访问寄存器的特定位置:

  • rax (8 bytes)
  • eax (4 bytes)
  • ax (2 bytes)
  • ah (1 bytes, ax 的高位)
  • al (1bytes, ax 的低位)

但是注意,使用 eax 这种别名访问寄存器部分 bits 的时候,mov 会把另外一半没用的 bits 也清空为 0.

另外还有一个 lea 指令可以存内存地址而不是取东西。

lea rax, [mem] ;# 把符号 mem 地址直接作为 rax 的值

syscall

调用操作系统内核的 call,在 Linux 上,rax 存放 call 函数码,然后参数从前往后分别是 rdi / rsi / rdx / r10 / r8 / r9,随后多余的参数压栈,另外 rcx 会在调用的时候被修改。

比如说调用 write 函数。

mov rax, 1 ;# WRITE
mov rdi, 1 ;# STDOUT
lea rsi, [mem] ;# mem 存放了 string
lea rdi, 10 ;# 字符串长度 10 bytes
syscall ;# call

注意,这个和函数调用一样,所以别指望调用完函数后原来的寄存器还能保有值,详细参照下面的函数调用。

条件控制流

jmp 能够直接跳跃。

jmp exit ;# 跳跃到 exit 标签
 
exit:
mov rax, 60
mov rdi, 0
syscall

此外还有一些 jmp 变体,根据 FLAG 进行选择性 jmp。不过再在此之前需要先知道 cmp

cmp 能够比较两个数,并根据结果改变 FLAG。

mov rax, 1
cmp rax, 2 ;# 这里 cmp 会把对应的 FLAG 设置了.
je 0x402000 ;# 如果 equal 那就 jump. (如果相等就 jmp). 由于上面 cmp 设置了 equal 的 flag, 所以这里不相等无法跳跃.

或者另一个 test,可以按位进行与运算,然后设置 FLAG。

test rax, rbx ;# 如果 a&b 为 0, ZF 为 1 否则为 0. 其他寄存器也会变.

接着就是 jmp 的各种变体,主要分成了有符号和无符号、通用三种大类。

通用

  • je: equal 时跳跃
  • jne: je 不跳时跳跃
  • jz: 值为 zero 时跳跃
  • jnz: 不为 zero 时跳跃

有符号

  • jl: less 跳跃
  • jle: less or qeual 时跳跃
  • jg: greater 跳跃
  • jge: greater or equal 时跳跃
  • js: 符号位 1 时跳跃(也就是负数)
  • jns: 符号位 0 时跳跃
  • jo: 有符号计算溢出时跳跃
  • jno: jo 不跳跃时跳跃

无符号

  • ja: 无符号更大时跳跃
  • jae
  • jb: 无符号更小时跳跃
  • jbe
  • jnae
  • jnbe

调用栈和函数调用

为什么调用函数需要栈就不说了,感兴趣请自行去搜。

call 可以自动把调用前的下一条地址压入栈,这样在调用结束后 ret 可以把地址弹出栈从而使得函数调用能够正常进行。

一个小细节是函数压栈是反向压的,也就是早压的在后面的内存,晚压的在前面的内存。更实际说,类似于从 0x4020100x402002 这种方向,早压的会在 0x402010,晚压的会在 0x402002.

另外在函数调用的时候,可能函数会搞乱我们的寄存器,为此可以使用 push 把内容(最常见的就是存自己的参数)放入堆栈,在调用结束后 pop 弹出出来读取。

而关于函数调用的传参和返回值,通常会有几套规范来统一大家写的代码如何互相调用,这里使用的时 System V ABI,然后参数从前往后分别是 rdi / rsi / rdx / rcx / r8 / r9,多余参数压栈,同时函数返回值存在 rax 里。

下面举一个详细的例子。

 
_start:
mov rax a
call function_add ;# 下面写的一个能够把 rax 和 rbx 相加的函数.
jmp exit
 
exit:
mov rax, 60 ;# EXIT
mov rdi, 0 ;# exit code
syscall
 
funtion_add:
push rax ;# rax 压入栈防止 call 的函数乱改寄存器, 因为我们后面要用到 rax, 如果乱改会搞乱我们的流程.
push rbx ;# 另一个参数
call strange_function
pop rax
pop rbx
add rax, rbx
ret ;# 如果我们自己是被别人 call 调用的, 也应该 ret 让 rip 返回去。
 
strange_function:
mov rax, rbx ;# 乱搞寄存器
ret

虽然借用了函数这个词,实际上应该不存在函数这个形式化的概念,因为汇编只管流程,而不会有「函数」这种类型或者概念,在这里,函数只能算一种代码复用的技巧。

复杂运算

mul 无符号乘法. imul 有符号乘法. div 除法.

不过差别还是挺大的,mul 默认操作 rax 作为乘法因子之一,也默认使用 rax 保存结果。

mov rax, 3
mov rbx, 1 ;# mul 不能直接用立即数, 只能用寄存器去乘.
mul rbx ;# 默认了使用 rax 去乘以传入的 rbx, 并将结果保存在 rax.

如果计算结果很大 rax 存不住就会溢出,设置溢出 FLAG,并使用 rdx 来存储结果的高位,rax 存储结果的低位。

imulmul 基础上可以更灵活,指定两个寄存器,不过结果还是存在 rdx + rax 的组合里面。

mov rbx, 2
mov rdx, 3
mul rbx, rdx ;# rbx * rdx, 其中高位存 rdx, 低位存 rax.

divmul 差不多,同样也是 rdx:rax 的存法。但不同的是,这次连计算前也需要 rdx 作为被除数的高位。也就是说被除数实际上是 rdx:rax 的 128 位数。

mov rdx, 0
mov rax, 6
mov rbx, 3
div rbx ;# 6 / 3 = 2 -> rax.

但是 div 更类似于小学除法,算出来的是商和余。

mov rdx, 0
mov rax, 6
mov rbx, 4
div rbx ;# 6 / 4 = 1 ... 2
 
;# rax = 1, rdx = 2

不能除以 0,我在模拟器尝试了,会报错。

有符号除法 idiv 也类似,不过由于有符号数的表示方式,不能简单初始化 rdx0。可以使用 cqo 来让 rdxrax 对齐表示方式。

mov rax, -7
mov rbx, 4
cqo
idiv rbx

常见结构或技巧

Windows 下的系统调用

不能使用 syscall 而是调用 kernal32.dllntdll.dll 封装 syscall 后的 API 来操作。

调用 ABI

System V ABI

按顺序传参,rdi, rsi, rdx, rcx, r8, r9,多余压入栈.

Windows x64 ABI

按顺序传参,rcx, rdx, r8, r9,多余压入栈,并且调用者必须预留 32 字节的空间(称为阴影空间).

Scale-Index-Base (SIB) 寻址

SIB 的公式是 内存地址=基址+变址*缩放倍数+立即数.

这个公式可以同时表示数组和结构体这两种常见的数据存储方式。

解释一下公式里的内容:

  • 基址:数组的首地址或者结构体的起始地址
  • 变址:数组的下标
  • 缩放倍数:数组的类型大小,也就是 1 / 2 / 4 / 8. 比如说对 C 的 int 类型就是 4.
  • 立即数:就是偏移,可以用来指定访问特定对象,或者结构体的特定偏移。

PIC 位置无关代码

在无法知道当前地址的地方写代码需要先获取地址。比如说注入器的 ShellCode 被注入后可能会被随机扔在了某块内存空地。

使用 call \$+5; pop rbx 可以获取 rip 的地址。原理先从 \$+5 说起,\$ 可以指当前指令的位置,+5 刚好是 call \$+5 指令字节的长度,也就是下一条 pop rbx 指令的位置。随后 pop rbx 就可以取出来了。

Windows ShellCode 的内存对齐

写 ShellCode 的时候,CPU call 会压入 8 bytes 大小的返回地址,又由于 x64 调用函数有个规则「在 call 之前,rsp 必须是 16 位对齐(也就是十六进制地址0结尾)」。因此除了阴影空间需要的 32 bytes 外,由于 0x20 + 0x8 = 0x28 不能对齐,因此还需要额外 8 bytes 的空间来进行对齐。

所以 shellcode 通常使用 sub rsp, 0x28call 前进行阴影空间的留存而非使用 sub rsp, 0x20.

再调用完成后使用 add rsp, 0x28 收回阴影空间。