28、网络编程安全:socket错误处理、数据序列化安全、协议解析防御、超时机制

网络编程,说白了就是让两个程序隔着网线聊天。但这条网线不太靠谱——数据可能丢、可能乱、可能被人偷看,甚至可能有人故意发垃圾数据搞破坏。我做了十几年嵌入式网络开发,踩过的坑比代码行数还多。今天咱们就聊聊怎么把网络通信写得更结实。

28.1 socket错误处理:别让程序死得不明不白

很多新手写socket代码,喜欢这样:

int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);
send(sock, data, len, 0);
recv(sock, buf, size, 0);

嗯,看着挺顺眼。但实际跑起来,任何一个步骤都可能失败。我见过一个产品,就因为没检查connect返回值,程序一直往一个不存在的服务器发数据,日志里全是「发送成功」——其实全丢黑洞里了。

核心原则:每个系统调用都要检查返回值。

正确的做法是这样:

int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1) {
    // 记录错误码 errno
    log_error("socket() failed: %s", strerror(errno));
    return -1;
}

struct timeval timeout = {5, 0};  // 5秒超时
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
    log_error("connect() failed: %s", strerror(errno));
    close(sock);
    return -1;
}

ssize_t sent = send(sock, data, len, 0);
if (sent == -1) {
    log_error("send() failed: %s", strerror(errno));
} else if (sent != len) {
    log_warn("send() partial: sent %zd of %zu", sent, len);
}

这里有个细节:send() 可能只发送了部分数据。我早期就吃过这个亏,以为一次send就能发完所有数据。后来养成了循环发送的习惯:

ssize_t send_all(int sock, const void *data, size_t len) {
    const char *ptr = (const char*)data;
    size_t remaining = len;
    
    while (remaining > 0) {
        ssize_t sent = send(sock, ptr, remaining, 0);
        if (sent == -1) {
            if (errno == EINTR) continue;  // 被信号中断,重试
            return -1;  // 真正的错误
        }
        ptr += sent;
        remaining -= sent;
    }
    return len;
}
避坑指南:我曾经在一个项目中忽略了 EINTR 错误,结果程序在收到 SIGALRM 信号后,send() 返回 -1,我直接断开了连接。后来排查了半天才发现是信号中断的问题。

28.2 数据序列化安全:别让结构体直接往外扔

很多嵌入式工程师喜欢这样:

struct Packet {
    uint32_t id;
    uint32_t length;
    char data[256];
};

struct Packet pkt;
send(sock, &pkt, sizeof(pkt), 0);

你想想看,这有什么问题?问题大了去了。

  • 字节序问题:大端和小端机器之间直接传结构体,数据全乱套
  • 对齐问题:不同编译器、不同架构的结构体填充不一样
  • 版本兼容问题:结构体一改,新旧版本就聊不到一块了

我建议的做法是:使用明确的序列化格式。最简单的方案是逐字段手动序列化:

// 序列化:主机字节序 -> 网络字节序
int serialize_packet(const struct Packet *pkt, uint8_t *buf, size_t buf_size) {
    if (buf_size < 4 + 4 + pkt->length) {
        return -1;  // 缓冲区不够
    }
    
    uint32_t id = htonl(pkt->id);
    uint32_t len = htonl(pkt->length);
    
    memcpy(buf, &id, 4);
    memcpy(buf + 4, &len, 4);
    memcpy(buf + 8, pkt->data, pkt->length);
    
    return 8 + pkt->length;
}

// 反序列化:网络字节序 -> 主机字节序
int deserialize_packet(const uint8_t *buf, size_t buf_size, struct Packet *pkt) {
    if (buf_size < 8) {
        return -1;  // 至少要有头部
    }
    
    uint32_t id, len;
    memcpy(&id, buf, 4);
    memcpy(&len, buf + 4, 4);
    
    pkt->id = ntohl(id);
    pkt->length = ntohl(len);
    
    if (pkt->length > 256) {
        return -1;  // 长度检查!防止缓冲区溢出
    }
    if (buf_size < 8 + pkt->length) {
        return -1;  // 数据不够
    }
    
    memcpy(pkt->data, buf + 8, pkt->length);
    return 8 + pkt->length;
}
关键点:反序列化时一定要做长度校验。我曾经见过一个攻击,就是通过构造超长的 length 字段,让 memcpy 把数据拷贝到栈外,实现缓冲区溢出攻击。

如果项目复杂,可以考虑使用 protobuf、MessagePack 这类成熟的序列化库。但在嵌入式环境里,我更喜欢自己写一个轻量级的 TLV(Type-Length-Value)格式:

// TLV 格式:1字节类型 + 2字节长度 + 数据
int tlv_encode(uint8_t type, const void *value, uint16_t len, 
               uint8_t *buf, size_t buf_size) {
    if (buf_size < 3 + len) return -1;
    
    buf[0] = type;
    buf[1] = (len >> 8) & 0xFF;
    buf[2] = len & 0xFF;
    memcpy(buf + 3, value, len);
    
    return 3 + len;
}

28.3 协议解析防御:别信对方发来的任何数据

网络编程的第一条铁律:永远不要信任对端发来的数据。你想想看,如果对方是个恶意程序,或者线路被干扰了,什么奇怪的数据都可能出现。

协议解析时,必须做以下几件事:

  1. 魔数检查:每个包开头放一个固定魔数,比如 0xAA55,用来快速过滤垃圾数据
  2. 长度校验:声明的长度不能超过缓冲区大小,也不能小于头部大小
  3. 范围检查:枚举值、索引等必须在合法范围内
  4. 完整性校验:CRC32 或 MD5 校验和

来看一个完整的协议解析示例:

#define MAGIC 0xAA55
#define MAX_PAYLOAD 1024

struct ProtocolHeader {
    uint16_t magic;
    uint16_t length;   // 包含头部在内的总长度
    uint32_t crc32;
    uint8_t type;
};

int parse_packet(const uint8_t *raw, size_t raw_len, struct Packet *out) {
    // 1. 最小长度检查
    if (raw_len < sizeof(struct ProtocolHeader)) {
        log_warn("packet too short: %zu", raw_len);
        return -1;
    }
    
    // 2. 魔数检查
    struct ProtocolHeader *hdr = (struct ProtocolHeader*)raw;
    if (ntohs(hdr->magic) != MAGIC) {
        log_warn("bad magic: 0x%04X", ntohs(hdr->magic));
        return -1;
    }
    
    // 3. 长度检查
    uint16_t total_len = ntohs(hdr->length);
    if (total_len < sizeof(struct ProtocolHeader)) {
        log_warn("length too small: %u", total_len);
        return -1;
    }
    if (total_len > MAX_PAYLOAD + sizeof(struct ProtocolHeader)) {
        log_warn("length too large: %u", total_len);
        return -1;
    }
    if (raw_len < total_len) {
        log_warn("incomplete packet: need %u, have %zu", total_len, raw_len);
        return -1;
    }
    
    // 4. CRC校验
    uint32_t expected_crc = calculate_crc32(raw + sizeof(uint32_t), 
                                            total_len - sizeof(uint32_t));
    uint32_t received_crc = ntohl(hdr->crc32);
    if (expected_crc != received_crc) {
        log_warn("CRC mismatch: expected 0x%08X, got 0x%08X", 
                 expected_crc, received_crc);
        return -1;
    }
    
    // 5. 类型检查
    uint8_t type = hdr->type;
    if (type >= MAX_PACKET_TYPE) {
        log_warn("unknown packet type: %u", type);
        return -1;
    }
    
    // 解析成功
    out->type = type;
    out->length = total_len - sizeof(struct ProtocolHeader);
    memcpy(out->data, raw + sizeof(struct ProtocolHeader), out->length);
    
    return 0;
}
特别注意:CRC校验时,要排除 CRC 字段本身。否则你算出来的 CRC 永远对不上——因为原始数据里包含的是旧的 CRC 值。

28.4 超时机制:别让程序死等

网络通信最怕什么?最怕程序卡在 recv() 上不动了。服务器宕机、网线断了、防火墙丢包……任何一个环节出问题,你的程序就可能永远等下去。

我个人的习惯是:每个网络操作都必须有超时。有三种实现方式:

方法 优点 缺点 适用场景
setsockopt SO_RCVTIMEO 简单,一行代码搞定 精度不高,不能区分超时和错误 简单的客户端程序
select/poll 可同时监控多个socket,精度高 代码稍复杂 需要同时处理多个连接
非阻塞 + epoll 高性能,适合大量连接 代码最复杂 高并发服务器

对于嵌入式设备,我推荐用 select,它简单可靠:

int recv_with_timeout(int sock, void *buf, size_t len, int timeout_ms) {
    fd_set readfds;
    struct timeval tv;
    
    FD_ZERO(&readfds);
    FD_SET(sock, &readfds);
    
    tv.tv_sec = timeout_ms / 1000;
    tv.tv_usec = (timeout_ms % 1000) * 1000;
    
    int ret = select(sock + 1, &readfds, NULL, NULL, &tv);
    if (ret == 0) {
        errno = ETIMEDOUT;
        return -1;  // 超时
    }
    if (ret == -1) {
        return -1;  // 错误
    }
    
    // socket可读,执行recv
    return recv(sock, buf, len, 0);
}
避坑指南:我曾经在一个项目中用了 SO_RCVTIMEO,结果发现它在某些内核版本上行为不一致。有的版本超时后 recv() 返回 -1 并设置 errno 为 EAGAIN,有的版本返回 0。后来我统一改用 select,再也没出过问题。

还有一个容易被忽略的点:连接超时。connect() 默认超时时间很长(Linux 上大约 20-30 秒)。如果对方服务器没开,你的程序就会卡住。解决办法是把 socket 设为非阻塞,然后用 select 等它:

int connect_with_timeout(int sock, const struct sockaddr *addr, 
                         socklen_t addrlen, int timeout_ms) {
    // 设为非阻塞
    int flags = fcntl(sock, F_GETFL, 0);
    fcntl(sock, F_SETFL, flags | O_NONBLOCK);
    
    int ret = connect(sock, addr, addrlen);
    if (ret == 0) {
        // 立即连接成功
        fcntl(sock, F_SETFL, flags);  // 恢复阻塞模式
        return 0;
    }
    
    if (errno != EINPROGRESS) {
        return -1;  // 真正的错误
    }
    
    // 等待连接完成
    fd_set writefds;
    struct timeval tv;
    
    FD_ZERO(&writefds);
    FD_SET(sock, &writefds);
    
    tv.tv_sec = timeout_ms / 1000;
    tv.tv_usec = (timeout_ms % 1000) * 1000;
    
    ret = select(sock + 1, NULL, &writefds, NULL, &tv);
    if (ret <= 0) {
        return -1;  // 超时或错误
    }
    
    // 检查连接是否成功
    int error = 0;
    socklen_t errlen = sizeof(error);
    getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &errlen);
    if (error != 0) {
        errno = error;
        return -1;
    }
    
    fcntl(sock, F_SETFL, flags);  // 恢复阻塞模式
    return 0;
}

28.5 知识体系总览

下面这张图总结了网络编程安全的四个核心维度:

网络编程安全四维防御 socket错误处理 • 每个系统调用检查返回值 • 循环发送/接收完整数据 • 处理 EINTR 信号中断 • 记录 errno 和错误上下文 数据序列化安全 • 使用 htonl/ntohl 统一字节序 • 手动序列化避免对齐问题 • TLV 格式灵活扩展 • 反序列化必须做长度校验 协议解析防御 • 魔数检查过滤垃圾数据 • 长度校验防止缓冲区溢出 • CRC32 完整性校验 • 类型/范围检查 超时机制 • SO_RCVTIMEO 简单配置 • select/poll 精确控制 • 非阻塞 connect + select • 应用层心跳保活 防御式编程:不信任任何外部输入,每个操作都有退路

这四个维度缺一不可。socket错误处理保证程序不会因为一次失败就崩溃;数据序列化保证不同平台之间能正确通信;协议解析防御防止恶意数据攻击;超时机制保证程序不会死锁。把这四点做好了,你的网络程序才算真正健壮。

最后说一句:网络编程没有银弹。你写的每一行代码,都要假设对端是个不靠谱的家伙。做好防御,才能睡得安稳。

公众号:蓝海资料掘金营,微信deep3321