摘要

TCP/IP 协议栈是操作系统、网络设备、工业控制设备、物联网设备和嵌入式系统中最基础的网络通信组件。由于协议栈直接处理来自网络的数据包,一旦解析逻辑存在缺陷,就可能被远程触发,造成拒绝服务、信息泄露、越界读写,甚至远程代码执行。

从历史上的 Ping of Death、Teardrop,到 Linux TCP SACK Panic,再到 Urgent/11、Ripple20、AMNESIA:33 等系列漏洞,可以看到 TCP/IP 协议栈漏洞并不是单一系统的问题,而是网络协议实现中长期存在的一类共性安全问题。

本文从协议栈的工作流程出发,结合典型代码模式,分析 TCP/IP 协议栈中常见的漏洞成因,包括长度字段校验不足、整数溢出、缓冲区越界、分片重组错误、TCP 选项解析缺陷和状态机异常等,并给出安全修复思路。

本文不提供攻击代码和漏洞利用 PoC,仅用于安全研究、代码审计和安全开发参考。


目录

  • 一、TCP/IP 协议栈为什么容易出漏洞
  • 二、协议栈漏洞的常见类型
  • 三、代码分析一:IP 数据包长度校验缺陷
  • 四、代码分析二:IP 分片重组中的整数溢出
  • 五、代码分析三:TCP 选项解析越界
  • 六、代码分析四:TCP SACK 处理逻辑问题
  • 七、代码分析五:状态机异常导致拒绝服务
  • 八、协议栈漏洞的检测思路
  • 九、安全修复建议
  • 十、总结

一、TCP/IP 协议栈为什么容易出漏洞

TCP/IP 协议栈负责处理网络通信中的多层协议,例如:

应用层:HTTP、DNS、FTP、SMTP
传输层:TCP、UDP
网络层:IP、ICMP
链路层:Ethernet、ARP

协议栈的特点是:

1. 直接处理外部输入
2. 数据包结构复杂
3. 长度字段多
4. 状态机复杂
5. 需要高性能处理
6. 大量代码使用 C/C++ 编写
7. 很多嵌入式设备长期不更新

这就导致协议栈非常容易出现以下问题:

长度字段不可信
边界检查不完整
整数计算溢出
结构体解析错误
内存生命周期管理复杂
异常状态处理不充分

和普通应用漏洞不同,TCP/IP 协议栈漏洞往往具有远程触发特点。攻击者不一定需要账号,也不一定需要登录应用系统,只要能向目标发送网络数据包,就可能触发漏洞。

这也是协议栈漏洞危险的根本原因。


二、协议栈漏洞的常见类型

TCP/IP 协议栈漏洞通常可以归纳为以下几类:

类型 典型位置 可能后果
缓冲区溢出 IP/TCP/UDP 解析、选项字段处理 崩溃、RCE
整数溢出 长度计算、偏移计算、分片重组 越界读写
越界读取 数据包长度不足但继续解析 信息泄露、崩溃
越界写入 拷贝长度大于目标缓冲区 内存破坏
Use-After-Free 连接状态释放后继续使用 崩溃、RCE
空指针解引用 异常路径未判断对象 DoS
状态机错误 TCP 连接状态处理异常 连接劫持、DoS
资源耗尽 半连接、分片缓存、重传队列 拒绝服务

可以看到,协议栈漏洞的核心并不是协议本身有问题,而是协议实现过程中对“不可信网络输入”的处理不够严谨。


三、代码分析一:IP 数据包长度校验缺陷

IP 数据包头部中有两个非常重要的长度字段:

IHL:IP Header Length,表示 IP 头长度
Total Length:表示整个 IP 包长度

其中 IHL 以 4 字节为单位,例如:

IHL = 5
表示 IP 头长度 = 5 × 4 = 20 字节

如果解析代码没有正确校验这些字段,就可能产生越界读取。


1. 存在问题的代码示例

下面是一段简化后的错误示例:

#include <stdint.h>
#include <string.h>

typedef struct {
    uint8_t  ver_ihl;
    uint8_t  tos;
    uint16_t total_len;
    uint16_t id;
    uint16_t frag_off;
    uint8_t  ttl;
    uint8_t  protocol;
    uint16_t checksum;
    uint32_t src;
    uint32_t dst;
} ipv4_hdr_t;

void parse_ipv4_packet(uint8_t *packet, uint16_t packet_len)
{
    ipv4_hdr_t *ip = (ipv4_hdr_t *)packet;

    uint8_t ihl = ip->ver_ihl & 0x0F;
    uint16_t ip_header_len = ihl * 4;

    uint8_t *payload = packet + ip_header_len;

    /*
     * 问题:
     * 没有判断 packet_len 是否至少包含 IP 头
     * 没有判断 ihl 是否小于 5
     * 没有判断 ip_header_len 是否超过 packet_len
     */
    process_payload(payload, packet_len - ip_header_len);
}

这段代码的问题很典型:

1. packet_len 可能小于 20 字节
2. IHL 可能被构造为异常值
3. ip_header_len 可能大于 packet_len
4. packet_len - ip_header_len 可能发生无符号整数下溢

例如:

packet_len = 10
ip_header_len = 20

计算:

packet_len - ip_header_len
= 10 - 20

由于 packet_lenip_header_len 是无符号类型,结果不会变成 -10,而是变成一个非常大的正数。

这就可能导致后续函数认为 payload 很长,从而发生越界读取或越界写入。


2. 修复后的安全写法

安全解析代码应当先校验长度,再访问字段:

#include <stdint.h>
#include <stddef.h>

#define IPV4_MIN_HEADER_LEN 20

int parse_ipv4_packet_safe(uint8_t *packet, uint16_t packet_len)
{
    if (packet == NULL) {
        return -1;
    }

    if (packet_len < IPV4_MIN_HEADER_LEN) {
        return -1;
    }

    ipv4_hdr_t *ip = (ipv4_hdr_t *)packet;

    uint8_t version = ip->ver_ihl >> 4;
    uint8_t ihl = ip->ver_ihl & 0x0F;

    if (version != 4) {
        return -1;
    }

    if (ihl < 5) {
        return -1;
    }

    uint16_t ip_header_len = ihl * 4;

    if (ip_header_len > packet_len) {
        return -1;
    }

    uint16_t payload_len = packet_len - ip_header_len;
    uint8_t *payload = packet + ip_header_len;

    process_payload(payload, payload_len);

    return 0;
}

修复重点是:

先判断最小长度
再读取协议字段
再判断字段合法性
最后再计算偏移

协议解析代码中有一个基本原则:

任何来自网络包的长度字段都不能直接相信。

四、代码分析二:IP 分片重组中的整数溢出

IP 分片重组是协议栈漏洞高发区域。

IP 分片中有两个关键字段:

Fragment Offset:分片偏移
More Fragments:后面是否还有分片

Fragment Offset 的单位不是字节,而是 8 字节。

也就是说:

真实偏移 = Fragment Offset × 8

如果代码没有正确处理乘法、加法和边界,就可能出现整数溢出。


1. 危险代码示例

#define REASSEMBLY_BUFFER_SIZE 65535

uint8_t reassembly_buffer[REASSEMBLY_BUFFER_SIZE];

void handle_fragment(
    uint16_t frag_offset,
    uint16_t frag_len,
    uint8_t *frag_data
)
{
    uint16_t offset = frag_offset * 8;

    /*
     * 问题:
     * offset + frag_len 可能发生整数溢出
     */
    if (offset + frag_len <= REASSEMBLY_BUFFER_SIZE) {
        memcpy(reassembly_buffer + offset, frag_data, frag_len);
    }
}

这段代码的问题在于:

offset + frag_len

如果使用 16 位整数保存,可能发生溢出。

例如:

offset = 65520
frag_len = 100

理论结果:

65620

但如果使用 16 位无符号整数,超过 65535 后会回绕,变成较小值。

于是判断:

if (offset + frag_len <= 65535)

可能错误通过。

最终:

memcpy(reassembly_buffer + offset, frag_data, frag_len);

会写出缓冲区边界。


2. 安全写法

修复时应使用更大的整数类型,并避免直接做可能溢出的加法:

#include <stdint.h>
#include <string.h>

#define REASSEMBLY_BUFFER_SIZE 65535

uint8_t reassembly_buffer[REASSEMBLY_BUFFER_SIZE];

int handle_fragment_safe(
    uint16_t frag_offset,
    uint16_t frag_len,
    uint8_t *frag_data
)
{
    if (frag_data == NULL) {
        return -1;
    }

    uint32_t offset = (uint32_t)frag_offset * 8U;
    uint32_t length = (uint32_t)frag_len;

    if (offset >= REASSEMBLY_BUFFER_SIZE) {
        return -1;
    }

    if (length > REASSEMBLY_BUFFER_SIZE - offset) {
        return -1;
    }

    memcpy(reassembly_buffer + offset, frag_data, length);

    return 0;
}

这里的关键点是:

不要写 offset + length <= max
要写 length <= max - offset

因为前者可能溢出,后者不会溢出。

这是 C/C++ 安全编码中非常重要的一条规则。


五、代码分析三:TCP 选项解析越界

TCP 头部中也存在可变长度字段。

TCP 头部最小长度为 20 字节,最大长度为 60 字节。TCP 头长度由 Data Offset 字段表示,单位是 4 字节。

TCP 选项字段中常见选项包括:

MSS
Window Scale
SACK Permitted
SACK Block
Timestamp
NOP
End of Option List

TCP 选项解析复杂,很容易出现越界问题。


1. 存在问题的 TCP 选项解析代码

void parse_tcp_options(uint8_t *opts, uint8_t opts_len)
{
    uint8_t i = 0;

    while (i < opts_len) {
        uint8_t kind = opts[i];

        if (kind == 0) {
            break;
        }

        if (kind == 1) {
            i++;
            continue;
        }

        uint8_t len = opts[i + 1];

        /*
         * 问题:
         * 1. 没有判断 i + 1 是否越界
         * 2. 没有判断 len 是否至少为 2
         * 3. 没有判断 i + len 是否超过 opts_len
         */
        handle_option(kind, opts + i + 2, len - 2);

        i += len;
    }
}

这类代码非常常见。

问题集中在三个地方:

读取 opts[i + 1] 前没有确认 i + 1 合法
选项长度 len 可能为 0 或 1
i + len 可能超过 opts_len

如果攻击者构造异常 TCP Option,就可能导致解析器越界读取,甚至进入死循环。


2. 安全解析代码

int parse_tcp_options_safe(uint8_t *opts, uint8_t opts_len)
{
    uint8_t i = 0;

    if (opts == NULL) {
        return -1;
    }

    while (i < opts_len) {
        uint8_t kind = opts[i];

        if (kind == 0) {
            break;
        }

        if (kind == 1) {
            i++;
            continue;
        }

        if (i + 1 >= opts_len) {
            return -1;
        }

        uint8_t len = opts[i + 1];

        if (len < 2) {
            return -1;
        }

        if (len > opts_len - i) {
            return -1;
        }

        handle_option(kind, opts + i + 2, len - 2);

        i += len;
    }

    return 0;
}

安全写法中有三个关键判断:

i + 1 不能越界
len 不能小于 2
len 不能超过剩余选项长度

解析 TLV 结构时,一定要遵循:

先判断 Type
再判断 Length 字段是否存在
再判断 Length 是否合法
最后再读取 Value

TCP Option 本质上就是一种 TLV 结构,因此必须按 TLV 安全解析方式处理。


六、代码分析四:TCP SACK 处理逻辑问题

SACK 是 TCP Selective Acknowledgment,选择性确认机制。

普通 ACK 只能告诉对端:

我已经连续收到某个序号之前的数据

而 SACK 可以进一步告诉对端:

某些不连续的数据块我也收到了

SACK 可以提升丢包场景下的传输效率,但也让 TCP 协议栈维护的数据结构更加复杂。

SACK 通常涉及:

序列号范围
已确认区间
未确认区间
重传队列
SKB / buffer 管理
GSO / 分段计算

这类逻辑中,整数计算和状态更新非常容易出错。


1. 简化的危险逻辑

下面是一段抽象示例,用于说明 SACK 区间处理可能出现的问题:

typedef struct {
    uint32_t start_seq;
    uint32_t end_seq;
} sack_block_t;

void handle_sack_block(sack_block_t *blk)
{
    uint32_t len = blk->end_seq - blk->start_seq;

    /*
     * 问题:
     * 如果 end_seq < start_seq,会发生无符号整数下溢
     */
    update_retransmission_queue(blk->start_seq, len);
}

正常情况下:

end_seq > start_seq

但如果输入异常:

start_seq = 5000
end_seq   = 1000

计算:

end_seq - start_seq

在无符号整数中不会得到负数,而是得到一个非常大的值。

这可能导致后续逻辑:

错误更新重传队列
错误计算分段数量
错误访问链表或缓冲区
触发内核异常

2. 安全处理方式

int handle_sack_block_safe(sack_block_t *blk)
{
    if (blk == NULL) {
        return -1;
    }

    if (blk->end_seq <= blk->start_seq) {
        return -1;
    }

    uint32_t len = blk->end_seq - blk->start_seq;

    if (len > MAX_SACK_BLOCK_LEN) {
        return -1;
    }

    update_retransmission_queue(blk->start_seq, len);

    return 0;
}

SACK 处理逻辑的安全重点是:

确认区间必须合法
序列号比较不能简单使用普通整数比较
长度计算前必须判断大小关系
链表或队列更新必须保持一致性

在真实协议栈中,TCP 序列号还存在回绕问题,因此实际判断会更复杂,通常需要专门的序列号比较宏或函数。


七、代码分析五:状态机异常导致拒绝服务

TCP 是典型的状态机协议。

常见状态包括:

CLOSED
LISTEN
SYN_SENT
SYN_RECEIVED
ESTABLISHED
FIN_WAIT_1
FIN_WAIT_2
CLOSE_WAIT
CLOSING
LAST_ACK
TIME_WAIT

如果协议栈对异常状态转换处理不当,就可能出现资源泄露、空指针解引用、重复释放等问题。


1. 状态机缺陷示例

typedef enum {
    TCP_CLOSED,
    TCP_LISTEN,
    TCP_SYN_RECV,
    TCP_ESTABLISHED,
    TCP_FIN_WAIT,
} tcp_state_t;

typedef struct {
    tcp_state_t state;
    void *send_buffer;
} tcp_conn_t;

void handle_fin(tcp_conn_t *conn)
{
    if (conn->state == TCP_ESTABLISHED) {
        free(conn->send_buffer);
        conn->state = TCP_FIN_WAIT;
    }
}

这段代码看似简单,但存在问题:

1. 没有判断 conn 是否为空
2. 没有判断 send_buffer 是否为空
3. free 后没有置空
4. 重复收到 FIN 时可能重复释放

如果异常报文导致 handle_fin() 被多次调用,就可能出现 double free。


2. 修复示例

void handle_fin_safe(tcp_conn_t *conn)
{
    if (conn == NULL) {
        return;
    }

    if (conn->state != TCP_ESTABLISHED) {
        return;
    }

    if (conn->send_buffer != NULL) {
        free(conn->send_buffer);
        conn->send_buffer = NULL;
    }

    conn->state = TCP_FIN_WAIT;
}

修复重点:

状态检查
空指针检查
释放后置空
避免重复释放
异常状态直接返回

协议栈开发中,状态机安全和内存安全同样重要。


八、协议栈漏洞的检测思路

协议栈漏洞检测不能只依赖端口扫描。更有效的方法是从代码、流量和运行状态三个层面入手。


1. 代码审计

重点关注:

memcpy
memmove
strcpy
strncpy
sprintf
指针偏移
长度字段计算
分片重组
TCP Option 解析
链表操作
状态机切换

典型危险模式:

memcpy(dst, src, len);

如果 len 来自网络包,就必须追踪:

len 从哪里来
是否经过校验
是否可能溢出
是否超过 dst 大小
是否和真实包长一致

2. 静态分析

可以使用:

Coverity
CodeQL
Clang Static Analyzer
Cppcheck
Semgrep
Infer

重点规则包括:

整数溢出
缓冲区越界
空指针解引用
Use-After-Free
Double Free
未检查返回值
未验证长度字段

3. 动态测试

协议栈适合使用模糊测试:

AFL++
libFuzzer
boofuzz
Peach Fuzzer
Syzkaller
Honggfuzz

测试重点:

异常长度字段
非法 TCP Option
重叠 IP 分片
乱序分片
极小 MSS
异常 SACK Block
异常状态切换
重复 FIN/RST

但要注意,模糊测试必须在隔离实验环境中进行,不能直接对生产网络设备或生产服务器发起异常包测试。


4. 运行监控

生产环境可以关注:

内核崩溃
协议栈异常日志
网络设备重启
连接表异常增长
分片缓存异常增长
CPU 异常升高
网络栈 softirq 异常
异常 ICMP/TCP/UDP 流量

在 Linux 系统中,可以关注:

dmesg
journalctl -k
netstat -s
ss -s
sar -n TCP,ETCP

示例:

netstat -s | grep -i -E "retransmit|reset|fragment|error|overflow"

这类命令不能直接判断是否存在漏洞,但可以帮助发现网络栈异常行为。


九、安全修复建议

1. 永远不要信任网络包中的长度字段

错误思路:

len = packet->length;
memcpy(dst, packet->data, len);

正确思路:

if (len > packet_len - header_len) {
    return -1;
}

if (len > dst_size) {
    return -1;
}

memcpy(dst, packet->data, len);

2. 避免整数溢出

不要写:

if (offset + len <= max_size) {
    memcpy(buf + offset, data, len);
}

推荐写:

if (offset >= max_size) {
    return -1;
}

if (len > max_size - offset) {
    return -1;
}

memcpy(buf + offset, data, len);

3. 对 TLV 结构逐层校验

TCP Option、IPv6 Extension Header、ICMP Extension 等都属于类似 TLV 的结构。

安全解析顺序应为:

1. 判断 Type 是否存在
2. 判断 Length 字段是否存在
3. 判断 Length 是否合法
4. 判断 Value 是否在边界内
5. 再进入具体处理函数

4. 状态机必须显式处理异常路径

不要只处理正常路径:

SYN → SYN/ACK → ACK → ESTABLISHED

还要处理异常路径:

重复 SYN
异常 ACK
乱序 FIN
重复 RST
半连接超时
状态回退
资源释放失败

安全协议栈一定要把异常状态当作常态处理。


5. 使用更安全的开发语言或安全库

对于新项目,可以考虑:

Rust
Go
内存安全封装库
经过验证的协议解析库

对于已有 C/C++ 协议栈,应加强:

编译器保护
ASLR
DEP/NX
Stack Canary
CFI
FORTIFY_SOURCE
UBSan
ASan
KASAN

这些保护不能替代安全编码,但可以降低漏洞利用成功率。


6. 建立 SBOM 和组件清单

很多协议栈漏洞发生在第三方组件中,例如嵌入式设备使用的开源 TCP/IP 协议栈或商业协议栈。

企业应建立:

设备型号清单
固件版本清单
协议栈组件清单
第三方库清单
漏洞影响清单
补丁状态清单

也就是 SBOM 思路。

否则当出现类似 Urgent/11、Ripple20、AMNESIA:33 这类供应链式协议栈漏洞时,很难判断自己到底受不受影响。


十、总结

TCP/IP 协议栈漏洞的本质,是复杂协议解析与底层内存操作之间的矛盾。

从代码层面看,最常见的问题包括:

长度字段未校验
整数溢出
缓冲区越界
状态机异常
资源释放错误
链表和队列维护不一致

从攻击面看,协议栈直接面对网络输入,因此很多漏洞具备远程触发条件。

从防守角度看,协议栈安全不能只依赖补丁,还需要:

资产清单
组件清单
代码审计
模糊测试
运行监控
边界访问控制
及时升级固件和系统补丁

对于开发人员来说,分析协议栈漏洞最重要的不是记住某一个 CVE,而是掌握一类问题的分析方法:

从网络输入进入
追踪长度字段
分析偏移计算
检查内存拷贝
验证状态转换
确认异常路径

只要沿着这条思路,就能够在代码审计、漏洞分析和安全开发中发现大量潜在问题。


参考资料

  • NVD:CVE-2019-11477 Linux TCP SACK Panic
  • Red Hat:TCP SACK Panic 漏洞说明
  • Armis:Urgent/11 研究资料
  • JSOF:Ripple20 研究资料
  • Forescout:AMNESIA:33 研究资料
  • RFC 791:Internet Protocol
  • RFC 793:Transmission Control Protocol
  • RFC 2018:TCP Selective Acknowledgment Options

免责声明

本文仅用于网络安全研究、代码审计和安全开发学习。文中代码为简化后的漏洞模式示例,用于说明协议解析中常见的安全缺陷,不构成真实漏洞利用代码,也不提供攻击脚本、攻击流量或可直接复现的利用流程。任何安全测试均应在合法授权环境中进行。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐