1 函数调用过程
1.1 函数调用约定
调用约定规范了函数之间调用的方式、参数如何传递、返回值如何传递、栈由谁来平衡
比较常见的调用约定:
__cedcl- 又称为 C 调用约定,是C/C++ 语言缺省的调用约定。参数按照从右至左的方式入栈,函数本身不清理栈,此工作由调用者负责,返回值在RAX中。由于由调用者清理栈,所以允许可变参数函数存在,如
int sprintf(char* buffer,const char* format……)
- 又称为 C 调用约定,是C/C++ 语言缺省的调用约定。参数按照从右至左的方式入栈,函数本身不清理栈,此工作由调用者负责,返回值在RAX中。由于由调用者清理栈,所以允许可变参数函数存在,如
__stdcall- 很多时候被称为pascal调用约定。pascal语言是早期很常见的一种用于教学计算机程序设计语言,其语法严谨。参数从右至左的方式入栈。函数自身清理堆栈,返回值在RAX中
__fastcell- 特点就是快,是由CPU寄存器来传递参数,用RCX和RDX传递前两个四字(QWORD)或更小的参数,剩下的参数按照从右至左的方式入栈,函数自身清理堆栈,返回值在RAX中 一、 核心寄存器定义 在 x86_64 (64位) 函数调用体系中,有两个专门用于管理栈内存的寄存器:
RSP (Stack Pointer / 栈顶指针): 物理层面的指针。它永远指向当前栈空间的最顶部(最低内存地址处)。
RBP (Base Pointer / 栈底基址指针): 逻辑层面的指针。它指向当前正在执行的函数栈帧的固定基准线。
1.1.1 为什么不能只用 RSP?
在函数执行的过程中,程序经常需要计算临时数据,这会导致频繁的 push(压栈)和 pop(出栈)操作。
RSP 的缺陷(高度动态): 随着每一次压栈和出栈,RSP 的值都在实时跳动。如果编译器试图通过 RSP + 偏移量 来寻找参数或局部变量,由于 RSP 本身在不断变化,这个偏移量也必须实时重新计算,这在工程实现上是极其复杂且容易出错的。
RBP 的解法(绝对静止): 为了提供一个稳定的参照物,系统在进入函数之初,会将当时的栈顶状态“冻结”并保存在 RBP 中。在整个函数运行期间,RBP 的值固定不变。编译器只需使用固定的 RBP ± 偏移量,就能极其精准地定位所有数据。
1.1.2 标准汇编范式
理解以下两段标准的汇编代码,是分析任何二进制程序的基石。
1.1.2.1 函数序言—— 建立当前函数的领地
当程序刚跳入一个新函数时,必定会执行以下指令:
push rbp ; 1. 备份:将调用者(父函数)的 RBP 压入栈中保存(Saved RBP)。
mov rbp, rsp ; 2. 锚定:将当前的 RSP 赋值给 RBP,确立当前函数的新基准线。
sub rsp, 0x40 ; 3. 分配:将 RSP 向上移动(减小地址),为局部变量腾出空间。
1.1.2.2 函数结语—— 恢复现场并返回
当函数执行完毕,准备返回父函数时:
mov rsp, rbp ; 1. 释放:将 RSP 降回 RBP 的位置,瞬间废弃所有局部变量。
pop rbp ; 2. 恢复:从栈顶弹出之前保存的父函数 RBP,恢复父函数的基准线。
ret ; 3. 返回:弹出栈顶的“返回地址”到 RIP 寄存器,程序跳回原处。
1.1.2.3 RBP 静态坐标系速查表
| 内存位置 (基于 RBP) | 存放内容 | 说明 | 读写权限 / 危险性 |
|---|---|---|---|
[rbp + 0x18] | 参数 2 (Arg 2) | 从右向左压栈的第二个参数 | 正常读取 |
[rbp + 0x10] | 参数 1 (Arg 1) | 从右向左压栈的第一个参数 | 正常读取 |
[rbp + 0x08] | 返回地址 (Return Address) | 函数执行完后,下一条要执行的指令地址 | 极度危险!PWN 的核心目标 |
[rbp + 0x00] | Saved RBP | 父函数的基址指针备份 | 危险,被覆盖会导致父函数栈崩溃 |
[rbp - 0x08] | 局部变量 1 | 函数内部声明的第一个变量 | 正常读写,溢出的源头 |
[rbp - 0x10] | 局部变量 2 | 函数内部声明的第二个变量 | 正常读写 |
1.2 检查操作
1.2.1 查看程序基本信息file
命令作用:查看程序的架构(32位/64位)、链接方式(动态链接/静态链接)、文件格式(ELF)等信息
实操执行:file demo
解读:
ELF 64-bit LSB executable:这是个64位的ELF可执行文件,运行在x86-64上dynamically linked:这是动态链接库,体积小,主要依赖于外部的系统库not stripped没有剥离符号表,包含了函数名、变量名等信息
1.2.2 检查保护机制checksec
root@ubuntu-pwn:/learning# checksec demo
[*] '/learning/demo'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
结果解读:
CANARY- 金丝雀保护,金丝雀会生成随机值,程序执行时会放入栈帧中,栈溢出时会先覆盖掉金丝雀,会导致程序检测崩溃,关闭后才能自由覆盖返回地址
PIE- 地址随机化保护:开启后,程序地址
.text、.data、栈、堆等区域都会随机变化
- 地址随机化保护:开启后,程序地址
NX- Linux默认开启的保护机制,作用是让栈上的数据不能当成代码执行
RELRO- 作用是保护
.got表,阻止.got被篡改
- 作用是保护
1.2.3 查看依赖库ldd
命令作用:查看动态程序依赖的所有系统库
结果解读:
因为libc库里面有很多system函数,要调用函数,就必须知道它在内存中的地址
1.2.4 扫描程序中的文本strings
命令作用:快速扫描程序中所有可打印的纯文本字体
2 演示
#include <stdio.h>
#include <string.h>
//这是一个带有初始值的全局变量,编译后它会在.data段(数据段)
//全局变量,定义在所有函数外部,程序运行期间一直存在,不会随着函数结束而销毁
char global_msg[]="Hello Pwn Data Segent!";
//这是一个没有初始值的全局变量,编译后会在.bss段(未初始化数据段)
//注意:未初始化的全局变量,系统会默认赋值为0(int类型)、空字符串(char类型)等默认值
int global_bss_var;
//这是一个故意留有破绽的函数(漏洞函数),也就是我们重点攻击的目标
void vulnerable_function(){ char buf[16];
//分配在栈上的局部变量空间(16字节)
//局部变量,定义在函数内部,只有函数执行时才存在,函数结束后自动释放
printf("Please input your name:");
//gets()函数,极度危险的函数,不检查输入函数
//只要用户不按回车,就会一直在buf里写数据,哪怕超出了buf的16字节
//这就是"栈溢出漏洞"的根源
gets(buf);
printf("welcome,%s\n",buf); };
int main(){
print("=== Pwn Training Day 1 ===\n");
vulnerable_function();//调用漏洞函数
return 0;
}
编译函数
-fno-stack-protector:关闭栈溢出保护
-no-pie:关闭地址随机化
-Wno-deprecated-declarations:忽略对gets函数危险性的警告
2.1 查看$rip

b main
r
p $rip
如图可以看出 $rip指向main地址,说明接下来第一个指令会是main
2.2 反汇编
main函数,看指向
disassemble main
2.3 单步执行指令→看rip自动跳转
ni
p $rip
3 C语言函数调用栈
函数调用栈是指程序运行时内存一段连续的区域,它用来保存函数与运行时的状态信息,包括函数参数以及局部变量等
称之为栈是因为发生函数调用时,调用函数的状态被保存在栈内,被调用函数的状态被压入调用栈的栈顶
在函数调用结束时,栈顶的函数状态被弹出,栈顶恢复到调用函数的状态
函数调用栈在内存中从高地址向低地址生长,所以栈顶对应的内存地址在压栈时变小,退栈时变大
4 保护机制
4.1 NX(DEP)
数据执行防护
栈上的数据没有执行权限,防止攻击手段:栈溢出 + 跳到栈上执行shellcode
4.2 Canary(FS)
栈溢出保护
在函数开始时就随机产生一个值,将这个值CANARY放到栈上紧挨ebp的上一个位置,当攻击者想通过缓冲区溢出覆盖ebp或者ebp下方的返回地址时,一定会覆盖掉CANARY的值;当程序结束时,程序会检查CANARY这个值和之前的是否一致,如果不一致,则不会往下运行,从而避免了缓冲区溢出攻击。
防止攻击手段:所有单纯的栈溢出
4.3 RELRO(ASLR)
地址随机化
防止攻击手段:所有需要用到堆栈精确地址的攻击,要想成功,必须用提前泄露地址
4.4 PIE
代码地址随机化
防止攻击手段:构造ROP链攻击
5 栈溢出
危险点来了,主要是由于栈的设计:
- 栈的地址是由高向低生成的
- 数据写入栈时时由低向高的
此时加上gets()不检查输入长度,就导致了”栈溢出”的漏洞——我们可以通过输出过长的数据,超出buf的16字节,进而覆盖后面的Saved RBP和返回地址
5.1 返回到什么地址?
一般来说,多数情况下我们只需要让程序执行这一段代码:
system("/bin/sh")
也就是说在远程机器上开一个命令终端,这样就可以控制目标机
5.2 ret2text
理想情况下,程序中有一段代码直接就能满足我们的需求
我们只需把执行流劫持到代码即可
5.3 ret2shellcode
如果程序中没有代码怎么办
我们可以自己写shellcode
shellcode就是一段可以独立运行开启shell的一段汇编代码
前提:NX关闭
5.3.1 思路
如果程序中存在让用户向一段长度足够的缓冲区中输入数据,我们向其中输入shellcode,将程序劫持到shellcode上即可
5.3.1.1 纯手写
asm(shellcraft.amd64.linux.execve("/bin/sh", 0, 0))
好处:生成的机器码体积极度精简(通常只有 20 多字节)。
缺点:攻击性不高,容易被阻拦
5.3.1.2 快捷指令
asm(shellcraft.sh())
好处:为了保证在各种复杂或奇葩的漏洞场景下都能 100% 弹 Shell 成功,pwntools 会在这个宏里面塞入一些“防御性/初始化代码”(比如主动清空一些可能会干扰运行的寄存器、提升权限等)。
缺点:正因为它想得太周到,导致生成的机器码体积比较大(在 64 位下通常在 40 到 50 字节左右)。
5.4 ret2libc
有时候,我们需要调用一些系统函数,就比如system或者execv等
程序中可能不会提供一些现成的函数
如果我们能拿到libc中的地址,就可以直接调用libc中的函数
只需要传递好参数,然后call即可
- 如何调用
system(/bin/sh);- 只需要将rdi设置成/bin/sh字符串地址,然后
call system即可 pop rdi ret + /bin/sh地址 + system
- 只需要将rdi设置成/bin/sh字符串地址,然后
5.4.1 ROP
函数调用过程:
- 调用函数:只需要将
rip压栈,即push rip,然后将rip赋值为被调用函数的起始地址,这一操作被隐形的内置在call指令中 - 被调用函数:
push rbp; move rbp rsp; sub rsp 0xxx。即保存调用函数的rbp指针,将自己的rbp指针指向栈顶,然后开辟栈空间给自己用,此时rbp就变成了被调用函数的栈底 - 函数返回:
leave ; ret,意思是:mov rsp rbp; pop rbp; pop rip;即恢复栈顶,返回调用函数的返回地址
很多情况下,程序中我们能利用的只有栈,也就是说,程序中没有一个可读可写可执行的区域让我们输入shellcode
同时,大多数题目也不会给你留一个后门函数直接执行system,那么这个时候就需要rop
rop称为返回导向编程,说人话就是程序以一堆ret来完成代码逻辑,我们需要利用程序中的一些指令片段,一点点拼接出来,拼成我们想要的样子
拿system("/bin/sh");举例,我们要将rdi改成/bin/sh这个字符串的地址,然后call system,但是我们不能执行shellcode,所以需要用栈来导向pop rdi ret + /bin/sh地址 + system
不能shellcode,构造:padding+pop rdi;ret+/bin/sh+system
工具:ropper和ROPgadgets
使用:
ROPgadget --binary file
ropper --file file
5.4.1.1 通用ROP
在64位程序中,函数的前6个参数都是通过寄存器传递的,但是大多数时候,我们很难找到一个寄存器对应的gadgets
这时候,就需要利用__libc_csu_init中的gadgets
这个函数是用来对libc进行初始化操作的,而一般的程序都会调用libc函数,所以这个函数一定存在
5.5 ret2syscall
和正常函数调用没什么区别,找一下系统调用表,想调用哪个函数就把rax设置成那个数,然后syscall就行