逆向角度看cpp(一)
干,学了几天又被打回原形了,这次直接进入正题吧,mj!
一、字符串
基本概念
先来看一段简单的c代码
char[] a = "ABC";在c里,根本没有string这个类型,字符串其实是字符数组,内容包含了4个字节。
没错,就是4个字节!
内存示意图(假设首地址为 0x1000)0x1000 0X1001 0X1002 0X1003'A' 'B' 'C' '\0'最后一个字节 0x00 是空字符 '\0',表示 C 风格字符串结束。
结合指针
char s[] = "ABC";char *p = s;众所周知,指针存放的是地址,这里的意思就是,指针
c语言常用api
#include <stdio.h>#include <string.h>
int main(void) { char s[] = "ABC";
printf("%zu\n", sizeof(s)); // 4:包含结尾的 '\0' printf("%zu\n", strlen(s)); // 3:不包含结尾的 '\0'}注意:sizeof(s) 能得到数组大小,是因为此处 s 仍是数组;如果把它作为参数传给函数,数组通常会退化为指针,函数中的 sizeof(s) 就会得到指针大小。
必须认识的 C API
| API | 作用 | 常见用途与注意事项 |
|---|---|---|
strlen(s) | 计算 C 字符串长度 | 识别长度检查;会一直读到 '\0',输入未正确终止时可能越界 |
strcmp(a, b) | 比较两个字符串,返回正负是比较的字典序。 | 分析字符串校验、命令分支;返回值是负数、0(两字符串相等) 或正数,不保证只返回 -1/0/1。 |
strncmp(a, b, n) | 最多比较 n 个字符 | 分析前缀检查;它不等于通用的二进制比较 |
strcpy(dst, src) | 复制字符串 | 常出现在字符串处理逻辑;目标缓冲区不够大时会发生越界写 |
strncpy(dst, src, n) | 最多复制 n 个字符 | 不保证目标一定以 '\0' 结尾,不能简单当作安全版 strcpy |
strchr(s, c) | 查找字符 | 分析分隔符、后缀、路径处理 |
strstr(s, sub) | 查找子字符串 | 分析关键字、协议字段或固定标记 |
snprintf(dst, size, ...) | 格式化写入,限制目标缓冲区大小 | 分析字符串拼接、日志和格式化数据;仍要检查返回值和截断情况 |
memcpy(dst, src, n) | 复制指定字节数 | 分析二进制数据、结构体复制、协议字段;源与目标重叠时行为未定义 |
memmove(dst, src, n) | 复制指定字节数,允许重叠 | 分析缓冲区移动 |
memcmp(a, b, n) | 比较指定字节数 | 分析固定长度数据比较、标记或校验值比较;比较的是字节,不是“字符串语义” |
越界
来看一个越界的例子。
#include<string.h>void copy_name(const char* input){ char name[8]; strcpy(name,input)}name 只有 8 个字节,而 strcpy 不知道目标缓冲区的实际容量。假如输入需要 20 个字节(包括结尾的 '\0'),复制就会越界。
另外strlen只能识别’\0’之前的东西,也就是以这个作为分界线标志一个字符串的结束。所以当缓冲区出现实际上是4字节数据A 00 B 00,但是strlen只能识别到1字节。
对比cpp的string
#include<string>#include<iostrem>
int main(){ std::cout << s.size() << '\n'; // 3 std::cout << s.length() << '\n'; // 3 std::cout << s.c_str() << '\n'; // 提供以 '\0' 结尾的字符序列 std::cout << s[1] << '\n'; // B}cpp里的std::string是一个类,与c相比能够自动管理与扩容。
他有如下api
| API | 作用 | 逆向需要知道的点 |
|---|---|---|
size() / length() | 获取字符串长度 | 长度通常以字节计,不是 Unicode 字符数 |
c_str() | 获取以 '\0' 结尾的字符序列 | 经常用于调用 C API 或 JNI 相关代码 |
data() | 获取底层字符数据的访问入口 | 现代 C++ 中,非 const 重载可用于修改现有字符,但不能借此随意扩展字符串长度 |
find() | 查找字符或子字符串 | 可能对应字符串匹配逻辑 |
compare() | 比较字符串 | 识别校验和分支 |
append() / += | 追加内容 | 观察拼接和内存扩容行为 |
substr() | 截取子字符串 | 观察协议字段或数据片段的提取 |
std::string里的sso
这个相当重要,我们来细看一下。
SSO(Small String Optimization,小字符串优化):短字符串直接存放在 std::string 对象内部,长字符串才使用额外的动态内存。
sso为什么会出现?比如有很多短字符'a','vb,'agag这种,如果以简单对象设计(保存指针以及长度这种),那频繁大量单独申请堆内存就可能产生额外的分配与释放开销。
所以sso就解决了这个问题:既然字符串很短,为什么不直接把他塞进对象里?
┌─────────────────────────────────────────┐│ std::string 对象(约 32 字节,64 位) │├─────────────────────────────────────────┤│ union { ││ struct { ← 长字符串模式 ││ char* data; 指向堆上的数据 ││ size_t size; 长度 ││ size_t capacity; 容量 ││ }; ││ char buf[N]; ← 短字符串模式 ││ }; 内联字符数组 │└─────────────────────────────────────────┘关键:同一个对象,根据字符串长短,union 里存的东西不同:
短字符串(size ≤ N-1):字符直接写进 buf,不分配堆内存。
长字符串(size > N-1):data 指向堆,size/capacity 记录信息。这里的N也有讲究,各个库实现不同
| 实现 | 内联容量 | 说明 |
|---|---|---|
| libstdc++(GCC) | 15 字节 | 32 字节对象,16 字节 union 区,留 1 字节给 '\0' |
| libc++(Clang) | 22 字节 | 对象 24 字节,布局更紧凑 |
| MSVC | 15 字节 | 16 字节内联区 |
libc++的布局。长字符串模式: 字节 0~7 : data 指针 字节 8~15 : size 字节 16~23: capacity,其中 LSB = 0 表示"长模式"
短字符串模式: 字节 0 : size << 1 | 1 ← 长度左移 1 位,最低位置 1 当标志 字节 1~23 : 内联字符数据
// 判断是不是短模式:看第一个字节的最低位bool is_short = (first_byte & 1) != 0;
// 提取短模式长度:右移 1 位,把标志位移掉size_t len = first_byte >> 1;
汇编里通常这么判断。if ( (a[0] & 1) != 0 ) v5 = v74; else v5 = (unsigned __int64)(unsigned __int8)a[0] >> 1; v6 = (std::ostream *)std::ostream::operator<<(a1: v4, a2: v5);几个常见的sso误区
-
c_str()返回的指针不一定指向堆内存情况 p指向哪里短字符串 可能指向 s对象内部长字符串 通常指向堆内存 字符串被修改或重新分配存储 原来的 p可能失效 -
SSO 会影响移动构造
std::string a = "hi";std::string b = std::move(a);这里涉及移动语义。如果使用了sso,是短字符串的话,实现通常是把a复制到b的缓冲区。如果是长字符串就是把a在堆上的所有权转移给b.
二、结构体:从字段到内存偏移
结构体的本质
ida的反编译里,我们常常能看到有一个对象经常被访问附近的内存地址:
value = *(int *)(obj + 0x04);flag = *(char *)(obj + 0x08);这可能意味着 obj 指向一个包含多个字段的结构体或对象:
obj + 0x04:可能是某个整数成员;obj + 0x08:可能是某个字节型标志。
但这只是推断。还需要观察其他读写操作、字段大小、对象创建位置和调用关系,才能确认结构。
#include <stdint.h>
struct User { int32_t id; int32_t level; char active;};可以把它理解成一块连续的内存区域,里面放着三个成员。但编译器可以在成员之间或结构体末尾添加填充字节(padding),以满足对齐要求。
来看个简单的例子
#include <stdio.h>#include <stddef.h>#include <stdint.h>
struct User { int32_t id; int32_t level; char active;};
int main(void) { printf("sizeof(User) = %zu\n", sizeof(struct User)); printf("id = %zu\n", offsetof(struct User, id)); printf("level = %zu\n", offsetof(struct User, level)); printf("active = %zu\n", offsetof(struct User, active));}在常见 ABI 中,这个结构体通常是 12 字节,成员偏移分别是 0、4、8。末尾会有填充,使结构体大小满足整体对齐要求。具体结果仍应以目标编译器和 ABI 的实际输出为准。
这里有两个特别重要的 API:
sizeof(struct User):获取整个结构体对象的大小。offsetof(struct User, active):获取active字段相对于结构体起始地址的偏移。
偏移不一定等于大小之和
struct Example { char a; int32_t b; char c;};由于内存对齐的存在,b不会在第二个字节,他是int32,也就是4字节,所以中间会有填充,把他冲到4那,整个结构体变成12字节。这对逆向很关键:如果你只根据字段大小累加偏移,就可能错误地还原结构体。
偏移量: 0 1 2 3 4 5 6 7 8 9 10 11 ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐字节: │ a │pad │pad │pad │ b (int32_t) │ c │pad │pad │pad │ └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘ ↑ ↑ ↑ ↑ ↑ a@0 填充3字节 b@4 c@8 尾部填充3字节为什么需要对齐
CPU 访问内存不是逐字节的,而是按**字(word)**读取。以 4 字节 int32_t 为例:
- 如果
b的起始地址是 4 的倍数(如偏移 4),CPU 一次就能读完。 - 如果
b跨越了对齐边界(比如从偏移 1 开始),CPU 需要读两次再拼接,甚至在某些架构(ARM、部分 RISC)上直接触发硬件异常/崩溃。
所以编译器自动插入填充字节,保证每个成员都从自身对齐要求的整数倍地址开始。
这里给出三条规则:
比如刚刚那个例子,最大对齐数是 b 的 4。当前布局到 c 结束是偏移 9,不是 4 的倍数,所以尾部填充到 12。
Example 的对齐数是 4(来自 b)。所以 Example 数组里每个元素起始地址都必须是 4 的倍数。
做个小练习
struct Test { char a; int32_t b; char c; int64_t d;};
a 占偏移 0。b 需要 4 字节对齐,所以从偏移 4 开始。c 位于偏移 8。d 需要 8 字节对齐,所以从偏移 16 开始。最后总大小向上取整为 8 的倍数,因此是 24 字节。另外,对齐看的是元素,而不是大小!
比如
union Payload{ std::int32_t number; float value; char name[16];};
struct Request{ std::uint32_t type; std::uint32_t length; Payload payload;};结构体里内存如下0是type,4是length,8是payload。别看payload里有个16字节的name数组,但那是大小,对齐看元素而不是整体。并且联合体看最大的也就是int32和float的4字节,所以是偏移8。相关重要api
| 内容 | 作用 | 逆向价值 |
|---|---|---|
sizeof | 计算类型或对象大小 | 推断对象大小和缓冲区容量 |
offsetof | 获取成员偏移 | 还原字段布局 |
memcpy | 按字节复制内存 | 识别结构体复制和数据搬运 |
memcmp | 按字节比较内存 | 识别校验、数据块比较 |
memset | 按字节填充内存 | 识别对象初始化、清零和标志位初始化 |
. | 通过对象访问成员 | 常见于直接字段访问 |
-> | 通过指针访问成员 | 对应指针解引用后访问字段 |
&obj | 获取对象地址 | 分析地址传递 |
&obj.field | 获取成员地址 | 观察字段如何传给其他函数 |
packed 属性 | 某些编译器支持的紧凑布局扩展 | 分析协议结构体时可能遇到;不能当成通用标准 C/C++ 语法 |
cpp的结构体和c对比
可以说完全一样,唯一不同的是cpp有类这个概念,并且所有成员都相当于public。
从逆向角度,还要注意以下几个问题:
- 嵌套结构体: 一个结构体里可以包含另一个结构体,其成员偏移需要结合内部布局计算。
- 数组成员:
char name[16]会直接在对象中占据 16 字节,而char *name通常只存放一个指针。 - 指针成员: 结构体中若保存指针,结构体的大小和字段偏移会受到目标架构影响。
- 联合体
union: 多个成员共享同一块存储区域,大小至少要能容纳最大的成员,并满足相应对齐要求。 - 位域: 可以把多个逻辑标志压缩到较小的存储单元中,但具体布局存在实现相关性。
三、联合体
联合体和结构体的区别
struct DataStruct { int i; float f;};
union DataUnion { int i; float f;};DataUnion d;d.i = 10;对于结构体来讲,f和i都占4字节,两个成员分别位于偏移 0 和偏移 4,整个对象通常占 8 字节。
对于联合体来说,共享内存,常见平台上,整个联合体通常只需要 4 字节,实际大小还要满足最大成员的大小和对齐要求。
执行 d.i = 10 时,写入的是联合体的那块存储空间。i 和 f 不是两份互不相关的数据,它们对应的是同一块内存。
这就带来一个问题
union Number { int i; float f;};
Number n;n.i = 1065353216;1065353216 的十六进制是 0x3F800000。
按int解释,值为1065353216,而按照float解释则为1.0
从逆向的角度看,这意味着:
- 你在内存窗口看到的只是字节。
- 反汇编器根据指令和上下文推断这些字节被当作什么类型。
- 同一块内存可能被代码以整数、浮点数或其他数据格式解释。
嗯,相当之隐蔽。
struct Message { int type;
union Payload { int code; float value; char name[8]; } payload;};可以把它理解成:
type:说明当前消息是什么类型。payload:提供一块共享存储区域。code、value、name:是这块区域的不同解释方式。
在常见 ABI 下,假设 int 和 float 为 4 字节,且 name 为 8 字节,那么:
| 成员 | 典型偏移 |
|---|---|
type | 0 |
payload | 4 |
payload.code | 4 |
payload.value | 4 |
payload.name | 4 |
联合体的大小通常为 8 字节,整个 Message 通常为 12 字节。实际布局仍需结合目标编译器和 ABI 确认。
如何在逆向识别联合体
可以记住这几个线索:
- 多个不同类型的访问使用相同偏移。 例如同一个偏移的数据,有时被作为整数使用,有时被作为浮点数使用。
- 前面有类型判断。
switch(type)或条件分支决定后续如何解释同一块数据。 - 多个成员具有相同起始地址。 这是联合体布局的重要线索。
- 结合上下文验证。 不能只凭同一偏移被访问就认定是联合体,因为强制类型转换、指针别名和手动编码的字节缓冲区也可能产生类似现象。
最后还有一个细节:联合体的大小至少要足以容纳最大的成员,并满足其对齐要求;它并不总是简单地等于最大成员的 sizeof。
四、实战与反思
#include <iostream>#include <string>#include <cstring>#include <cstdint>#include <cstddef>#include <iomanip>
// 防止常见编译器轻易内联这些函数,方便初次观察调用关系#if defined(_MSC_VER)#define NOINLINE __declspec(noinline)#elif defined(__GNUC__) || defined(__clang__)#define NOINLINE __attribute__((noinline))#else#define NOINLINE#endif
union Payload{ std::int32_t number; float value; char name[16];};
struct Request{ std::uint32_t type; std::uint32_t length; Payload payload;};
std::uint32_t getRequestType(const Request *req){ return req->type;}
std::uint32_t getRequestLength(const Request *req){ return req->length;}
Payload getRequestPayload(const Request *req){ return req->payload;}
std::int32_t getPayloadLength(const Request *req){ return req->payload.number;}
std::int32_t processRequest(const Request *req){ Payload p = getRequestPayload(req); if (req->type == 1) { p.number += 100; return p.number; } else if (req->type == 2) { if (req->payload.value > 10.0f) { return 200; } return 201; } return -1;}
// c风格长度std::size_t c_len(const char *s){ return std::strlen(s);}
// 字符串比较NOINLINE bool isAdmin(const char *s){ return std::strcmp(s, "admin") == 0;}
int main(){ std::cout << '\n' << "string相关"; std::string a = "nihao"; std::string b = "i am mj,nice to meet you.mjmjmjmjmjmj!"; std::cout << "short_size: " << a.size() << '\n'; std::cout << "long_size: " << b.size() << '\n'; std::cout << "sizeof(std::string) = " << sizeof(std::string) << '\n'; std::cout << "short_str->object: " << static_cast<const void *>(&a) << '\n'; std::cout << "short_str data: " << static_cast<const void *>(a.data()) << '\n'; std::cout << "long_str->object: " << static_cast<const void *>(&b) << '\n'; std::cout << "long_str data: " << static_cast<const void *>(b.data()) << '\n';
// 观察c字符串 std::cout << "===============观察c字符串===============" << '\n'; std::cout << "isAdmin(admin) = " << isAdmin("admin") << '\n';
std::cout << "strlen(hello) = " << c_len("hello") << '\n';
// 观察结构体字段偏移 std::cout << "sizeof(Request) = " << sizeof(Request) << '\n'; std::cout << "offsetof(type) = " << offsetof(Request, type) << '\n'; std::cout << "offsetof(length) = " << offsetof(Request, length) << '\n'; std::cout << "offsetof(payload) = " << offsetof(Request, payload) << '\n';
// 联合体 Request req{}; req.length = sizeof(req.payload.number); req.type = 1; req.payload.number = 25;
std::cout << "request type = " << getRequestType(&req) << '\n'; std::cout << "request length = " << getRequestLength(&req) << '\n'; std::cout << "payload code = " << getPayloadLength(&req) << '\n'; std::cout << "process result = " << processRequest(&req) << '\n';
std::cout << '\n' << "观察联合体的底层字节" << '\n'; const auto *bytes = reinterpret_cast<const unsigned char *>(&req.payload); std::cout << "payload bytes = "; for (int i = 0; i < sizeof(req.payload); i++) { // 在字节输出循环中 std::cout << std::hex << std::setw(2) << std::setfill('0') << static_cast<unsigned int>(bytes[i]) << ' '; } std::cout << std::dec << '\n';
// D. 改为使用 value 成员,不读取 code 成员 req.type = 2; req.payload.value = 12.5f; std::cout << "payload bytes = "; for (int i = 0; i < sizeof(req.payload); i++) { // 在字节输出循环中 std::cout << std::hex << std::setw(2) << std::setfill('0') << static_cast<unsigned int>(bytes[i]) << ' '; }
std::cout << std::dec << '\n'; std::cout << "process float result = " << processRequest(&req) << '\n';
return 0;}输出
PS D:\Data structure\cpp> g++ -O0 -std=c++17 -g -fno-inline lab.cpp -o lab.exePS D:\Data structure\cpp> ./lab.exe
string相关short_size: 5long_size: 38sizeof(std::string) = 32下面四行体现了sso,long_Str的data和对象地址相隔甚远short_str->object: 0xfb859ff600short_str data: 0xfb859ff610long_str->object: 0xfb859ff5e0long_str data: 0x2b422e87d00===============观察c字符串===============isAdmin(admin) = 1strlen(hello) = 5下面体现了结构体内存布局与对齐sizeof(Request) = 24offsetof(type) = 0offsetof(length) = 4offsetof(payload) = 8request type = 1request length = 4payload code = 25下面是修改结构体数据process result = 125
观察联合体的底层字节payload bytes = 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00修改成float后,可以发现确实是公用一块内存,16字节。payload bytes = 00 00 48 41 00 00 00 00 00 00 00 00 00 00 00 00process float result = 200部分信息可能已经过时









