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::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()
就能正常工作?
想到这里,新的想法就诞生了:
c_str()
里是怎么判断该读取 buf 还是 ptr 的?
- 替换 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;
接着,下面分成两种情况讨论:
- 如果你没有什么改值的需求,那么 在 name_val_use_ptr 为 0 时就不用进行任何操作了 。此时整个 buf 已经在栈上被复制了一遍,不用另外申请内存了。这样原有的性能优化也得到了保留。
- 如果你有改值的需求(本例中的需求),那么 除了需要自行申请内存,还最好保证新申请的内存和 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 号注册了吾爱破解,今天是注册的第十一天,总算写出来一篇算得上技术贴的东西。这样应该不会因为只会灌水而被清了吧……(望天