在 Hook 时替换 std::string 的内容

最近在逆某大型软件,需要拦截并修改接收和发送的网络包。要 Hook 函数的点位朋友已经找好,大致签名如下:

 复制代码 隐藏代码
void send_package(std::string *name, std::vector<unsigned char> *data);

这个点位很棒啊,一参是包名,二参是包体,而且都是 STL,直接用 .data() 读取数据后再用 .clear() 和 .insert() 写回不就行了吗?

然而被朋友告知不能这么做,原因大概是对象本身使用了特制的 Allocator,直接使用 std 上的 mutable 方法就会导致整个程序闪退。

既然这样,接下来应该怎么做就很清楚了——既然下层函数都是网络相关逻辑,只会读取内存,不会再修改了,那我们直接替换掉 string 和 vector 里的各个指针,让下层的发包函数读我们整个替换后的数据就搞定了。

0x02 第一次尝试

说干就干。先凭借对 STL 微弱的记忆写出 std::string 和 std::vector 两个类的内存布局:

 复制代码 隐藏代码
struct StringVal { char *ptr; size_t size; size_t capacity; };
struct VectorVal { unsigned char *first; unsigned char *last; unsigned char *end; };

接着在调用原函数的两侧做好替换的准备:

 复制代码 隐藏代码
StringVal *name_val = reinterpret_cast<StringVal *>(name);
VectorVal *data_val = reinterpret_cast<VectorVal *>(data);
StringVal origin_name_val = *name_val;
VectorVal origin_data_val = *data_val;

// TODO:这里读取并替换

send_package(name, data);

*name_val = origin_name_val;
*data_val = origin_data_val;

先把 name 用 reinterpret_cast 转换为 StringVal * 类型,然后在栈上直接把原来的 name_val 复制一份存为 origin_name_val,待调用完毕后写回。data 同理。接下来就可以在标记「TODO」的地方开始替换数据了。

为了测试这种方法是否真的可行,我决定先把 name 和 data 都 替换为和原来一样的数据,看看程序还能否正常工作:

 复制代码 隐藏代码
// 申请新的 name
name_val->ptr = new char[name_val->size];
// 复制
std::copy(
    origin_name_val.ptr,
    origin_name_val.ptr + origin_name_val.size,
    name_val->ptr);

size_t data_size = data_val->last - data_val->first;
// 申请新的 data
data_val->first = new unsigned char[data_size];
// 复制
std::copy(origin_data_val.first, origin_data_val.last, data_val->first);
// 额外地,设置 last 和 end 指针
data_val->end = data_val->last = data_val->first + data_size;

// 在调用原函数的后面加上下面的两行。永远不能忘记回收内存~
delete[] name_val->ptr;
delete[] data_val->first;

写完直接编译、注入。不出意外地程序炸了。(悲

不过是否只是 name 和 data 的其中一个出现了问题呢?尝试只注释掉 name 和 data 后分别试一遍,惊喜地发现 data 是正常工作的。(虽然我直到写此文时都没弄明白 vector 的 end 指针究竟有什么用,是 capacity 类似的东西吗(有没有大神能解惑),但他确实工作了。可能下层函数只读 first 和 last 吧。

那么接下来,就要想想办法,怎么样 优雅地在 Hook 时替换 std::string 的内容 了。正篇开始。

0x03 从内存布局开始

首先该怀疑的就是上文用脑子写出来的这个 std::string 的内存布局了。第一次测试的时候用 cout 输出了下 name_val->size,发现所有 size 都是 0。怎么可能。

可 std::string 的内存布局究竟是个啥样啊?总之先创建一个最小的 C++ 项目看看吧。由于是试验项目就懒得写 CMake 了,Visual Studio,启动!

总之先写一个最小最小的使用 std::string 的例子:

 复制代码 隐藏代码
#include <iostream>

int main()
{
    std::string* s = new std::string("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
    std::cout << s->c_str() << std::endl;
}

在 cout 行打断点,编译,调试,开本地变量窗口,看原始视图。

 

 

 

瞪眼三分钟却越看越乱,_MyPair 和 _MyVal2 是什么?_Bx 里的 _Buf_Ptr 和 _Alias 三个又是什么?string 的内存里怎么有这么多东西,直接给我干晕了……尝试询问群友后得知可以在 WinDbg 里调试,然后用「dx」指令看,可我敲入 dx 后又出来另一堆看不懂的东西……只能怪自己学艺不精,基础不牢了。可我只想得到 struct {}; 一样的伪代码看看 内存分布呀……没办法,还是 IDA 启动吧。

在 VS 里右键项目 – 属性 – C/C++ – 优化 – 优化,选择「优化关闭(/Od)」,然后链接 – 调试 – 生成调试信息,选择「生成调试信息(/DEBUG)」,接着 C/C++ – 代码生成 – 运行时库,选择「多线程(/MT)」,最后重新编译,然后扔进 IDA。IDA 会自动询问是否加载 pdb,选择「是」后就可以看到所有函数的签名和实现了。进入反汇编视图后直接按 F5 生成伪代码,然后找到 std::string 右键选择「Jump to local type…」,最后右键选择 Edit。这样我们终于得到了 string 的内存分布:

 复制代码 隐藏代码
struct __cppobj std::basic_string<char,std::char_traits<char>,std::allocator<char> >
{
    struct __cppobj std::_Compressed_pair<std::allocator<char>,std::_String_val<std::_Simple_types<char> >,1> : std::allocator<char>
    {
        struct __cppobj std::_String_val<std::_Simple_types<char> > : std::_Container_base0
        {
            union __cppobj std::_String_val<std::_Simple_types<char> >::_Bxty
            {
                char _Buf[16];
                char *_Ptr;
                char _Alias[16];
            } _Bx;
            unsigned __int64 _Mysize;
            unsigned __int64 _Myres;
        } _Myval2;
    } _Mypair;
};

没想到 string 还真是这么复杂。简化一下:

 复制代码 隐藏代码
struct StringVal {
    union {
        char buf[16];
        char *ptr;
        char alias[16];
    } bx;
    size_t size;
    size_t res;
};

跟我之前想当然猜测的结构也没差多少,主要差在这个 bx 上了。bx 是一个 union,可能是 buf(长度 16),ptr(长度 8),alias(长度 16),所以 bx 的长为 16,size 是从 0x10 开始的。这就解释了之前为什么 size 获取不对了。把新的 StringVal 类放到原来的代码中,不修改数据,只打印 name_val->size,成功打印出了正确的 size。看来内存结构应该没有问题了。

0x04 _Bxty 探究

可这个神奇的 union——「_Bxty」又是啥东西啊?看到 buf[16] 其实基本能够猜到是短字串的优化措施,但 alias 又是什么?

总之先当作短字串优化来写写吧。

 复制代码 隐藏代码
// 什么情况下用 ptr?总之先猜是 size 大于 16 的情况
bool name_val_use_ptr = name_val->size > 16;
// 申请新的 name
name_val->bx.ptr = new char[name_val->size];
// 复制
std::memcpy(
    name_val->bx.ptr,
    name_val_use_ptr
        ? origin_name_val.bx.ptr
        : reinterpret_cast<char *>(&origin_name_val.bx.buf),
    name_val->size);

然后 std::cout << name.c_str(),编译注入。结果发生了变化:

  • 大部分包名显示出来了,但后面跟着长长的乱码
  • 少部分还是没有显示

看到「后面跟着 长长的 乱码」就立刻意识到,字符串忘记附终止符了。修改代码:

 复制代码 隐藏代码
bool name_val_use_ptr = name_val->size > 16;
name_val->bx.ptr = new char[name_val->size + 1]; // 加一
std::memcpy(
    name_val->bx.ptr,
    name_val_use_ptr
        ? origin_name_val.bx.ptr
        : reinterpret_cast<char *>(&origin_name_val.bx.buf),
    name_val->size + 1); // 加一

这样乱码问题就解决了。

可现在还是有少部分字串的输出是完全的乱码,难道是「16」这个数的问题?首先不能往大改了(buf 放不下了),那就改小试试吧。可尝试改成「15」「8」「7」「1」「0」都是一样的乱码。

这下没什么办法了。直接把「_Bxty」扔进搜索引擎搜搜看,竟然真的有人曾经问过这个问题—— StackOverflow 27157058 贴:《Aliasing in STL string》。

贴中只有一个回答。回答提到:

The basic idea is that depending on string size, either _Buf or _Ptr is valid. But here’s the problem: which of the two is active? You can’t look at the content of either to figure it out, because you may violate the only-read-active rule (which is a specific case of aliasing).

However, regardless of which of the two members is active, you can access _Alias. In particular, you can memcpy copy it such that you either memcpy the pointer or memcpy the characters, without knowing what you memcpy’ed.

说人话:

基本想法就是,根据字符串的大小,buf 或者 ptr 二者之一是可用的。但问题就来了:你怎么知道哪个可用?直接读内容判断肯定是不行的,因为这么做就可能违反 only-read-active 规则(也是 alias 的一种特殊情况)。

但不管二者谁是可用的,你可以使用 alias。具体来说,你可以在 memcpy 的时候用 alias,这样就无需知道你复制的是 buf 还是 ptr 了。

原来 alias 是这个作用。在 memcpy 的时候直接用 alias 就可以了吗?具体来说,

 复制代码 隐藏代码
bool name_val_use_ptr = name_val->size > 16;
name_val->bx.ptr = new char[name_val->size + 1];
std::memcpy(
    name_val->bx.ptr,
    origin_name_val.bx.alias,
    name_val->size + 1);

这么写就能用吗?

总觉得哪里不对,这一个是指针一个不是指针,类型就不一样吧。试了下果然是一样的炸。

所以这个 alias 似乎完全没起到作用,把他删掉好了(气

结果又走进了死路。大部分的 name 都正常了,说明「复制到字符数组 – 替换」这条路本身是行得通的,就剩短字串的问题了。把代码改回未替换过的 name.c_str(),运行一切正常。真是奇了怪了, 为什么不替换的时候 c_str() 就能正常工作?

想到这里,新的想法就诞生了:

  1. c_str() 里是怎么判断该读取 buf 还是 ptr 的?
  2. 替换 ptr 后工作异常,那是否说明还有其他的字段需要替换?

0x05 c_str() 的实现

立即回到 IDA,双击打开 c_str()。

 复制代码 隐藏代码
const char *__fastcall std::string::c_str(std::string *this)
{
  return std::_String_val<std::_Simple_types<char>>::_Myptr(&this->_Mypair._Myval2);
}

继续查看这个 _MyPtr 的 getter。

 复制代码 隐藏代码
const std::allocator<char> *__fastcall std::_String_val<std::_Simple_types<char>>::_Myptr(
        std::_String_val<std::_Simple_types<char> > *this)
{
  std::_String_val<std::_Simple_types<char> > *_Result; // [rsp+20h] [rbp-18h]

  _Result = this;
  if ( std::_String_val<std::_Simple_types<char>>::_Large_mode_engaged(this) )
    return std::addressof<char *>((std::_Compressed_pair<std::allocator<char>,std::_String_val<std::_Simple_types<char> >,1> *)this->_Bx._Ptr);
  return (const std::allocator<char> *)_Result;
}

结果瞬间明朗:std::_String_val<std::_Simple_types<char>>::_Large_mode_engaged() 方法负责判断字符串是否应该按短字串处理。继续进入:

 复制代码 隐藏代码
_BOOL8 __fastcall std::_String_val<std::_Simple_types<char>>::_Large_mode_engaged(
        std::_String_val<std::_Simple_types<char> > *this)
{
  return this->_Myres > 0xF;
}

没想到逻辑这么简单。

所以, 在 MSVC 里,capacity 小于等于 15 的字符串,其内容是直接存放在前 16 字节的;capacity 大于 15 的字符串,首 8 位是指向实际数据的指针。

接下来就该改最后一次代码了。

 复制代码 隐藏代码
bool name_val_use_ptr = name_val->res > 0xF;

接着,下面分成两种情况讨论:

  1. 如果你没有什么改值的需求,那么 在 name_val_use_ptr 为 0 时就不用进行任何操作了 。此时整个 buf 已经在栈上被复制了一遍,不用另外申请内存了。这样原有的性能优化也得到了保留。
  2. 如果你有改值的需求(本例中的需求),那么 除了需要自行申请内存,还最好保证新申请的内存和 capacity 二者均大于 16。这样后续就不会出现任何问题了。
 复制代码 隐藏代码
// 取原内存长度和 16 之间较大的那个,作为新内存的大小
size_t name_val_alloc_size =
    ((name_val->size + 1) > 16) ? (name_val->size + 1) : 16;
// 申请内存
name_val->bx.ptr = new char[name_val_alloc_size];
// 将 capacity 设置为新的大小
name_val->res = name_val_alloc_size;
// 最后复制
std::memcpy(
    name_val->bx.ptr,
    name_val_use_ptr
        ? origin_name_val.bx.ptr
        : reinterpret_cast<char *>(&origin_name_val.bx.buf),
    name_val->size + 1);

一切搞定。编译注入,完美运行。

0x06 尾声

到这里,最开始的问题就算解决了。

解决之后,我又有点好奇 MSVC 以外的实现的怎么样的。看看 GCC 吧。

一如既往地不想写 CMake,这次连 IDE 都懒得开了,直接 heredoc 吧:

 复制代码 隐藏代码
cat << EOF > a.cpp
#include <iostream>

int main()
{
    std::string* s = new std::string("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
    std::cout << s->c_str() << std::endl;
}

EOF

然后编译:

 复制代码 隐藏代码
g++ -g3 -O0 -o a.exe a.cpp -static-libstdc++

扔进 IDA 直接瞪眼法找 c_str() 的实现,代码如下:

 复制代码 隐藏代码
__int64 __fastcall sub_10041BD00(__int64 a1)
{
  return *(_QWORD *)a1;
}

嗯……所以 GCC 是没有 MSVC 一样的优化吗,还是我漏看了什么东西……打开 libstdc++ 的 doxygen 翻源码,似乎也是单指针的存放方式。可能 GCC 并没有针对短字串的优化吧。

0x07 附:完整实现代码

 复制代码 隐藏代码
struct StringVal {
  union {
    char buf[16];
    char *ptr;
  } bx;
  size_t size;
  size_t res;
};

struct VectorVal {
  unsigned char *first;
  unsigned char *last;
  unsigned char *end;
};

StringVal *name_val = reinterpret_cast<StringVal *>(name);
VectorVal *data_val = reinterpret_cast<VectorVal *>(data);
StringVal origin_name_val = *name_val;
VectorVal origin_data_val = *data_val;

bool name_val_use_ptr = name_val->res > 0xF;
// 取原内存长度和 16 之间较大的那个,作为新内存的大小
size_t name_val_alloc_size =
    ((name_val->size + 1) > 16) ? (name_val->size + 1) : 16;
// 申请内存
name_val->bx.ptr = new char[name_val_alloc_size];
// 将 capacity 设置为新的大小
name_val->res = name_val_alloc_size;
// 最后复制
std::memcpy(
    name_val->bx.ptr,
    name_val_use_ptr
        ? origin_name_val.bx.ptr
        : reinterpret_cast<char *>(&origin_name_val.bx.buf),
    name_val->size + 1);

// vector 的处理,中规中矩
size_t data_size = data_val->last - data_val->first;
data_val->first = new unsigned char[data_size];
std::copy(origin_data_val.first, origin_data_val.last, data_val->first);
data_val->end = data_val->last = data_val->first + data_size;

// 调用原函数
send_package(name, data);

// 回收内存
delete[] name_val->ptr;
delete[] data_val->first;

// 还原
*name_val = origin_name_val;
*data_val = origin_data_val;
本站资源来自互联网收集,仅提供信息发布
一旦您浏览本站,即表示您已接受以下条约:
1.使用辅助可能会违反游戏协议,甚至违法,用户有权决定使用,并自行承担风险;
2.本站辅助严禁用于任何形式的商业用途,若被恶意贩卖,利益与本站无关;
3.本站为非营利性网站,但为了分担服务器等运营费用,收费均为赞助,没有任何利益收益。
死神科技 » 在 Hook 时替换 std::string 的内容

死神科技,因为专业,所以领先。

网站首页 24小时自动发卡
在线客服
24小时在线客服
阿里云自动发卡,购卡进群售后
12:01
您好,有任何疑问请与我们联系!

选择聊天工具: