2020 工业信息安全技能大赛-济南站 WriteUp
Misc
COTP(COTP 协议)

一眼过去全是 COTP 协议,过滤下

COTP 协议默认端口是 102,所以 192.168.99.22 是工控设备

再新增 ip.src == 192.168.99.22 过滤下,查看工控响应的内容有没有什么异常

第 217 个包长度突然不一样,是之前的两倍

Payload 中还能看到明显的设备信息字符串
1 | 6ES7 211-1BE51-0XB0 ;V3.0 |
这说明攻击者/扫描方正在读取设备型号、版本等信息
因此,这个响应包就是题目要找的 “遭到攻击的数据包”
最后的 flag 如下
1 | flag{31312D31424535312D30584230203B56332E308240001505323B32383882410003000300A20000000072010000} |
工控大楼的道闸怎么坏了(1527 芯片)

EM1527 是常见的 433MHz/315MHz 无线遥控编码芯片协议,经常用于道闸、卷帘门、遥控开关等设备
协议数据一般为 24 bit,也就是 6 个十六进制字符
它通常使用 OOK/ASK 调制,也就是:
1 | 有载波 = 高电平 |
题目附件给的是某种原始无线 IQ 采样数据
常见 SDR 原始采样格式是:
1 | I, Q, I, Q, I, Q ... |
用 Python 读取后,将相邻两个字节作为一组 I/Q
1 | raw = np.fromfile(path, dtype=np.int8) |
为了把 OOK 信号还原成高低电平,需要计算幅度
1 | # mag2 大时,表示有载波,即高电平;当 mag2 接近 0 时,表示无载波,即低电平 |
对幅度做一个简单平滑,然后设置阈值
1 | env = np.convolve(mag2, np.ones(100) / 100, mode="same") |
这样可以得到类似逻辑分析仪中的高低电平序列
1 | 高电平 低电平 高电平 低电平 ... |
本题中观察到的编码方式有点不同
1 | 1 = 长高电平 + 短低电平 |
也就是
1 | bit 1: HHH + L |
从信号中截取一帧,统计高低电平长度后,可以得到类似下面的脉冲序列
1 | 高 2324 低 853 |
按规则解码得到 24 位二进制
1 | 101010110010110010000001 |
转 ASCII 码是 AB2C81
所以 flag 就是 flag{ab2c81}
1 | import numpy as np |
泄露的信号(无线 IQ 信号分析 + 频谱图隐写)
文件名没有后缀,看十六进制
1 | od -Ax -tx1z -N 64 ppt |
按小端 int16 解释
1 | 28, -3, -3, -28, -28, 3, 4, 28 ... |
如果把它两两分组
1 | I, Q, I, Q, I, Q ... |
会发现它很像复数 IQ 采样
1 | (28, -3), (-3, -28), (-28, 3), (4, 28) ... |
也就是说,这个文件大概率是 IQ 原始无线采样
可以用 Python 对 IQ 数据做 STFT,生成频谱瀑布图
1 | import numpy as np |
生成的 waterfall.png 里可以看到一块非常明显的异常区域

1 | import numpy as np |
翻转过来再颜色反转可以识别出 flag

所以最后 flag 就是
1 | flag{1cbnz723x81il1812ss2} |
被篡改的数据(S7comm 协议)

“被大量修改的数据” 对应的写入内容,也就是功能码 0x05
1 | import struct |
得到 flag{931377ad4a}
工业物联网智能网关数据分析(MQTT 协议 + PNG 宽高篡改 + LSB 隐写)

先分析 MQTT PUBLISH 消息,过滤语法
1 | mqtt.hdrflags == 0x30 |
可以看到这些关键字的长度有明显异常
1 | f : ZIP 十六进制数据开头,payload 以 504B0304 开始 |

拼接 f + l + a + g 的 ZIP 数据得到压缩包
再用 d 的解压密码解压缩出图片
修改宽高拿到 flag{21png_
剩下的一半需要 LSB 隐写提取出来然后拼接再反转颜色即可

工控协议数据分析(ARP 协议)

重点看 ARP 报文,可以发现 192.168.1.55 这台设备多次发出异常 ARP

192.168.1.0/24 中的 192.168.1.0 是网络地址,正常不应该被配置为主机地址或网关地址
所以这里的异常点是 PLC / 工控设备可能配置了错误的网关、通信目标或网络参数,导致它不断尝试 ARP 解析 192.168.1.0 从而产生大量告警日志
司机的身份(JT/T808 车载终端通信协议)

打开流量包可以发现大量以 7e 开头、7e 结尾的 JT/T 808 帧

在 No.16 流量中,其中关键报文头
1 | 07 02 40 eb 01 00 00 00 00 01 77 70 64 12 11 00 72 |
解析
1 | 消息 ID:0x0702 |
0x0702 正好是驾驶员身份信息采集上报,消息体开头为
1 | 01 状态:IC 卡插入 |
取出这 180 字节发现是 UTF-16BE。每两个字节对应一个汉字
1 | 8a f8 -> 諸 |
完整取出来得到
1 | 諸隸僧降吽諸陀摩隸僧缽薩願皤耨皤眾劫阿我皤慧阿亦皤我哆兜降嚤祗愍皤迦闍眾皤陀亦聞宣咤迦劫薩囉愍聞須皤塞迦皤諦亦劫亦皤闍願皤咤修闍薩嚤宣皤我菩菩皤迦聞諦婆亦聞皤阿薩所願如空如如囑皤囑 |
用 “新约佛论禅 / 新佛曰” 解密,得到一串 Base64
1 | TVpXR0NaMzNJTlpIUzREWkdBWVhHUlRWSlo2UT09PT0= |
连续 Base 解码得到 flag{Crypy01sFuN}
奇怪的声音(签到)

在识别过程中第一个摩斯码为
1 | ---.. -- ..... ..--- ---.. ..- -.- --... ....- .... --... -.... |
而倒数第二位的 --... 并没有这个莫斯码,所以要将音频文件倒放
倒放过来得到
1 | ....- ...-- .... -.... ...-- -.- -.. ..--- ---.. ..... -- ..--- |
经过转换得到 flag{43h63kd285m2}
Reverse
某嵌入式设备固件升级包(VxWorks 固件)

题目给出的是一个名为 UpgradePackage.bin 的固件升级包
首先需要理解固件升级包的常见结构:大多数升级包包含一个明文头部(记录设备型号、版本等信息),后面跟着压缩或加密的固件本体
用十六进制编辑器或 binwalk 查看文件,能发现开头有可读字符串:140-NOE-771-01 和 Quantum Ethernet Executive firmware Ver. 6.40
这告诉我们设备型号和固件版本,同时也提示固件可能是 VxWorks 操作系统
VxWorks 是一种嵌入式实时操作系统,广泛应用于工业控制、网络设备中,其固件镜像通常包含符号表用于调试
继续分析升级包,需要找到真正的固件数据在哪里
偏移 0x385 处存在 zlib 压缩数据,用 Python 直接从 0x385 开始 zlib 解压
1 | import zlib |
在解压后的固件里可以看到 VxWorks 痕迹
1 | # -a:全文件扫描(--all) |

1 | # 该固件使用 VxWorks 实时操作系统(RTOS),版本信息格式为 L8 |
题目说要找 bsolute__C14SequenceNumber 函数所对应的文件偏移
1 | # -t x 输出字符串所在的十六进制文件偏移 |
得到偏移是 0x26634c,并且是 Absolute__C14SequenceNumber,题目可能给错了

固件里的符号表一般不会保存 “文件偏移”,而是保存运行时地址
1 | 运行时地址 = 文件偏移 + 加载基址 |
获取加载基址的思路如下
1 | 固件中有很多字符串文件偏移 |
所以我们枚举 base,看哪个 base 能解释最多的 “指向字符串的指针”
1 | from pathlib import Path |
可以看到 0x10000 命中数遥遥领先,所以基址就是 0x10000

所以目标字符串运行地址 = 0x26634C + 0x10000 = 0x27634C
因为 PowerPC 是大端,所以 0x0027634C 在文件里的字节形式是 00 27 63 4C
接下来就是找到哪个符号表项引用了 Absolute__C14SequenceNumber 这个字符串
1 | from pathlib import Path |

将 symbol item bytes 按 4 字节大端拆开
1 | 00 00 00 00 00 27 63 4C 00 11 FD 38 00 00 05 00 |
对应结构体
1 | struct symbol_item { |
解析结果
1 | unk = 0x00000000 |
value_addr 就是 Absolute__C14SequenceNumber 的函数运行时地址
题目要的是 函数所对应的文件偏移,所以要减去基址
得到 flag 后半部分 0x0011FD38 - 0x00010000 = 0x0010FD38
symbol item start 是符号表项,值为 0x302010,那么整张符号表应该是一段连续的 16 字节结构数组
所以我们要做的是
1 | 从 0x302010 开始 |
判断 “像符号表项” 的标准是:
- 第 1 字段通常为
0 - 第 2 字段
name_addr - 0x10000后能指向一个合法字符串 - 第 3 字段
value_addr落在固件地址范围内 - 第 4 字段
type是少量重复的类型值,例如0x500、0x700、0x900等
1 | from pathlib import Path |
输出
1 | symbol table start: 0x301e60 |
symbol table end 说明 0x3293B0 起就不再是符号表项
手动观察表尾
1 | # -g 4:改为 4 个字节一组 |
回显
1 | 00329380: 00000000 00232b08 00367788 00000900 |
前三行仍然符合符号表项结构
1 | 00000000 [name_addr] [value_addr] [type] |
但到了 0x3293B0,第一字段变成了 00002755。它不再是符号表项,而是表项数量
所以表尾就是 0x3293B0,第一部分 flag 已经确认为 00002755
最终 flag 为 flag{000027550010FD38}
ddddynamic(算法优化)

逆向分析代码,solve() 里面没有用动态规划,而是调用了 dfs() 枚举子序列
1 | void dfs(int target_len, int start, int depth) { |
检查逻辑是 if (tmp[i] < last) fail;
所以它找的是最长非降子序列
因为数组元素只在 0 ~ 10 之间,所以最长非降子序列可以用很简单的 DP 求
1 | MOD = 2147483647 |
IoT(RISC-V 架构 + XOR)

IDA 逆向,根据流程图一步一步来

main 函数用途:
调用
scanf从标准输入读取字符串到全局缓冲区str调用
strlen获取输入长度若长度等于 20,则跳转到
loc_1018E进入处理逻辑;否则顺序执行
1 | main: |
loc_1018E 函数用途:
- 将局部变量
i置零,然后跳到loc_101F4
1 | loc_1018E: |
loc_101F4 函数用途:
从栈上重新加载
i,调用strlen获得长度 20,用bltu比较,若i < 20则跳回loc_10194(循环体),否则退出循环实现一个 20 次迭代的
for循环,用于逐字节处理输入字符串
1 | loc_101F4: |
该循环体对输入字符串 str 的每个字符 str[i] 进行如下操作:
读取
str[i]从
xor表中读取一个 32 位整数xor[i]计算
xor[i] ^ str[i]从
__DATA_BEGIN__表中读取expected[i]比较
xor[i] ^ str[i]与expected[i]
1 | loc_10194: |
提取 xor 表和 __DATA_BEGIN__ 的数据解密
1 | # 已知的 xor 表与 expected 表(32 位整数数组) |
最后得到 flag{XoR_I5_S0_Easy}