← 返回 Day 156 正文

Day 156 · 图解

Day 156 图解 — Scapy 数据包构造原理

全部为 Mermaid 代码块与 ASCII 字符画,不生成图片文件。


图 1:协议封装与 / 运算符(ASCII)

  你在 Python 里写:
      Ether() / IP(dst="127.0.0.1") / TCP(dport=80, flags="S") / b"GET / HTTP/1.0\r\n\r\n"

  实际对应的字节布局(自上而下 = 从内到外):
  ┌────────────────────────────────────────────────────────────┐
  │ Ethernet II                                                 │
  │  ┌───────────────┬───────────────┬────────────┐            │
  │  │ dst MAC (6B)  │ src MAC (6B)  │ type 0x0800│            │
  │  └───────────────┴───────────────┴────────────┘            │
  │  ┌────────────────────────────────────────────────────────┐ │
  │  │ IPv4 Header (20B)                                      │ │
  │  │  ver/ihl │ tos │ len │ id │ flags/frag │ ttl │ proto=6 │ │
  │  │  chksum  │ src │ dst                                  │ │
  │  │  ┌──────────────────────────────────────────────────┐  │ │
  │  │  │ TCP Header (20B)                                  │  │ │
  │  │  │  sport │ dport=80 │ seq │ ack │ flags=S │ win    │  │ │
  │  │  │  chksum │ urgptr │ options(MSS/SAckOK/WScale)     │  │ │
  │  │  │  ┌────────────────────────────────────────────┐  │  │ │
  │  │  │  │ Payload: b"GET / HTTP/1.0\r\n\r\n"         │  │  │ │
  │  │  │  └────────────────────────────────────────────┘  │  │ │
  │  │  └──────────────────────────────────────────────────┘  │ │
  │  └────────────────────────────────────────────────────────┘ │
  └────────────────────────────────────────────────────────────┘

  Scapy 的字段默认值策略:
    · 能自动算的 → None(len / chksum / ihl)
    · 有合理默认的 → 具体值(ttl=64, version=4)
    · 用户必须给的 → 保持默认(dst 默认 127.0.0.1,你不改就发给自己)

图 2:发送路径对比(ASCII)

【send / sr】—— 内核参与,L3
   pkt (IP/TCP)
        │  Scapy 序列化
        ▼
   socket(AF_INET, SOCK_RAW, IPPROTO_RAW)
        │  sendto()
        ▼
   内核:查路由表 → 选网卡 → 查 ARP 缓存得到目的 MAC
        │
        ▼
   内核补 Ether 头 → 网卡发送
   ↳ 好处:不用管 MAC、自动走对网卡
   ↳ 限制:IP 头由内核"部分监督",某些字段会被内核覆盖

【sendp / srp】—— 绕过内核路由,L2
   pkt (Ether/IP/TCP)
        │  Scapy 序列化(包括你自己写的 Ether 头)
        ▼
   socket(AF_PACKET, SOCK_RAW)  bind(iface)
        │  send()
        ▼
   直接交给网卡驱动
   ↳ 好处:能完全控制 L2(ARP 欺骗、自定义以太类型、VLAN 等)
   ↳ 代价:必须自己写对 dst MAC,否则包发出去没人收

图 3:SYN 扫描与检测特征(Mermaid)

sequenceDiagram participant S as Scapy 扫描器 participant T as 目标主机 participant I as IDS / 防火墙 Note over S,I: 开放端口 S->>T: SYN (seq=random) T->>S: SYN-ACK S->>T: RST Note over I: 记录: SYN→SYN-ACK→RST<br/>无应用层数据 ⇒ 疑似扫描 Note over S,I: 关闭端口 S->>T: SYN T->>S: RST,ACK Note over I: 记录: 大量 RST,ACK 来自同一源 Note over S,I: 被过滤 S->>T: SYN Note over T: 被规则丢弃(无响应) Note over S: 超时 ⇒ 判定 filtered Note over I: 记录: 一个源短时间内发出大量<br/>无后续的 SYN ⇒ SYN Flood 特征 Note over S,I: 全连接扫描(对比:socket.connect) S->>T: SYN T->>S: SYN-ACK S->>T: ACK S->>T: GET / HTTP/1.0 Note over T: ✅ 应用层日志留下访问记录
sequenceDiagram
    participant S as Scapy 扫描器
    participant T as 目标主机
    participant I as IDS / 防火墙

    Note over S,I: 开放端口
    S->>T: SYN (seq=random)
    T->>S: SYN-ACK
    S->>T: RST
    Note over I: 记录: SYN→SYN-ACK→RST<br/>无应用层数据 ⇒ 疑似扫描

    Note over S,I: 关闭端口
    S->>T: SYN
    T->>S: RST,ACK
    Note over I: 记录: 大量 RST,ACK 来自同一源

    Note over S,I: 被过滤
    S->>T: SYN
    Note over T: 被规则丢弃(无响应)
    Note over S: 超时 ⇒ 判定 filtered
    Note over I: 记录: 一个源短时间内发出大量<br/>无后续的 SYN ⇒ SYN Flood 特征

    Note over S,I: 全连接扫描(对比:socket.connect)
    S->>T: SYN
    T->>S: SYN-ACK
    S->>T: ACK
    S->>T: GET / HTTP/1.0
    Note over T: ✅ 应用层日志留下访问记录

为什么防火墙能识别扫描? → 因为"正常用户"不会有"大量只有 SYN 没有后续"或"大量 RST"的行为。 行为特征,而不是单包特征。


图 4:嗅探的内核路径与丢包点(ASCII)

   网卡收到帧
        │
        ▼
   内核 netif_receive_skb
        │
        ├──────────────────────────────► 正常协议栈处理(TCP/IP)
        │
        └──► AF_PACKET 套接字(Scapy)
                 │
                 │ ① BPF 过滤(filter="icmp")  ← 尽量在这里砍掉 99% 的包
                 │      内核态执行,零拷贝到用户态
                 ▼
             ② 环形缓冲区(默认 ~208KB)        ← ⚠️ 丢包点 1:突发流量写满即丢
                 │
                 ▼
             ③ 用户态 recvfrom() → Scapy dissect
                 │                              ← ⚠️ 丢包点 2:Python 解析太慢
                 ▼
             ④ pkts.append(pkt)  (store=True)   ← ⚠️ 丢包点 3(内存):越攒越多 → OOM

   对策:
     · filter=...        内核态过滤
     · store=False       不攒列表
     · 提高 rmem_max      放大环形缓冲区
     · PcapReader 流式    处理大 PCAP 时不要 rdpcap

图 5:traceroute 的 TTL 逐跳原理(ASCII)

   TTL=1  ┌─────────┐
   ──────►│ 路由器1 │  TTL→0 → 回 ICMP Time Exceeded (from 路由器1)
          └─────────┘        ▲ 记录第 1 跳

   TTL=2  ┌─────────┐
   ──────►│ 路由器1 │ TTL→1 继续
          └────┬────┘
               ▼
          ┌─────────┐
          │ 路由器2 │  TTL→0 → 回 ICMP Time Exceeded (from 路由器2)
          └─────────┘        ▲ 记录第 2 跳

   TTL=N  ... ─────► 目标主机 → 回 ICMP Echo Reply 或 TCP RST
                              ▲ 到达,结束

   Scapy 写法(一行就够):
       ans, unans = sr(IP(dst=target, ttl=(1, 30)) / ICMP(), timeout=1)
                     ↑ ttl 给一个范围 = 自动展开成 30 个包

   为什么会出现 `*`(无响应)?
     ① 路由器配置了不回复 ICMP
     ② 该跳丢包
     ③ timeout 太短
     ④ 运营商做了 ICMP 限速

图 6:ARP 查询与局域网发现(ASCII)

   探测主机 192.168.1.100                    目标 192.168.1.1
        │                                          │
        │ Ether(dst=ff:ff:ff:ff:ff:ff)             │
        │ ARP(op=1, pdst=192.168.1.1)              │
        │ ──────── 广播 ──────────────────────────►│
        │    "谁是 192.168.1.1?告诉 192.168.1.100" │
        │                                          │
        │                                          │ 查自己的 IP
        │◄──────── 单播应答 ───────────────────────│
        │    "192.168.1.1 是 aa:bb:cc:dd:ee:ff"     │
        │                                          │

  为什么 ARP 扫描比 ping 扫描可靠?
   ┌────────────────────────────┬──────────────┬──────────────┐
   │ 方式                       │ 依赖         │ 主机禁 ICMP 时│
   ├────────────────────────────┼──────────────┼──────────────┤
   │ ICMP ping                  │ 网络层       │ ❌ 误判离线   │
   │ ARP 请求(L2)              │ 必须回 ARP   │ ✅ 仍能发现   │
   └────────────────────────────┴──────────────┴──────────────┘

  ⚠️ 安全提示:
     ARP 无认证 ⇒ 可以伪造应答(ARP 欺骗)⇒ 局域网中间人的基础。
     本日只做「查询」,不做「应答」;不提供任何欺骗示例。
     防御:动态 ARP 检测(DAI)、ARP 静态绑定、端口安全、VLAN 隔离。

图 7:校验和的三种覆盖范围(Day156 示例 04 的实验对象)

① IPv4 头校验和:只覆盖头部,每跳都要更新
   ┌───────────────── IPv4 头(20~60 字节)─────────────────┐
   │ ver/ihl │ tos │ total_len │ id │ flags/frag │ ttl │ proto │
   │ checksum ← 算它的时候先把本字段置 0                     │
   │ src │ dst │ options                                    │
   └────────────────────────────────────────────────────────┘
   · 不含载荷!(所以载荷改了不影响 IP 校验和 —— 这正是"改了载荷却只报 TCP
     checksum incorrect"的原因)
   · 路由器每跳把 TTL 减 1,用 RFC 1624 的增量更新(不重算整头)

② TCP/UDP 校验和:伪首部 + 整段(头 + 载荷)
   ┌──── 伪首部(12 字节,**不发送**,只参与计算)────┐
   │ src IP(4) │ dst IP(4) │ 0 │ proto(1) │ L4长度(2) │
   └──────────────────────────────────────────────────┘
            +
   ┌──────── TCP/UDP 头 + 载荷 ────────┐
   │ sport │ dport │ …checksum          │
   └────────────────────────────────────┘
   · 伪首部的"那个 0 字节"是为了 16 位对齐,漏掉它校验和必错
   · 源/目的 IP 参与计算 ⇒ NAT 改地址必须同时改校验和

③ ICMP 校验和:只覆盖 ICMP 消息本身(**没有伪首部**)
   ┌──── type │ code │ checksum │ id │ seq │ 载荷 ────┐
   └──────────────────────────────────────────────────┘
   · 这条最容易和 ② 记混:TCP/UDP 有伪首部,ICMP 没有

反码和算法(RFC 1071):
   1) 16 位一组当大端整数相加(奇数长度末尾补 0x00)
   2) 进位回卷(可能卷多次)
   3) 取反码 = 校验和字段的值
   4) 验证:含校验和字段整段再算一次,结果应为 0xFFFF

   官方测试向量:00 01 f2 03 f4 f5 f6 f7 → 和 0xddf2,校验和 0x220d

图 8:pcap 文件格式与"自己写、让 Scapy 读"的交叉验证

全局头 24 字节(小端示例):
  偏移 0    d4 c3 b2 a1   ← 魔数 0xA1B2C3D4 的小端写法(判字节序就看它)
  偏移 4    02 00 04 00   ← 版本 2.4
  偏移 8    00 00 00 00   ← thiszone(永远是 0)
  偏移 12   00 00 00 00   ← sigfigs(永远是 0)
  偏移 16   ff ff 00 00   ← snaplen 65535
  偏移 20   65 00 00 00   ← network = 101(DLT_RAW:裸 IP,没有链路层头)
                          ⚠️ 这个字段决定解析起点:标成 1(Ethernet) 就会去读 14 字节头

每包记录头 16 字节:
  ┌ 秒(4) │ 微秒(4) │ incl_len(4) │ orig_len(4) ┐ + incl_len 字节的包数据
  └ 浮点误差保护:usec >= 1000000 时 秒+1、微秒-1000000

交叉验证(Day156 示例 04 实验 4 / Day157 pcap_lib 自检):
   我们自己写 ─────────────► scapy.rdpcap 读 ──► 字节/时间戳逐项比对 ✅
   scapy.wrpcap 写 ────────► 我们自己读 ────────► 包数/字节比对 ✅
   ↑ 只有两个**独立实现**都读出同样的东西,才能说明"格式我们理解对了"

字节序判断表(按小端读前 4 字节):
   0xa1b2c3d4 → 小端 + 微秒     0xa1b23c4d → 小端 + 纳秒
   0xd4c3b2a1 → 大端 + 微秒     0x4d3cb2a1 → 大端 + 纳秒
   判错的后果:incl_len 变成天文数字 → read() 一口吃掉大半个文件

截断容错:抓包进程被 kill → 最后一条记录不完整 → **丢弃残包正常结束**
          (比"抛异常让整次分析失败"更符合工程现实)

图 9:分片与 MTU(Day156 示例 04 实验 5)

原始包:IP(20) + ICMP(8) + 载荷(4000) = 4028 字节

fragment(pkt, fragsize=1480)   ← fragsize 指"每片的 IP 载荷"上限

 ① frag=0    MF=1  偏移=0      1480 字节载荷  ┐
 ② frag=185  MF=1  偏移=1480   1480 字节载荷  ├ 偏移单位是 8 字节!185×8=1480
 ③ frag=370  MF=0  偏移=2960   1048 字节载荷  ┘ 末片 MF=0 表示结束

每个分片:IP 头被**重写**(total_len / flags / frag 都变了)
        ⇒ 每个分片的 IP 校验和都必须重算(自检里逐片验证)

重组(自己实现,别用 scapy 的 defrag —— 见 C2 的实测记录):
   按 frag×8 排序 → 去掉各片 IP 头 → 载荷首尾相接
   必须检查两件事:
     · 偏移连续(缺中间片 → 报错)
     · 末片 MF=0(末尾缺片 → 报错)
   ↑ "尽力拼"比"报错"更危险:残缺数据会一路解析出错误结论

图 10:本日 4 个代码文件的职责划分

                 ┌──────────────────────────────────────────┐
                 │  01-scapy-basics.py                      │
                 │  分层构造 / 字段读写 / chksum 的 None 语义 │
                 │  send·sendp·sr1(默认 dry-run,需 --send)│
                 └───────────────┬──────────────────────────┘
                                 │ 都依赖同一个前提:
                                 │ "我知道字节长什么样"
                 ┌───────────────▼──────────────────────────┐
                 │  04-checksum-and-pcap-lab.py(新增)      │
                 │  ① 反码和从零实现(RFC 1071/1624)        │
                 │  ② pcap 字节结构与双向交叉验证            │
                 │  ③ 分片:每片校验和 + 自己重组            │
                 └───────────────┬──────────────────────────┘
                                 │ 把"看不见的一层"摊开后
        ┌────────────────────────┴────────────────────────┐
        │                                                 │
┌───────▼───────────────────┐              ┌──────────────▼────────────────┐
│ 02-sniff-pitfalls.py      │              │ 03-network-probe.py           │
│ 嗅探 8 坑(store/BPF/权限/  │              │ ping / traceroute / ARP /      │
│ 混杂模式/丢包/prn/大文件/合规)│             │ SYN 扫描(含护栏与结果判定)     │
└───────────────────────────┘              └───────────────────────────────┘