Cyber Apocalypse 2021 五道硬件小题:总线电路信号分析
Cyber Apocalypse 2021 五道硬件小题:总线电路信号分析
最近时间比较空闲学习学习一下硬件的知识,这5道题做下来发现步骤都是一样的首先提取数据,简单的题flag 直接写在数据里面,稍微难一点就是将数据转化为图片或者让你分析附带的程序解密得到 flag ,这类题还是比较单一的,就比如25年的强网杯初赛就有一道题跟下面的 Off the grid 基本一样,而且现在 AI 已经很强了很多题就可以一把出了,如果之前做过这5道题强网杯那道就可以直接秒了。如果目的是知道如何是获得 flag 其实意义不大,了解各个协议的特点知道如何提取出数据这才是这篇文章的最重要的。
参考文章。
Cyber Apocalypse 2021 五道硬件小题:总线电路信号分析 | Clang裁缝店
- logic2软件下载:https://www.saleae.com/downloads/
- 总线接口介绍:[lowkeyway: Embedded: Hardware: Hardware Interface](https://github.com/lowkeyway/Embedded/tree/master/Hardware/Hardware Interface)
Serial Logs
根据题目和只有一条通道信号判断是串口协议 UART,并且从方波的密集程度很容易看出他的波特率是有两个。

如何计算波特率
波特率就是一秒钟内传输了多少个比特位,UART 是一种异步(Asynchronous)*通信协议,这意味着它*没有独立的时钟线。发送端和接收端必须提前约定好一个频率,这就是波特率,它会按照约定的时间间隔(比如每 $8.68 \mu s$)去“扎一针”(采样)。在没有时钟线的情况下,我们通过测量最小电平变化的时间宽度来计算。
计算公式如下:

其实上面不理解也可以,因为软件已经帮我计算出来了

设置一下波特率

但是没有我们想要的 flag

然后看看第二段的波特率是多少,找一下最小电平,点一下就可以,发现是 74184

重新设置一下发现 flag

Compromised
打开的第一反应就是iic协议,两个通道上面的是数据下面是时钟,因为25年的强网杯刚出了一道有关iic的题,感兴趣的朋友可以看一下[传送门](2025强网杯 线上赛wp - 何思泊河 - 博客园)

Analyzers看一下,发现是不断向0x2c和0x34发送数据。

用 ai 分析一下发现向 0x2c 写入的就是 flag 。

让 ai 写一个脚本将字符串提取出来是CHTB{nu11_732m1n47025_c4n_8234k_4_532141_5y573m!@52)#@%}(我原本还以为脚本会比较复杂,但是看了一下发现很简单就是构造一个函数使用re.findall搜索构造好的正则匹配的字符串)

Secure
打开发现是四路信号,刚开始没起来是什么,后来看了文章说是 SPI ,才想起来 SPI 是什么,因为之前实验室搞过某厂的隔离设备,当时使用逻辑分析仪安全芯片的 key 那个芯片的协议就是 SPI ,当时是将片选信号当作触发,读取 MOSI、MISO 。之所以将片选信号当成触发是因为它是通信的“起跑信号”,SPI 是一种总线协议,主设备(Master)可能连接了多个从设备(如 Flash 存储、传感器、DAC 等),可以把片选信号当成一个开关,当片选信号发生变化时代表选择与一台从设备开始进行通信。

D3通道是 SCK (时钟信号),连续且规整的方波脉冲。
D2通道是 CS/SS (片选信号),因为该信号是低电平有效,可以看到只有当红线处于低电平时,黄线(时钟)才会密集地翻转。 一旦红线跳高,黄线就立刻停止活动,变成一条死线,也可以从白线的翻转看出来。这很符合 SPI 的工作原理,主控在读取大量数据时,会将 CS 拉低保持很长时间,中间只在切换指令或重置状态时短暂拉高。
D1通道是 MOSI(主出从入),将方波放大可以发现当 CS 刚刚处于低电平橙线(D1)立马发生翻转,这是因为在 SPI Flash 通信规则中,每次拉低片选信号开启通道时,主控必须先说话。它要告诉 Flash 芯片 “我要读数据(指令码 0x3),地址是从 xxx 位置开始”。因此D0就是 MISO


编写脚本找到 flag
import re
import os
def find_flag(file_patch):
if not os.path.exists(file_patch):
print(f"找不到文件:{file_patch}")
return
with open(file_patch,'r',encoding='utf-8') as f:
raw_data=f.read()
# takens=raw_data.split()
# print(raw_data)
# 将所有的空格删除掉
cleaned_data=re.sub(r'\s+','',raw_data)
#将0x和/替换为空格
cleaned_data=cleaned_data.replace('0x',' ').replace('/',' ')
hex_parts = cleaned_data.split()
printable_chars = []
for part in hex_parts:
try:
val=int(part,16)
if 32<=val<=126:
printable_chars.append(chr(val))
except ValueError:
continue
extracted_text="".join(printable_chars)
print(f'flag在下面的字符串中{extracted_text}')
if __name__ =="__main__":
data_file="data.txt"
find_flag((data_file))
Off the grid
题目描述是:我们的一名特工设法在一台带有空隔离密码的硬件中存储了一些有价值的信息,并删除了网络中所有相关信息,但设备在运输过程中受损,OLED屏幕也坏了。我们需要帮助来恢复存储在里面的信息!
核心就是通过分析逻辑分析仪抓到的通信波形,还原出主控芯片发送给 OLED 屏幕的图像内容(即 flag),一般流程是分析是什么协议,找到各个通道是什么,然后使用脚本将提取出的数据转化为图片就可以。
分析给出的原理图发现是 sh1306型号的屏幕,这是一种标准的 SPI 通信协议。
根据原理图,我们可以得出逻辑分析仪通道与屏幕引脚的对应关系:
- CH0 -> DIN (Data In / MOSI):数据线,传输实际的数据位。( 因为屏幕是单向接收故没有
MISO) - CH1 -> CLK (Clock / SCLK):时钟线,同步数据传输。
- CH2 -> CS (Chip Select):片选线,低电平有效,告诉屏幕“现在在和你通信”。
- CH3 -> D/C (Data/Command):最关键的一根线! 数据/命令选择线。
- 当 D/C 为低电平 (0) 时:DIN 传过来的是命令(比如设置坐标、清屏)。
- 当 D/C 为高电平 (1) 时:DIN 传过来的是图像像素数据(要画在屏幕上的点)。
- CH4 -> RES (Reset):复位线。

因为是 SPI 协议所以说分析方法跟上一题一样,放大图像分析很容易知道 D1 是 SCLK 时钟信号,因为有密集的翻转。 D2 是 CS 片选信号。 D0 就是 MOSI。 D3 则是 D/C 引脚 ,低电平表示传来的数据是命令,高电平表示传来的数据是图像。

但是这样提取出的数据中包含命令不能直接转化为图像。其实也很好解决手动将命令删除掉找一下规律,根据D3 则是 D/C 引脚 ,低电平表示传来的数据是命令,高电平表示传来的数据是图像。可以发现 D/C 第一次低电平时时间比较长,红框中应是初始化命令蓝框中大概率是设置坐标,分析发现之后的每次命令对应的是0xB0~0xB7,0x02,0x10,写个脚本将这些命令删除掉就可以

使用脚本只得到一个图片但是并没有 flag ,想了一下可能是之前的脚本把所有的帧都死死地叠在同一张画布上,导致最后一帧直接覆盖了前面的内容。所以说需要修改脚本,让它每当遇到 0xBX 0x02 0x10(也就是屏幕准备从最顶端重新开始画的时候),就把上一幅画保存下来,并换一张新的白纸。

成功提取出6张图片, flag 在第四张图片中。


hidden
一条通信线路是串口协议,这道题有一个很妙的点,就是你不能使用每帧8位去读这个数据,即使波特率是正确的也读不出来,除了8位其他都可以读到下面的字符,但是正常情况下我们想到的都是8位,波特率是57600。
读到的字符如下:
k@@@E@@@b@@@k@@@n@@@m@@@k@@@d@@@m@@@d@@@I@@@O@@@d@@@I@@@a@@@I@@@J@@@L@@@b@@@p@@@b@@@J@@@F@@@p@@@E@@@L@@@n@@C@@@J@@@I@@@p@@@O@@@a@@@C@@@b@@@J@@@p@@@a@@@g@@@C@@@k@@@p@@@I@@@J@@@d@@@J@@@E@@@E@@@F@@@p@@@J@@@J@@@O@@@a@@I@@@g@@@C@@@E@@@a@@@a@@@O@@@g@@@a@@@J@@@J@@@n@@@p@@@C@@@g@@@g@@@h@@@h@@@a@@@a@@@d@@@g@@@E@@@d@@@I@@@a@@@@@@a@@@d@@@J@@@b@@@p@@@h@@@h@@@E@@@E@@@b@@@a@@@E@@@p@@@n@@@a@@@C@@@E@@@g@@@n@@@
去除@@@后
kEbknmkdmdIOdIaIJLbpbJFpELnCJIpOaCbJpagCkpIJdJEEFpJJOaIgCEaaOgaJJnpCgghhaadgEdIaOadJbphhEEbaEpnaCEgn
这道题还有一个二进制程序,看起来需要逆一下看看怎么解密得到 flag,它的主要作用是将一段 Flag 文本进行加密、拆分、膨胀,最后通过一个配置极其扭曲的物理串口发送出去。现在我们已经得到加密后的字符串,下面要做的就是逆回去得到 flag。
打开就可以看到存放flag的地方但是很明显这是本地测试所使用的。

但是我们可以发现他的初始密钥是固定的,其实就不用逆它的加密直接黑盒测试得到字典然后查字典得到 flag,下面就是写出对应的 python 版本功能是“底层仿真生成字典”和“查字典破解”,其实我比较讨厌这个步骤因为我的 python 功底不是很好,但幸运的是这部分工作可以交给 AI 来完成。
def get_simulated_datas():
"""
第一部分:在内存中模拟加密和 UART 物理层畸变
(相当于在内存里瞬间生成了那个 38KB 的 data.txt)
"""
all_data_output = ""
for i in range(32, 127):
next_in_seq = 0x2E9D3
# 内部 LCG 生成器
def key_generator():
nonlocal next_in_seq
next_in_seq = (0x303577D * next_in_seq + 5210) & 0xFFFFFFFF
return next_in_seq % 0xFF
test_char = chr(i).encode('ascii')
fake_flag = b"CTHB{" + test_char * 44 + b"}"
run_output = ""
for byte_val in fake_flag:
cipher_byte = byte_val ^ key_generator()
high_nibble = ((cipher_byte >> 4) & 0xF) + 1
low_nibble = (cipher_byte & 0xF) + 1
# UART 物理层畸变公式
parity_h = bin(high_nibble).count('1') % 2
h_char = chr(high_nibble | (parity_h << 5) | 0x40)
parity_l = bin(low_nibble).count('1') % 2
l_char = chr(low_nibble | (parity_l << 5) | 0x40)
# 拼装带着填充符 @ 的底层数据
run_output += h_char + "@@@" + l_char + "@@@"
all_data_output += run_output
return all_data_output
def extract_msg(data):
"""提取密文块(过滤掉 @)"""
msg = []
c2 = ""
i = 0
for c in data:
if c == '@': continue
i += 1
c2 += c
if i % 2 == 0:
msg.append(c2)
c2 = ""
return msg[5:-1]
def solve():
"""
第二部分:构建彩虹表字典,并破解目标密文
"""
print("[*] 正在内存中进行全链路硬件仿真...")
datas = get_simulated_datas() # 直接从内存获取数据,不需要 data.txt!
print("[*] 正在构建位置映射字典...")
lookup = {}
for i in range(32, 127):
index = i - 32
data_block = datas[index * 400: index * 400 + 400]
msg = extract_msg(data_block)
lookup[chr(i)] = msg
# 这是你在波形里揪出来的目标密文
enc_str = 'IO dI aI JL bp bJ Fp EL nC JI pO aC bJ pa gC kp IJ dJ EE Fp JJ Oa Ig CE aa Og aJ Jn pC gg hh aa dg Ed Ia Oa dJ bp hh EE ba Ep na CE'
enc = enc_str.split(' ')
print("\n[+] 🎯 字典比对完成,正在输出最终 Flag:\n")
print("CTHB{", end='')
for i in range(len(enc)):
c = enc[i]
for k, v in lookup.items():
if c == v[i]:
print(k, end='')
break
print("}\n")
if __name__ == '__main__':
solve()


浙公网安备 33010602011771号