ilharp 发表于 2024-3-24 16:43

【原创】优雅地在 Hook 时替换 std::string 的内容

## 0x01 缘起

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

```cpp
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` 两个类的内存布局:

```cpp
struct StringVal { char *ptr; size_t size; size_t capacity; };
struct VectorVal { unsigned char *first; unsigned char *last; unsigned char *end; };
```

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

```cpp
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 都 **替换为和原来一样的数据**,看看程序还能否正常工作:

```cpp
// 申请新的 name
name_val->ptr = new char;
// 复制
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;
// 复制
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` 的例子:

```cpp
#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 的内存分布:

```c
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;
                char *_Ptr;
                char _Alias;
            } _Bx;
            unsigned __int64 _Mysize;
            unsigned __int64 _Myres;
      } _Myval2;
    } _Mypair;
};
```

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

```c
struct StringVal {
    union {
      char buf;
      char *ptr;
      char alias;
    } 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` 其实基本能够猜到是短字串的优化措施,但 alias 又是什么?

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

```cpp
// 什么情况下用 ptr?总之先猜是 size 大于 16 的情况
bool name_val_use_ptr = name_val->size > 16;
// 申请新的 name
name_val->bx.ptr = new char;
// 复制
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()`,编译注入。结果发生了变化:

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

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

```cpp
bool name_val_use_ptr = name_val->size > 16;
name_val->bx.ptr = new char; // 加一
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 就可以了吗?具体来说,

```cpp
bool name_val_use_ptr = name_val->size > 16;
name_val->bx.ptr = new char;
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()。

```cpp
const char *__fastcall std::string::c_str(std::string *this)
{
return std::_String_val<std::_Simple_types<char>>::_Myptr(&this->_Mypair._Myval2);
}
```

继续查看这个 `_MyPtr` 的 getter。

```cpp
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; //

_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()` 方法负责判断字符串是否应该按短字串处理。继续进入:

```cpp
_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 位是指向实际数据的指针。**

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

```cpp
bool name_val_use_ptr = name_val->res > 0xF;
```

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

1. 如果你没有什么改值的需求,那么 **在 name_val_use_ptr 为 0 时就不用进行任何操作了** 。此时整个 buf 已经在栈上被复制了一遍,不用另外申请内存了。这样原有的性能优化也得到了保留。
2. 如果你有改值的需求(本例中的需求),那么 **除了需要自行申请内存,还最好保证新申请的内存和 capacity 二者均大于 16**。这样后续就不会出现任何问题了。

```cpp
// 取原内存长度和 16 之间较大的那个,作为新内存的大小
size_t name_val_alloc_size =
    ((name_val->size + 1) > 16) ? (name_val->size + 1) : 16;
// 申请内存
name_val->bx.ptr = new char;
// 将 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 吧:

```sh
cat << EOF > a.cpp
#include <iostream>

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

EOF
```

然后编译:

```sh
g++ -g3 -O0 -o a.exe a.cpp -static-libstdc++
```

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

```cpp
__int64 __fastcall sub_10041BD00(__int64 a1)
{
return *(_QWORD *)a1;
}
```

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

## 0x07 附:完整实现代码

```cpp
struct StringVal {
union {
    char buf;
    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;
// 将 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;
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;
```

这个月 13 号注册了吾爱破解,今天是注册的第十一天,总算写出来一篇算得上技术贴的东西。这样应该不会因为只会灌水而被清了吧……(望天

msn882 发表于 2024-3-24 21:59

本帖最后由 msn882 于 2024-3-24 22:01 编辑

正常情况下根本不用管是不是 string 而是看它的结构, 很容易就知道长字串是指针,短字串是直接存放, 通过0xf长度来判断, 在不少游戏逆向中一直都是这样。 而如果去强行往string上凑反而麻烦, 因为不同编译器,不同的版本实现的方法不一样。   特别是 map 等复杂的容器, 直接分析结构简单很多

ilharp 发表于 2024-3-26 21:14

本帖最后由 ilharp 于 2024-3-26 21:16 编辑

helloworld0011 发表于 2024-3-26 17:22
没想到 gcc 没有短字符优化,学到了
发帖后我又咨询了我的群友,群友给了 Raymond Chen 博客《The Old New Thing》里的一篇帖子:《Inside STL: The string》,里面有详细介绍各 C++ 实现在做 STL String 时的方法,值得一读。

里面提到了 GCC 的做法:

```cpp
template<typename T>
struct basic_string
{
    T* ptr;
    size_t size;
    union {
      size_t capacity;
      T buf;
    } storage;
};
```

GCC 的做法是把 capacity 和 buf 作为一个 union,然后通过 ptr 是否指向 buf 来判断当前字串是否是短字串。这么做的话 `c_str()` 方法里就可以直接返回 ptr 了。所以实际上 GCC 也是有优化的,是我无知了(趴

个人认为这种方法稍微先进一点,不过总体来说和 MSVC 的实现相比还是各有优劣吧。

里面还提到了 Clang 的做法,是使用内存首字节的小端最低位来判断是否为短字串。定义了这个规则以后,String 在申请内存时对大字串申请的内存一律为 2 的倍数,就直接解决了判断问题,非常先进。不过相对地理解起来就比较烧脑了,实现起来也需要用布局完全不同的两个结构来实现,比较麻烦。

6767 发表于 2024-3-25 21:59

不考虑 inline hook+ jmp 的方式嘛,直接传个新的string指针完事

sgsu 发表于 2024-3-24 19:28

锄禾日当午,回帖真辛苦!

ilharp 发表于 2024-3-24 22:23

msn882 发表于 2024-3-24 21:59
正常情况下根本不用管是不是 string 而是看它的结构, 很容易就知道长字串是指针,短字串是直接存放, 通过 ...

原来如此,多谢指教

pojie20230721 发表于 2024-3-25 12:09

这个利害, 先顶后看

q12569463 发表于 2024-3-25 13:06

先顶后看

9300 发表于 2024-3-25 15:04

厉害,帮顶

kapibl 发表于 2024-3-25 15:05

先顶再学习!

nitian0963 发表于 2024-3-25 15:48

感谢楼主讲解

mylackz 发表于 2024-3-25 16:42

爱看这种的帖子
页: [1] 2 3 4 5 6 7 8
查看完整版本: 【原创】优雅地在 Hook 时替换 std::string 的内容