汇编的基本背景我就不提了,然后本篇笔记也是记得比较潦草。这篇笔记主要是用来给我在忘了的时候快速复习用的。
另外由于教程使用的是 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: 无符号更大时跳跃jaejb: 无符号更小时跳跃jbejnaejnbe
调用栈和函数调用
为什么调用函数需要栈就不说了,感兴趣请自行去搜。
用 call 可以自动把调用前的下一条地址压入栈,这样在调用结束后 ret 可以把地址弹出栈从而使得函数调用能够正常进行。
一个小细节是函数压栈是反向压的,也就是早压的在后面的内存,晚压的在前面的内存。更实际说,类似于从 0x402010 往 0x402002 这种方向,早压的会在 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 存储结果的低位。
imul 在 mul 基础上可以更灵活,指定两个寄存器,不过结果还是存在 rdx + rax 的组合里面。
mov rbx, 2
mov rdx, 3
mul rbx, rdx ;# rbx * rdx, 其中高位存 rdx, 低位存 rax.div 和 mul 差不多,同样也是 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 也类似,不过由于有符号数的表示方式,不能简单初始化 rdx 为 0。可以使用 cqo 来让 rdx 跟 rax 对齐表示方式。
mov rax, -7
mov rbx, 4
cqo
idiv rbx常见结构或技巧
Windows 下的系统调用
不能使用 syscall 而是调用 kernal32.dll 和 ntdll.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, 0x28 在 call 前进行阴影空间的留存而非使用 sub rsp, 0x20.
再调用完成后使用 add rsp, 0x28 收回阴影空间。