ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux socket自定义协议与序列化:从粘包到反序列化防护

Linux socket自定义协议与序列化:从粘包到反序列化防护 搞Linux下socket开发的朋友迟早都会碰上一个问题客户端和服务端之间到底用什么格式传数据。直接用JSON字符串一把梭的见过不少传着传着字段改名了、结构体对不上了、线上出问题只能靠抓包硬看那感觉谁碰谁知道。在应用层敢这么干多半是量还小、没踩到真正痛点上。等并发上来、消息变长、跨端联调多了以后你就会意识到应用层网络编程的核心并不是调socket收发——而是协议设计以及协议背后的序列化。这篇文章我不讲什么高深理论就基于Linux网络编程的实际场景把自定义协议的坑、序列化方案的取舍、粘包半包怎么拆、反序列化怎么防攻击一次性讲透。这篇文章适合两类人看一类是刚入门的Linux服务端开发在纠结怎么设计业务协议的另一类是客户端和服务端一把抓的全栈每次联调都靠共同维护一堆JSON样例想彻底摆脱这种状态的。老手也可以看看里面关于协议演进和坑位的内容有些坑不踩一遍是真不长记性。1. 为什么自定义协议里塞个JSON后期会有这么多坑1.1 从一次线上故障说起之前做一个物联网网关上报服务设备端上报的数据格式是JSON字符串长度不定最长的能到几十KB。服务端收到的逻辑很简单就是判断字符串的前4个字节是不是{如果是就当JSON处理。当时跑得好好的结果某天设备固件升级之后上报的JSON里多了一个大字段单包被TCP切成了好几段。服务端按前4字节判断的逻辑完全是废的——因为这个逻辑没有处理首包还没收全的情况。结果就是解析器疯狂报错进程内积压了一堆半包状态直接雪崩。这个事故暴露出来的其实是三个问题第一没有定义包边界解析器根本不知道一条消息何时完整第二JSON这种文本协议在解析时对长度极其敏感截断一个{或者都可能引发连锁错误第三双方居然靠约定字段名做接口联调没有任何版本控制机制。所以后来我彻底想明白了只要你的服务需要长期演化、需要多端联调、需要对异常包有可预期的处理就一定要在应用层设计协议帧自己管理边界和序列化。1.2 应用层协议到底解决什么问题应用层协议通常解决三件事报文边界、语义定义、传输可靠性。用老话讲就是怎么知道一条消息完了消息里都装着什么丢了重传怎么办。TCP本身只保证字节流不保证消息边界所以拆包粘包问题必须由应用层解决。而序列化解决的问题则是结构体怎么变成字节流字节流怎么变回结构体。这两件事是分开的但实际做的时候经常被混淆很多人直接在业务代码里写strlen(s) memcpy看着像在自定义协议实际上连帧头帧尾都没有那就是裸奔。有些从上层语言转来做Linux开发的同事会问我们直接用HTTP不行吗不是不行而是很多时候不值得。HTTP头部开销很大一条消息可能半个frame都被Header占了而且HTTP的语义模型请求/响应对长连接双向推送很别扭。自定义二进制协议的好处就是体积小、解析快、字段可控、安全性也更好——你不需要对付那些意料之外的HTTP头。1.3 应用层开发不等于嵌入式先纠正这个思维定势网上搜应用层开发经常和嵌入式绑在一起实际上两者是完全不同的方向。嵌入式应用层通常指跑在单片机或RTOS上的业务代码资源受限协议设计必须极度抠门而Linux上的应用层网络开发通常指服务端进程的业务逻辑资源相对富裕你在设计协议时有更大的空间。但反过来说如果你是做嵌入式Linux那抠门的习惯要保持住因为设备端的内存和功耗依然敏感。这篇文章后续的示例会尽量兼顾这两类场景但整体思路以Linux服务端为主。2. 从零定义包头长度字段到底放多少字节2.1 一个最基本的二进制帧结构设计自定义二进制协议第一件事就是定帧头。我见过最踏实的方案是固定8字节的头内部包含魔数、版本、标志位、长度。比如这样// protocol.h #pragma pack(push, 1) typedef struct { uint32_t magic; // 魔数用于校验是不是我们协议的包 uint16_t version; // 协议版本号 uint16_t flags; // 标志位压缩、加密、请求响应等 uint32_t body_len; // 报文主体长度 // body 紧跟其后 } proto_head_t; #pragma pack(pop)为什么长度字段要单独占4字节因为如果单包上限设在16MB2字节的uint16_t最多表示65535字节根本不够用。你要是觉得业务体量到不了这个级别用2字节也行但必须显式约定上限不要留隐含假设。长度字段这件事上我最想说的经验就是长度字段类型和上限必须写进协议文档否则不同端各写各的长度语义排查起来痛不欲生。2.2 魔数与版本字段的设计细节魔数magic的作用是快速识别非法包。你服务端收一个字节流第一眼看到的是个随机字节序列魔数可以帮你在一开始的字节比较中过滤掉垃圾流量。选魔数时有点讲究不要选\x00开头的因为很多调试工具会截断也不要选纯字符比如HTTP这种容易和处理文本协议的代码产生误判。我见过一个项目用0xFA7A当魔数因为展开后有FA7A刚好是两个字节抓包时肉眼好认。这属于个人喜好但确实方便。版本字段解决的是兼容问题。别以为协议定了就永远不变等客户端升级、服务端没升的时候你才会发现版本号是多么救命。通常处理方式是服务端读到version比当前低走老流程解析读到比当前高直接返回版本不支持的错误。这里有个小细节版本字段放Flags前面还是后面也是有讲究的。建议放在长度前面因为解析器一开始要读的东西越少分支越早对非法包的抵御能力越强。2.3 计算实际收包长度的几种方式说完了帧头你可能还在想上一个案例里的JSON问题——服务端只知道我收到了N个字节但怎么知道一条完整的消息已经到齐了最稳妥的做法是先收帧头再收正文两步走read()或者recv()先填满sizeof(proto_head_t)字节从帧头里解析出body_len继续收body_len字节直到完整收到一条消息。听起来简单实际上麻烦在永远不要假设一次recv能拿全数据。Linux是字节流4字节的帧头可能一次只来了2个字节下面的代码就是错例// 错误示范一次recv直接当整帧读 recv(fd, buf, sizeof(proto_head_t), 0); // 此时buf可能只有2个字节body_len读到的是垃圾值正确的做法是维护一个累积收包缓冲区等缓冲区里的数据达到帧头长度后再解析。这个过程一般叫拆包会在后面单独用一节详细展开。这里先记住一个原则长度字段是协议设计里最容易出问题的你宁可多分配几百字节的内存也不要依赖recv肯定能一次收完的幻觉——因为我实测下来局域网丢包率虽然极低但绝对不会为零。2.4 跨机器传输大小端踩坑实录你定义了uint32_t body_len然后在小端机器上直接memcpy发给大端机器对方读出来的是一个天文数字然后按照这个天文数字去分配内存接着就是OOM或者非法访问。这就是字节序问题。我们日常接触的x86、ARM小端模式机器往网络上一发必须转成网络字节序大端。Linux下提供了标准函数uint32_t htonl(uint32_t hostlong); // 主机序转网络序通常叫打包 uint32_t ntohl(uint32_t netlong); // 网络序转主机序解包用 uint16_t htons(uint16_t hostshort); uint16_t ntohs(uint16_t netshort);不要嫌麻烦也不要只在字段是多字节时转一次就完事。正确的做法是所有多字节整型在写入发送缓冲区之前一律htonl在读出来之后一律ntohl。这是协议设计中最容易糊弄过去、但后期最麻烦的部分。千万别手写字节交换宏来替代那玩意在优化等级高的时候可能出现问题。3. 序列化从结构体硬编码到TLV再到成熟框架3.1 结构体直接memcpy的坑早期做嵌入式开发的时候我最喜欢干的事就是把一个结构体直接memcpy到buffer里发出去目标端再memcpy回来。这个方案在同架构、同编译器、同对齐模式下是能跑通的但也仅此而已。一旦你换了一个编译器结构体填充字节不一样或者换了一台大小端机器直接废。更恐怖的坑是结构体里有指针或长度字段时你把指针值直接传过去那对端拿到的就是一个不可用的内存地址。所以结构体直接作为网络传输格式是作弊它把内存布局错当成了网络协议。如果你现在还在用这招我建议你至少做两件事一是在结构体定义前加#pragma pack(push, 1)让所有字段紧凑排列不要有填充字节二是所有整型字段在发送前htonl转换。做到这两点至少在同一个字节序体系下是能用的。但长期方案还是切换到真正面向传输的序列化格式。3.2 TLV结构的原理与实现TLV就是Type-Length-Value三个字段组成一个数据单元。这个结构的好处是自描述性很强——解析方看到Type就知道是什么字段看到Length就知道该读取多长然后取Value。它的形态和自定义协议帧完美契合因为TLV本身就可以作为body内部的字段组织方式。简单实现可以这样定义typedef struct { uint16_t type; // 字段类型 uint16_t len; // 字段长度 // uint8_t value[]; } tlv_item_t;TLV的缺点是每个字段都要额外占Type和Length的空间对于大量小字段来说有点浪费。但它的扩容性极好你未来加一个新字段只要分配一个新的Type值老客户端不认这个Type时直接跳过天然兼容版本演进。这就是为什么很多RPC协议和物联网协议内部都用TLV组织字段。不过要注意TLV会带来一个问题解析方依赖字段顺序吗如果不做规定你可能收到的字段顺序是乱的。所以好的设计是TLV中字段顺序无关解析时按Type查找并填充到结构体对应成员对于可选字段找不到就保持默认值。有关键字段缺失时则要主动报错。这是反序列化容错里很重要的一环。3.3 手工序列化的顺序与长度边界手动序列化的核心流程分为三步固定帧头、逐字段写入、计算总长度再回填。写代码时最容易犯的错误是先发帧头再发字段最后发现body_len没填。所以我的习惯是申请一个足够大的stack buffer先把body所有字段序列化完量出真实长度再回填帧头最后一次性send()。这样做还附带一个好处你能保证整个报文是一个连续的内存块提升发送效率。手工序列化的一个隐藏难点是字符串字段。字符串本身没有固定长度你必须在字符串前加一个长度字段否则对端不知道读多长。更保险的做法是字符串统一用uint16_t str_len 原始UTF-8字节表示并且规定一个上限比如4096。超过上限就拒绝序列化不要“智能地”截断——截断会导致前后端看到的语义不一致。3.4 常用序列化工具ProtoBuf、MessagePack、JSON的适用场景现在回到开头说的JSON层面。JSON确实是人类可读性最好的序列化格式联调起来也方便在日志系统、配置下发中很有价值。但用做TCP长连接的业务协议我个人是不推荐把大量JSON作为线上报文直接传输的除非压测数据证明开销可接受。一来体积膨胀严重二来解析开销高三来没有统一的schema约束字段错一个拼写就出一个线上bug。更适合放在协议帧体里的方案Protocol Buffers紧凑、跨语言、自带schema校验解析速度非常快。缺点是调试时需要protoc编译且.proto文件的演进需要流程管理。如果你们的服务端是Linux C或Go强烈推荐。MessagePack类似JSON但二进制化体积小了很多在很多语言里都有现成库。如果你已经有JSON格式的业务数据想平滑切换用MessagePack是最省事的。FlatBuffers流式FlatBuffers零拷贝反序列化访问字段时不需要先把整个对象展开适合读多写少的高频路径。在单帧内做大数组传输时优势明显。自研TLV当你的协议非常定制化比如字段极少、但要极致的速度和体积TLV依然是最优解。它不需要引入任何依赖也最容易做到解析失败时安全跳过。列一张表对比下方案体积解析速度可读性跨语言版本演进JSON大慢好好差字段靠约定MessagePack中中差好中Protobuf小快差好好有schemaFlatBuffers小极快差好好自研TLV最小极快差一般中靠Type维护就我自己的经验来说如果你要做一个从零开始的Linux服务端应用层项目而且后续可能有多端接入优先考虑帧头用自定义二进制body用Protobuf这套组合。帧头负责边界、魔数、版本和可选压缩标志body用Protobuf描述业务数据两边优点都能拿到。4. 粘包与半包Linux socket拆包状态机的正确打开方式4.1 为什么会粘包、为什么会半包TCP是字节流不是消息流。接收方从recv()拿到的是截至某时刻对方已经发来的字节集合它不知道这些字节属于几条消息。多条消息在缓冲区内连在一起就是粘包一条消息被分成几段到达就是半包。这两个是同一件事的两面你从缓冲区看到的字节边界不一定等于消息边界。网上很多老教程教你发送方Sleep一下接收方也Sleep一下然后每次recv刚好是一条消息这纯属于侥幸。实测里即使在同一台机器上send两条消息接收方也可能一次性recv到两条而一条消息完全可能被拆成多次到达。所以拆包是协议设计的一部分不是接收程序的补丁。4.2 固定长度拆包与长度字段拆包拆包最简单的方式是把每条消息长度固定比如每条固定1024字节不够就补零。这样接收方只需要读够1024字节就处理一个包。缺点是浪费带宽和内存但代码最简单适合对实时性要求高、消息结构极其固定的场景。另一种是我常用的长度字段拆包消息由包头包体组成包头固定长度且在固定位置包含包体长度。解析循环的伪代码如下while (缓冲区可读字节数 sizeof(proto_head_t)) { 读取帧头; 校验magic和version; 如果 body_len 超过合法上限丢弃连接; 如果 缓冲区可读字节数 body_len等待更多数据; 取出完整 body交给业务解析; 移除已处理的数据; }这里最容易忽略的是读完帧头后body可能还没来齐。此时必须把缓冲区余留数据继续积压直到满足body_len再解析。不要图省事把半包直接当异常处理它根本不是异常只是正常网络到达时序。4.3 状态机实现收包头、收包体、校验三个状态一个健壮的拆包器内部应该是一个简单的状态机。状态分三种HEADER正在收帧头、BODY正在收包体、CHECK校验。伪代码描述一下typedef struct { int state; // 0: HEADER, 1: BODY, 2: CHECK uint8_t header[HEADER_SIZE]; size_t header_len; // 已收包头字节数 uint32_t body_len; // 目标包体长度 uint8_t *body; size_t body_len_recv; // 已收包体字节数 } unpacker_t;在HEADER状态下把从recv读到的字节逐字节填入header缓冲区直到攒满8字节帧头然后解析出body_len对body_len做一次合法性检查如果上限是16MB超过就断开连接防止内存耗尽进入BODY状态每次收几段拼几段收满后进入CHECK状态CHECK状态里做魔数、版本、可选CRC校验通过则交给解析器不通过则丢弃并断开连接。实际还有第三个状态CHECK完成后怎么重新回到HEADER这里有个技巧处理完当前包后缓冲区可能还留着下一条消息的字节所以要继续回HEADER继续解析直到缓冲区没有足够数据为止。4.4 实战代码对照基于read循环的拆包器下面是一个简化版的实现只演示核心逻辑生产环境需要改为可重入式API#define MAX_BODY_LEN (16 * 1024 * 1024) int unpack_stream(struct unpacker_t *un, char *recv_buf, ssize_t recv_len, int (*on_msg)(proto_head_t *h, uint8_t *body, void *ctx), void *ctx) { ssize_t p 0; while (p recv_len) { if (un-state 0) { // HEADER while (p recv_len un-header_len HEADER_SIZE) { un-header[un-header_len] recv_buf[p]; } if (un-header_len HEADER_SIZE) { proto_head_t *h (proto_head_t *)un-header; un-body_len ntohl(h-body_len); if (un-body_len 0 || un-body_len MAX_BODY_LEN) { return -1; // 非法长度断开 } un-body malloc(un-body_len); un-body_len_recv 0; un-state 1; // BODY } } if (un-state 1) { // BODY size_t need un-body_len - un-body_len_recv; size_t avail recv_len - p; size_t copy need avail ? need : avail; memcpy(un-body un-body_len_recv, recv_buf p, copy); un-body_len_recv copy; p copy; if (un-body_len_recv un-body_len) { on_msg((proto_head_t *)un-header, un-body, ctx); free(un-body); un-header_len 0; un-state 0; } } } return 0; }这个函数的思路就是每次调用把它当喂数据来看不论你recv到几个字节都往状态机里塞能拆出多少条完整消息是拆出多少条剩余的留在内部缓冲区。用这种方式业务层完全不用关心recv边界代码逻辑很清爽。而且它天然就是异步安全的对高并发收包很友好。5. 反序列化攻击与协议防护别让你的协议裸奔在公网5.1 反序列化攻击是什么反序列化攻击的一般套路是攻击者构造一个恶意的序列化字节流导致接收端在解析时触发非预期的行为比如将超长数据写入预期之外的缓冲区、把某字段解析成巨大的循环次数、或者通过某个反序列化框架触发了危险的类实例化。这类攻击不只存在于Java、PHP的序列化框架中即使在纯C的二进制协议里也一样会出现只不过表现形式不同。比如你解析一个长度字段时没有校验上限攻击者直接塞一个0xFFFFFFFF进去你照着这个值去malloc轻则进程内存被打爆重则堆溢出被利用直接在服务端拿到权限。5.2 协议层面最容易犯的三个安全错误第一个错误是不校验长度上限。前面提到的body_len就是一个典型目标必须做上限校验。第二个错误是不对字段做范围校验。解析用户提供的整型时业务语义里它该在[0, 100]之间你直接把它当作数组下标使用。攻击者就能构造一个边界值触发现内存越界。无论你用什么序列化方案业务字段的范围校验都是反序列化层必须做的事。第三个错误是反射框架滥用。如果你用了支持动态反序列化的框架比如某些自动根据className实例化对象的库攻击者可以控制你服务端的类加载路径。这个在C里少见在Java/Python服务端里很常见。如果你们的服务端是这类语言的务必禁止客户端传递类名或类型标识符到服务端。5.3 一个绝不信任输入的校验清单我自己在写协议解析器时会在代码注释里放一份检查清单每加一个解析分支就走一遍长度字段是否有限制是否会把值转换成下标使用字符串字段是否确保以\0结尾解析时是否预留了结尾字符空间版本号过高的包是拒绝还是忽略帧头里的flags位是否有未定义位未定义位置一能否通过校验魔数合法性、保留位合法性、长度值合法性是否在同一个入口集中检查多段数据拼接时拼接总长度是否上限可控计数类字段比如消息条数循环次数是否有上限防止被用于耗尽CPU如果这些点都能在解析入口统一处理那么你的反序列化攻击面已经砍掉一大半。防住长度和边界问题基本上就防住了最普遍的协议攻击手法。5.4 加固手段校验和、白名单、降级策略在协议帧里加一个CRC32字段是个好习惯。服务端收到完整body后先算一把CRC再决定是否解析。注意CRC不承担加密责任它只是防传输过程中被篡改或内存数据被破坏。真正防恶意篡改需要HMAC——给body算一个密钥相关的摘要。它们的定位不同CRC本地防错HMAC网络防伪。另一个做法是解析白名单。如果你们的前端设备类型是固定的可以在设备注册后约定每个设备只能使用协议里定义的那几个字段Type当服务端收到未注册的Type值时直接丢弃而不是尝试解析。这能有效降低未知漏洞被利用的概率。降级策略也很重要某些字段解析失败时评估是整个包废弃还是这个字段置空。我的建议是核心字段解析失败报废整个包并计数告警可选字段解析失败置默认值并继续。不要一刀切否则会出现攻击者不断注入可选字段垃圾值导致业务被挤兑。6. 一个完整可编译的收发Demo与性能感想6.1 发送端组装一帧带TLV的天气数据为了把前面几点串起来我写了一个极简的Demo客户端向服务端上报一条天气数据包含城市、温度、湿度三个字段。协议设计成8字节帧头 若干TLV。发送端的组装逻辑如下static void add_tlv_item(uint8_t *buf, size_t *cur, uint16_t type, const void *val, uint16_t val_len) { uint16_t t htons(type), l htons(val_len); memcpy(buf *cur, t, 2); *cur 2; memcpy(buf *cur, l, 2); *cur 2; memcpy(buf *cur, val, val_len); *cur val_len; }组装一帧时核心点是先把body组装完再回填frame头的body_len。这里有一个优化的小技巧body_len用的网络序精简包体时最好用局部变量而非反复回填到buf里。6.2 接收端配合前面拆包器解析接收端在recv之后调用unpack_stream在on_msg回调里拿到body后按TLV循环解析。解析TLV时的关键点是每取一个item之前先判断remaining_len 4然后判断len remaining_len 4否则直接return -1。这个边界检查通常是在解析TLV时最容易漏掉的部分漏的结果就是读越界。这个Demo跑在局域网内环境是两台Linux机器一个x86一个ARM小端。用1KB大小的消息体压测延迟稳定在0.3ms左右吞吐能到近万帧每秒。作为对比同一份数据改为JSON后吞吐掉到了三千帧左右内存占用也明显上升。不是说二进制协议一定要比JSON快多少倍而是它在高并发下的CPU占用更可控GC压力也小。6.3 实测中的意外情况与经验体会最后分享一点很实用的经验我一开始做拆包器时把状态机里的head_len判断写在HEADER分支的外面结果导致每收一次数据都要先判断是否满8字节帧头代码逻辑也不对。后来重构成每次while循环只保证进入一个分支代码可读性和正确性都上来了。这个教训是状态机实现的难点从来不是状态本身而是状态之间的边界处理。还有一次测试时发现CRC校验偶尔不过排查了好久发现是发送端把body长度算错了多加了4字节——所以收到校验失败先别怀疑CRC算法先检查长度回填对不对。现在跑这个协议稳定了我心里就有底了。应用层网络编程这摊事核心就是你对字节流到消息流的理解有多深。协议定清楚了序列化选型到位了拆包器写对了后面再想加加密、加压缩、加多路复用都是往这套骨架上添砖加瓦的事。这批东西不补早晚得在线上流一次血。
返回列表