2026/9/25 9:52:18

CTF流量分析实战:USB键盘鼠标流量报告还原指南

CTF流量分析实战:USB键盘鼠标流量报告还原指南 大约五六年前我第一次在CTF赛题里见到 usb.pcap 这个文件时整个人是懵的。题目描述写得很文艺——有人用键盘敲了一封信。但捕获文件出了点问题:数据包的存储打开Wireshark之后满屏都是 interrupt in 包一排排字节看得我头皮发麻。当时我完全想不到键盘的每一次按下、鼠标的每一次移动居然都会被USB总线原原本本记录下来而且还原之后就是flag。后来在Misc题里反复碰壁又反复复盘才算把USB流量这几类常见题型摸清楚。这篇是CTF流量分析系列的第二篇专讲USB流量面向正要入门或是已经卡在杂项题里的朋友。你会发现USB流量题并不神秘核心就一句话:搞清楚设备是什么、报告怎么组织、再把HID报告翻译回人话。1. 先分清手里的pcap到底是键盘还是鼠标1.1 USB抓包抓到的到底是什么很多人一看到USB两个字就心里发怵总觉得那是硬件逆向才需要关心的东西。其实CTF里出现的USB pcap包绝大多数是usbmon或者内核抓包工具在主机侧抓下来的总线数据里面保存的是主机与设备之间的URBUSB Request Block。URB是USB世界里最小的传输单元它带着设备地址、端点和数据缓冲区。我们常说的USB流量分析本质上就是分析这些URB里的payload而不是像TCP流量那样去重组一段完整的会话。USB设备品类很多但CTF流量题用得最多的是HID类设备也就是键盘、鼠标这类人机接口设备。HID设备有个特点它与人交互的每一步都会主动向主机发送一个输入报告Input Report。键盘输入一个字符发送一帧报告鼠标移动一小段也发送一帧报告。这些报告格式固定、长度固定、字段含义公开所以只要抓到了就可以从总线数据里把用户敲了什么、画了什么一点不差地还原出来。网上流行一句话叫流量分析一把梭实际上USB流量恰恰是最不能一把梭的题型——你连处理对象是键盘还是鼠标都得先搞清楚。1.2 开局三连问:设备地址、传输类型、HID数据我的习惯是拿到USB pcap之后先不急着用Wireshark一张张翻帧而是打开终端跑几个tshark命令把三个关键问题问清楚。第一问:这个pcap里有哪些USB设备键盘鼠标这类交互设备都走中断传输Interrupt Transfer所以先用传输类型过滤看一眼:tshark -r usb.pcap -Y usb.transfer_type 0x03 -T fields -e usb.bus_id -e usb.device_address -e usb.idVendor -e usb.idProduct | sort | uniq -c0x03对应USB 2.0协议里的中断传输。输出里每一行代表总线上出现过的一个设备地址uniq -c 前面的数字能让你直观看到哪个设备在疯狂发包——一般情况下发包最多的就是键盘或者鼠标。如果厂商ID和产品ID都显示出来了对照一下USB ID数据库基本就能确定设备类型。第二问:Wireshark认不认得出里面有HID数据这一步决定你后面怎么提取:tshark -r usb.pcap -Y usbhid.data -T fields -e usb.device_address -e usbhid.datausbhid.data是Wireshark的HID解析器从URB payload里解析出的报告字段。能列出数据说明抓包质量不错可以直接在这个字段上做文章。如果这个命令一条输出都没有就要去看原始的usb.capdata字段或者检查是不是设备被识别成了厂商私有设备。第三问:关键数据都在哪个方向USB传输是有方向的设备到主机称为IN主机到设备称为OUT。键盘和鼠标的输入报告一定是IN方向所以后续的提取命令里通常要加上方向过滤:tshark -r usb.pcap -Y usbhid.data usb.endpoint_address.direction 1 -T fields -e usb.device_address -e usb.endpoint_address -e usbhid.data这里direction 1表示IN。不同Wireshark版本里这个方向字段的写法偶尔有差异如果过滤没生效退一步用usb.endpoint_address直接看以0x81这类地址开头的就是IN端点。三问跑完这个pcap里有没有键盘、有没有鼠标、数据能否提取心里基本就有数了。为了不让后面看命令的人迷惑我把几个常用的传输类型数字列在这里:数值传输类型常见用途0x00Control控制传输设备枚举、获取描述符、设置地址0x01Isochronous等时传输音频、视频等实时数据0x02Bulk批量传输U盘、大块文件传输0x03Interrupt中断传输键盘、鼠标、游戏手柄1.3 报告长度直接告诉你这是键盘还是鼠标HID报告的长度其实是个很好的判断信号。普通键盘在Boot协议下的报告长度是8字节从0到7分别是修饰键、保留字节、六个普通按键鼠标常见的报告长度则是4字节前三个字节装按键和位移第四个字节是滚轮。一旦你从tshark里提取出一条条数据看它们的十六进制长度就能判断题目大致方向:8字节多半是键盘题4字节或6字节多半是鼠标题。报告长度常见设备主要还原目标8字节键盘Boot Keyboard按键序列转字符串4字节三键鼠标坐标轨迹、按下状态6字节带滚轮/高分辨率鼠标轨迹、滚轮事件8字节部分游戏鼠标/手柄坐标或其他数值当然这里面有例外比如有些自定义HID设备也会选择8字节的报告长度但字段含义完全不同。所以我的建议是:长度只是线索真正决定解题思路的还是字段内容。2. 键盘流量:把每次按键翻译成字符2.1 8字节报告里哪一位才是按键值键盘的Boot Keyboard报告结构非常规整8个字节各司其职。从下标0开始:Byte 0修饰键Modifier是一个8位位图bit0~bit7分别代表左Ctrl、左Shift、左Alt、左GUI、右Ctrl、右Shift、右Alt、右GUI。注意它不是一个字符而是一组开关状态。Byte 1保留字节标准键盘报告里恒为0x00。Byte 2当前按下的第一个按键的键值HID Usage ID。Byte 3~7当前按下的第2~6个按键的键值按住多键时才有内容单键输入时全为0x00。所以标准键盘题里每一帧8字节里真正需要关心的就是Byte 2。这也是网上几乎所有提取脚本的核心逻辑。但只有Byte 2还不够因为大小写和符号层的决定权在Byte 0没有修饰键状态你只会得到一份全小写字符串。2.2 键值对照表:背不下没关系用的时候查得到就行HID键值表是USB HID规范里的Usage ID表一个键值对应一个物理按键。CTF题里用到的其实就两大类:字母数字、常见符号。下面这张表是我比赛中常用的核心子集够覆盖绝大多数题目。HID键值(Hex)无Shift输出加Shift输出备注0x04aA字母区0x05bB0x06cC0x07dD0x08eE0x09fF0x0agG0x0bhH0x0ciI0x0djJ0x0ekK0x0flL0x10mM0x11nN0x12oO0x13pP0x14qQ0x15rR0x16sS0x17tT0x18uU0x19vV0x1awW0x1bxX0x1cyY0x1dzZ0x1e1!数字与符号0x1f20x203#0x214$0x225%0x236^0x2470x258*0x269(0x270)0x28EnterEnter功能键0x29EscEsc0x2aBackspaceBackspace0x2bTabTab0x2c空格空格0x2d-_符号键0x2e0x2f[{0x30]}0x31\|0x33;:0x340x35~0x36,0x37.0x38/?完整表可以查USB HID Usage Tables官方文档里的Keyboard/Keypad页。比赛时不用背直接查表或者写进脚本里的字典就行。2.3 从pcap到明文的完整提取脚本第一步把报告提取到文本文件:tshark -r usb.pcap -Y usbhid.data usb.device_address 2 -T fields -e usbhid.data keys.txt注意设备地址要根据第1节的探测结果改别把鼠标和其他设备混进来。如果usbhid.data提取不到数据把字段换成usb.capdata再试一次。第二步用Python脚本还原:# 还原按键序列 import sys hid2char { 0x04: (a, A), 0x05: (b, B), 0x06: (c, C), 0x07: (d, D), 0x08: (e, E), 0x09: (f, F), 0x0a: (g, G), 0x0b: (h, H), 0x0c: (i, I), 0x0d: (j, J), 0x0e: (k, K), 0x0f: (l, L), 0x10: (m, M), 0x11: (n, N), 0x12: (o, O), 0x13: (p, P), 0x14: (q, Q), 0x15: (r, R), 0x16: (s, S), 0x17: (t, T), 0x18: (u, U), 0x19: (v, V), 0x1a: (w, W), 0x1b: (x, X), 0x1c: (y, Y), 0x1d: (z, Z), 0x1e: (1, !), 0x1f: (2, ), 0x20: (3, #), 0x21: (4, $), 0x22: (5, %), 0x23: (6, ^), 0x24: (7, ), 0x25: (8, *), 0x26: (9, (), 0x27: (0, )), 0x28: (\n, \n), 0x29: ([Esc], [Esc]), 0x2a: ([BS], [BS]), 0x2b: ([Tab], [Tab]), 0x2c: ( , ), 0x2d: (-, _), 0x2e: (, ), 0x2f: ([, {), 0x30: (], }), 0x31: (\\, |), 0x33: (;, :), 0x34: (, ), 0x35: (, ~), 0x36: (,, ), 0x37: (., ), 0x38: (/, ?), } for line in sys.stdin: line line.strip() if not line: continue try: data bytes.fromhex(line) except ValueError: continue if len(data) 3: continue modifier data[0] key data[2] if key 0: continue item hid2char.get(key) if item is None: continue norm, shifted item if (modifier 0x02) or (modifier 0x20): # Left Shift 或 Right Shift sys.stdout.write(shifted) else: sys.stdout.write(norm) sys.stdout.flush()脚本思路很直白逐行读入十六进制报告取Byte 2的键值查表再根据Byte 0里的Shift位决定输出哪个字符。注意修饰键判断用的是位运算modifier 0x02和modifier 0x20这比直接比较整形值要严谨因为有时多个修饰键会同时按下。如果还原出来的字符串里夹杂着大量重复字符你再回头检查原始数据:很多抓包会把按下和松开分成两帧一按一松字符就会重复两次。此时可以在脚本里加一个简单的去重逻辑比如记录上一次输出的键值连续相同就不重复输出。但要注意有些题目恰恰用连续输入同样字符来藏信息所以去重之前一定要先用肉眼观察原始序列有没有隐藏意义。2.4 键盘题最容易翻车的四个细节第一Shift状态不是只看某一帧。虽然上面的脚本是按帧判断Shift但真实键盘的Shift按下和松开可能相差好几帧如果题目故意把report打乱结果会错位。比较稳的做法是先按时间顺序把所有非空键值列出再用一个状态机维护Shift的按下与松开这样复杂类型却不容易出错。第二不要用字符串比较代替位运算。之前见过有人写if modifier 0x02一旦出现CtrlShift组合键这个判断就失效整串flag少几个字符还找不到原因。修饰键本质上是一个8位位图存在多个修饰键同时按下的情况所以必须用位运算去判断每一位。第三注意方向过滤。键盘输入的IN包才是有效payload有些URB的OUT包是主机给键盘的LED状态反馈里面也会有个别非零字节。有人不过滤方向直接全量提取结果字符串里多出不少乱码还以为是键值表错了。第四usbhid.data提取不到数据时优先看usb.capdata。两字段的差异在于前者是解析器识别成HID报告之后给出的字段后者是URB层原始数据。遇到抓包工具比较老或者设备描述符不全的情况分析器可能没能识别HID直接改用usb.capdata往往就能拿到原始报告。3. 鼠标轨迹还原:相对位移拼出的画面3.1 鼠标报告是一组有符号位移鼠标报告的通用结构比键盘更灵活最常见的4字节格式是:Byte 0按钮状态bit0代表左键bit1代表右键bit2代表中键Byte 1X方向相对位移类型为int8Byte 2Y方向相对位移类型为int8Byte 3滚轮位移类型为int8这里的X、Y是相对位移——鼠标每次上报的不是我在屏幕哪个坐标而是我从上次位置往右移了多少、往下移了多少。第一次使用的人最常犯的错就是拿到十六进制之后按无符号数解析。比如0xff在X位移里不是255而是-1因为它是int8的补码表示。忽略这一点轨迹会在某几条边上来回乱跳。用Python处理时记得把字节变成有符号整数:x int.from_bytes(data[1:2], byteorderlittle, signedTrue) y int.from_bytes(data[2:3], byteorderlittle, signedTrue)如果你手里的报告不止4字节比如6字节或8字节不要急着套这个解析。6字节版本通常带分辨率或者额外按钮8字节版本可能是游戏鼠标的私有格式先到Wireshark里看有没有HID Report Descriptor再决定每个字节的含义。3.2 从相对位移到绝对坐标:累加、偏移、放大因为报告给的是相对位移要画出完整轨迹必须把每一帧的位移累加成一个绝对坐标。pos_x x pos_y y points.append((pos_x, pos_y))考虑到位移范围很小通常每个坐标的取值在[-2, 2]附近直接画出点图会糊成一团。这时要把坐标成倍放大常见的是乘以10到50倍。还有一个细节:鼠标报告里的Y方向和屏幕坐标不一定一致有的题目画出来是镜像你可以先画一版看看如果flag是倒着的翻转Y轴重画如果字是反的翻转X轴。别问我为什么知道——我第一次画出来是一堆镜像字符差点以为自己数据提错了。3.3 用PIL把轨迹画成一张看得懂的图下面是一段可直接用的绘图脚本PIL里最基础的操作:from PIL import Image, ImageDraw points [] pos_x, pos_y 0, 0 with open(mouse.txt) as f: for line in f: line line.strip() if not line: continue data bytes.fromhex(line) if len(data) 3: continue pos_x int.from_bytes(data[1:2], little, signedTrue) pos_y int.from_bytes(data[2:3], little, signedTrue) points.append((pos_x, pos_y)) min_x min(p[0] for p in points) min_y min(p[1] for p in points) max_x max(p[0] for p in points) max_y max(p[1] for p in points) scale 20 w (max_x - min_x) * scale 10 h (max_y - min_y) * scale 10 img Image.new(RGB, (w, h), white) draw ImageDraw.Draw(img) for px, py in points: draw.rectangle( [(px - min_x) * scale, (py - min_y) * scale, (px - min_x) * scale 2, (py - min_y) * scale 2], fillblack ) img.save(mouse_trail.png)画完先用看图软件打开。如果flag是一串手写字符黑点直接就是答案如果flag被夹在一些无关移动里那多半要看按钮状态来切分轨迹——左键按下期间的轨迹才是有效笔迹。注意脚本里我用了offset来避免负坐标因为累加过程中坐标可能为负直接画会把整个图案压到画布角上。3.4 鼠标题里容易错过的信息细节鼠标题并不都是画轨迹。有几种变体我见过不止一次:第一种位移数值本身就编码了字符。如果某道题所有位移都很小比如只在0到25之间变化把它转成a-z看看很可能flag就藏在位移序列里。判断依据很简单——位移如果全是正数且范围很小画图通常是看不出形状的但它们可能代表字母。第二种按钮状态是分割符。左键0和1的切换表示抬笔和落笔这时要把按下的轨迹段分开画每段单独着色不然所有线段会连在一起看不出任何图形。第三种滚轮数据参与计算。6字节报告中第4个字节代表滚轮有时候题目会把横向移动隐藏在滚轮里遇到这种题必须把滚轮和X位移一起累加否则轨迹总是缺一条轴。第四种多鼠标设备。pcap里如果有两个鼠标在同时移动先按设备地址过滤分开画两个轨迹你可能会发现一个鼠标负责拖出flag另一个鼠标只是干扰项。4. 进阶题型:混合设备、U盘镜像与私有协议4.1 多设备混合时先把报告按设备拆开现实中USB总线上不可能只挂一个设备。比赛题为了增加难度经常在同一个pcap里同时抓下键盘、鼠标、甚至U盘的通信。如果你不管设备地址直接提取usbhid.data得到的就是一堆来自不同设备的报告混在一起还原出来的字符串乱七八糟。正确的做法是先在前面的探测命令里把每个设备地址对应的数据分别导出:for addr in 2 3 4; do tshark -r usb.pcap -Y usbhid.data usb.device_address $addr \ -T fields -e usbhid.data dev_$addr.txt done然后逐个跑还原脚本。很多时候光是这一步就能把干扰清除干净。需要注意同一个设备地址在不同USB总线编号下可能复用严谨一点还要加上usb.bus_id条件。4.2 Mass Storage类:别拿键盘脚本去解U盘镜像有些题表面打着USB流量分析实际上要还原的是一个U盘镜像。这类题的pcap里键盘鼠标的报告几乎为零大量出现的是批量传输Bulk数据。USB存储设备用Bulk-Only Transport协议传输数据数据块外面裹着CBW命令块包装和CSW状态包装去掉这些包装之后里面就是按512字节对齐的磁盘扇区。CBW固定31字节CSW固定13字节识别签名分别是USBC和USBS在连续数据流里找到这两个签名就能切出真正的扇区数据。判断是不是这种类型有个土办法:用tshark过滤批量传输的IN方向数据统计一下payload长度是否高度集中在512字节或其整数倍。如果是基本可以断定这是一个U盘镜像在往外传。提取思路一般是:tshark -r usb.pcap -Y usb.transfer_type 0x02 usb.endpoint_address.direction 1 \ -T fields -e usb.capdata bulk_in.txt再用Python把所有十六进制payload按顺序拼成一个文件剔除CBW/CSW头最后交给binwalk或foremost去扫。这个方向其实已经比较靠近磁盘取证而不是流量协议分析但既然它出现在USB流量分类下多少还是要会一点。4.3 Badusb与厂商私有HID协议近两年的赛题喜欢考一种场景:攻击者往主机上插了一个模拟键盘的设备这个设备会自动输入一串命令。这类题在pcap里看到的设备可能是一个陌生的厂商ID数据报告也是8字节键盘格式还原出来的字符串不是flag本身而是一段powershell命令或者base64字符串需要你继续解码。还有个更隐蔽的情况:设备以厂商私有协议上报报告的第一个字节不是修饰键而是Report ID。如果不先看HID Report Descriptor直接把每个字节按标准键盘报告去解析前两个字节就会被错位理解还原结果会差之千里。我的自查办法是:如果标准8字节脚本还原出的字符串前几个字符明显不合逻辑先回到Wireshark里看这个报告有没有带Report ID再决定解析起点是不是要从下标2改成下标3。这种题对基本功的要求是能看懂设备描述符和端点描述符。不用背但要能在Wireshark的USB描述符树里找到Report Descriptor看懂报告里每个字段的位置和长度。4.4 捕获文件被做过手脚时的排查思路题目描述里如果写了数据包的存储出了问题多半是在提醒关注底层字节的排列方式。实战中我遇到过三种改法:第一种每个报告被截断只剩下前几个字节。键盘报告只剩4字节时按8字节解析你会丢掉后4个按键如果flag恰好是连续多键输入结果就是不全的。这种只能用usb.data_fragment这类字段去尝试重组。第二种报告字节被翻转。比如每2字节一做一次高低位交换或者整体倒序前者会让修饰键跑到Byte 2上后者会直接让整个解析脚本失效。识别方法很简单:把第一个非零报告手工拆一下看哪些字节符合修饰键的习惯位置。第三种报告被拆成多段存储。一个8字节键盘报告被拆成两个4字节的URB按帧解析时你会看到同一帧里出现两个不完整的字段。处理思路是先把同一次事务里的fragment拼起来再解析。遇到这类题我的经验是先不要怀疑脚本有bug。准备一段已知的按键输入作为标定数据如果脚本跑通样例仍然解析不了赛题数据再去看字节层面的特殊处理。5. 比赛现场能救命的一些小习惯5.1 平时就把脚本库准备好CTF比赛是限时的现场写脚本不是不行但压力很大。我习惯把USB流量相关的工具提前放在一个目录里包含这些脚本:键盘报告还原脚本支持Shift和符号层鼠标轨迹绘图脚本支持按钮状态切分轨迹设备探测脚本一键列出全部设备的地址、厂商、传输类型批量传输提取脚本用于Mass Storage类题目报告字节排列检测脚本快速判断有没有Report ID、字节是否倒序再加上一个可以查HID键值表的离线文件内存放不下也没关系。另外像随波逐流这类CTF离线工具箱我平时也会带一份比赛时处理十六进制、ASCII和base64互转能省不少时间。5.2 tshark几个字段用熟了效率翻倍很多人在Wireshark图形界面里手工复制数据费时还容易出错。我更推荐直接用tshark批量导出。几个核心字段总结如下:tshark字段含义主要用途usb.device_address设备地址分离多设备usb.bus_id总线编号区分不同hub总线usb.idVendor / usb.idProduct厂商/产品ID判断具体设备usb.transfer_type传输类型判断中断/批量/控制usb.endpoint_address.direction传输方向只取IN或OUT数据usbhid.dataHID报告键鼠题核心数据usb.capdataURB原始数据解析器没识别HID时的备选usb.data_fragment数据分片报告被拆分时重组稍微背熟这张表现场过滤命令基本可以脱离搜索。用-T fields配合-e指定字段再配合-Y过滤表达式一条命令搞定提取比在GUI里多点好几下要快得多。5.3 键值映射之外还有两个隐藏门槛第一是Boot Protocol和Report Protocol的差异。键盘鼠标在启动阶段通常运行在Boot Protocol下报告结构是固定的进入操作系统后可能切换到Report Protocol报告结构由设备描述符动态决定。如果用死记的8字节键盘结构去解非Boot协议的设备前几个字节的含义就会出错。遇到奇怪长度的报告建议先看Report Descriptor。第二是OUT方向包里也能藏信息。主机给键盘的LED状态反馈NumLock、CapsLock、ScrollLock就是OUT方向的中断包第0字节的bit0到bit2对应这三个灯。有的题目不给你IN方向的按键report只给你OUT方向的反馈包这时候可以用LED状态变化来推断哪些键被按过虽然数据粒度粗但总比没有强。5.4 还原结果不是可读文本的时候如果完整跑完键盘脚本得到的不是flag而是乱码先别急着改脚本。把报告里的非零键值全部打印出来自己数一数数量:不同键值的种类是不是明显超过26个字母加10个数字如果是很可能题目对报告本身做了二次编码常见的有字节异或和base64。可以先把所有Byte 2的值dump成一串十六进制再按字节频率观察规律。另一个容易被忽略的情况是按下的不是普通键而是组合快捷键这时候Byte 3~7里会有内容还原出来不是字符而是CtrlC这类键位序列需要结合上下文去理解。最后分享一个我自己的习惯:解完USB流量题之后我会把原始按键流和还原出的字符串都保存一份并顺手用脚本统计一下按键次数。之所以这么做是因为有些题把flag藏在某个键被按了多少次或者连续两次同键之间的间隔里只看最终字符串会漏掉这类时间维度上的信息。USB流量分析的终点不一定是字符把报告拆成结构化数据之后很多隐藏线索才有地方放。上面这些思路我从训练到比赛用了好几年基本覆盖了主流USB流量题的考法如果你正好卡在杂项这关照着这个流程走一遍看到usb.pcap应该不会再发怵了。