吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 8271|回复: 67
上一主题 下一主题
收起左侧

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

  [复制链接]
跳转到指定楼层
楼主
ilharp 发表于 2024-3-24 16:43 回帖奖励

0x01 缘起

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

void send_package(std::string *name, std::vector<unsigned char> *data);

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

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

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

0x02 第一次尝试

说干就干。先凭借对 STL 微弱的记忆写出 std::stringstd::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;

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

免费评分

参与人数 47吾爱币 +39 热心值 +38 收起 理由
BlueRiverLHR + 1 + 1 我很赞同!
ycwlhc + 1 + 1 用心讨论,共获提升!
laoxiao414 + 1 + 1 用心讨论,共获提升!
JerryChan + 1 + 1 我很赞同!
sxb180388831 + 1 + 1 用心讨论,共获提升!
time2s + 1 + 1 用心讨论,共获提升!
RnGMaNFY02348 + 1 + 1 谢谢@Thanks!
A1den + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
yixi + 1 + 1 谢谢@Thanks!
huawei518 + 1 + 1 用心讨论,共获提升!
zzddys0201 + 1 + 1 谢谢@Thanks!
LANsanchengxing + 1 热心回复!
CodeBlue + 1 我很赞同!
吾之名翎 + 1 用心讨论,共获提升!
discom + 1 谢谢@Thanks!
fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
不爱everyone + 1 用心讨论,共获提升!
zzyzy + 1 + 1 谢谢@Thanks!
hopecolor514 + 1 我很赞同!
GouKu + 1 我很赞同!
nanaqilin + 1 + 1 用心讨论,共获提升!
0xUYR7s + 1 热心回复!
heitxin + 1 + 1 用心讨论,共获提升!
helloworld0011 + 1 热心回复!
gaosld + 1 + 1 用心讨论,共获提升!
DQQQQQ + 1 谢谢@Thanks!
plazy + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
skiss + 1 + 1 谢谢@Thanks!
initialheart + 1 + 1 优雅 太优雅了 学到一个新思路
moriv4 + 1 + 1 我很赞同!
去旅行 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
heyang1743 + 1 我很赞同!
janken + 1 + 1 热心回复!
悠悠 + 1 + 1 谢谢@Thanks!
LaoMao233 + 1 + 1 热心回复!
5omggx + 1 用心讨论,共获提升!
蜜雪精灵 + 1 + 1 新人都是怪物系列
mylackz + 1 + 1 谢谢@Thanks!
misszhou111 + 1 + 1 用心讨论,共获提升!
shuiangao + 1 热心回复!
bigmojin + 1 我很赞同!
三滑稽甲苯 + 2 + 1 用心讨论,共获提升!
wh785496332 + 1 用心讨论,共获提升!
Issacclark1 + 1 谢谢@Thanks!
Yasso2 + 1 + 1 谢谢@Thanks!
warobot + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
MicroBlock + 1 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
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 的做法:

template<typename T>
struct basic_string
{
    T* ptr;
    size_t size;
    union {
        size_t capacity;
        T buf[BUFSIZE];
    } 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指针完事
3#
sgsu 发表于 2024-3-24 19:28
锄禾日当午,回帖真辛苦!
4#
 楼主| ilharp 发表于 2024-3-24 22:23 |楼主
msn882 发表于 2024-3-24 21:59
正常情况下根本不用管是不是 string 而是看它的结构, 很容易就知道长字串是指针,短字串是直接存放, 通过 ...

原来如此,多谢指教
5#
pojie20230721 发表于 2024-3-25 12:09
这个利害, 先顶后看
6#
q12569463 发表于 2024-3-25 13:06
先顶后看
7#
kapibl 发表于 2024-3-25 15:05
先顶再学习!
8#
nitian0963 发表于 2024-3-25 15:48
感谢楼主讲解
9#
mylackz 发表于 2024-3-25 16:42
爱看这种的帖子
10#
蜜雪精灵 发表于 2024-3-25 18:43
新人都是怪物
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-11-22 03:01

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表