diff --git "a/docs/hc-ics/1527 \350\212\257\347\211\207.md" "b/docs/hc-ics/1527 \350\212\257\347\211\207.md" new file mode 100644 index 0000000000..f83299a027 --- /dev/null +++ "b/docs/hc-ics/1527 \350\212\257\347\211\207.md" @@ -0,0 +1,264 @@ +--- +comments: true + +--- + +# 1527 芯片 + +## 芯片定位与调制方式 + +1527 是一颗固定码无线遥控编码芯片,专为低成本的单向遥控设计。典型工作方式: + +- 射频频率:315 MHz 或 433 MHz(由外部声表/晶振和发射电路决定) + +- 调制方式:OOK (开关键控),即发射载波的有无代表数据 + +- 数据发送:按下按键后,同一帧数据会以约 10 ms 的间隔重复发送,直到松手 + +## 数据结构 + +一帧完整数据由同步头和 24 位数据(20 位地址 + 4 位按键值)组成,每发完一帧会有一段较长的低电平间隙,然后重复下一帧 + +``` +[同步头] + [数据] + 间隙…… 再重复 +``` + +- 同步头:一个宽高电平脉冲,用来唤醒接收端并标志一帧的开始 + +- 数据位:24 位,包含 C0~C19(地址码) 和 D0~D3(数据码),不同芯片手册的位序可能不同,但原理一致 + +## 编码方式 + +1527 采用脉宽调制来表示一位数据,每一位由 "一个高电平 + 紧随的一个低电平" 组成,高低电平的宽度比例决定逻辑值 + +以常见的 4T/12T 方案为例(T 为时钟基本周期,由外接振荡电阻决定): + +- 逻辑 0:高电平 ≈ 4T,低电平 ≈ 12T(高短低长) + +- 逻辑 1:高电平 ≈ 12T,低电平 ≈ 4T(高长低短) + +接收端可以通过测量每个高电平的宽度来区分 0/1 + +有的芯片或接收方案也可能是 1T/3T 或 3T/1T,但本质上都是 "短高长低" 表示 0,"长高短低" 表示 1 + +## 地址码与数据码 + +地址码(20 位) +- 由芯片的 A0~A19 管脚通过三态编码确定:接地(0)、接正(1)或悬空(浮动) + +- 这 20 位决定了遥控器的 "身份",出厂时或现场跳线来设置,保证不同遥控器间不冲突 + +数据码(4 位) + +- 对应 D0~D3 按键输入,通常低电平有效,内部上拉,按下某键后对应位发送 "1" 数据,其余为 "0"。例如按下 D2 键,则数据码为 0100 + +- 有的 1527 芯片支持组合键,即同时按下多键会送出多位 1 + +## 发射与接收流程 + +发射端:按键按下 → 1527 按地址、按键生成数据帧 → 通过 DATA 脚输出编码脉冲 → 控制 433/315 MHz 发射模块(ASK 方式)发射出去 + +接收端:超再生或超外差接收模块解调出脉冲 → 送至 1527 解码芯片(如 EV1527 配套的解码器,或与 PT2272 类似的方案)或单片机进行软件解码 + +解码器核对两帧以上地址匹配后,输出对应的按键电平 + +## 实战案例 + +### 工控大楼的道闸怎么坏了 + +![](https://pic1.imgdb.cn/item/6a35930c71d0c6ff61485786.png) + +EM1527 是常见的 433MHz/315MHz 无线遥控编码芯片协议,经常用于道闸、卷帘门、遥控开关等设备 + +协议数据一般为 24 bit,也就是 6 个十六进制字符 + +它通常使用 OOK/ASK 调制,也就是: + +``` +有载波 = 高电平 +无载波 = 低电平 +``` + +题目附件给的是某种原始无线 IQ 采样数据 + +常见 SDR 原始采样格式是: + +``` +I, Q, I, Q, I, Q ... +``` + +用 Python 读取后,将相邻两个字节作为一组 I/Q + +```python +raw = np.fromfile(path, dtype=np.int8) + +I = raw[0::2] +Q = raw[1::2] +``` + +为了把 OOK 信号还原成高低电平,需要计算幅度 + +```python +# mag2 大时,表示有载波,即高电平;当 mag2 接近 0 时,表示无载波,即低电平 +mag2 = I * I + Q * Q +``` + +对幅度做一个简单平滑,然后设置阈值 + +```python +env = np.convolve(mag2, np.ones(100) / 100, mode="same") +level = env > 1000 +``` + +这样可以得到类似逻辑分析仪中的高低电平序列 + +``` +高电平 低电平 高电平 低电平 ... +``` + +本题中观察到的编码方式有点不同 + +``` +1 = 长高电平 + 短低电平 +0 = 短高电平 + 长低电平 +``` + +也就是 + +``` +bit 1: HHH + L +bit 0: H + LLL +``` + +从信号中截取一帧,统计高低电平长度后,可以得到类似下面的脉冲序列 + +``` +高 2324 低 853 +高 728 低 2416 +高 2329 低 854 +高 727 低 2416 +高 2329 低 854 +高 726 低 2414 +高 2331 低 861 +高 2326 低 857 +高 724 低 2417 +高 721 低 2414 +高 2331 低 860 +高 721 低 2415 +高 2330 低 857 +高 2330 低 859 +高 722 低 2413 +高 725 低 2412 +高 2333 低 859 +高 722 低 2411 +高 728 低 2415 +高 723 低 2420 +高 719 低 2413 +高 724 低 2414 +高 725 低 2412 +高 2333 低 857 +``` + +按规则解码得到 24 位二进制 + +``` +101010110010110010000001 +``` + +转 ASCII 码是 AB2C81 + +所以 flag 就是 `flag{ab2c81}` + +```python +import numpy as np + +path = "题目附件" + +# 1. 按 signed int8 IQ 读取 +raw = np.fromfile(path, dtype=np.int8) + +I = raw[0::2].astype(np.int16) +Q = raw[1::2].astype(np.int16) + +# 2. 计算幅度平方,OOK 中幅度高表示有载波 +mag2 = I * I + Q * Q + +# 3. 平滑包络 +win = 100 +env = np.convolve(mag2, np.ones(win) / win, mode="same") + +# 4. 二值化 +level = env > 1000 + +# 5. 统计高低电平 run length +runs = [] +cur = level[0] +start = 0 + +for i, v in enumerate(level[1:], 1): + if v != cur: + runs.append((int(cur), start, i - start)) + cur = v + start = i + +runs.append((int(cur), start, len(level) - start)) + +# 过滤掉很短的噪声毛刺 +runs = [(v, s, l) for v, s, l in runs if l > 300] + + +def classify_len(l): + if 500 <= l <= 1100: + return "S" # short + if 1900 <= l <= 2800: + return "L" # long + return "?" + + +frames = [] + +# EM1527 一帧 24 bit,每 bit 由 高电平 + 低电平 组成 +for i in range(len(runs) - 48): + if runs[i][0] != 1: + continue + + bits = [] + ok = True + + for k in range(24): + vh, sh, lh = runs[i + 2 * k] + vl, sl, ll = runs[i + 2 * k + 1] + + if vh != 1 or vl != 0: + ok = False + break + + h = classify_len(lh) + l = classify_len(ll) + + if h == "L" and l == "S": + bits.append("1") + elif h == "S" and l == "L": + bits.append("0") + else: + ok = False + break + + if ok: + bitstr = "".join(bits) + code = f"{int(bitstr, 2):06X}" + frames.append((runs[i][1], bitstr, code)) + +# 去重输出 +seen = set() + +for start, bitstr, code in frames: + if code in seen: + continue + + seen.add(code) + + print("[+] bits:", bitstr) + print("[+] code:", code) + print("[+] flag:", f"flag{{{code.lower()}}}") +``` diff --git "a/docs/hc-ics/COTP \345\215\217\350\256\256.md" "b/docs/hc-ics/COTP \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..2d96e88fef --- /dev/null +++ "b/docs/hc-ics/COTP \345\215\217\350\256\256.md" @@ -0,0 +1,160 @@ +--- +comments: true + +--- + +# COTP 协议 + +## 概述 + +COTP 是 ISO OSI 七层模型中的传输层协议,其正式标准为 ISO 8073,对应的 RFC 文档是 RFC 905。你可以把它理解为 OSI 世界里的 "TCP" + +## 报文结构 + +### 连接包 + +用于建立、维护和断开连接。其头部结构通常如下: + +- Length (1 字节):后续数据的长度 + +- PDU Type (1 字节):数据包类型,常见的有: + + - `0x0E`:连接请求 (CR) + + - `0x0D`:连接确认 (CC) + + - `0x08`:断开请求 (DR) + + - `0x0C`:断开确认 (DC) + +- Destination/Source Reference (各 2 字节):目的和源引用 + +- Options (1 字节):包含扩展格式、流控等标志位 + +- Parameters (可变长):包含关键参数,如 TSAP、TPDU Size(传输数据大小)等 + +### 功能包 + +连接建立后,用于数据传输和控制。主要包括: + +- 数据传输 (DT):实际发送用户数据 + +- 控制功能:如流量控制、数据确认(AK)、错误报告(ER)等 + +## 工作流程 + +COTP 在西门子 S7 通信中扮演关键角色。一次典型的通信流程如下: + +- TCP 三次握手:首先在计算机和 PLC 之间建立 TCP 连接,默认端口为 102 + +- COTP 连接请求 (CR):客户端发送 COTP CR 包,其中包含目的 TSAP,以明确要连接的具体 CPU + +- COTP 连接确认 (CC):服务端回复 COTP CC 包,确认连接建立 + +- 上层协议交互:COTP 连接建立后,上层协议(如 S7COMM)开始进行应用层数据交换 + +## 实战案例 + +### S7Comm 攻击协议分析(COTP 协议) + +![](https://pic1.imgdb.cn/item/6a339e2291b65c4475ab6915.png) + +流量包中有两个 IP,其中 `192.168.1.188` 的功能码全是 `0x00` CPU 服务,所以可以直接排除 + +![](https://pic1.imgdb.cn/item/6a339e0791b65c4475ab6914.png) + +但是 `192.168.1.33` 的功能码全是 `0xf0` 建立连接的,它为什么要一直建立连接呢? + +![](https://pic1.imgdb.cn/item/6a339e8a91b65c4475ab6923.png) + +这题考点不是普通 S7comm 的 Read/Write Var,而是 COTP 连接请求里的 TSAP 字段 + +它在工业自动化(如西门子 S7 通信)和一些传统 OSI 网络中很常见,你可以把它理解成一个 "加强版"的 TCP + +使用 `cotp && ip.src == 192.168.1.33` 过滤一下,分析第一个请求包 + +![](https://pic1.imgdb.cn/item/6a3396e591b65c4475ab323b.png) + +简单分析如下: + +- 包类型与目的 + + - PDU 类型为 0x0e(CR):这是一个主动发起连接建立请求的报文,相当于 TCP 的 SYN 包 + +- 连接标识(引用) + + - Source reference(源引用)为 0x002f(47):发起方生成的本地连接标识,用于唯一标记这条会话 + + - Destination reference(目标引用)为 0x0000:因为尚未建立连接,所以目标引用填 0 + +- 协议等级(Class) + + - Class 为 0:这是最基础的传输层等级,不支持错误恢复和复用,只提供简单可靠的传输服务。这在工业控制等确定性场景中很常见 + +- 关键参数(TSAP 地址) + + - 源 TSAP(0xc1):0x0100 —— 代表发起方(如工程师站/上位机)的访问点地址 + + - 目标 TSAP(0xc2):0x0102 —— 代表接收方(如 PLC)的访问点地址 + + - 含义:TSAP 相当于应用层的 "门牌号" 或端口扩展。在西门子 S7 协议栈中,0x0102 常对应 PLC 的编程/HMI 通信入口,0x0100 常对应 PC 端。这明确指明了双方的应用实体 + +- 传输能力协商 + + - TPDU size(0xc0)为 1024:表示发送方建议在这个连接上传输的数据块(TPDU)最大长度为 1024 字节。接收方若接受,后续通信将以此值(或协商后的较小值)为准 + +往下看发现这里连续两次发送请求 + +![](https://pic1.imgdb.cn/item/6a339f7391b65c4475ab695e.png) + +对比如下: + +- 上一个包:目标 TSAP 是 0102 + +- 当前这个包:目标 TSAP 是 0205 + +符合题目描述的 "对设备进行了修改设备参数操作" + +![](https://pic1.imgdb.cn/item/6a33a0d091b65c4475ab6a85.png) + +右键追踪 TCP 流拿原始数据 + +![](https://pic1.imgdb.cn/item/6a33a00691b65c4475ab6977.png) + +所以 flag 就是 `flag{11e00205}` + +### COTP(COTP 协议) + +![](https://pic1.imgdb.cn/item/6a3584f571d0c6ff6148384e.png) + +一眼过去全是 COTP 协议,过滤下 + +![](https://pic1.imgdb.cn/item/6a3584eb71d0c6ff6148384d.png) + +COTP 协议默认端口是 102,所以 `192.168.99.22` 是工控设备 + +![](https://pic1.imgdb.cn/item/6a35872471d0c6ff61483874.png) + +再新增 `ip.src == 192.168.99.22` 过滤下,查看工控响应的内容有没有什么异常 + +![](https://pic1.imgdb.cn/item/6a3587bb71d0c6ff6148387b.png) + +第 217 个包长度突然不一样,是之前的两倍 + +![](https://pic1.imgdb.cn/item/6a3587ea71d0c6ff6148387d.png) + +Payload 中还能看到明显的设备信息字符串 + +``` +6ES7 211-1BE51-0XB0 ;V3.0 +``` + +这说明攻击者/扫描方正在读取设备型号、版本等信息 + +因此,这个响应包就是题目要找的 "遭到攻击的数据包" + +最后的 flag 如下 + +``` +flag{31312D31424535312D30584230203B56332E308240001505323B32383882410003000300A20000000072010000} +``` diff --git "a/docs/hc-ics/IC \345\215\241.md" "b/docs/hc-ics/IC \345\215\241.md" new file mode 100644 index 0000000000..4a4f1dfcdf --- /dev/null +++ "b/docs/hc-ics/IC \345\215\241.md" @@ -0,0 +1,160 @@ +--- +comments: true + +--- + +# MIFARE Classic 1K IC 卡 + +## 概述 + +门禁卡、电梯卡、水卡大量使用它 + +用读卡器 dump 出的文件通常就是一个二进制镜像,大小正好 1KB(1024字节),在十六进制编辑器里就是 1024 个字节,每行 16 字节 + +## 总体结构 + +16 个扇区,扇区号 0~15 + +每个扇区 4 个块,块号 0~3,每块 16 字节(32 个十六进制字符) + +总块数 64,线性排列 + +## 扇区与十六进制地址的对应 + +MIFARE Classic 1K 共 16 个扇区,每个扇区包含 4 个块,每块 16 字节,因此每个扇区占: + +4 × 16 = 64 字节 = 0x40 字节 + +所以扇区 n 的起始偏移为: + +起始地址 = n × 0x40 + +结束地址 = (n + 1) × 0x40 - 1 + +例如: + +- 扇区 0:`000–03F` + +- 扇区 1:`040–07F` + +- 扇区 2:`080–0BF` + +- 扇区 3:`0C0–0FF` + +- 扇区 4:`100–13F` + +- ... + +- 扇区 15:`3C0–3FF` + +整个 MIFARE Classic 1K Dump 的地址范围为:`000–3FF`,共 `0x400 = 1024` 字节 + +## 访问控制字节的十六进制规律 + +以常见门禁卡为例,若块 3 为: + +``` +FF FF FF FF FF FF 78 77 88 C1 FF FF FF FF FF FF +│ │ │ │ +│ │ │ └─ Key B:6 字节 +│ │ └──── GPB / 用户字节:1 字节 +│ └────────────── Access Bits:3 字节 +└──────────────────────────────── Key A:6 字节 +``` + +- 78 77 88 就是访问控制位(以及它的非值,实际存储是取反备份) + +- C1 是用户字节,在不同应用中意义不同 + +- 从这些十六进制就能判断每个数据块是用密钥 A 还是密钥 B 读写,以及控制块自身能否更改 + +这种格式是分析卡片权限的基础 + +## 实战案例 + +### IC 卡分析(MIFARE Classic 1K IC 卡) + +![](https://pic1.imgdb.cn/item/6a36cfc32830ce602a508bb3.png) + +两个文件只有 sector 2 的两个 Value Block 不同 + +``` +文件 1 block 9: +ce 63 00 00 31 9c ff ff ce 63 00 00 01 fe 01 fe + +文件 2 block 9: +78 69 00 00 87 96 ff ff 78 69 00 00 01 fe 01 fe +``` + +这题的关键是通过金额块发现 IC 卡 Value Block 的存储规律 + +``` +[ value ][ ~value ][ value ][ addr ][ ~addr ][ addr ][ ~addr ] +``` + +其中金额是小端序 + +``` +0x000063ce = 25550 +0x00006978 = 27000 +``` + +金额差是 `27000 - 25550 = 1450` + +更重要的是可以看出加密/校验规律 + +``` +ce 63 00 00 -> 31 9c ff ff +78 69 00 00 -> 87 96 ff ff +``` + +也就是 + +``` +后 4 字节 = 前 4 字节按位取反 +``` + +后面有多组伪造的 Value Block,结构类似 + +``` +7b 00 00 00 a6 ff ff ff 30 00 00 00 01 fe 01 fe +75 00 00 00 a0 ff ff ff 34 00 00 00 03 fe 03 fe +72 00 00 00 9a ff ff ff 5f 00 00 00 05 fe 05 fe +31 00 00 00 96 ff ff ff 6b 00 00 00 07 fe 07 fe +65 00 00 00 a0 ff ff ff 6d 00 00 00 09 fe 09 fe +79 00 00 00 a0 ff ff ff 73 00 00 00 a1 fe a1 fe +69 00 00 00 8c ff ff ff 74 00 00 00 a2 fe a3 fe +65 00 00 00 8d ff ff ff 7d 00 00 00 a5 fe a5 fe +``` + +每组取 3 个位置 + +``` +第 1 字节:明文 +第 5 字节:取反后得到明文 +第 9 字节:明文 +``` + +```python +from pathlib import Path + +data = Path("1").read_bytes() + +# 每组隐藏信息所在的 block +blocks = [17, 21, 25, 29, 33, 37, 41, 45] + +res = "" + +for b in blocks: + blk = data[b * 16:(b + 1) * 16] + + c1 = blk[0] + c2 = (~blk[4]) & 0xff + c3 = blk[8] + + res += chr(c1) + chr(c2) + chr(c3) + +print("flag" + res) +``` + +得到 `flag{Y0u_4re_1ike_my_sister}` diff --git "a/docs/hc-ics/JT T 808 \345\215\217\350\256\256.md" "b/docs/hc-ics/JT T 808 \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..089b9b7f1d --- /dev/null +++ "b/docs/hc-ics/JT T 808 \345\215\217\350\256\256.md" @@ -0,0 +1,843 @@ +--- +comments: true + +--- + +# JT/T 808 协议 + +## 概述 + +JT/T 808 是交通运输行业标准《道路运输车辆卫星定位系统 北斗兼容车载终端通讯协议》,规定了车载终端与监控平台之间的数据通信格式 + +协议基于 TCP 或 UDP,采用大端字节序,报文整体以 `0x7E` 为帧边界,内部需经过转义处理 + +## 报文封装格式 + +每个完整报文由标识位包裹,内部包含消息头、消息体和校验码 + +``` ++--------+--------+----------+--------+--------+ +| 标识位 | 消息头 | 消息体 | 校验码 | 标识位 | +| 0x7e | 变长 | 变长 | 1 byte | 0x7e | ++--------+--------+----------+--------+--------+ +``` + +- 标识位:固定 `0x7E`,标识一帧的开始和结束 + +- 消息头:固定 12 字节(无分包)或 16 字节(有分包),包含消息 ID、属性、终端手机号、流水号等 + +- 消息体:长度由消息头中的 "消息体长度" 字段决定,内容取决于消息 ID + +- 校验码:从消息头第一个字节到消息体最后一个字节,纯异或 + +## 消息头结构 + +消息头结构如下: + +``` +Offset: 0 2 4 10 12 + +------+-------+--------+--------+ + | 消息ID | 消息体属性 | 终端手机号 | 消息流水号 | + +------+-------+--------+--------+ + u16 u16 u48 u16 +``` + +### 消息 ID(2 字节,u16) + +标识消息类型,如: + +- `0x0001`:终端通用应答 + +- `0x0100`:终端注册 + +- `0x0200`:位置信息汇报 + +- `0x8001`:平台通用应答 +(高位可用于区分终端/平台消息) + +### 消息体属性(2 字节,u16) + +JT/T 808—2019 中消息体属性按如下位域解析: + +Bit: 15 14 13 12 10 9 0 + +--------+--------+--------+------------+----------------+ + | 保留 |版本标识 | 分包标志 | 数据加密方式 | 消息体长度 | + +--------+--------+--------+------------+----------------+ + +- bit 15:保留 + +- bit 14:协议版本标识 + + - 0:未启用 2019 版版本标识机制,按旧版消息头格式解析; + + - 1:启用版本标识机制,消息头中增加 1 字节协议版本号, + + - 且终端手机号字段使用 BCD[10] + +- bit 13:分包标志 + + - 0:不分包; + + - 1:长消息分包,消息头后增加“消息总包数 + 包序号”共 4 字节 + +- bit 12~10:数据加密方式 + + - 000:消息体不加密; + + - bit10 = 1:RSA 加密; + + - 其他值保留 + +- bit 9~0:消息体长度,范围 0~1023 字节 + +### 终端手机号(6 字节,u48) + +BCD 码格式,长度 6 字节,存储 12 位手机号(不足高位补 0)。例如 013800123456 编码为 `0x01 0x38 0x00 0x12 0x34 0x56` + +### 消息流水号(2 字节,u16) + +由发送方递增,从 0 开始循环,用于匹配应答 + +通用解题脚本 + +```python +import argparse +import json +import os +import re +import socket +import struct +from collections import defaultdict + +MSG_NAMES = { + 0x0001: "终端通用应答", + 0x0002: "终端心跳", + 0x0003: "终端注销", + 0x0100: "终端注册", + 0x0102: "终端鉴权", + 0x0104: "查询终端参数应答", + 0x0107: "查询终端属性应答", + 0x0200: "位置信息汇报", + 0x0201: "位置信息查询应答", + 0x0301: "事件报告", + 0x0302: "提问应答", + 0x0500: "车辆控制应答", + 0x0700: "行驶记录数据上传", + 0x0701: "电子运单上报", + 0x0702: "驾驶员身份信息采集上报", + 0x0704: "定位数据批量上传", + 0x0800: "多媒体事件信息上传", + 0x0801: "多媒体数据上传", + 0x0900: "数据上行透传", + 0x0A00: "终端RSA公钥", + 0x8001: "平台通用应答", + 0x8100: "终端注册应答", + 0x8103: "设置终端参数", + 0x8104: "查询终端参数", + 0x8105: "终端控制", + 0x8201: "位置信息查询", + 0x8202: "临时位置跟踪控制", + 0x8300: "文本信息下发", + 0x8400: "电话回拨", + 0x8500: "车辆控制", + 0x8600: "设置圆形区域", + 0x8602: "设置矩形区域", + 0x8604: "设置多边形区域", + 0x8606: "设置路线", + 0x8801: "摄像头立即拍摄命令", + 0x8900: "数据下行透传", +} + +RESULT_NAMES = { + 0: "成功/确认", + 1: "失败", + 2: "消息有误", + 3: "不支持", + 4: "报警处理确认", +} + +def hx(b): + return b.hex() + +def bcd_to_str(b): + out = [] + for x in b: + hi, lo = (x >> 4) & 0xF, x & 0xF + out.append(str(hi) if hi <= 9 else "?") + out.append(str(lo) if lo <= 9 else "?") + return "".join(out) + +def bcd_time_yyMMddhhmmss(b): + s = bcd_to_str(b) + if len(s) != 12 or "?" in s: + return s + yy = int(s[:2]) + # JT808 BCD timestamp usually means 20yy in modern traffic. + return "20%s-%s-%s %s:%s:%s" % (s[0:2], s[2:4], s[4:6], s[6:8], s[8:10], s[10:12]) + +def try_decode(data): + """Return the most readable text plus all candidates.""" + if not data: + return "", {} + encs = ["utf-8", "gbk", "gb18030", "utf-16-be", "utf-16-le", "ascii"] + candidates = {} + for enc in encs: + try: + text = data.decode(enc).rstrip("\x00") + if text: + candidates[enc] = text + except Exception: + pass + if not candidates: + return "", {} + # Prefer printable and meaningful text. CTF hidden strings often use UTF-16BE. + def score(item): + enc, text = item + printable = sum(1 for c in text if c.isprintable()) + ascii_letters = sum(1 for c in text if 32 <= ord(c) <= 126) + chinese = sum(1 for c in text if "\u4e00" <= c <= "\u9fff") + penalty = text.count("\ufffd") * 10 + text.count("\x00") * 5 + bonus = 5 if enc in ("gbk", "gb18030", "utf-8") else 0 + return printable + ascii_letters + chinese + bonus - penalty + best_enc, best_text = max(candidates.items(), key=score) + return best_text, candidates + +def jt808_unescape(frame): + """Input includes 0x7e delimiters. Output excludes delimiters and is unescaped.""" + if len(frame) >= 2 and frame[0] == 0x7E and frame[-1] == 0x7E: + data = frame[1:-1] + else: + data = frame + out = bytearray() + i = 0 + while i < len(data): + if data[i] == 0x7D and i + 1 < len(data): + if data[i + 1] == 0x02: + out.append(0x7E) + i += 2 + continue + if data[i + 1] == 0x01: + out.append(0x7D) + i += 2 + continue + out.append(data[i]) + i += 1 + return bytes(out) + +def jt808_checksum(data_without_delimiters_and_unescaped_without_checksum): + c = 0 + for x in data_without_delimiters_and_unescaped_without_checksum: + c ^= x + return c + +def extract_frames_from_bytes(buf): + """Extract delimiter-based frames from a byte stream.""" + frames = [] + start = None + for i, b in enumerate(buf): + if b == 0x7E: + if start is not None and i > start: + frame = buf[start:i+1] + # Minimal header after unescape: msg_id 2 + prop 2 + terminal 6 + seq 2 + checksum 1 + if len(frame) >= 2 + 2 + 2 + 6 + 2 + 1: + frames.append(frame) + start = i + return frames + +def parse_pcap_classic(path): + """ + Return list of source dictionaries: + {kind: 'tcp_stream'/'udp_packet', desc: ..., payload: bytes} + Supports classic PCAP with Ethernet/IPv4/TCP/UDP. + """ + sources = [] + with open(path, "rb") as f: + gh = f.read(24) + if len(gh) < 24: + return sources + + magic = gh[:4] + if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): + endian = "<" + elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): + endian = ">" + else: + return sources + + _magic, vmaj, vmin, tz, sig, snaplen, linktype = struct.unpack(endian + "IHHIIII", gh) + + tcp_streams = defaultdict(bytearray) + udp_payloads = [] + + while True: + ph = f.read(16) + if len(ph) < 16: + break + ts_sec, ts_frac, incl_len, orig_len = struct.unpack(endian + "IIII", ph) + pkt = f.read(incl_len) + if len(pkt) < 14: + continue + + # Ethernet only. Other linktypes are still covered by raw fallback. + if linktype != 1: + continue + + off = 12 + eth_type = struct.unpack("!H", pkt[off:off+2])[0] + l3off = 14 + # VLAN tag(s) + while eth_type in (0x8100, 0x88A8, 0x9100) and len(pkt) >= l3off + 4: + eth_type = struct.unpack("!H", pkt[l3off+2:l3off+4])[0] + l3off += 4 + + if eth_type != 0x0800 or len(pkt) < l3off + 20: + continue + + ip = pkt[l3off:] + ihl = (ip[0] & 0x0F) * 4 + if len(ip) < ihl or ihl < 20: + continue + + proto = ip[9] + src = socket.inet_ntoa(ip[12:16]) + dst = socket.inet_ntoa(ip[16:20]) + total_len = struct.unpack("!H", ip[2:4])[0] + ip_payload = ip[ihl:total_len] if total_len >= ihl else ip[ihl:] + + if proto == 6 and len(ip_payload) >= 20: + tcp = ip_payload + sport, dport = struct.unpack("!HH", tcp[:4]) + data_offset = (tcp[12] >> 4) * 4 + if len(tcp) < data_offset: + continue + payload = tcp[data_offset:] + if payload: + key = (src, sport, dst, dport, "TCP") + tcp_streams[key].extend(payload) + elif proto == 17 and len(ip_payload) >= 8: + udp = ip_payload + sport, dport, ulen, _ = struct.unpack("!HHHH", udp[:8]) + payload = udp[8:ulen] if ulen >= 8 else udp[8:] + if payload: + udp_payloads.append({ + "kind": "udp_packet", + "desc": "%s:%d -> %s:%d UDP" % (src, sport, dst, dport), + "payload": payload + }) + + for key, payload in tcp_streams.items(): + src, sport, dst, dport, proto = key + sources.append({ + "kind": "tcp_stream", + "desc": "%s:%d -> %s:%d %s" % (src, sport, dst, dport, proto), + "payload": bytes(payload) + }) + sources.extend(udp_payloads) + return sources + +def parse_with_scapy(path): + """Optional PCAP/PCAPNG reader. Returns [] if Scapy is unavailable.""" + try: + from scapy.all import rdpcap, TCP, UDP, IP, IPv6, Raw + except Exception: + return [] + + sources = [] + tcp_streams = defaultdict(bytearray) + + packets = rdpcap(path) + for pkt in packets: + if Raw not in pkt: + continue + payload = bytes(pkt[Raw].load) + if not payload: + continue + + src = dst = "?" + if IP in pkt: + src, dst = pkt[IP].src, pkt[IP].dst + elif IPv6 in pkt: + src, dst = pkt[IPv6].src, pkt[IPv6].dst + + if TCP in pkt: + sport, dport = int(pkt[TCP].sport), int(pkt[TCP].dport) + key = (src, sport, dst, dport, "TCP") + tcp_streams[key].extend(payload) + elif UDP in pkt: + sport, dport = int(pkt[UDP].sport), int(pkt[UDP].dport) + sources.append({ + "kind": "udp_packet", + "desc": "%s:%d -> %s:%d UDP" % (src, sport, dst, dport), + "payload": payload + }) + + for key, payload in tcp_streams.items(): + src, sport, dst, dport, proto = key + sources.append({ + "kind": "tcp_stream", + "desc": "%s:%d -> %s:%d %s" % (src, sport, dst, dport, proto), + "payload": bytes(payload) + }) + return sources + +def get_sources(path, force_raw=False): + sources = [] + if not force_raw: + sources = parse_with_scapy(path) + if not sources: + sources = parse_pcap_classic(path) + + # Raw fallback: useful for pcapng, unknown linktype, or files containing only hex-ish binary. + with open(path, "rb") as f: + raw = f.read() + sources.append({ + "kind": "raw_scan", + "desc": "raw whole-file scan", + "payload": raw + }) + return sources + +def parse_extra_items(extra): + items = [] + i = 0 + while i + 2 <= len(extra): + item_id = extra[i] + item_len = extra[i + 1] + i += 2 + value = extra[i:i + item_len] + i += item_len + item = { + "id": "0x%02X" % item_id, + "len": item_len, + "raw": hx(value) + } + try: + if item_id == 0x01 and len(value) == 4: + item["name"] = "里程" + item["value"] = struct.unpack("!I", value)[0] / 10 + item["unit"] = "km" + elif item_id == 0x02 and len(value) == 2: + item["name"] = "油量" + item["value"] = struct.unpack("!H", value)[0] / 10 + item["unit"] = "L" + elif item_id == 0x03 and len(value) == 2: + item["name"] = "行驶记录速度" + item["value"] = struct.unpack("!H", value)[0] / 10 + item["unit"] = "km/h" + elif item_id == 0x04 and len(value) == 2: + item["name"] = "需要人工确认报警事件ID" + item["value"] = struct.unpack("!H", value)[0] + elif item_id == 0x30 and len(value) == 1: + item["name"] = "无线通信网络信号强度" + item["value"] = value[0] + elif item_id == 0x31 and len(value) == 1: + item["name"] = "GNSS定位卫星数" + item["value"] = value[0] + else: + item["name"] = "未知/扩展项" + except Exception: + item["name"] = "解析失败" + items.append(item) + return items + +def parse_location_body(body, offset=0): + if len(body) - offset < 28: + return {"error": "location body too short", "raw": hx(body[offset:])} + + p = offset + alarm = struct.unpack("!I", body[p:p+4])[0]; p += 4 + status = struct.unpack("!I", body[p:p+4])[0]; p += 4 + lat_raw = struct.unpack("!I", body[p:p+4])[0]; p += 4 + lon_raw = struct.unpack("!I", body[p:p+4])[0]; p += 4 + altitude = struct.unpack("!H", body[p:p+2])[0]; p += 2 + speed = struct.unpack("!H", body[p:p+2])[0] / 10; p += 2 + direction = struct.unpack("!H", body[p:p+2])[0]; p += 2 + tm = bcd_time_yyMMddhhmmss(body[p:p+6]); p += 6 + + # Sign bits are in status word in many implementations: + # bit2 = south latitude, bit3 = west longitude. + lat = lat_raw / 1_000_000 + lon = lon_raw / 1_000_000 + if status & (1 << 2): + lat = -lat + if status & (1 << 3): + lon = -lon + + return { + "alarm_flag": "0x%08X" % alarm, + "status": "0x%08X" % status, + "latitude": lat, + "longitude": lon, + "altitude_m": altitude, + "speed_kmh": speed, + "direction": direction, + "time": tm, + "extra_items": parse_extra_items(body[p:]), + "parsed_len": p + len(body[p:]) + } + +def parse_0702_driver(body, versioned=False): + res = {"raw": hx(body)} + if len(body) < 7: + res["error"] = "body too short" + return res + + p = 0 + status = body[p]; p += 1 + res["status"] = status + res["status_desc"] = {0x01: "从业资格证IC卡插入", 0x02: "从业资格证IC卡拔出"}.get(status, "未知") + res["time"] = bcd_time_yyMMddhhmmss(body[p:p+6]); p += 6 + + if p >= len(body): + return res + + # Usually exists when status == 0x01. + read_result = body[p]; p += 1 + res["ic_card_read_result"] = read_result + res["ic_card_read_result_desc"] = {0: "成功", 1: "卡片密钥认证未通过", 2: "卡片已被锁定", 3: "卡片被拔出", 4: "数据校验错误"}.get(read_result, "未知/厂商扩展") + + if p >= len(body): + return res + + name_len = body[p]; p += 1 + res["driver_name_len"] = name_len + + if p + name_len <= len(body): + name_bytes = body[p:p+name_len] + p += name_len + name, candidates = try_decode(name_bytes) + res["driver_name"] = name + res["driver_name_raw"] = hx(name_bytes) + res["driver_name_decode_candidates"] = candidates + else: + res["warning"] = "name_len exceeds remaining body" + name_bytes = body[p:] + name, candidates = try_decode(name_bytes) + res["driver_name"] = name + res["driver_name_raw"] = hx(name_bytes) + res["driver_name_decode_candidates"] = candidates + return res + + remaining = body[p:] + res["remaining_hex"] = hx(remaining) + + # Try to locate ID card-like strings from remaining bytes. + ascii_text = "" + try: + ascii_text = remaining.decode("ascii", errors="ignore") + except Exception: + pass + ids = re.findall(r"[0-9]{15,18}[0-9Xx]?", ascii_text) + if ids: + res["possible_id_cards"] = ids + + # Some versions/implementations store a fixed 20-byte ID card field. + if len(remaining) >= 20: + fixed20 = remaining[:20].rstrip(b"\x00") + try: + fixed20_text = fixed20.decode("ascii") + if fixed20_text: + res["id_card_fixed20_guess"] = fixed20_text + except Exception: + pass + + # Keep the rest generic because 2013/2019/vendor fields vary. + return res + +def parse_register_0100(body): + res = {"raw": hx(body)} + if len(body) < 37: + res["warning"] = "body shorter than common 2013 register format" + return res + p = 0 + res["province_id"] = struct.unpack("!H", body[p:p+2])[0]; p += 2 + res["city_id"] = struct.unpack("!H", body[p:p+2])[0]; p += 2 + res["manufacturer_id"] = try_decode(body[p:p+5])[0] or hx(body[p:p+5]); p += 5 + res["terminal_model"] = try_decode(body[p:p+20])[0] or hx(body[p:p+20]); p += 20 + res["terminal_id"] = try_decode(body[p:p+7])[0] or hx(body[p:p+7]); p += 7 + res["plate_color"] = body[p]; p += 1 + plate, cand = try_decode(body[p:]) + res["plate_no"] = plate + res["plate_decode_candidates"] = cand + return res + +def parse_auth_0102(body): + text, candidates = try_decode(body) + return {"auth_code": text, "decode_candidates": candidates, "raw": hx(body)} + +def parse_common_response(body): + if len(body) < 5: + return {"raw": hx(body), "error": "body too short"} + serial = struct.unpack("!H", body[0:2])[0] + msg_id = struct.unpack("!H", body[2:4])[0] + result = body[4] + return { + "response_serial": serial, + "response_msg_id": "0x%04X" % msg_id, + "response_msg_name": MSG_NAMES.get(msg_id, "未知"), + "result": result, + "result_desc": RESULT_NAMES.get(result, "未知"), + "raw": hx(body) + } + +def parse_0704_batch_location(body): + res = {"raw": hx(body)} + if len(body) < 3: + res["error"] = "body too short" + return res + count = struct.unpack("!H", body[0:2])[0] + batch_type = body[2] + p = 3 + res["count"] = count + res["batch_type"] = batch_type + res["locations"] = [] + for idx in range(count): + if p + 2 > len(body): + res["warning"] = "truncated before item %d length" % idx + break + item_len = struct.unpack("!H", body[p:p+2])[0] + p += 2 + item_body = body[p:p+item_len] + p += item_len + item = parse_location_body(item_body) + item["item_len"] = item_len + res["locations"].append(item) + return res + +def parse_body(msg_id, body, versioned=False): + if msg_id in (0x0001, 0x8001): + return parse_common_response(body) + if msg_id == 0x0100: + return parse_register_0100(body) + if msg_id == 0x0102: + return parse_auth_0102(body) + if msg_id == 0x0200: + return parse_location_body(body) + if msg_id == 0x0201: + if len(body) >= 2: + return { + "response_serial": struct.unpack("!H", body[:2])[0], + "location": parse_location_body(body, 2), + "raw": hx(body) + } + return {"raw": hx(body), "error": "body too short"} + if msg_id == 0x0702: + return parse_0702_driver(body, versioned=versioned) + if msg_id == 0x0704: + return parse_0704_batch_location(body) + + text, candidates = try_decode(body) + return { + "raw": hx(body), + "text_guess": text, + "decode_candidates": candidates + } + +def parse_jt808_frame(frame, source_desc="", index=0): + raw = jt808_unescape(frame) + result = { + "index": index, + "source": source_desc, + "frame_hex": hx(frame), + "unescaped_hex": hx(raw), + } + + if len(raw) < 2 + 2 + 6 + 2 + 1: + result["error"] = "frame too short" + return result + + checksum_expected = raw[-1] + checksum_calc = jt808_checksum(raw[:-1]) + result["checksum"] = "0x%02X" % checksum_expected + result["checksum_calc"] = "0x%02X" % checksum_calc + result["checksum_ok"] = checksum_expected == checksum_calc + + data = raw[:-1] + p = 0 + msg_id = struct.unpack("!H", data[p:p+2])[0]; p += 2 + prop = struct.unpack("!H", data[p:p+2])[0]; p += 2 + + body_len = prop & 0x03FF + encryption = (prop >> 10) & 0x07 + subpackage = bool(prop & 0x2000) + versioned = bool(prop & 0x4000) + + result.update({ + "msg_id": "0x%04X" % msg_id, + "msg_name": MSG_NAMES.get(msg_id, "未知消息"), + "property": "0x%04X" % prop, + "body_len_declared": body_len, + "encryption_flag": encryption, + "subpackage": subpackage, + "versioned_2019": versioned, + }) + + if versioned: + if len(data) < p + 1 + 10 + 2: + result["error"] = "2019 header too short" + return result + version = data[p]; p += 1 + terminal_raw = data[p:p+10]; p += 10 + result["protocol_version"] = "0x%02X" % version + result["terminal_id_raw"] = hx(terminal_raw) + result["terminal_id_bcd"] = bcd_to_str(terminal_raw) + else: + if len(data) < p + 6 + 2: + result["error"] = "legacy header too short" + return result + terminal_raw = data[p:p+6]; p += 6 + result["terminal_id_raw"] = hx(terminal_raw) + result["terminal_id_bcd"] = bcd_to_str(terminal_raw) + + serial = struct.unpack("!H", data[p:p+2])[0]; p += 2 + result["serial"] = serial + + if subpackage: + if len(data) < p + 4: + result["error"] = "subpackage header too short" + return result + total = struct.unpack("!H", data[p:p+2])[0] + seq = struct.unpack("!H", data[p+2:p+4])[0] + p += 4 + result["package_total"] = total + result["package_index"] = seq + + body = data[p:] + result["body_len_actual"] = len(body) + if len(body) != body_len: + result["body_len_warning"] = "declared %d, actual %d" % (body_len, len(body)) + + result["body"] = parse_body(msg_id, body, versioned=versioned) + return result + +def collect_frames(path, force_raw=False): + sources = get_sources(path, force_raw=force_raw) + seen = set() + parsed = [] + idx = 1 + for src in sources: + frames = extract_frames_from_bytes(src["payload"]) + for frame in frames: + # De-duplicate because raw_scan will usually rediscover frames from parsed streams. + fingerprint = frame + if fingerprint in seen: + continue + seen.add(fingerprint) + item = parse_jt808_frame(frame, source_desc=src["desc"], index=idx) + parsed.append(item) + idx += 1 + return parsed + +def print_human(items, only_msg_id=None, show_raw=False): + for it in items: + if only_msg_id: + want = int(only_msg_id, 16) if isinstance(only_msg_id, str) else int(only_msg_id) + got = int(it.get("msg_id", "0xFFFF"), 16) if "msg_id" in it else None + if got != want: + continue + + print("=" * 100) + print("#%s %s %s" % (it.get("index"), it.get("msg_id", ""), it.get("msg_name", ""))) + print("source :", it.get("source")) + print("terminal :", it.get("terminal_id_bcd", it.get("terminal_id_raw", ""))) + print("serial :", it.get("serial")) + print("checksum :", "%s calc=%s ok=%s" % (it.get("checksum"), it.get("checksum_calc"), it.get("checksum_ok"))) + print("versioned :", it.get("versioned_2019"), "version=", it.get("protocol_version", "legacy")) + print("body length :", it.get("body_len_declared"), "actual=", it.get("body_len_actual")) + if "body_len_warning" in it: + print("warning :", it["body_len_warning"]) + + body = it.get("body", {}) + print("body parsed :") + print(json.dumps(body, ensure_ascii=False, indent=2)) + + if show_raw: + print("frame hex :", it.get("frame_hex")) + print("unescaped :", it.get("unescaped_hex")) + +def main(): + ap = argparse.ArgumentParser(description="JT/T 808 PCAP/stream parser") + ap.add_argument("input", help="pcap/pcapng/raw file") + ap.add_argument("-m", "--msg-id", help="only show this message ID, e.g. 0702 or 0x0702") + ap.add_argument("-j", "--json", dest="json_out", help="write parsed result to JSON") + ap.add_argument("--show-raw", action="store_true", help="print raw frame hex") + ap.add_argument("--force-raw", action="store_true", help="skip packet parser and scan whole file only") + args = ap.parse_args() + + only = args.msg_id + if only and not only.lower().startswith("0x"): + only = "0x" + only + + items = collect_frames(args.input, force_raw=args.force_raw) + + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as f: + json.dump(items, f, ensure_ascii=False, indent=2) + print("[+] JSON saved to", args.json_out) + + print("[+] JT/T 808 candidate frames:", len(items)) + print_human(items, only_msg_id=only, show_raw=args.show_raw) + +if __name__ == "__main__": + main() +``` + +## 实战 + +### 司机的身份(JT/T808 车载终端通信协议) + +![pasted_1784030036432_94e7qb.png](https://pic.imgdb.cn/i/033pJtWj7kJ7CaF8OUoVZM.png) + +打开流量包可以发现大量以 `7e` 开头、`7e` 结尾的 JT/T 808 帧 + +![pasted_1784099243714_u78427.png](https://pic.imgdb.cn/i/033pmCWz2arTprt83vIpyy.png) + +在 No.16 流量中,其中关键报文头 + +``` +07 02 40 eb 01 00 00 00 00 01 77 70 64 12 11 00 72 +``` + +解析 + +``` +消息 ID:0x0702 +消息体属性:0x40eb +协议版本:0x01 +终端号:00000000017770641211 +流水号:0x0072 +消息体长度:0x00eb = 235 +``` + +`0x0702` 正好是驾驶员身份信息采集上报,消息体开头为 + +``` +01 状态:IC 卡插入 +20 06 27 09 48 56 时间:2020-06-27 09:48:56 +00 IC 卡读取成功 +b4 驾驶员姓名长度:0xb4 = 180 字节 +``` + +取出这 180 字节发现是 UTF-16BE。每两个字节对应一个汉字 + +``` +8a f8 -> 諸 +96 b8 -> 隸 +50 e7 -> 僧 +96 4d -> 降 +54 3d -> 吽 +``` + +完整取出来得到 + +``` +諸隸僧降吽諸陀摩隸僧缽薩願皤耨皤眾劫阿我皤慧阿亦皤我哆兜降嚤祗愍皤迦闍眾皤陀亦聞宣咤迦劫薩囉愍聞須皤塞迦皤諦亦劫亦皤闍願皤咤修闍薩嚤宣皤我菩菩皤迦聞諦婆亦聞皤阿薩所願如空如如囑皤囑 +``` + +用 "新约佛论禅 / 新佛曰" 解密,得到一串 Base64 + +``` +TVpXR0NaMzNJTlpIUzREWkdBWVhHUlRWSlo2UT09PT0= +``` + +连续 Base 解码得到 `flag{Crypy01sFuN}` diff --git "a/docs/hc-ics/LSIS FEnet \345\215\217\350\256\256.md" "b/docs/hc-ics/LSIS FEnet \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..8b8a47c2e2 --- /dev/null +++ "b/docs/hc-ics/LSIS FEnet \345\215\217\350\256\256.md" @@ -0,0 +1,163 @@ +--- +comments: true + +--- + +# LSIS FEnet 协议 + +LSIS XGB Fast Ethernet 模块(即 FEnet I/F 模块,型号如 XGL-EFMT、XGL-EFMF 等)是 LS 电气(原 LS 产电)PLC 用于高速通信的接口模块。它使用专有的 LSIS FEnet 协议 + +## 封装与传输 + +LSIS FEnet 协议主要运行在以下传输层上: + +| 用途 | 传输层 | 端口 | +|------|--------|------| +| XGT 服务(上位/主站请求) | TCP | 2004 | +| Fast Ethernet 通信(主站直连) | UDP | 2005 | +| DLINK(专有总线) | 数据链路层 | - | + +## 报文结构 + +LSIS FEnet 报文由 20 字节的专有头部 + 具体数据/命令体组成 + +| 偏移 | 长度(字节) | 字段名 | 含义 | +|--------|--------------|--------------|----------------------------------------------| +| `0x00` | 2 | LSIS_Header | 协议标识符,固定为 0x4C53 ("LS") | +| `0x02` | 2 | Reserved1 | 保留(通常为 0x0000) | +| `0x04` | 2 | Command | 命令码(详见下文) | +| `0x06` | 2 | DataType | 数据类型(Bit/Byte/Word) | +| `0x08` | 2 | Reserved2 | 保留/填充(部分版本用作帧长) | +| `0x0A` | 2 | BlockNum | 操作数量(读取/写入的块数) | +| `0x0C` | 2 | FrameLen | 后续数据体的长度 | +| `0x0E` | 2 | ErrorCode | 错误码(响应时有效,请求为 0) | +| `0x10` | 2 | SourceCPU | 源 CPU 编号/槽位 | +| `0x12` | 2 | DestCPU | 目标 CPU 编号/槽位 | +| `0x14` | N | Data | 数据负载(具体取决于命令) | + +## 核心字段 + +### 命令码 + +命令码决定了此报文是读还是写,以及操作的对象类型。常见值如下: + +| 码值 | 功能 | 方向 | +|--------|--------------------|-------| +| `0x0000` | 连接请求/心跳 | C→S | +| `0x0011` | 直接变量读 | C→S | +| `0x0014` | 直接变量写 | C→S | +| `0x0018` | 连续地址读 | C→S | +| `0x0019` | 连续地址写 | C→S | +| `0x00A0` | 响应(带数据) | S→C | +| `0x00C1` | 连接确认 | S→C | + +### 数据类型 + +| 值 | 含义 | 大小 | +|----------|----------|-----------| +| `0x0001` | Bit(位) | 1 bit | +| `0x0003` | Byte | 1 字节 | +| `0x0004` | Word | 2 字节 | +| `0x0006` | DWord | 4 字节 | +| `0x0007` | LWord | 8 字节 | + +### 错误码 + +仅在响应报文中有意义。`0x0000` 表示成功。常见错误码如 `0x0F04`(地址越界)等 + +## 数据负载结构(读取/写入) + +如果命令是读写连续地址(`0x0018`/`0x0019`),数据负载结构如下 + +请求读取: + +``` +[起始地址] (4 bytes, 地址格式取决于 PLC 内存区域) +[读取长度] (2 bytes, 要读的字数/位数) +``` + +请求写入: + +``` +[起始地址] (4 bytes) +[写入长度] (2 bytes) +[写入数据] (N bytes, 按 DataType 打包) +``` + +响应: + +``` +[数据字节数] (1 byte, 表示后续数据的字节长) +[结果数据] (N bytes) +``` + +## PLC 内存地址编码 + +在写入/读取时,地址字段需根据 PLC 内存区域进行编码。例如 XGB 系列(XBC, XBM, XGK 等)的常用地址映射: + +| 区域 | 地址码 | 说明 | +|------|--------|------| +| P | 0x5058 | I/O 接触点 | +| M | 0x4D58 | 内部继电器 | +| L | 0x4C58 | 锁存继电器 | +| K | 0x4B58 | 保持继电器 | +| D | 0x4458 | 数据寄存器 | +| U | 0x5558 | 模拟数据寄存器 | + +## 实战案例 + +### 协议精准定位分析(LSIS XGB Fast Ethernet 模块协议) + +![pasted_1784850075270_zndenz.png](https://pic1.imgdb.cn/i/033ujJ4cdjTwY1uZIgceHD.png) + +**此题与常规的 LSIS FEnet 协议不一样** + +查看会话只有这一组通信 + +![pasted_1784914009843_mjyxfu.png](https://pic1.imgdb.cn/i/033v9SHWsfBGmTZ6XHLGU1.png) + +考察 LSIS FEnet 协议,过滤出有用的 TCP 协议 + +```bash +ip.src == 192.168.2.191 && +ip.dst == 192.168.2.186 && +tcp.dstport == 2004 && +tcp.len > 0 +``` + +![pasted_1784914376192_9yykba.png](https://pic1.imgdb.cn/i/033v9bbOWIdSrhNFVOfwwn.png) + +第一个包的 Data 如下 + +``` +4c 53 49 53 2d 58 47 54 00 00 00 00 a0 33 00 00 16 00 +03 47 58 00 14 00 00 00 01 00 06 00 25 4d 42 32 30 30 +04 00 7d 00 00 00 +``` + +按照 PDF 解析如下: + +| 偏移 | 字节数 | 值 | 说明 | +| -----: | --: | ------------------------------- | --------------------------------------------- | +| `0x00` | 10 | `4c 53 49 53 2d 58 47 54 00 00` | Company ID / LSIS ID,ASCII 为 `"LSIS-XGT\0\0"` | +| `0x0A` | 2 | `00 00` | PLC Information,PLC 信息字段,本报文值为 `0x0000` | +| `0x0C` | 1 | `a0` | CPU Information,CPU 类型/系列标识,值为 `0xA0` | +| `0x0D` | 1 | `33` | Frame Direction,`0x33` 表示客户端发往 PLC 的请求帧 | +| `0x0E` | 2 | `00 00` | Invoke ID / Frame Order No.,请求序号,本报文为 `0` | +| `0x10` | 2 | `16 00` | Application Data Length,小端 `0x0016 = 22` 字节 | +| `0x12` | 1 | `03` | Position Information,模块/站点位置信息 | +| `0x13` | 1 | `47` | Header Checksum,XGT 头部校验和 | +| `0x14` | 2 | `58 00` | Command,写请求,协议记作 `0x5800` | +| `0x16` | 2 | `14 00` | Data Type,连续字节类型,协议记作 `0x1400` | +| `0x18` | 2 | `00 00` | Reserved,保留字段 | +| `0x1A` | 2 | `01 00` | Number of Blocks,小端值 `1`,本次操作包含 1 个变量块 | +| `0x1C` | 2 | `06 00` | Variable Name Length,变量名长度为 6 字节 | +| `0x1E` | 6 | `25 4d 42 32 30 30` | 变量名 ASCII:`"%MB200"` | +| `0x24` | 2 | `04 00` | Data Count / Data Length,后续写入数据长度为 4 字节 | +| `0x26` | 4 | `7d 00 00 00` | 写入 `%MB200` 的连续 4 字节数据 | + +而 `7d` 刚好是 `}`,所以写入的数据是倒序的,把所有数据取出来过后得到 flag + +``` +flag{c93650241853da240f9760531a79cbcf} +``` diff --git "a/docs/hc-ics/MMS \345\215\217\350\256\256.md" "b/docs/hc-ics/MMS \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..de5c25979b --- /dev/null +++ "b/docs/hc-ics/MMS \345\215\217\350\256\256.md" @@ -0,0 +1,154 @@ +--- +comments: true + +--- + +# MMS 协议 + +## 概述 + +MMS 提供了一套面向对象的客户端/服务器通信模型,核心功能包括: + +- 读/写变量:读取或修改设备内部的数据点(如温度、电压、开关状态) + +- 报告:设备在数据变化或事件触发时主动向客户端推送信息 + +- 文件操作:上传/下载设备上的配置文件或固件 + +- 控制:启停设备、执行预定义程序等。 + +- 设备建模:通过虚拟制造设备(VMD)和域(Domain)等抽象概念,统一不同厂家的设备数据接口 + +最常见的应用场景是 电力系统 IEC 61850 通信(变电站自动化),以及制造业的 SCADA 系统、机器人控制等 + +## 报文结构 + +MMS 协议的 PDU 完全使用 ASN.1 基本编码规则(BER) 来描述,也就是 Tag - Length - Value 结构 + +### BER 快速回顾 + +- Tag(标签):1~多字节,说明数据类型及上下文 + +- Length(长度):1~多字节,指明后续 Value 的字节数 + +- Value(内容):可以是简单整数、字符串,也可以是嵌套的 TLV 结构 + +MMS 中几乎都用上下文特定的构造标签(如 0xA0、0xA1 等) + +### 顶层的 MMS PDU 类型 + +MMS 顶层 PDU 使用 ASN.1 BER 编码中的 Context-specific 标签。 +对于 `[0]`~`[13]` 这类低编号标签,其顶层 BER Tag 可直接对应为 +`A0`~`AD`。 + +| PDU 类型 | ASN.1 标签 | BER 顶层标签 | 说明 | +|---------------------------|-----------:|-------------:|------| +| confirmed-RequestPDU | [0] | A0 | 确认请求,需要对端响应 | +| confirmed-ResponsePDU | [1] | A1 | 对确认请求的正常响应 | +| confirmed-ErrorPDU | [2] | A2 | 对确认请求返回的错误 | +| unconfirmed-PDU | [3] | A3 | 无确认报文,如 InformationReport | +| rejectPDU | [4] | A4 | 拒绝某个 MMS PDU | +| cancel-RequestPDU | [5] | A5 | 取消请求 | +| cancel-ResponsePDU | [6] | A6 | 取消响应 | +| cancel-ErrorPDU | [7] | A7 | 取消操作错误 | +| initiate-RequestPDU | [8] | A8 | MMS 会话初始化请求 | +| initiate-ResponsePDU | [9] | A9 | MMS 会话初始化响应 | +| initiate-ErrorPDU | [10] | AA | 初始化过程错误 | +| conclude-RequestPDU | [11] | AB | 请求结束 MMS 会话 | +| conclude-ResponsePDU | [12] | AC | 对结束会话请求的响应 | +| conclude-ErrorPDU | [13] | AD | 结束会话过程错误 | + +## 实战案例 + +### 窃取数据的黑客 + +![](https://pic1.imgdb.cn/item/6a3678eb2830ce602a4e8adb.png) + +查看会话可以看到一直在跟 `192.168.51.34:102` 通信 + +所以 `192.168.51.34` 就是服务器 + +![](https://pic1.imgdb.cn/item/6a367fe12830ce602a4e8cda.png) + +按照长度排列,第 3096 包没有 info 信息有点可疑(3022、3059 也一样) + +![](https://pic1.imgdb.cn/item/6a3682232830ce602a4ee6b7.png) + +展开后可以看到 `flag.7z` 以及 `flag.txt` + +![](https://pic1.imgdb.cn/item/6a3682c32830ce602a4ee6e0.png) + +按照最先发包顺序,从 3022 之后看攻击者 IP `192.168.51.33` 请求操作 + +在 3066 包中可以看到访问了 `flag.7z` + +![](https://pic1.imgdb.cn/item/6a3683c22830ce602a4ee746.png) + +编写脚本去响应中找到这个压缩文件 + +```python +import struct + +pcap = "tmflag(1).pcapng" # 要解析的 pcapng 文件 +data = open(pcap, "rb").read() # 一次性读取全部二进制内容 + +endian = "<" # 默认小端字节序,后续可根据块类型修正 +pos = 0 # 当前解析位置(字节偏移) +packets = [] # 存储提取的原始数据包内容 + +# 逐块解析 pcapng 文件 +while pos + 12 <= len(data): # 至少需要类型(4) + 块长(4) + 尾部块长(4) + block_type, block_len = struct.unpack(endian + "II", data[pos:pos+8]) + if block_len < 12 or pos + block_len > len(data): # 非法块长度则停止解析 + break + + body = data[pos+8:pos+block_len-4] # 块体,去掉头和尾的块长度字段 + + if block_type == 0x0A0D0D0A: # Section Header Block:决定字节序 + bom = struct.unpack("" # 根据标记设置端序 + elif block_type == 6: # Enhanced Packet Block + if len(body) >= 20: + # 接口ID、时间戳高/低、捕获长度、原始长度(只关注 caplen) + _, _, _, caplen, _ = struct.unpack(endian + "IIIII", body[:20]) + packets.append(body[20:20+caplen]) # 保存捕获到的数据包 + + pos += block_len # 移动到下一个块 + +# 分析提取的以太网数据包 +for idx, pkt in enumerate(packets, 1): + if len(pkt) < 14: + continue # 不够以太网帧头长度,跳过 + + eth_type = struct.unpack("!H", pkt[12:14])[0] # 以太网类型字段 + if eth_type != 0x0800: + continue # 非 IPv4 包则跳过 + + ip = pkt[14:] # IP 头部起始 + ihl = (ip[0] & 0x0f) * 4 # IP 头长度(字节) + proto = ip[9] # 传输层协议号 + if proto != 6: + continue # 非 TCP 则跳过 + + total_len = struct.unpack("!H", ip[2:4])[0] # IP 总长度 + tcp = ip[ihl:total_len] # TCP 段(头部+数据) + sport, dport = struct.unpack("!HH", tcp[:4]) # 源端口、目的端口 + tcp_hlen = (tcp[12] >> 4) * 4 # TCP 头长度(字节) + payload = tcp[tcp_hlen:] # TCP 有效载荷(应用层数据) + + # 7z 文件魔数签名 + sig = b"7z\xbc\xaf\x27\x1c" + off = payload.find(sig) # 在载荷中查找该签名 + + if off != -1: # 找到 7z 文件头 + # 提取从签名开始的一段数据(长度 0x8f 字节) + filedata = payload[off:off + 0x8f] + open("flag.7z", "wb").write(filedata) # 写出文件 + print(f"[+] frame {idx}: extracted flag.7z, size={len(filedata)} bytes") +``` + +![](https://pic1.imgdb.cn/item/6a3684ba2830ce602a4ee77a.png) + +解压后拿到 flag 值 + +![](https://pic1.imgdb.cn/item/6a36851e2830ce602a4ee793.png) diff --git "a/docs/hc-ics/MQTT \345\215\217\350\256\256.md" "b/docs/hc-ics/MQTT \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..c153a7bcb1 --- /dev/null +++ "b/docs/hc-ics/MQTT \345\215\217\350\256\256.md" @@ -0,0 +1,279 @@ +--- +comments: true + +--- + +# MQTT 协议 + +## 概述 + +MQTT(Message Queuing Telemetry Transport)是一种基于发布/订阅模式的轻量级通信协议,专为低带宽、不可靠网络下的物联网设备设计 + +## 报文结构 + +每个 MQTT 报文由三部分构成:固定报头(所有报文必含)、可变报头(部分报文)、有效载荷(部分报文) + +### 固定报头 + +字节 1:高 4 位为 MQTT 控制报文类型,低 4 位为标志位(特定位有含义,如 PUBLISH 的 DUP/QoS/RETAIN) + +剩余长度(Remaining Length):从字节 2 开始,用 变长编码(每字节低 7 位为值,最高位为继续位),表示可变报头 + 有效载荷的总字节数 + +在 Wireshark 中,固定报头被解析为 + +``` +MQ Telemetry Transport Protocol, ... + Header Flags: 0x... (类型和标志) + Msg Len: 剩余长度数值 +``` + +### 可变报头 + +内容根据报文类型不同,如 CONNECT 包含协议名、协议等级、连接标志、保活时间;PUBLISH 包含 Topic Name 和可能的 Packet Identifier + +### 有效载荷 + +内容亦因类型而异,如 CONNECT 有 Client ID、Will Topic/Message、Username、Password;PUBLISH 有实际消息内容 + +## 报文类型 + + +### CONNECT 报文(连接请求) + + +固定报头类型 = 1(0x10),标志全0,Wireshark 显示 Connect Command + + +可变报头(Wireshark 逐字段显示): + + +- `Protocol Name Length: 4` + + +- `Protocol Name: MQTT` (v3.1.1 固定为 "MQTT",旧版是 "MQIsdp") + + +- `Protocol Level: 4` (3.1.1 为 4,5.0 为 5) + + +- `Connect Flags: 0x..` 展开可看到:`User Name Flag`、`Password Flag`、`Will Retain`、`Will QoS`、`Will Flag`、`Clean Session` + + +- `Keep Alive: 60` (秒) + + +有效载荷: + + +- `Client ID Length: ...`、`Client ID: my_client` + + +- 如果 Will Flag 为 1,则出现 `Will Topic` 和 `Will Message` + + +- 如果 User Name Flag 为 1,出现 `User Name` + + +- 如果 Password Flag 为 1,出现 `Password` + + +### CONNACK 报文(连接确认) + + +固定报头类型 = 2(0x20) + + +可变报头:`Connect Acknowledge Flags`(如 Session Present)、`Return Code`(0 表示连接成功,其余为失败原因) + + +无有效载荷 + + +### PUBLISH 报文(发布消息) + + +固定报头类型 = 3(0x30),低 4 位标志携带着 DUP、QoS Level、RETAIN + + +可变报头: + + +- Topic Length(2字节) + Topic(主题名) + + +- 若 QoS > 0,则紧跟 Packet Identifier(2字节) + + +有效载荷: + + +- 应用消息内容(二进制/文本) + + +### PUBACK 报文(发布确认 - QoS 1 应答) + + +固定报头类型 = 4(0x40),剩余长度 = 2 + + +可变报头:仅包含报文标识符(2字节),与确认的 PUBLISH 中的标识符相同 + + +无有效载荷 + + +### PUBREC 报文(发布收到 - QoS 2 第一步应答) + + +固定报头类型 = 5(0x50),剩余长度 = 2 + + +可变报头:报文标识符(2字节) + + +无有效载荷 + + +### PUBREL 报文(发布释放 - QoS 2 第二步) + + +固定报头类型 = 6(0x62),标志位固定为 0010(必须) + + +可变报头:报文标识符(2字节) + + +无有效载荷 + + +### PUBCOMP 报文(发布完成 - QoS 2 第三步) + + +固定报头类型 = 7(0x70),剩余长度 = 2 + + +可变报头:报文标识符(2字节) + + +无有效载荷 + + +### SUBSCRIBE 报文(订阅请求) + + +固定报头类型 = 8(0x82),标志位固定为 0010 + + +可变报头:`Packet Identifier`(2字节) + + +有效载荷:可包含多个 `Topic Filter`(长度+字符串)+ `Requested QoS`(1字节)对 + + +### SUBACK 报文(订阅确认) + + +固定报头类型 = 9(0x90) + + +可变报头:与对应 SUBSCRIBE 相同的 `Packet Identifier` + + +有效载荷:每个订阅的授予 QoS 列表(每个 1 字节,低 2 位有效) + + +### UNSUBSCRIBE 报文(取消订阅请求) + + +固定报头类型 = 10(0xA2),标志位固定为 0010 + + +可变报头:`Packet Identifier`(2字节) + + +有效载荷:一个或多个要取消的 `Topic Filter`(长度+字符串) + + +### UNSUBACK 报文(取消订阅确认) + + +固定报头类型 = 11(0xB0),剩余长度 = 2 + + +可变报头:与 UNSUBSCRIBE 相同的 `Packet Identifier` + + +无有效载荷 + + +### PINGREQ 报文(心跳请求) + + +固定报头类型 = 12(0xC0),剩余长度 = 0 + + +只有固定报头,报文仅两个字节:`0xC0 0x00` + + +### PINGRESP 报文(心跳响应) + + +固定报头类型 = 13(0xD0),剩余长度 = 0 + + +只有固定报头,报文仅两个字节:`0xD0 0x00` + + +### DISCONNECT 报文(断开连接) + + +固定报头类型 = 14(0xE0),剩余长度 = 0 + + +只有固定报头,报文仅两个字节:`0xE0 0x00` + + +### AUTH 报文(认证交换 - 仅 MQTT 5.0) + + +固定报头类型 = 15(0xF0) + + +可变报头:认证方法(UTF-8 字符串)、认证数据(二进制)、可能包含原因码和属性(Properties) + + +有效载荷:视认证机制而定,通常在属性中携带 + +## 实战案例 + +### 工业物联网智能网关数据分析(MQTT 协议 + PNG 宽高篡改 + LSB 隐写) + +![](https://pic1.imgdb.cn/item/6a36f8412830ce602a50ee62.png) + +先分析 MQTT PUBLISH 消息,过滤语法 + +``` +mqtt.hdrflags == 0x30 +``` + +可以看到这些关键字的长度有明显异常 + +``` +f : ZIP 十六进制数据开头,payload 以 504B0304 开始 +l : ZIP 中间碎片 +a : ZIP 中间碎片 +g : ZIP 十六进制数据结尾 +d : ZIP 密码 pass_1s_ea4y +``` + +![](https://pic1.imgdb.cn/item/6a36ff212830ce602a50eefd.png) + +拼接 `f + l + a + g` 的 ZIP 数据得到压缩包 + +再用 `d` 的解压密码解压缩出图片 + +修改宽高拿到 `flag{21png_` 上半部分 + +剩下的一半需要 LSB 隐写提取出来然后拼接再反转颜色即可,如图所示 + +![](https://pic1.imgdb.cn/item/6a36ffe32830ce602a51136e.png) diff --git "a/docs/hc-ics/Omron Fins \345\215\217\350\256\256.md" "b/docs/hc-ics/Omron Fins \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..39b3960bcb --- /dev/null +++ "b/docs/hc-ics/Omron Fins \345\215\217\350\256\256.md" @@ -0,0 +1,115 @@ +--- +comments: true + +--- + +# Omron Fins 协议 + +## 概述 + +欧姆龙(Omron)的通信协议体系是其工业自动化生态系统的核心,它并非单一协议,而是一套为不同层级和应用场景设计的多层次协议家族 + +这些协议共同构建了一个从设备级、控制级到信息级的无缝通信网络,使得PLC、上位机、传感器、执行器等设备能够高效协同工作 + +FINS(Factory Interface Network Service)是欧姆龙(Omron)为其工业自动化网络设计的核心指令/响应系统 + +它的核心优势在于能够实现以太网、控制网络和串行通信等多种物理网络间的无缝通信 + +## 协议类型 + +FINS 协议主要在 TCP/IP 模型的应用层工作,并有两种主要实现方式: + +- FINS/UDP:基于 UDP 协议,是一种无连接的通信方式,通信前不需要建立连接,速度更快,适合实时性要求高的数据交换 + +- FINS/TCP:基于 TCP 协议,是一种面向连接的通信方式,需要先建立会话,提供更可靠的通信,适合大数据量或对可靠性要求高的传输 + +两种方式默认都使用 端口 9600 进行通信 + +## FINS/TCP 会话建立 + +当使用FINS/TCP时,通信双方在传输数据前需要建立一个会话 + +- 连接请求:客户端向服务器的9600端口发起TCP连接,并发送一个连接请求帧 + +- 确认响应:服务器收到请求后,会回复一个连接确认帧,其中包含其自身的节点参数 + +- 数据传输:会话建立成功后,双方即可开始进行FINS命令和数据的交换 + +## FINS 报文结构 + +一个 FINS 报文主要由报头(Header) 和数据(Data) 两大部分构成 + +其结构会因底层传输协议(UDP 或 TCP)的不同而有所差异 + +### FINS/UDP 报文结构 + +FINS/UDP 的报文结构最为基础,由 FINS/UDP 报头和命令数据组成 + +FINS/UDP 报头长度为 12 个字节,包含以下关键字段 + +| 字段 (Field) | 长度 (字节) | 描述 (Description) | +| :--- | :--- | :--- | +| **ICF** | 1 | 信息控制字段。包含是否使用网关、是命令还是响应等信息 | +| **RSV** | 1 | 保留字段,通常为 `0x00` | +| **GCT** | 1 | 网关计数字段。表示数据包经过的网关/桥接数量 | +| **DNA** | 1 | 目标网络地址。`0x00` 代表本地网络 | +| **DA1** | 1 | 目标节点地址。目标 PLC 的节点号 | +| **DA2** | 1 | 目标单元地址。`0x00` 代表 CPU 单元 | +| **SNA** | 1 | 源网络地址 | +| **SA1** | 1 | 源节点地址 | +| **SA2** | 1 | 源单元地址 | +| **SID** | 1 | 服务 ID。用于标识不同的通信进程 | +| **Command Code** | 2 | 命令码。标识具体的操作,如读取、写入等 | + +### FINS/TCP 报文结构 + +FINS/TCP 的报文是在 FINS/UDP 报文的基础上,增加了一个 FINS/TCP 报头 + +- FINS/TCP 报头:长度固定,用于 TCP 层面的连接管理和数据传输控制。其结构通常包含协议 ID、数据长度等信息 + +- FINS/UDP 报文:作为 FINS/TCP 报头后面的数据负载(Payload)存在 + +## 功能码 + +FINS 协议通过不同的命令码来实现对 PLC 的各种操作。命令码是两个字节的十六进制数 + +| 功能分类 | 命令码 (Hex) | 命令名称 (Name) | 功能描述 (Description) | +| :--- | :--- | :--- | :--- | +| I/O内存访问 | 01 01 | MEMORY AREA READ | 读取连续I/O内存区域的数据 | +| I/O内存访问 | 01 02 | MEMORY AREA WRITE | 向连续I/O内存区域写入数据 | +| I/O内存访问 | 01 03 | MEMORY AREA FILL | 用相同数据填充指定范围的I/O内存 | +| I/O内存访问 | 01 04 | MULTIPLE MEMORY AREA READ | 在一个命令中读取多个不连续的I/O内存区域 | +| I/O内存访问 | 01 05 | MEMORY AREA TRANSFER | 将数据从一个I/O内存区域复制到另一个 | +| 参数区访问 | 02 01 | PARAMETER AREA READ | 读取PLC参数区域 | +| 参数区访问 | 02 02 | PARAMETER AREA WRITE | 写入PLC参数区域 | +| 参数区访问 | 02 03 | PARAMETER AREA FILL (CLEAR) | 填充或清除参数区域 | +| 程序区访问 | 03 06 | PROGRAM AREA READ | 读取用户内存(UM)区域的程序数据 | +| 程序区访问 | 03 07 | PROGRAM AREA WRITE | 写入用户内存(UM)区域的程序数据 | +| 程序区访问 | 03 08 | PROGRAM AREA CLEAR | 清除用户内存(UM)区域的程序数据 | +| 运行模式控制 | 04 01 | RUN | 将CPU单元的运行模式切换为RUN或MONITOR | +| 运行模式控制 | 04 02 | STOP | 将CPU单元的运行模式切换为PROGRAM(停止) | +| 状态/数据读取 | 05 01 | CPU UNIT DATA READ | 读取CPU单元数据(如型号等) | +| 状态/数据读取 | 06 01 | CPU UNIT STATUS READ | 读取CPU单元的运行状态 | +| 状态/数据读取 | 06 20 | CYCLE TIME READ | 读取CPU的循环时间(最大、最小、平均) | +| 时钟访问 | 07 01 | CLOCK READ | 读取PLC的内部时钟 | +| 时钟访问 | 07 02 | CLOCK WRITE | 写入/修改PLC的内部时钟 | +| 访问权限 | 0C 01 | ACCESS RIGHT ACQUIRE | 获取访问权限(如果未被其他设备占用) | +| 访问权限 | 0C 02 | ACCESS RIGHT FORCED ACQUIRE | 强制获取访问权限(即使被其他设备占用) | +| 访问权限 | 0C 03 | ACCESS RIGHT RELEASE | 释放已获取的访问权限 | +| 错误日志 | 21 01 | ERROR LOG READ | 读取CPU单元的错误日志 | +| 错误日志 | 21 02 | ERROR LOG CLEAR | 清除错误日志 | +| 错误日志 | 21 03 | ERROR CLEAR | 清除错误或错误信息 | + +## 实战案例 + +### Omron Fins + +![](https://pic1.imgdb.cn/item/6a33a36991b65c4475ab6c19.png) + +过滤 `omron` 协议,可以看到操作中有读取有写入 + +![](https://pic1.imgdb.cn/item/6a33ad4991b65c4475abab15.png) + +根据题目描述找写入的流量,其 `Command Data` 就是 flag + +![](https://pic1.imgdb.cn/item/6a33ad7d91b65c4475abab2b.png) diff --git "a/docs/hc-ics/S7comm \345\215\217\350\256\256.md" "b/docs/hc-ics/S7comm \345\215\217\350\256\256.md" new file mode 100644 index 0000000000..dec85f8ef7 --- /dev/null +++ "b/docs/hc-ics/S7comm \345\215\217\350\256\256.md" @@ -0,0 +1,444 @@ +--- +comments: true + +--- + +# 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 协议) + +![](https://pic1.imgdb.cn/item/6a33579da6693733f7e20ba4.png) + +题目说了是 "突然发生停机事件",所以推测是 `0x29` 或者 `0x28`,直接过滤出来 + +`P_PROGRAM` 是实现 PLC 模式转换的关键服务。它主要包含两种操作: + +- 启动:将 PLC 从停止状态切换至运行状态 + +- 停止:将 PLC 从运行状态切换至停止状态 + +![](https://pic1.imgdb.cn/item/6a335e6aa6693733f7e20e73.png) + +本题的 flag 是 `flag{3201414d}` + +### 上位机通讯异常分析(S7comm 协议) + +![](https://pic1.imgdb.cn/item/6a335ee3a6693733f7e20ea6.png) + +题目说 "无法查询控制设备异常情况" + +观察数据包发现功能码都是 `0x04`,所以判断是因为响应不正常才导致的 "无法查询" + +![](https://pic1.imgdb.cn/item/6a3362e1a6693733f7e23913.png) + +我们随便一个响应来分析一下结构 + +![](https://pic1.imgdb.cn/item/6a336422a6693733f7e239a4.png) + +📦 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` + +我们右键这个值,选择 "准备作为过滤器" --> "选中" + +![](https://pic1.imgdb.cn/item/6a338bb791b65c4475ab01af.png) + +然后加上 `!` 筛选出没有读取成功的流量包 + +![](https://pic1.imgdb.cn/item/6a338c3291b65c4475ab0218.png) + +其 `data` 内容就是 `flag{010400100100}` + +![](https://pic1.imgdb.cn/item/6a338c6091b65c4475ab2cbe.png) + +### 工控协议数据分析(S7comm 协议) + +![](https://pic1.imgdb.cn/item/6a33af1a91b65c4475abac4f.png) + +题目说 "获取到了哪些信息",过滤协议可以看到全部都是 `0x04` 和 `0x05` 的流量 + +![](https://pic1.imgdb.cn/item/6a33e3ef91b65c4475ad0a3d.png) + +过滤 `s7comm.param.func == 0x05` 可以看到多次写入,每个写入包的 `data` 字段里都有类似内容: + +``` +01100110 +``` + +![](https://pic1.imgdb.cn/item/6a33e52d91b65c4475ad0a98.png) + +提取出来拼接转成 ASCII 码得到 `flag{flag_is_here}` + +### 异常的 S7 数据(S7comm 协议) + +大概看了一下,全是 `0x05` 功能码 + +![](https://pic1.imgdb.cn/item/6a34018a91b65c4475ada627.png) + +绝大多数写入数据都以 `ffff` 开头 + +![](https://pic1.imgdb.cn/item/6a340e2591b65c4475adf946.png) + +右键作为过滤器应用 + +![](https://pic1.imgdb.cn/item/6a34100c91b65c4475adf96f.png) + +WireShark 生成的语法是 `s7comm.resp.data == ff:ff:96:e7:8f:51:c4:d0:3a:2b` + +我们更改为 `s7comm.resp.data[0:2] != ff:ff`,筛选出数据包 + +![](https://pic1.imgdb.cn/item/6a34106e91b65c4475adf978.png) + +所以 flag 是 `flag{FFAD28A0CE69DB34751F}` + + +### 黑客的 Fuzz(S7comm 协议) + +![](https://pic1.imgdb.cn/item/6a36f15e2830ce602a50ec82.png) + +打开流量包过滤 `s7comm`,全是写入和读取变量 + +题目说的是 FUZZ,所以重点应该看读取的流量,功能码为 `0x04` + +FUZZ 的参数每次请求肯定都是不一样的 + +最后比对发现就 Address 字段的最后一个字节每次不一样 + +![](https://pic1.imgdb.cn/item/6a36f3272830ce602a50ed24.png) + +编写脚本提取 + +```python +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 协议) + +![](https://pic1.imgdb.cn/item/6a36f5672830ce602a50ee0a.png) + +"被大量修改的数据" 对应的写入内容,也就是功能码 `0x05` + +```python +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}` diff --git "a/docs/hc-ics/SCD \351\205\215\347\275\256\344\277\241\346\201\257.md" "b/docs/hc-ics/SCD \351\205\215\347\275\256\344\277\241\346\201\257.md" new file mode 100644 index 0000000000..b9be878a75 --- /dev/null +++ "b/docs/hc-ics/SCD \351\205\215\347\275\256\344\277\241\346\201\257.md" @@ -0,0 +1,280 @@ +--- +comments: true + +--- + +# SCD 文件 + +## 定义 + +在 IEC 61850 标准体系中,SCD(Substation Configuration Description)文件是变电站配置描述文件,它是整个变电站自动化系统的 "总配置文件" + +它包含了: + +- 一次设备接线拓扑:母线、线路、开关、互感器等的连接关系 + +- 二次智能电子设备(IED)信息:保护、测控、智能终端等装置的模型,包括逻辑设备、逻辑节点、数据对象等 + +- 通信配置:GOOSE、SV 等报文的控制块、发布/订阅关系,MMS 报告设置等 + +- 映射关系:将设备间的逻辑信号绑定到具体网络报文 + +简单说,SCD 文件是变电站二次系统集成和运维的基础数据,必须保证正确性和唯一性,否则会导致保护误动、拒动或通信故障 + +## 实战案例 + +### 智能变电站设备异常诊断(SCD 文件) + +010 打开发现文件头被颠倒了 + +![](https://pic1.imgdb.cn/item/6a3427c791b65c4475ae6310.png) + +改回来后打开就是一个 `dcs.dcs` 文件 + +![](https://pic1.imgdb.cn/item/6a34280791b65c4475ae631c.png) + +初步查看文件类型 + +```bash +file dcs.dcs +strings -a dcs.dcs | head +``` + +可以看到文件不是普通 XML + +```bash +dcs.dcs: data +``` + +但文件头附近有明显标识 + +``` +$KEMOV-SCD-FILE$ +``` + +继续查看十六进制 + +``` +od -Ax -tx1z -N 128 dcs.dcs + +ff ff 24 4b 45 4d 4f 56 2d 53 43 44 2d 46 49 4c 45 24 +``` + +对应了 `$KEMOV-SCD-FILE$` + +同时后面出现了类似 + +``` +20 31 20 31 20 30 20 6b 20 76 20 2e 20 73 20 63 20 64 +``` + +这种结构解出来是 + +``` +110kv.scd +``` + +说明这个文件本质上保存的是一个变电站 SCD 配置信息,只是被厂商工具二进制化了 + +观察文件里的 ASCII 字符串会发现它不是正常连续保存的 + +``` +20 49 20 4c 20 31 20 31 20 30 20 31 +``` + +这不是普通 ASCII,而是类似 + +``` +0x20 + ASCII 字符 +``` + +所以 + +``` +20 49 -> I +20 4c -> L +20 31 -> 1 +20 31 -> 1 +20 30 -> 0 +20 31 -> 1 +``` + +解出来是 + +``` +IL1101 +``` + +中文部分则基本可以按 UTF-16BE 理解 + +也就是说,文件里的大量字段是 1 字节长度 + 2 字节字符序列 + +编写脚本来恢复 + +```python +from pathlib import Path +import re +from collections import Counter + +b = Path("dcs.dcs").read_bytes() + +print("[+] header:", b[2:18].decode()) + +def dec(pos): + if pos >= len(b): + return None + + L = b[pos] + + # 字符串长度一般为偶数,每个字符 2 字节 + if L < 2 or L > 200 or L % 2: + return None + + data = b[pos + 1:pos + 1 + L] + if len(data) != L: + return None + + out = [] + + for hi, lo in zip(data[0::2], data[1::2]): + # ASCII 字符:20 xx + if hi == 0x20 and 0x20 <= lo <= 0x7e: + out.append(chr(lo)) + else: + try: + ch = bytes([hi, lo]).decode("utf-16be") + except Exception: + return None + + # 本题需要的非 ASCII 基本都是中文,严格过滤误判 + if not (0x4e00 <= ord(ch) <= 0x9fff): + return None + + out.append(ch) + + s = "".join(out) + + if not re.search(r"[A-Za-z0-9\u4e00-\u9fff]", s): + return None + + return s, pos + 1 + L + + +def strs(start=0, end=None): + p = start + end = len(b) if end is None else min(end, len(b)) + out = [] + + while p < end: + r = dec(p) + if r: + s, p2 = r + out.append((p, s)) + p = p2 + else: + p += 1 + + return out + + +allstr = strs() + +print("[+] decoded strings:", len(allstr)) +print("[+] first strings:") +for off, s in allstr[:8]: + print(hex(off), repr(s)) + + +print("\n[+] 110kV line intelligent terminals:") +for idx, (off, s) in enumerate(allstr): + if "110kV" in s and "线路智能终端" in s: + print("---") + for o, t in allstr[max(0, idx - 3):idx + 2]: + print(hex(o), t) + + +print("\n[+] duplicated IED_GOCB:") + +records = [] + +for m in re.finditer(b"IED_GOCB", b): + vals = [s for _, s in strs(m.end(), m.end() + 700)] + + gocb = next((x for x in vals if re.fullmatch(r"gocb\d+", x)), None) + ref = next((x for x in vals if "/LLN0" in x), "") + + mi = re.search(r"0?([A-Z]{1,4}\d{3,4})(?=[A-Z]+/LLN0)", ref) + + if gocb and mi: + ied = mi.group(1) + records.append((m.start(), ied, gocb, vals[:6])) + +cnt = Counter((ied, gocb) for _, ied, gocb, _ in records) + +for (ied, gocb), c in sorted(cnt.items()): + if c > 1: + print(ied, gocb, "count =", c) + + for off, ied2, gocb2, vals in records: + if (ied2, gocb2) == (ied, gocb): + print(" ", hex(off), vals[:5]) +``` + +脚本首先能恢复出原始 SCD 文件名 + +``` +[+] header: $KEMOV-SCD-FILE$ +[+] decoded strings: 8174 +[+] first strings: +0x21 '九曲110kv.scd' +0x3b 'version' +0x4d 'reversion' +0x63 'template' +0x77 'toolID' +``` + +接着筛选 110kV 线路智能终端,可以看到 + +``` +[+] 110kV line intelligent terminals: +--- +0x2525d IL1101 +0x2526d 110kV1#线路智能终端 +--- +0x30a87 IL1102 +0x30a97 110kV2#线路智能终端 +``` + +题目说问题可能来源于线路智能终端,所以重点检查 + +``` +IL1101 +IL1102 +``` + +然后分析 IED_GOCB 记录,即 GOOSE Control Block 配置,异常输出为 + +``` +[+] duplicated IED_GOCB: +IL1101 gocb5 count = 10 + 0x28756 ['gocb5', '0IL1101RPIT/LLN0', 'GOOSE采样', '0IL1101RPIT/LLN0', 'GO$gocb5 0IL1101RP'] + 0x2953e ['gocb5', '0IL1101RPIT/LLN0', 'GOOSE采样', '0IL1101RPIT/LLN0', 'GO$gocb5 0IL1101RP'] + 0x2a24e ['gocb5', '0IL1101RPIT/LLN0', 'GOOSE采样', '0IL1101RPIT/LLN0', 'GO$gocb5 0IL1101RP'] + ... +``` + +这里的异常点非常明确: + +``` +IL1101 / RPIT / LLN0 / gocb5 +``` + +被重复配置了 10 次 + +在 IEC 61850/SCD 配置里,同一个 IED、同一个逻辑节点下的 GOOSE 控制块名称应该唯一。重复的 `gocb5` 会导致控制块引用冲突、GOOSE 配置异常,从而引发运行异常 + +题目问的是 "分析存在问题根源" + +真正的问题是 "变电站基础配置文件 SCD 存在错误" + +所以 flag 是 `flag{scdisbad}` diff --git a/mkdocs.yml b/mkdocs.yml index 87559525de..d5765a5860 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -216,6 +216,18 @@ nav: - 内存取证: hc-misc/memory.md - 附:文件签名表 : hc-misc/file_sign.md + - ICS | 工控安全: + - S7comm 协议: hc-ics/S7comm 协议.md + - COTP 协议: hc-ics/COTP 协议.md + - MMS 协议: hc-ics/MMS 协议.md + - Omron Fins 协议: hc-ics/Omron Fins 协议.md + - LSIS FEnet 协议: hc-ics/LSIS FEnet 协议.md + - JT/T 808 协议: hc-ics/JT T 808 协议.md + - MQTT 协议: hc-ics/MQTT 协议.md + - IC 卡: hc-ics/IC 卡.md + - 1527 芯片: hc-ics/1527 芯片.md + - SCD 配置信息: hc-ics/SCD 配置信息.md + - Web | 网络攻防: - hc-web/index.md - Web入门题单: hc-web/web_start.md