S7comm 协议¶
概述¶
S7comm 是西门子专有的工业控制协议,主要用于西门子S7-300/400系列PLC(可编程逻辑控制器)的编程、数据交换和诊断
报文结构¶
一个典型的 S7comm 报文由三部分组成:
-
Header (头部):固定 12 字节,包含协议标识、PDU 类型等关键信息。
-
Parameter (参数):长度可变,包含具体的功能码(如读取、写入)及其参数
-
Data (数据):长度可变,在写入等操作中携带实际数据
通信过程¶
Wireshark 可以清晰捕捉到 S7 通信的建立过程,分为三步:
-
TCP 三次握手:客户端通过 TCP 端口 102 与 PLC 建立连接
-
COTP 层连接:双方交换 COTP 连接请求和确认报文,协商通信参数
-
S7comm 层会话建立:客户端发送功能码为
0xf0的 "建立通信" 请求,双方协商 S7comm 层参数,完成连接
核心功能码¶
| 功能码 | 描述 | 说明 |
|---|---|---|
| 0x00 | CPU 服务 | 用于执行与 CPU 本身相关的系统级功能 |
| 0xF0 | 建立通信 | 在数据传输前,用于协商通信参数,如 PDU 大小 |
| 0x04 | 读取变量 | 请求读取 PLC 中一个或多个变量的值 |
| 0x05 | 写入变量 | 请求向 PLC 写入一个或多个变量的值 |
| 0x1A | 请求下载 | 开始下载程序块或数据块前的请求 |
| 0x1B | 下载块 | 传输程序块或数据块的实际数据内容 |
| 0x1C | 下载结束 | 通知 PLC 下载过程已全部完成 |
| 0x1D | 开始上传 | 开始从 PLC 上传程序块或数据块 |
| 0x1E | 上传 | 传输从 PLC 上传的数据内容 |
| 0x1F | 上传结束 | 通知上位机上传过程已全部完成 |
| 0x28 | PLC 控制 | 用于远程控制 PLC 的运行模式,例如启动(Start) |
| 0x29 | 停止 PLC | 远程停止 PLC 的运行 |
实战案例¶
S7 协议恶意攻击分析(S7comm 协议)¶

题目说了是 "突然发生停机事件",所以推测是 0x29 或者 0x28,直接过滤出来
P_PROGRAM 是实现 PLC 模式转换的关键服务。它主要包含两种操作:
-
启动:将 PLC 从停止状态切换至运行状态
-
停止:将 PLC 从运行状态切换至停止状态

本题的 flag 是 flag{3201414d}
上位机通讯异常分析(S7comm 协议)¶

题目说 "无法查询控制设备异常情况"
观察数据包发现功能码都是 0x04,所以判断是因为响应不正常才导致的 "无法查询"

我们随便一个响应来分析一下结构

📦 1. Header (头部) — 确认响应类型
| 字段 | 值 | 说明 |
|---|---|---|
| Protocol Id | 0x32 | S7 协议固定标识,表明这是 S7comm 报文 |
| ROSCTR | Ack_Data (3) | 表示这是一个数据确认包,即对请求的响应并携带数据 |
| PDU Reference | 52417 | 用于匹配请求与响应的流水号 |
| Parameter length | 2 | 参数部分的长度为 2 字节(仅包含 "Item count") |
| Data length | 6 | 数据部分的长度为 6 字节(包含 1 个数据项的元信息+实际值) |
| Error class / code | 0x00 / 0x00 | 无错误,表示操作成功 |
📋 2. Parameter (参数) — 确认读取内容
| 字段 | 值 | 说明 |
|---|---|---|
| Function | Read Var (0x04) | 响应的是 "读取变量" 请求 |
| Item count | 1 | 数据部分包含 1个 数据项,与请求中的项数对应 |
💾 3. Data (数据) — 实际读取到的值
| 字段 | 值 | 说明 |
|---|---|---|
| Return code | Success (0xff) | 该项读取成功 |
| Transport size | BYTE/WORD/DWORD (0x04) | 表示数据长度以字节为单位,且 "Length" 字段按字节计数 |
| Length | 2 | 数据长度为 2个字节 |
| Data | 01 00 | 实际读取到的原始值,按大端序存储 |
可以看到 Return code 的值决定是否读取成功,成功的话为 0xff
我们右键这个值,选择 "准备作为过滤器" → "选中"

然后加上 ! 筛选出没有读取成功的流量包

其 data 内容就是 flag{010400100100}

工控协议数据分析(S7comm 协议)¶

题目说 "获取到了哪些信息",过滤协议可以看到全部都是 0x04 和 0x05 的流量

过滤 s7comm.param.func == 0x05 可以看到多次写入,每个写入包的 data 字段里都有类似内容:

提取出来拼接转成 ASCII 码得到 flag{flag_is_here}
异常的 S7 数据(S7comm 协议)¶
大概看了一下,全是 0x05 功能码

绝大多数写入数据都以 ffff 开头

右键作为过滤器应用

WireShark 生成的语法是 s7comm.resp.data == ff:ff:96:e7:8f:51:c4:d0:3a:2b
我们更改为 s7comm.resp.data[0:2] != ff:ff,筛选出数据包

所以 flag 是 flag{FFAD28A0CE69DB34751F}
黑客的 Fuzz(S7comm 协议)¶

打开流量包过滤 s7comm,全是写入和读取变量
题目说的是 FUZZ,所以重点应该看读取的流量,功能码为 0x04
FUZZ 的参数每次请求肯定都是不一样的
最后比对发现就 Address 字段的最后一个字节每次不一样

编写脚本提取
import struct
import re
# 打开 pcapng 文件并读取全部内容
pcap = "s7.pcapng"
raw = open(pcap, "rb").read()
# 默认使用小端字节序
endian = "<"
# 当前读取偏移
off = 0
# 存放提取出的隐藏数据
hidden = bytearray()
def parse_tcp(pkt):
"""解析以太网帧中的 TCP 报文,返回 (src_ip, dst_ip, src_port, dst_port, payload)"""
# 以太网帧最小长度 14 字节(目的 MAC + 源 MAC + 类型)
if len(pkt) < 14:
return None
# 以太类型字段(大端)
eth_type = int.from_bytes(pkt[12:14], "big")
pos = 14
# 若存在 802.1Q VLAN 标签(0x8100),再读取内层类型
if eth_type == 0x8100:
eth_type = int.from_bytes(pkt[16:18], "big")
pos = 18
# 只处理 IPv4(0x0800)
if eth_type != 0x0800:
return None
# IP 头部长度(ihl * 4 字节)
ihl = (pkt[pos] & 0x0f) * 4
# IP 协议字段(6 = TCP)
proto = pkt[pos + 9]
if proto != 6:
return None
# 源 IP 和目标 IP(点分十进制)
src = ".".join(map(str, pkt[pos + 12:pos + 16]))
dst = ".".join(map(str, pkt[pos + 16:pos + 20]))
# TCP 首部起始位置
tcp = pos + ihl
# 源端口、目的端口
sport = int.from_bytes(pkt[tcp:tcp + 2], "big")
dport = int.from_bytes(pkt[tcp + 2:tcp + 4], "big")
# TCP 数据偏移(4 位,单位 4 字节)
doff = (pkt[tcp + 12] >> 4) * 4
# TCP 有效载荷
payload = pkt[tcp + doff:]
return src, dst, sport, dport, payload
# 解析 pcapng 文件:逐块读取
while off + 12 <= len(raw):
# 块类型和块长度(使用当前字节序)
block_type, block_len = struct.unpack(endian + "II", raw[off:off + 8])
# 块长度不合理则退出
if block_len < 12 or off + block_len > len(raw):
break
# 块体(去除头部类型/长度和尾部重复长度)
body = raw[off + 8:off + block_len - 4]
# 块类型 0x0A0D0D0A 是 Section Header Block(SHB),用于判断字节序
if block_type == 0x0A0D0D0A:
# 魔术数字判断大小端:b"\x4d\x3c\x2b\x1a" 表示小端,否则大端
endian = "<" if body[:4] == b"\x4d\x3c\x2b\x1a" else ">"
# 块类型 6 是 Enhanced Packet Block(EPB),包含数据包
elif block_type == 6:
if len(body) >= 20:
# 块选项中的捕获长度(caplen)在固定字段的第三个 int 里
caplen = struct.unpack(endian + "IIIII", body[:20])[3]
# 提取数据包字节
pkt = body[20:20 + caplen]
# 解析 TCP 报文
parsed = parse_tcp(pkt)
if parsed:
src, dst, sport, dport, payload = parsed
# 只取攻击者发往 PLC 102 端口的数据
if dport == 102 and payload.startswith(b"\x03\x00"):
# S7Comm Job 包,Read Var 功能,固定长度 31
if (
len(payload) == 31
and payload[7] == 0x32 # 功能码:Read Var
and payload[8] == 0x01 # 读取变量数量:1
and payload[17] == 0x04 # 数据长度:4 字节
):
# 提取最后一个字节(读取到的数据)
hidden.append(payload[-1])
# 移动到下一个块
off += block_len
# 将隐藏数据写入文件
open("hidden", "wb").write(hidden)
# 打印恢复信息
print("hidden length:", len(hidden))
print("file header:", hidden[:10].hex())
# 搜索 flag 并输出
flags = re.findall(rb"flag\{[^}]+\}", hidden)
for f in flags:
print(f.decode())
最后得到 flag:flag{50f84daf3a6dfd6a9f20c9f8ef428942}
被篡改的数据(S7comm 协议)¶

"被大量修改的数据" 对应的写入内容,也就是功能码 0x05
import struct
import socket
import re
# 读取 pcapng 文件
pcap = "/mnt/data/s702(1).pcapng"
data = open(pcap, "rb").read()
# -------- 解析 pcapng 块 --------
packets = []
off = 0
endian = "<" # 默认小端
while off + 12 <= len(data):
# 块类型和块总长度(含固定尾)
block_type, block_len = struct.unpack(endian + "II", data[off:off+8])
if block_len < 12 or off + block_len > len(data):
break
body = data[off+8:off+block_len-4] # 排除尾部的块总长度字段
# Section Header Block (SHB)
if block_type == 0x0A0D0D0A:
# 字节序标记:0x1A2B3C4D 表示大端
endian = ">" if body[:4] == b"\x1a\x2b\x3c\x4d" else "<"
# Enhanced Packet Block (EPB)
elif block_type == 0x00000006 and len(body) >= 20:
# 接口ID、时间戳高低位、捕获长度、原始长度
iface, ts_high, ts_low, cap_len, orig_len = struct.unpack(
endian + "IIIII", body[:20]
)
# 提取实际捕获的报文数据
packets.append(body[20:20+cap_len])
off += block_len
def get_tcp_payload(pkt):
"""从原始以太网帧中提取 TCP 载荷及四元组"""
if len(pkt) < 54: # 最小长度:以太网14 + IP20 + TCP20
return None
# 以太网类型必须为 IPv4 (0x0800)
if pkt[12:14] != b"\x08\x00":
return None
# IP 协议字段必须为 TCP (6)
if pkt[23] != 6:
return None
# IP 头长度(IHL)及偏移
ihl = (pkt[14] & 0x0F) * 4
ip_start = 14
tcp_start = ip_start + ihl
# 源IP、目的IP
src = socket.inet_ntoa(pkt[ip_start+12:ip_start+16])
dst = socket.inet_ntoa(pkt[ip_start+16:ip_start+20])
tcp = pkt[tcp_start:]
if len(tcp) < 20:
return None
# 源端口、目的端口
sport, dport = struct.unpack("!HH", tcp[:4])
# TCP 数据偏移
data_offset = (tcp[12] >> 4) * 4
payload = tcp[data_offset:]
return src, sport, dst, dport, payload
def parse_s7_write_values(payload):
"""从 TCP 载荷中提取 S7 写变量操作的写入值(每次写1字节)"""
values = []
off = 0
while off + 4 <= len(payload):
# TPKT 头部:版本 0x03,保留 0x00,总长度
if payload[off:off+2] != b"\x03\x00":
break
tpkt_len = int.from_bytes(payload[off+2:off+4], "big")
tpkt = payload[off:off+tpkt_len]
# COTP 数据 TPDU:DT Data (0x0f) 且最后字节 0x80 表示数据
if len(tpkt) >= 17 and tpkt[4:7] == b"\x02\xf0\x80":
s7 = tpkt[7:]
# S7 协议标识 (0x32)
if len(s7) >= 10 and s7[0] == 0x32:
rosctr = s7[1] # 报文类型
# 0x01 = Job(请求)
if rosctr == 0x01:
# 参数长度、数据长度
param_len = int.from_bytes(s7[6:8], "big")
data_len = int.from_bytes(s7[8:10], "big")
param = s7[10:10+param_len]
d = s7[10+param_len:10+param_len+data_len]
# 功能码 0x05 = 写变量
if param and param[0] == 0x05:
# 本题每次写入 1 字节,S7 写数据前 4 字节为:
# 保留/传输大小/位长度
if len(d) >= 5:
values.append(d[4:5]) # 提取写入值
off += tpkt_len
return values
# 累积所有写入值,拼接成字节序列
seq = b""
for pkt in packets:
item = get_tcp_payload(pkt)
if not item:
continue
src, sport, dst, dport, payload = item
# 只看上位机 (192.168.88.2) 发给 PLC (192.168.88.23) 端口 102 的 S7 写入
if src == "192.168.88.2" and dst == "192.168.88.23" and dport == 102:
for v in parse_s7_write_values(payload):
seq += v
print("extracted length:", len(seq))
# 在累积的序列中搜索 flag{...} 模式
m = re.search(rb"flag\{[^}]+\}", seq)
if m:
print(m.group().decode())
else:
print("not found")
得到 flag{931377ad4a}