用 Rust 解析并生成 ICMP 包:checksum、nom 与 cookie-factory

本文是对 Parsing and serializing ICMP packets with cookie-factory 的整理与翻译。

内容结构概览

  1. 承接前文:第 11 篇已经能解析 IPv4 并筛选 ICMP,这一篇开始校验和解析 ICMP 本身。
  2. 为什么要做 checksum:IPv4 头部和 ICMP 都要用 internet checksum。
  3. checksum 的本质:它用于检测偶发传输错误,不用于抵御恶意篡改。
  4. 实现 IPv4 checksum :把字节切片对齐成 u16,做 16 位一补和,再取反。
  5. align_to::<u16>() 的作用:验证输入是否按 16 位对齐,长度是否是 2 的倍数。
  6. 用真实抓包验证 checksum:入站包 checksum 正常,出站包 checksum 可能为 0。
  7. 网卡 checksum offload:本机抓包可能抓到"网卡还没填 checksum 之前"的出站包。
  8. ICMP 包结构:type、code、checksum、rest of header、payload。
  9. 建模 ICMP type/code:Echo Reply、Echo Request、Destination Unreachable、Time Exceeded。
  10. 解析 ICMP 包 :使用 nom 读取 type、code、checksum,并接入 IPv4 payload。
  11. 解析 Echo header:Echo Request/Reply 的 rest-of-header 是 identifier 和 sequence number。
  12. Blob 类型:为二进制 payload 做简短 Debug 输出。
  13. 观察 Windows ping 行为:identifier 固定,sequence number 递增,payload 是字母序列。
  14. TTL expired 的 payload:Time Exceeded 的 payload 不是字符串,而是原始 IPv4 包的一部分。
  15. 开始序列化 ICMP :引入 cookie-factory,像用 nom 组合 parser 一样组合 serializer。
  16. 序列化 Echo、Header、Blob、Packet:先生成 checksum 为 0 的 ICMP 包。
  17. checksum 回填问题:checksum 字段在中间,必须先生成完整 buffer,再计算并覆盖。
  18. 为什么写 le_u16 也能对上:internet checksum 在一致字节序下具有特殊性质,但这让代码依赖小端机器。
  19. 最终验证:把解析出的 ICMP 包重新序列化,和原始字节逐字节比较。
  20. 结尾:下一步要继续序列化 IPv4 包和 Ethernet 帧,距离真正手搓网络流量更近一步。

前面几篇已经把项目推进到了一个很关键的位置。我们不再只是调用 Windows 的 IcmpSendEcho,也不再只是依赖系统 API 替我们完成 ICMP。项目已经能够通过 Npcap/rawsock 打开默认网卡,消费 Ethernet frame,解析 IPv4 packet,并且筛选出其中携带 ICMP 的 IPv4 包。也就是说,我们已经能看到真正从网卡经过的数据,不再只是看到系统 API 返回的抽象结果。

但到目前为止,还有两个重要问题没有解决。第一个问题是 checksum。IPv4 头部有 checksum,ICMP 也有 checksum。前面的解析代码只是把字段读出来,并没有验证它是否正确。如果有人发来一个损坏的 IPv4 包,我们仍然可能照样解析,甚至继续往下解析里面的 ICMP。第二个问题是序列化。我们已经能"读" Ethernet、IPv4、ICMP 的一部分,但如果想真正自己发 ping,就不能只会解析,还要会把结构体重新生成字节,也就是把 ICMP packet 序列化出来。

这一篇就围绕这两个问题展开:先实现通用的 internet checksum,然后用它验证 IPv4 头部;接着正式建模和解析 ICMP 包,包括 type、code、checksum、identifier、sequence number 和 payload;最后引入 cookie-factory,用和 nom 类似的组合式思路,把一个 icmp::Packet 重新序列化成字节,并正确填入 checksum。


一、为什么要先处理 checksum

IPv4 头部里有一个 checksum 字段。它不是对整个 IP packet 做校验,而是只对 IPv4 header 做校验。原因很直接:IPv4 头部中的某些字段在转发过程中会改变,比如 TTL。每经过一个路由器,TTL 通常会减一,这意味着头部内容变了,header checksum 也需要重新计算。

ICMP 也有 checksum,但它覆盖的是 ICMP 报文本身,包括 ICMP 头部和 payload。我们后面要自己构造 ICMP Echo Request,所以这个 checksum 不能跳过。如果 checksum 不正确,对方可能直接丢掉这个包,或者返回的行为和预期不一致。

checksum 的作用不是安全防护,而是检测偶发的数据损坏。传输过程中某个 bit 翻转、某段数据损坏,checksum 有机会发现它。但如果有人故意构造一个带正确 checksum 的恶意包,普通 checksum 当然拦不住。想防恶意篡改,需要密码学哈希、认证码、签名或加密协议。这里的 checksum 只是协议层面用于发现偶发错误的轻量机制。

在这个项目里,我们会复用同一套 internet checksum:一方面用于验证 IPv4 header,另一方面用于生成 ICMP packet。后面如果继续生成 IPv4 packet,也会再次用到它。所以它应该放在一个可复用的位置,比如 ipv4::checksum(slice: &[u8]) -> u16


二、internet checksum 到底怎么算

internet checksum 的算法可以概括成一句话:把输入按 16 位 word 分组,做一补和,最后再取一补。为了计算 checksum,checksum 字段本身要先当成 0。

这里有两个概念容易混淆。第一个是"一补",也就是 bitwise NOT,把所有 bit 翻转。在 Rust 里,整数的按位取反就是 !x。第二个是"一补和"。普通二进制加法溢出时会丢掉进位;一补和则不直接丢掉进位,而是把溢出的 carry 再加回最低位。

用 8 位数举例会更直观。假设加 96 + 64,二进制没有溢出,结果就是 160。再看 128 + 128,普通 8 位加法会得到 1_0000_0000,如果只保留低 8 位就是 0。但一补和会把溢出的那个 1 再加回最低位,所以结果不是 0,而是 1。

实现到 16 位 checksum 时,可以先把两个 u16 提升成 u32 相加。如果结果超过 0xffff,说明发生了 16 位溢出,就把 carry 加回低 16 位。最后对累加结果取反,得到 checksum。

可以把核心加法写成:

rust 复制代码
fn add(a: u16, b: u16) -> u16 {
    let s = (a as u32) + (b as u32);

    if s & 0x1_00_00 > 0 {
        (s + 1) as u16
    } else {
        s as u16
    }
}

这里的 0x1_00_00 表示第 17 位,也就是第一个放不进 u16 的 bit。Rust 允许在数字字面量里用下划线分隔,0x1_00_000x10000 是同一个值,只是前者更容易看出它的字节边界。

然后 checksum 函数大致可以写成:

rust 复制代码
pub fn checksum(slice: &[u8]) -> u16 {
    let (head, words, tail) = unsafe { slice.align_to::<u16>() };

    if !head.is_empty() {
        panic!("checksum() input should be 16-bit aligned");
    }

    if !tail.is_empty() {
        panic!("checksum() input size should be a multiple of 2 bytes");
    }

    fn add(a: u16, b: u16) -> u16 {
        let s = (a as u32) + (b as u32);

        if s & 0x1_00_00 > 0 {
            (s + 1) as u16
        } else {
            s as u16
        }
    }

    !words.iter().fold(0, |acc, word| add(acc, *word))
}

这里用了 slice.align_to::<u16>()。它会把一个 &[u8] 尝试切成三段:前面无法按 u16 对齐的 head,中间正确对齐的 &[u16],以及尾部不足以组成 u16 的 tail。我们期望 IPv4 header 是按 16 位对齐的,而且长度是 2 字节的倍数。如果这两个假设不成立,就直接 panic。

这不是最通用的 checksum 实现。更健壮的版本应该能处理奇数长度输入,也应该避免依赖调用方的对齐假设。但在当前阶段,IPv4 header 的长度本身以 32 位 word 为单位,通常能满足这些条件。先把假设写进代码,用 panic 明确暴露问题,比悄悄算错更好。


三、用真实 IPv4 包验证 checksum

实现 checksum 后,下一步是用抓到的真实 IPv4 包验证它。直觉上可以把 header 里的 checksum 字段清零,然后对整个 header 调 checksum(),计算结果应该等于原始 checksum 字段。

不过还有一种更方便的验证方式:直接对包含 checksum 字段在内的整个 IPv4 header 计算 checksum。如果这个包的 checksum 是正确的,结果应该是 0。因为正确的 checksum 本来就是为了让整段 16 位一补和最后抵消成全 1,再取反得到 0。

在 IPv4 parser 中,我们已经能读出 IHL。IHL 表示 IPv4 header 长度,单位是 32 位 word。因此 header 字节长度是 ihl * 4。可以从原始输入中截出 header:

rust 复制代码
let original_i = i;

// 先解析 version 和 ihl
let header_slice = &original_i[..(ihl.into() * 4)];
let computed_checksum = checksum(header_slice);

println!("computed {:04X}", computed_checksum);

运行时会看到一个有趣现象:入站包的 checksum 通常能算出 0,说明 checksum 正确;出站包的 checksum 字段却可能是 0,导致计算结果不为 0。比如从本机发往 8.8.8.8 的 Echo Request 可能显示 header checksum 为 0,而从 8.8.8.8 返回本机的 Echo Reply 则有正常 checksum。

这不是 parser 写错了,也不是 Windows 的 ping 发错了。原因很可能是 checksum offload。很多网卡支持在硬件中计算 checksum。抓包库可能在出站包真正到达网卡、硬件填入 checksum 之前就看到了这段数据,所以本机抓到的出站包 checksum 还是 0。入站包已经来自线上,checksum 已经存在,所以能验证通过。

这个现象非常重要。抓包看到的"不完整包"不一定等于网络上真的发出了不完整包。本机抓包位置、驱动、网卡 offload 都会影响观测结果。写协议栈时要区分"包在内存中的样子"和"包在线上的样子"。


四、正式进入 ICMP 包结构

到这里,IPv4 header checksum 已经能计算了,接下来终于可以正式看 ICMP。一个常见 ICMP 报文的前 4 个字节结构非常简单:

text 复制代码
0      type
1      code
2..4   checksum
4..8   rest of header
8..    payload

ICMP 的 type 字段决定消息类型。我们暂时关心几种:

text 复制代码
0   Echo Reply
3   Destination Unreachable
8   Echo Request
11  Time Exceeded

code 字段的含义依赖 type。比如 Echo RequestEcho Reply 的 code 通常是 0;Destination Unreachable 的 code 可能表示 host unreachable、network unreachable、fragmentation required 等;Time Exceeded 的 code 0 表示 TTL expired in transit。

这就不能简单把 typecode 都保留成裸 u8。更好的做法是把它们建模成 Rust enum。先定义 ICMP 的 Type:

rust 复制代码
#[derive(Debug)]
pub enum Type {
    EchoReply,
    DestinationUnreachable(DestinationUnreachable),
    EchoRequest,
    TimeExceeded(TimeExceeded),
    Other(u8, u8),
}

#[derive(Debug)]
pub enum DestinationUnreachable {
    HostUnreachable,
    Other(u8),
}

#[derive(Debug)]
pub enum TimeExceeded {
    TTLExpired,
    Other(u8),
}

然后实现从 (type, code)Type 的转换:

rust 复制代码
impl From<(u8, u8)> for Type {
    fn from(x: (u8, u8)) -> Self {
        let (typ, code) = x;

        match typ {
            0 => Self::EchoReply,
            3 => Self::DestinationUnreachable(code.into()),
            8 => Self::EchoRequest,
            11 => Self::TimeExceeded(code.into()),
            _ => Self::Other(typ, code),
        }
    }
}

impl From<u8> for DestinationUnreachable {
    fn from(x: u8) -> Self {
        match x {
            1 => Self::HostUnreachable,
            x => Self::Other(x),
        }
    }
}

impl From<u8> for TimeExceeded {
    fn from(x: u8) -> Self {
        match x {
            0 => Self::TTLExpired,
            x => Self::Other(x),
        }
    }
}

这样做的好处是,已知类型会被表示成明确的 Rust 变体,未知类型也不会让程序崩溃,而是被收进 Other。这和之前解析 Ethernet EtherType 时直接拒绝未知值不同。Ethernet 里我们暂时只想解析 IPv4,所以未知 EtherType 可以返回解析错误;ICMP 里则希望继续观察更多消息,因此 Other 更适合。


五、解析 ICMP 的 type、code 和 checksum

有了类型模型后,可以定义 icmp::Packet

rust 复制代码
use custom_debug_derive::*;

#[derive(CustomDebug)]
pub struct Packet {
    pub typ: Type,

    #[debug(format = "{:04x}")]
    pub checksum: u16,
}

解析逻辑用 nom 很直接。先读两个 u8,得到 type 和 code;再读一个 big-endian u16,得到 checksum:

rust 复制代码
use nom::{
    number::complete::{be_u16, be_u8},
    sequence::tuple,
};

impl Packet {
    pub fn parse(i: parse::Input) -> parse::Result<Self> {
        let (i, typ) = {
            let (i, (typ, code)) = tuple((be_u8, be_u8))(i)?;
            (i, Type::from((typ, code)))
        };

        let (i, checksum) = be_u16(i)?;

        let res = Self { typ, checksum };

        Ok((i, res))
    }
}

现在还没有解析 rest-of-header 和 payload,但已经能区分 Echo Request、Echo Reply、Destination Unreachable、Time Exceeded 等类型。

接下来要把 ICMP parser 接入 IPv4 parser。前一篇中,ipv4::Packet 已经有一个 payload 枚举:

rust 复制代码
pub enum Payload {
    Unknown,
}

现在可以扩展成:

rust 复制代码
use crate::icmp;

#[derive(Debug)]
pub enum Payload {
    ICMP(icmp::Packet),
    Unknown,
}

在 IPv4 parser 中,如果 protocol 字段是 ICMP,就调用 icmp::Packet::parse

rust 复制代码
let (i, payload) = match protocol {
    Some(Protocol::ICMP) => map(icmp::Packet::parse, Payload::ICMP)(i)?,
    _ => (i, Payload::Unknown),
};

此时 ipv4::Packet 里同时有 protocol 字段和 payload 字段,信息有一点冗余。比如如果 payload 是 Payload::ICMP,protocol 理论上就应该是 ICMP。现在类型系统还没有完全表达这种约束。后面做序列化时,可能会更多依赖 payload,而不是 protocol 字段。这种冗余在项目演进中很常见,先让结构跑通,再慢慢把不变量收紧。

运行后,已经能看到真实 ICMP 请求和响应:

text 复制代码
Packet {
    typ: EchoRequest,
    checksum: 3d34,
}

Packet {
    typ: EchoReply,
    checksum: 4534,
}

现在已经确定,抓到的 IPv4 payload 确实包含 ICMP,并且 parser 能识别 Echo Request 和 Echo Reply。


六、只显示 ICMP 流量

为了让输出更清楚,可以在 mainprocess_packet 中只打印 ICMP 包。流程是:先解析 Ethernet frame;如果 payload 是 IPv4,再看 IPv4 payload 是否是 ICMP;如果是 ICMP,就打印源地址、目标地址和 ICMP packet。

代码结构大致是:

rust 复制代码
fn process_packet(now: Duration, packet: &BorrowedPacket) {
    match ethernet::Frame::parse(packet) {
        Ok((_remaining, frame)) => {
            if let ethernet::Payload::IPv4(ref ip_packet) = frame.payload {
                if let ipv4::Payload::ICMP(ref icmp_packet) = ip_packet.payload {
                    println!(
                        "{:?} | ({:?}) => ({:?}) | {:#?}",
                        now,
                        ip_packet.src,
                        ip_packet.dst,
                        icmp_packet
                    );
                }
            }
        }

        Err(nom::Err::Error(e)) => {
            println!("{:?} | {:?}", now, e);
        }

        _ => unreachable!(),
    }
}

输出变得非常聚焦:

text 复制代码
942.155ms | (192.168.1.16) => (8.8.8.8) | Packet {
    typ: EchoRequest,
    checksum: 3bc6,
}

942.2091ms | (8.8.8.8) => (192.168.1.16) | Packet {
    typ: EchoReply,
    checksum: 43c6,
}

随着 Windows ping -t 8.8.8.8 在后台不断运行,会看到请求和响应成对出现。checksum 也会随着 sequence number 变化而变化。到这里,我们已经能从 rawsock 抓到的 Ethernet frame 中一路解析到 ICMP 的基本头部。


七、解析 rest of header:Echo 的 identifier 和 sequence number

ICMP 的前 4 个字节是 type、code、checksum。接下来的 4 个字节叫 rest of header,它的含义取决于 type。对于 Echo Request 和 Echo Reply,这 4 个字节由两个 u16 组成:identifier 和 sequence number。

可以定义一个 Echo 结构体:

rust 复制代码
#[derive(CustomDebug)]
pub struct Echo {
    #[debug(format = "{:04x}")]
    pub identifier: u16,

    #[debug(format = "{:04x}")]
    pub sequence_number: u16,
}

对应的 parser 很简单:

rust 复制代码
use nom::{combinator::map, sequence::tuple, number::complete::be_u16};

impl Echo {
    fn parse(i: parse::Input) -> parse::Result<Self> {
        map(
            tuple((be_u16, be_u16)),
            |(identifier, sequence_number)| Echo {
                identifier,
                sequence_number,
            },
        )(i)
    }
}

然后为 rest-of-header 定义一个枚举:

rust 复制代码
#[derive(Debug)]
pub enum Header {
    EchoRequest(Echo),
    EchoReply(Echo),
    Other(u32),
}

对于 Echo Request 和 Echo Reply,解析成 Echo;对于暂时不关心的类型,直接读取一个 u32 存进 Other

rust 复制代码
let (i, header) = match typ {
    Type::EchoRequest => map(Echo::parse, Header::EchoRequest)(i)?,
    Type::EchoReply => map(Echo::parse, Header::EchoReply)(i)?,
    _ => map(be_u32, Header::Other)(i)?,
};

现在 icmp::Packet 可以扩展成:

rust 复制代码
use crate::blob::Blob;

#[derive(CustomDebug)]
pub struct Packet {
    pub typ: Type,

    #[debug(skip)]
    pub checksum: u16,

    #[debug(format = "{:?}")]
    pub header: Header,

    pub payload: Blob,
}

这里把 checksum 从 Debug 输出里隐藏了,因为接下来更关心 type、header 和 payload。剩余的输入就是 ICMP payload,可以直接复制到 Blob

rust 复制代码
let payload = Blob::new(i);

八、给二进制 payload 做一个 Blob 类型

ICMP payload 是任意二进制数据。直接用 Vec<u8> 打印会非常长,也不好看。可以定义一个 Blob newtype,专门负责把二进制数据以简短十六进制形式显示出来:

rust 复制代码
use std::{cmp::min, fmt};

pub struct Blob(pub Vec<u8>);

impl Blob {
    pub fn new(slice: &[u8]) -> Self {
        Self(slice.into())
    }
}

impl fmt::Debug for Blob {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let total = self.0.len();
        let shown = 20;
        let slice = &self.0[..min(shown, total)];

        write!(f, "[")?;

        for (i, x) in slice.iter().enumerate() {
            let prefix = if i > 0 { " " } else { "" };
            write!(f, "{}{:02x}", prefix, x)?;
        }

        if total > shown {
            write!(f, " + {} bytes", total - shown)?;
        }

        write!(f, "]")
    }
}

这样 payload 只显示前 20 字节,后面用 + N bytes 表示还有多少未显示。抓包输出会更紧凑:

text 复制代码
Packet {
    typ: EchoRequest,
    header: EchoRequest(
        Echo {
            identifier: 0001,
            sequence_number: 02c8,
        },
    ),
    payload: [61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 + 12 bytes],
}

从这个输出可以看到,Echo Request 和 Echo Reply 的 identifier 相同,sequence number 也相同。下一个请求的 sequence number 会递增。这正是 ping 用来匹配请求和响应的一种方式。

Windows 的行为很有意思:identifier 在这个实验环境里固定为 0001,sequence number 递增。不同系统的策略不同。Linux 通常会为每个 ping 进程使用不同 identifier,并在进程内递增 sequence number;Windows 则有自己的实现策略。对我们来说,关键点是:只要 (identifier, sequence_number) 组合能区分请求和响应,就能完成匹配。后面自己生成包时,也应该让 sequence number 递增。


九、把 payload 当字符串看

Windows ping 默认 payload 是一段字母序列。既然 Blob 里有 Vec<u8>,可以临时把它用 String::from_utf8_lossy 打印成字符串:

rust 复制代码
let payload = String::from_utf8_lossy(&icmp_packet.payload.0);
println!("payload = {}", payload);

正常 Echo Request/Reply 的 payload 会显示成:

text 复制代码
abcdefghijklmnopqrstuvwabcdefghi

这和前面使用 Windows ICMP API 时观察到的 payload 一致。Echo Request 里发出的字母序列会在 Echo Reply 中原样返回。ping 不只是测试对方是否回应,还可以通过 payload 确认返回数据是否和请求数据匹配。

不过,不能假设所有 ICMP payload 都是字符串。接下来故意制造一个 TTL expired 的场景,就会看到完全不同的 payload。


十、TTL expired 返回的 payload 不是字符串

可以运行:

text 复制代码
ping -i 3 8.8.8.8

这里 -i 3 表示把 TTL 设得很小。包还没到 8.8.8.8,中间某个路由节点就会发现 TTL 递减到 0,于是返回 ICMP Time Exceeded。

这时抓包输出会看到:

text 复制代码
Packet {
    typ: TimeExceeded(TTLExpired),
    header: Other(0),
    payload: [45 00 00 3c 3e 56 00 00 01 01 a9 a3 c0 a8 01 10 08 08 08 08 + 8 bytes],
}

如果把这个 payload 当 UTF-8 字符串打印,会出现很多乱码和替代字符 。这不是字体问题,而是因为 payload 根本不是文本。String::from_utf8_lossy 遇到非法 UTF-8 字节时会插入替代字符。

更有意思的是,payload 开头的 45 00 00 3c 非常像 IPv4 header。45 可以拆成 version 4 和 IHL 5,也就是 IPv4,header 长度为 5 个 32 位 word,即 20 字节。00 3c 是 total length,十六进制 0x003c,十进制 60。也就是说,Time Exceeded 返回的 payload 里包含了原始 IPv4 packet 的一部分。

这符合 ICMP 错误消息的常见设计:如果某个中间节点无法继续转发,它会把原始数据包的一部分带回来,让发送方知道到底是哪一个包出了问题。TTL expired 的响应不是从 8.8.8.8 返回的,而是从路径中间某个路由节点返回的。你发往 8.8.8.8 的包没有到终点,而是在路上被某个节点拦下并报告"TTL 过期"。

如果把原始出站 IPv4 包也以 Blob 形式打印出来,会看到它和 Time Exceeded payload 非常相似。差别主要出现在 TTL 和 checksum 上。TTL 从 3 递减到 1 或接近 0,checksum 因为 header 变化而变化。出站包在本机抓包时 checksum 仍可能是 0,这是前面提到的网卡 checksum offload 现象。

这个实验告诉我们:ping 成功时,对方返回 Echo Reply;ping 失败时,不一定没有响应,也可能是路径上的其他主机返回 ICMP 错误消息,比如 Time Exceeded 或 Destination Unreachable。这也是 traceroute 一类工具能工作的基础。


十一、开始生成自己的 ICMP 包

现在已经能解析 ICMP 包,也知道 Echo Request/Reply 的结构。接下来需要反向操作:从结构体生成二进制 ICMP 包。前面解析使用的是 nom,它通过组合 parser 处理输入字节。序列化时也可以采用类似思路:用组合器把各个字段的 serializer 组合起来。

这里引入 cookie-factory。它和 nom 的思路很像,只不过方向相反:nom 从字节解析出结构体,cookie-factory 从结构体生成字节。

解析时,我们的函数大概是:

rust 复制代码
fn parse(input: &[u8]) -> IResult<&[u8], Self, Error<&[u8]>> {
    // parser
}

输入是借用的切片,不需要复制;输出是拥有所有权的结构体;错误可以引用输入。为了把 payload 保留下来,前面定义了 Blob(Vec<u8>),把原始字节复制出来。

序列化时,情况不同。我们是在生成字节,可以选择写到某个 io::Write,也可以生成一个 Vec<u8>。如果直接写一个 serialize(&self, w: &mut dyn io::Write),会引入 io::Error。但这里其实只是生成内存中的字节,不想让 ICMP 序列化逻辑关心底层 I/O 是否失败。因此,更适合使用 cookie-factory 的 serializer 组合。


十二、序列化 Echo

先从最简单的 Echo header 开始。Echo 只有两个 u16 字段:identifier 和 sequence number。它们都要以 big-endian 写出:

rust 复制代码
use cookie_factory as cf;
use std::io;

impl Echo {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::{bytes::be_u16, sequence::tuple};

        tuple((
            be_u16(self.identifier),
            be_u16(self.sequence_number),
        ))
    }
}

这个函数返回的不是字节,也不是 Result,而是一个 serializer 函数。它借用 self,并且在生命周期 'a 内有效。调用它时,cookie-factory 会把 identifier 和 sequence number 写入目标输出。

这和 nom 很像。nom 里可以写 tuple((be_u16, be_u16)) 来解析两个 big-endian u16cookie-factory 里可以写 tuple((be_u16(x), be_u16(y))) 来生成两个 big-endian u16

这种写法的好处是声明式。Echo 的二进制格式就是两个 u16,代码也几乎就是这个结构本身。


十三、序列化 Header

接下来处理 Header

rust 复制代码
#[derive(Debug)]
pub enum Header {
    EchoRequest(Echo),
    EchoReply(Echo),
    Other(u32),
}

不同 variant 要写出的内容不同。Echo Request 和 Echo Reply 的 rest-of-header 都是 Echo;Other 则写一个 u32cookie-factory 没有完全对应 nom::map 的东西,但 serializer 本质上只是函数,所以可以返回一个闭包:

rust 复制代码
impl Header {
    fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::bytes::be_u32;

        move |out| match self {
            Self::EchoRequest(echo) | Self::EchoReply(echo) => {
                echo.serialize()(out)
            }
            Self::Other(x) => {
                be_u32(*x)(out)
            }
        }
    }
}

这里 match 的一个好处是穷尽性检查。如果以后给 Header 增加新的 variant,编译器会提醒序列化逻辑没有处理它。对协议代码来说,这非常有用。结构体和枚举一旦扩展,序列化和解析都应该同步更新。

不过,ICMP packet 的 type/code 字段并不直接来自 Packet.typ,而应该从 Header 推导。因为 Header::EchoRequest(_) 已经足以说明 type 是 8、code 是 0;Header::EchoReply(_) 已经足以说明 type 是 0、code 是 0。typ 字段和 header 字段之间存在冗余。序列化时可以忽略 typ,只看 header

因此还需要一个方法,专门写 type 和 code:

rust 复制代码
impl Header {
    fn serialize_type_and_code<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::{bytes::be_u8, sequence::tuple};

        move |out| match self {
            Self::EchoRequest(_) => tuple((be_u8(8), be_u8(0)))(out),
            Self::EchoReply(_) => tuple((be_u8(0), be_u8(0)))(out),
            _ => unimplemented!(),
        }
    }
}

这里暂时只打算生成 Echo Request 或 Echo Reply,不打算主动生成 TTL Expired 或 Host Unreachable 之类的错误消息。因此其他 variant 可以先 unimplemented!()。以后如果要实现这些 ICMP 错误包,编译器和运行时都会提醒这里需要补齐逻辑。


十四、让 Blob 也能序列化

Blob 本质上就是一段字节,所以序列化最简单:

rust 复制代码
use cookie_factory as cf;
use std::io;

impl Blob {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::combinator::slice;

        slice(&self.0)
    }
}

它借用内部 Vec<u8>,用 slice 直接把字节写出去。没有大小端问题,也没有字段结构。这样 icmp::Packet 就可以组合 type/code、checksum、header 和 payload。


十五、先生成 checksum 为 0 的 ICMP 包

ICMP packet 的结构是:

text 复制代码
type/code
checksum
rest of header
payload

计算 checksum 时,checksum 字段本身要当成 0。因此可以先写一个 serialize_no_checksum(),生成 checksum 字段为 0 的版本:

rust 复制代码
impl Packet {
    pub fn serialize_no_checksum<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::{bytes::be_u16, sequence::tuple};

        tuple((
            self.header.serialize_type_and_code(),
            be_u16(0),
            self.header.serialize(),
            self.payload.serialize(),
        ))
    }
}

为了验证这个序列化是否正确,可以在 Packet::parse 的末尾临时把解析出的 Packet 重新序列化,并和原始 ICMP 字节做对比:

rust 复制代码
let serialized = cf::gen_simple(
    res.serialize_no_checksum(),
    Vec::new(),
).unwrap();

println!(" original = {:?}", Blob::new(original_i));
println!("serialized = {:?}", Blob::new(&serialized));

输出会看到两段非常相似的字节,只有 offset 2 和 3 不同:

text 复制代码
original   = [08 00 49 a4 00 01 03 b7 ...]
serialized = [08 00 00 00 00 01 03 b7 ...]

这正是 checksum 字段。除了 checksum,我们已经能把解析出的 ICMP packet 重新生成回同样的字节。这说明 type/code、identifier、sequence number 和 payload 的序列化逻辑是对的。


十六、checksum 字段在中间,怎么回填

现在剩下的问题是 checksum。要计算 ICMP checksum,需要整段 ICMP packet,包括 type、code、checksum=0、header、payload。可是 checksum 字段位于中间,不能一边生成一边直接写出最终值,因为写到 checksum 字段时,后面的 header 和 payload 还没全部生成出来。

cookie-factory 有一个叫 back_to_the_buffer 的组合器,适合处理"先预留字段,后面生成完后再回头填"的格式。比如某个二进制格式开头有一个长度字段,后面才是 payload,就可以先预留 4 字节,写 payload,最后回头填长度。

不过 ICMP 的情况稍微别扭。我们不是在最开头预留长度,而是在中间预留 checksum。理论上也可以写一个类似机制,但当前没必要追求这么复杂。最简单、最直观的办法是:

text 复制代码
先生成 checksum=0 的完整 ICMP packet 到 Vec<u8>
对这个 Vec<u8> 计算 checksum
把计算结果覆盖到 Vec 的第 2、3 字节
最后把整个 Vec 写到输出

代码大致是:

rust 复制代码
impl Packet {
    pub fn serialize<'a, W: io::Write + 'a>(
        &'a self,
    ) -> impl cf::SerializeFn<W> + 'a {
        use cf::{bytes::le_u16, combinator::slice};

        move |out| {
            let mut buf = cf::gen_simple(
                self.serialize_no_checksum(),
                Vec::new(),
            )?;

            let checksum = crate::ipv4::checksum(&buf);

            cf::gen_simple(le_u16(checksum), &mut buf[2..])?;

            slice(buf)(out)
        }
    }
}

这里看起来有一个奇怪点:回填 checksum 时用了 le_u16,也就是 little-endian,而不是网络协议里常见的 be_u16。这不是随手写错,而是和前面 checksum() 的实现方式有关。


十七、internet checksum 的字节序性质

前面的 checksum() 实现用 align_to::<u16>() 把字节切片按本机字节序解释成 u16。在 x86/x64 小端机器上,内存中的两个字节会被当成 little-endian 的 u16。按直觉看,这似乎和网络字节序 big-endian 矛盾。

但 internet checksum 有一个特殊性质:只要始终一致地处理字节序,最终结果在内存中可以对上。换句话说,可以把所有 16 位 word 都按 big-endian 读,做一补和,最后把结果按 big-endian 写回;也可以在小端机器上不转换,按 little-endian 读,做同样的一补和,最后按 little-endian 写回,最终写入内存的两个字节仍然正确。

这是 RFC 1071 里讨论过的性质。一补和在交换字节序时,carry 行为仍然保持一致。你可以理解为:所有 word 都被一致地 byte-swap,sum 也会对应地 byte-swap,最后写回内存时又保持同样顺序,因此最终网络字节序上的效果能对齐。

不过这也意味着当前代码依赖小端机器。因为我们显式用了 le_u16 回填 checksum。如果把这段代码拿到大端机器上,逻辑就可能不对。更跨平台的做法应该明确按网络字节序处理输入和输出,或者让 checksum 实现返回一个已经适合网络序写入的值。当前项目主要跑在常见 Windows x86/x64 环境上,所以先接受这个限制。


十八、验证完整序列化结果

补上 checksum 后,可以再次在 Packet::parse 里做验证:

rust 复制代码
let serialized = cf::gen_simple(res.serialize(), Vec::new()).unwrap();

println!(" original = {:?}", Blob::new(original_i));
println!("serialized = {:?}", Blob::new(&serialized));

assert_eq!(original_i, &serialized[..]);

这一次,原始字节和序列化字节完全相等:

text 复制代码
original   = [08 00 42 c1 00 01 0a 9a ...]
serialized = [08 00 42 c1 00 01 0a 9a ...]

original   = [00 00 4a c1 00 01 0a 9a ...]
serialized = [00 00 4a c1 00 01 0a 9a ...]

assert_eq! 很关键。Blob 的 Debug 输出只显示前 20 字节,后面有可能不同而肉眼看不到。加上断言后,如果序列化结果和原始输入有任何一个字节不同,程序都会直接失败。这比"看起来差不多"可靠得多。

到这里,ICMP 的解析和序列化形成了闭环:从 rawsock 抓到字节,解析成 icmp::Packet;再把这个 Packet 重新序列化成字节;结果和原始字节完全一致。我们不仅能读 ICMP,也能生成正确 ICMP。


十九、这一篇完成了什么

这一篇完成了两个非常关键的能力。

第一,项目现在能计算 internet checksum。它能验证 IPv4 header,也能生成 ICMP checksum。checksum 的实现虽然还带有对齐和小端机器的假设,但已经足够支撑当前实验。通过真实抓包还观察到了 checksum offload 现象:入站包 checksum 可验证,出站包在本机抓包时可能还是 0。

第二,项目现在能解析和序列化 ICMP。解析方面,ICMP type/code 被建模成 Rust enum;Echo Request/Reply 的 identifier 和 sequence number 被解析成结构体;payload 被保存成 Blob。序列化方面,cookie-factorynom 一样组合 serializer,先序列化 type/code、checksum=0、header、payload,再计算 checksum 并回填,最终生成和原始包完全一致的字节。

从协议栈角度看,ICMP 已经从"未知 payload"变成了真正被理解的协议层。我们能从 Ethernet frame 进入 IPv4,再进入 ICMP,读出 Echo Request、Echo Reply、Time Exceeded 等消息,也能反向生成 ICMP Echo 的字节表示。


二十、这一篇学到的 Rust 技术点

第一,align_to::<u16>() 可以把 &[u8] 重新视为 &[u16],但它是 unsafe,必须处理对齐和尾部剩余。当前 checksum 实现用 panic 明确表达输入必须 16 位对齐且长度是 2 的倍数。

第二,一补和不同于普通二进制加法。发生 16 位溢出时,要把 carry 加回低位。最后再对总和取反,得到 checksum。

第三,真实抓包结果可能受硬件 offload 影响。本机出站包抓到 checksum 为 0,不一定代表线上包错误,因为网卡可能稍后在硬件里填入 checksum。

第四,协议里的 type/code 组合适合用 enum 表达。Rust enum 能把已知组合变成有语义的变体,同时保留 Other 处理未知值。

第五,nom parser 可以逐层嵌套。IPv4 parser 根据 protocol 字段决定是否调用 ICMP parser,ICMP parser 再根据 type 决定如何解析 rest-of-header。

第六,二进制 payload 不应该直接用完整 Vec<u8> Debug 输出。Blob 这种小包装可以让调试输出更清楚,只显示前几个字节,并标注剩余长度。

第七,cookie-factorynom 的思路互补。nom 组合 parser,cookie-factory 组合 serializer。对于二进制协议,实现 parse/serialize 双向逻辑时,这种对称结构非常自然。

第八,序列化时不一定要直接写 io::Write。先构造 serializer,再通过 gen_simple 生成 Vec<u8>,可以避免让协议层过早关心 I/O 错误。

第九,checksum 位于中间字段时,最简单办法是先生成完整 buffer,再计算并覆盖。虽然不是最零拷贝、最高性能的方式,但更简单、更好验证。

第十,验证序列化逻辑时,直接 assert_eq!(original, serialized) 很有效。只看 Debug 输出不可靠,因为 Debug 可能截断。


二十一、当前还有哪些不足

现在 ICMP 的解析和序列化已经跑通,但整个项目还没有真正发出自己构造的 ping。原因是 ICMP 只是三层结构中最里面的一层。要把 ICMP 发到网络上,还需要序列化 IPv4 packet,再把 IPv4 packet 封装进 Ethernet frame。换句话说,下一步还要给 IPv4 和 Ethernet 也补上序列化逻辑。

checksum 实现也还有改进空间。当前依赖输入对齐和长度是 2 的倍数,而且代码对小端机器更友好。如果以后想做成更通用的库,应该处理未对齐输入、奇数长度输入,并明确网络字节序语义。

ICMP type/code 也只是建模了一小部分。真实 ICMP 类型很多,Destination Unreachable 的 code 也有很多种,Time Exceeded 也不止一种情况。当前只覆盖了这个系列里能直接实验到的 Echo Request、Echo Reply、Destination Host Unreachable、TTL Expired 等。作为学习项目足够,但不是完整 ICMP 实现。

另外,Time Exceeded 的 payload 里包含原始 IPv4 包的一部分。当前只是把它作为 Blob 打印出来,没有递归解析。如果要做更完整的诊断工具,可以继续解析这个嵌套 IPv4 header,从而更清楚地展示是哪一个原始包导致了错误。


二十二、总结

这一篇把项目从"能解析 IPv4 并识别 ICMP"推进到"能理解并生成 ICMP"。首先实现了 internet checksum:把字节按 16 位 word 对齐,做一补和,最后取反。这个函数既能用于 IPv4 header 验证,也能用于 ICMP packet 生成。真实抓包验证时,入站包 checksum 能算出 0,出站包却可能 checksum 为 0,这背后是网卡 checksum offload:抓包程序可能在网卡硬件填 checksum 之前就看到了出站包。

接着,ICMP 包被正式建模。type/code 被转换成 Rust enum,Echo Request 和 Echo Reply 的 rest-of-header 被解析成 Echo { identifier, sequence_number },剩余 payload 用 Blob 保存并以简短十六进制形式输出。通过后台运行 Windows ping,可以观察到 Echo Request 和 Echo Reply 成对出现,identifier 固定,sequence number 递增,payload 是字母序列。故意设置较低 TTL 后,还能看到 Time Exceeded 消息,其 payload 不是字符串,而是原始 IPv4 包的一部分。

最后,引入 cookie-factory 实现 ICMP 序列化。Echo 序列化为两个 big-endian u16Header 根据 variant 选择不同序列化方式,Blob 直接写出字节切片,Packet 先生成 checksum 为 0 的完整 ICMP 包,再计算 checksum 并覆盖 buffer 中第 2、3 字节。通过 assert_eq! 验证,重新序列化出的 ICMP 字节和抓包中的原始字节完全一致。

到这里,ICMP 这一层已经形成闭环:可以从网络字节解析成结构体,也可以从结构体重新生成网络字节。距离真正发送自制 ping 还差 IPv4 和 Ethernet 的序列化,但最核心的 ICMP Echo 已经具备了可读、可写、可校验的能力。下一步,就是继续把这种 parse/serialize 思路推广到 IPv4 packet 和 Ethernet frame,最终把手工构造的 ICMP Echo Request 真正送上网线。

相关推荐
蝎子莱莱爱打怪1 小时前
XZLL-IM干货系列 03|消息 ID 设计:一个 UUID 搞不定的事,我用两个 ID 解决了
后端·面试·开源
fliter1 小时前
从 panic 到 Result:用 Rust 重新整理一个 ping 项目的错误处理
后端
森蓝情丶2 小时前
我给 AI 搭了个法庭:一个前端仔的 LangGraph 实战全记录
前端·后端
JensCS猿2 小时前
从 Spring Boot 回看 SSM 框架:手动挡与自动挡的驾驶哲学
后端
爱勇宝2 小时前
干了近 8 年,一夜之间被裁:AI 时代,程序员最该害怕的不是 AI
前端·后端·程序员
科米米2 小时前
嵌入式日志模块
后端
血小溅3 小时前
三大 AI 编码框架深度对比:GSD vs OpenSpec vs Superpowers
人工智能·后端
ThanksGive3 小时前
层级时间轮看门狗
后端
GetcharZp3 小时前
告别繁琐命令行!这款容器可视化神器,让 Docker/K8s 管理变得如此简单
后端