安全序列化:防止缓冲区溢出、输入验证、避免反序列化漏洞
说实话,序列化这个活儿,写起来爽,出起事来也够狠。我见过太多项目,序列化代码跑得飞快,结果一遇到恶意数据就直接崩了。更可怕的是,有些漏洞不是崩,而是被悄悄利用——数据被篡改、内存被踩、甚至代码被远程执行。
所以这一章,咱们专门聊聊怎么把序列化做得安全。说白了,就是三件事:别让缓冲区溢出、做好输入验证、堵死反序列化漏洞。这三板斧砍下去,你的序列化代码才算真正能上战场。
缓冲区溢出:C语言的老冤家
缓冲区溢出,C语言程序员的老朋友了。我在嵌入式项目里遇到过一回,一个传感器上报的数据包长度字段被人为篡改,结果memcpy直接写爆了栈空间。嗯,从那以后,我写序列化代码第一件事就是检查长度。
来看一个典型的危险写法:
// 危险!没有长度检查
void unsafe_deserialize(uint8_t *buffer, uint32_t len) {
char name[64];
memcpy(name, buffer, len); // 如果len > 64,栈就炸了
}
正确的做法是:
// 安全版本:显式检查长度
int safe_deserialize(uint8_t *buffer, uint32_t len) {
char name[64];
if (len > sizeof(name)) {
return -1; // 拒绝处理
}
memcpy(name, buffer, len);
return 0;
}
输入验证:别信任何外来数据
我个人的习惯是:所有从外部来的数据,默认都是有毒的。不管是网络包、文件内容、还是传感器数据,只要不是你自己生成的,就必须验证。
验证什么?至少这几项:
- 长度范围:数据包总长度、每个字段的长度,是否在合理区间内
- 类型标记:枚举值是否在合法范围内,类型ID是否被支持
- 指针/偏移量:如果数据里有相对偏移,必须检查是否越界
- 字符串终止:确保字符串有'\0',且长度不超过缓冲区
举个例子,一个常见的序列化格式里包含字符串长度字段:
typedef struct {
uint16_t str_len; // 字符串长度
char data[]; // 柔性数组
} SafeString;
int parse_string(uint8_t *buf, uint32_t buf_size) {
SafeString *s = (SafeString *)buf;
// 第一步:检查总长度是否够头部
if (buf_size < sizeof(SafeString)) {
return -1;
}
// 第二步:检查字符串长度是否合理
uint16_t len = ntohs(s->str_len); // 网络字节序转换
if (len > 1024) { // 最大长度限制
return -1;
}
// 第三步:检查实际数据是否越界
if (sizeof(SafeString) + len > buf_size) {
return -1;
}
// 安全了,可以处理
return 0;
}
validate_packet_header()。这样主逻辑干净,验证逻辑也能复用。
反序列化漏洞:不止是缓冲区的事
你以为防住缓冲区溢出就完了?太天真了。反序列化漏洞的坑多着呢。我记得有一次,一个同事写的反序列化代码里,允许从数据流中动态分配任意大小的内存。结果攻击者传了个"长度=0xFFFFFFFF"的字段,malloc直接返回NULL,后续代码还没检查……
常见的反序列化漏洞包括:
| 漏洞类型 | 危害 | 防御方法 |
|---|---|---|
| 整数溢出 | 长度计算错误,导致缓冲区写越界 | 使用安全整数运算,检查溢出 |
| 类型混淆 | 错误解析数据类型,导致逻辑异常 | 严格校验类型标记,拒绝未知类型 |
| 递归深度 | 嵌套结构导致栈溢出 | 限制最大递归深度 |
| 内存耗尽 | 恶意构造大量对象,耗尽系统内存 | 限制总分配大小和对象数量 |
| 指针伪造 | 篡改偏移量,读写任意内存 | 验证所有偏移量在合法范围内 |
来看一个递归深度的例子:
#define MAX_NESTING 16
int deserialize_node(uint8_t *buf, uint32_t size, int depth) {
if (depth > MAX_NESTING) {
return -1; // 太深了,拒绝
}
// 解析当前节点...
// 如果有子节点,递归解析
if (has_child) {
return deserialize_node(child_buf, child_size, depth + 1);
}
return 0;
}
核心原则:反序列化时,永远假设输入是恶意的。每读一个字段,都要问自己:这个值如果被篡改,会出什么事?
实战:一个安全的序列化框架设计
说了这么多理论,咱们来点实际的。下面是我个人比较喜欢的一种安全序列化设计思路,用SVG画出来:
这个流程的核心思想是:层层过滤,逐级验证。每一道防线只做一件事,过了才放行到下一关。任何一关失败,直接拒绝整个数据包。
对应的代码骨架大概是这样的:
int safe_deserialize(uint8_t *buf, uint32_t buf_size, ParsedData *out) {
// 第一关:长度检查
if (buf_size < MIN_PACKET_SIZE) {
return ERR_TOO_SHORT;
}
// 第二关:字段验证
PacketHeader *hdr = (PacketHeader *)buf;
if (!validate_header(hdr, buf_size)) {
return ERR_INVALID_HEADER;
}
// 第三关:语义检查
if (!check_semantics(hdr)) {
return ERR_SEMANTICS;
}
// 通过所有检查,开始解析
return parse_payload(buf, buf_size, out);
}
assert 或者日志记录下失败原因。调试的时候特别有用,生产环境可以关掉日志,但保留错误码。
避坑指南:我曾经踩过的坑
讲几个我真实遇到过的坑,你写代码的时候留个心:
- 坑一:我曾经在解析变长数组时,先计算了总长度,然后忘了检查乘法溢出。结果
count * element_size溢出了,分配了一个很小的缓冲区,后面拷贝时直接写爆。从那以后,我所有乘法都用__builtin_mul_overflow或者手动检查。 - 坑二:有一次反序列化嵌套结构,我忘了限制递归深度。结果测试人员传了一个深度1000的嵌套数据,栈直接溢出,程序崩溃。嗯,后来我加了个
depth > 32的检查。 - 坑三:字符串处理时,我忘了检查
strlen是否真的能找到'\0'。如果数据里没有终止符,strlen会一直读到越界。现在我的代码里,所有字符串都用strnlen加上最大长度限制。
好了,这一章的内容就到这里。安全序列化说白了就是「别信任何人,检查每一寸数据」。你想想看,你的序列化代码可能跑在设备上几年不更新,一旦有漏洞,后果不堪设想。所以,多花点时间在验证上,值得。
公众号:蓝海资料掘金营,微信deep3321