mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3247 字
8 分钟
CPP沉淀(七)
2026-10-29

逆向角度看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 字节,布局更紧凑
MSVC15 字节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误区#

  1. c_str() 返回的指针不一定指向堆内存

    情况p 指向哪里
    短字符串可能指向 s 对象内部
    长字符串通常指向堆内存
    字符串被修改或重新分配存储原来的 p 可能失效
  2. 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)上直接触发硬件异常/崩溃。

所以编译器自动插入填充字节,保证每个成员都从自身对齐要求的整数倍地址开始。

这里给出三条规则:

  • 规则 1:每个成员的偏移量必须是其对齐数的整数倍#

  • 规则 2:结构体总大小必须是其最大成员对齐数的整数倍#

比如刚刚那个例子,最大对齐数是 b 的 4。当前布局到 c 结束是偏移 9,不是 4 的倍数,所以尾部填充到 12。

  • 规则 3:结构体的对齐数 = 成员中最大的对齐数#

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 字节,那么:

成员典型偏移
type0
payload4
payload.code4
payload.value4
payload.name4

联合体的大小通常为 8 字节,整个 Message 通常为 12 字节。实际布局仍需结合目标编译器和 ABI 确认。

如何在逆向识别联合体#

可以记住这几个线索:

  1. 多个不同类型的访问使用相同偏移。 例如同一个偏移的数据,有时被作为整数使用,有时被作为浮点数使用。
  2. 前面有类型判断。 switch(type) 或条件分支决定后续如何解释同一块数据。
  3. 多个成员具有相同起始地址。 这是联合体布局的重要线索。
  4. 结合上下文验证。 不能只凭同一偏移被访问就认定是联合体,因为强制类型转换、指针别名和手动编码的字节缓冲区也可能产生类似现象。

最后还有一个细节:联合体的大小至少要足以容纳最大的成员,并满足其对齐要求;它并不总是简单地等于最大成员的 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.exe
PS D:\Data structure\cpp> ./lab.exe
string相关short_size: 5
long_size: 38
sizeof(std::string) = 32
下面四行体现了sso,long_Str的data和对象地址相隔甚远
short_str->object: 0xfb859ff600
short_str data: 0xfb859ff610
long_str->object: 0xfb859ff5e0
long_str data: 0x2b422e87d00
===============观察c字符串===============
isAdmin(admin) = 1
strlen(hello) = 5
下面体现了结构体内存布局与对齐
sizeof(Request) = 24
offsetof(type) = 0
offsetof(length) = 4
offsetof(payload) = 8
request type = 1
request length = 4
payload 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 00
process float result = 200
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CPP沉淀(七)
https://fatdog.20060113.xyz/posts/cpp-reverse1/
作者
神秘大胖狗
发布于
2026-10-29
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00