[水贴]C++应该怎么UAF

2025-01-24

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;
}

Screenshot 2025-01-24 231137.png

   上图是ptr2 = ptr1之后的栈空间
   可以看到其实比较简单, shared_ptr对象本身是 地址(指针) + 一个虚表指针

Screenshot 2025-01-24 231631.png

   上述指针(即.get()获取的裸指针)指向的一个chunk的中间部分, 对象的位置. 前面的是0x555555557cc8虚表地址, 和0x1weak_count, 0x2shared_count(use count).
   值得一提的是weak_count返回的是0, 但实际上在内存中存的是1, 在之后提到的weak_count指的是内存中的weak_count
   作用域结束之后, 分别对两个std::shared_ptr对象进行析构
   第一个析构之后, 堆肯定是没有释放的, 但是use count变成了0x1
   然后追踪一下第二个析构

Screenshot 2025-01-24 213734.png

   注意下面的调用栈, 现在在_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 countweak count被清零了, 如下图

Screenshot 2025-01-24 214047.png

   然后分别call_M_dispose, 和_M_destroy

Screenshot 2025-01-24 221428.png Screenshot 2025-01-24 215026.png

   在_M_destory中, 在~__allocator_ptr, 之后堆块释放. 更细节的调用没再追踪了

Screenshot 2025-01-24 221129.png

   如果两个count不是1, 会进入下面的分支

Screenshot 2025-01-24 220024.png

 shared_ptr的UAF

   根据上面的分析, 得知两点
   第一, 必须要让use countweak 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;
}

Screenshot 2025-01-25 232012.png

   如图, 在wrapper3析构之后, 对应的内存没有free. 事实上, 在wrapper1之后, 三个chunk才会一起释放. 但这个过程中, 三个wrapper指向的内存的引用计数在正确地减少.
   ps: 作用域结束时, 析构的顺序是构造的顺序是相反的, v12是赋值是产生的copy, 可以不管

Screenshot 2025-01-25 234952.png

   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;
}

Screenshot 2025-01-25 224711.png

   如图, 当作用域结束之后, 两块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_countuse_count0x0000000100000000, 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);

Screenshot 2025-02-06 201603.png

   上图是chunk内的结构, 和裸指针一致, 也就是说篡改内存导致UAF不可能了, unique_ptr的创建和析构完全是编译器在编译期自动确定的.
   但是unique_ptrshared_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_ptrweak_ptr其实只能修改一个chunk的一部分, chunk释放之后的fdbk将无法修改(仅有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个函数指针

Screenshot 2025-02-06 233307.png

   可以去搞清楚它们各自是干什么的, 但这里我选择直接打5个断点, 挨个看rdi

Screenshot 2025-02-06 233813.png

   到达第一处中断, 是虚表的第三个指针::_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 5main+71, 如下图, 说明现在在析构中, 构造函数没用到虚表函数

Screenshot 2025-02-06 234119.png

   到这里就够了, 可以申请一个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;
}

Screenshot 2025-02-07 003143.png

   这么个写法还是美中不足, 虚表函数的第一个参数(rdi)都是虚表指针(或者说地址), 导致按照上述的写法, 传给unreachable&&cmd, 需要一次解引用
   但是话又说回来, 如果虚表地址填上cmd, 确实不用解引用, 但是没法虚表劫持了
   于是想到不用system(), 可以发现rsi指向的是shared_ptr指向的数据区域, 这一部分是可控的

Screenshot 2025-02-07 120625.png

   于是, 想到把数据改成/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