在桌上放一把带 TFT 彩色小屏幕的机械键盘,离开 Windows 驱动之后,最尴尬的不是不能换 GIF,而是屏幕右下角的时间永远停在出厂设置或最后一次插在 PC 上的那一秒。
AJAZZ AK35i V3 Max 就是这样一把三模客制化键盘。硬件底子很足:0C45:8009 控制芯片、三模切换、一颗 240 × 135 分辨率的彩色屏幕。但厂商只提供了一个 Windows 专用的 exe 驱动安装包,官方 macOS 驱动是零。
如果你在 Mac 上用它,想要时钟走准,官方给的唯一路线是:抱去一台 Windows PC,插线,打开驱动,点一次同步,再拔回来插回 Mac。
我用 Swift 6 和 IOKit 写了一个原生 macOS 控制中心与命令行工具 ak35i。包含 Swift 代码与测试一共 1297 行,打包为单文件二进制与原生 SwiftUI App。
但我做了一个在很多人看来“反直觉”的决定:界面上罗列了概览、时钟、电量、灯光、屏幕、改键、宏 7 个页面,除了时钟和设备诊断,其余 5 个功能全部亮红灯锁定,不允许任何写入操作。
这不是功能还没写完的临时占位,而是一套完整的防御性安全模型。这篇复盘讲讲从解包厂商驱动、拆解 HID 通道,到 macOS 下原生 IOKit 踩坑的完整过程,以及为什么在硬件逆向里,“忍住不猜 opcode”是保住设备唯一的办法。
一、28.9MB 驱动包里只有 1.8MB 是本体
逆向的第一步是摸清厂商到底在通过什么协议控制这把键盘。
从 AJAZZ 官方下载页拉下来的驱动包名叫 AJAZZ_AK35I_MAX_V3_Tripe_mode_RGB_with_screen_keyboard_driver_V1.0.0.zip,体积 28,924,504 字节(约 28.9 MB)。
我不想在自己的 Mac 上装来源不明的 Windows 软件,更不想为了抓包去启动装满 Hook 驱动的虚拟机。分析全部在离线只读环境进行:
zip (28.9 MB)
└── Setup.exe (29,483,325 字节, Inno Setup 6.1.0 安装器)
├── PE 映像本身约 0.9 MB (SetupLdr.exe, LZMA 解压模块)
└── 载荷 (约 28 MB)
用一次性 Alpine 容器挂载只读卷,跑 innoextract,一口气解出 145 个文件:
app/
├── DeviceDriver.exe 1,805,312 字节(真正的控制程序本体,32 位 PE)
├── mui.dll 1,195,008 字节(UI 渲染与图形转换动态库)
├── config.xml 853 字节(设备与接口声明)
├── layouts/ 键位布局数据
├── firmware/ 两个未触碰的固件升级包(80键与98键变体)
└── gif/ 留有 AJAZZ AK820Pro 的示例动图
这里马上发现了两个事实:
- 公版方案与多型号共用:驱动包目录里躺着 AK820Pro 的示例 GIF,外层 zip 叫
MAX V3,内部解出来的 PE 文件名和窗口标题却写着AK35I PRO V3。这说明厂商使用的是深圳常见的公版键盘驱动开发平台,同一套代码通过config.xml配置不同的 VID/PID、按键矩阵和屏幕尺寸。 config.xml敲定了物理身份:
USB 模式下的设备正是<keyboard name="AK35I PRO V3" device_type="101" device_info="rgb-keyboard"> <mode value="0" desc="USB" vid="0C45" pid="8009" hid_interface="VID_0C45&PID_8009&MI_00"/> <mode value="2" desc="2.4G TYPE-A" vid="0C45" pid="FDFD" hid_interface="VID_0C45&PID_FDFD&MI_03"/> </keyboard>0C45:8009,与我们在 Mac 上通过系统报告看到的 USB 设备一模一样。
二、从二进制看出的通信脉络
顺着 DeviceDriver.exe 往下摸导入表,程序并没有静态链接特殊的内核过滤驱动,而是动态加载 hid.dll 和 setupapi.dll,调用标准的 Windows HID API:
HidD_GetFeatureHidD_SetFeatureHidP_GetCaps- 以及异步的
WriteFile/ReadFile走 Output / Input 报告。
更关键的两个特征:
第一,程序在调用 HidD_GetFeature 时传入的缓冲区大小硬编码为 0x41(即 65 字节)。这与 USB HID 标准完全契合:64 字节的 Feature 载荷 + 1 字节的 Report ID。
第二,屏幕数据并不走 Feature 报告,而是走一条独立的大吞吐量数据通道。用 IOKit 扫描键盘枚举出的 HID Collections,能清晰看到三个厂商自定义集合:
| Usage Page | Usage | 报告类型与长度 | 推测用途 |
|---|---|---|---|
0xFF13 |
0x0001 |
Feature: 64B, Output: 64B | 时钟与核心配置控制通道 |
0xFFFD |
0x0001 |
Input: 64B, Output: 64B | 状态查询与按键响应 |
0xFF68 |
0x0061 |
Output: 4096B (4 KiB) | 屏幕原始图像/动图数据传输 |
在静态分析中,逆向工程师最容易犯的一个错误是:看到包结构,就以为自己掌握了协议。
我在 DeviceDriver.exe 里甚至定位出了一个 33 字节带 low-8-bit 累加和校验的逻辑包构造器。按很多开源项目的做法,这时候应该把这些 opcode 抄下来,在 UI 上做出一排“切换灯效”、“改写按键”的按钮。
但我决定停下来。
三、白名单写入:硬件开源项目的克制
软件开发里,你写错一个配置,大不了抛个异常、进程崩溃,或者删掉配置文件重来。
但在私有 USB HID 设备上,特别是带有板载 Flash / EEPROM 的机械键盘:
- 不可预期的键位死锁:发送一条语义错误的 Keymap 写入包,可能让键盘固件将某些按键映射到未定义扫描码,甚至覆盖 Bootloader 触发保护模式。
- Flash 扇区破坏:屏幕图片、宏和按键配置通常共用板载 Flash 的不同扇区。盲发未验证的写指令可能擦除相邻扇区,把不可恢复的字库或出厂校准抹掉。
- 不可逆性:macOS 下根本没有官方恢复工具。一旦变砖,你只能去借 Windows 电脑,寄希望于官方量产工具能把它救回来。
因此,在 docs/SAFETY_MODEL.md 中我立下了 6 条铁律:
1. 白名单写入:只有经过真机实测和重连验证的时钟通道允许写入。
2. 绝不枚举 opcode:不通过盲扫命令去探测设备响应。
3. 先恢复能力,后开放功能:没有验证过读取与回滚前,绝不开放改写。
4. 绝不触碰固件:固件升级、DFU 逻辑永远排除在代码库外。
5. 明确连接边界:蓝牙与 2.4G 协议未验证时不提供写入承诺。
6. 数据脱敏:不读取、不上传、不在日志显示设备硬件序列号。
在架构上,这套原则被直接编码成了状态机(DeviceModel.swift):
enum AK35iFeatureState: String, Codable {
case verified // 已真机验证,允许写入(仅时钟)
case captureRequired // 需要真实 Windows 抓包证据,锁定
case notConnected // 设备未连接
}
enum SafetyGate {
static func backupAvailability(for feature: AK35iFeature) -> BackupAvailability {
switch feature {
case .clock:
return .notNeeded("时钟同步是一次性写入,不存在可导出的板载时钟配置。")
case .screenUpload:
return .unavailable("屏幕内容尚无经验证的硬件读取协议,不能伪造备份。")
case .battery, .lighting, .keymap, .macros:
return .unavailable("此功能尚未取得 V3 Max 的读取与回滚抓包,不能安全备份。")
}
}
}
当你打开这个原生 App,切换到“RGB 灯光”或“板载改键”页时,它不是一个画好了滑块却点不动的假界面,而是明确显示:
功能锁定(等待抓包)
此功能尚未取得 V3 Max 的“操作 → 报文 → 应答 → 重连持久化”完整证据链。不猜测 opcode,不向设备盲发未知报文。
把未验证的危险暴露在界面之外,比做一个表面繁华实则危险的“半成品”驱动要负责得多。
四、时钟协议拆解与那多出来的 1 个字节
在所有功能中,屏幕时钟是唯一一个风险最低、收益最高的切入点:它是单向的状态校准,不改动键位矩阵,不改写持久宏,而且真机反馈肉眼可见。
通过实体机上对时钟同步动作的观察,时钟写入由连续 4 个 64 字节的 Feature Report 组成:
1. Start (开启校时会话)
[0x04, 0x18, 0x00, ...] (其余 62 字节填 0)
2. Preamble (同步前导报文)
[0x04, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, ...]
3. Data (时间核心数据载荷)
Byte 1: 0x01
Byte 2: 0x5A
Byte 3: 年份偏移 (Year - 2000, 比如 2026 年对应 26)
Byte 4: 月份 (1 - 12)
Byte 5: 日期 (1 - 31)
Byte 6: 小时 (0 - 23)
Byte 7: 分钟 (0 - 59)
Byte 8: 秒 (0 - 59)
Byte 10: 星期 (0 为周日,1-6 为周一至周六)
Byte 62-63: 魔数 [0xAA, 0x55] 帧尾
4. Save (确认并固化保存)
[0x04, 0x02, 0x00, ...] (其余 62 字节填 0)
看似非常直观,但在 macOS 上用 Swift IOKit 实现时,我踩了一个巨大的坑:时间死活写不进去,报文总是返回失败。
踩坑:IOKit 的 Unnumbered Report 陷阱
在跨平台的 C 语言 HID 库(如 hidapi)或 Windows 驱动中,与 HID 设备的通信接口通常要求传入包含 Report ID 的完整缓冲区:
- 如果设备固件使用的是没有 Report ID 的“无编号报告(Unnumbered Report)”,规范要求在第 0 个字节补上一个虚拟的
0x00。因此总长度是64 + 1 = 65字节。
但在 macOS 的原生 IOKit 体系里(IOHIDDeviceSetReport):
func IOHIDDeviceSetReport(
_ device: IOHIDDevice,
_ reportType: IOHIDReportType,
_ reportID: CFIndex,
_ report: UnsafePointer<UInt8>,
_ reportLength: CFIndex
) -> IOReturn
macOS 把 reportID 作为独立实参提取出来了!
如果你把为 Windows / hidapi 准备的 65 字节数组原样丢给 IOHIDDeviceSetReport(..., reportID: 0, ...),macOS 会把第一个字节 0x00 当作载荷的第 1 个数据字节发出去。
结果就是整整 64 字节的有效数据全部向后偏移了 1 位:
- 键盘固件期待在 Byte 0 看到
0x04,结果读到了0x00; - 期待在尾部看到
0xAA 0x55,结果被挤出了缓冲区!
修复这个问题的代码在 HIDFeatureReport.swift 里只有一行:
enum HIDFeatureReport {
/// hidapi represents an unnumbered HID report with a leading zero byte.
/// IOKit receives the report ID separately, so it must be omitted from the
/// data buffer passed to IOHIDDeviceSetReport/GetReport.
static func macOSPayload(from report: [UInt8]) -> [UInt8] {
precondition(report.first == 0, "AK35i RTC reports are unnumbered")
return Array(report.dropFirst())
}
}
把首字节虚拟的 0 剥除,剩下的 64 字节作为纯载荷送入 IOKit,屏幕应声刷新,时间精确同步。
五、macOS 设备匹配:别把普通键盘按键通道关了
第二个需要极其小心的工程细节是设备枚举。
很多初写 USB/HID 驱动的开发者,习惯用 VID/PID 抓取设备:
let matching: [String: Any] = [
kIOHIDVendorIDKey: 0x0c45,
kIOHIDProductIDKey: 0x8009,
]
这把键盘插上后,在 macOS 里会注册好几个 HID 接口:
- 标准键盘(Usage Page
0x0001, Usage0x0006)——这是你平时打字用的接口; - 媒体按键(Usage Page
0x000C, Usage0x0001); - 厂商私有控制通道(Usage Page
0xFF13, Usage0x0001); - 屏幕数据通道(Usage Page
0xFF68, Usage0x0061)。
如果你只用 VID/PID 进行匹配,IOHIDManagerCopyDevices 会返回 4 个设备。如果你的代码不加甄别地执行 IOHIDDeviceOpen(device, kIOHIDOptionsTypeNone),甚至尝试获取独占访问,你的键盘打字功能会当场失灵,必须拔插才能恢复。
在 HIDDiscovery.swift 和 HIDClockTransport.swift 中,必须做双重严密匹配:
let matching: [String: Any] = [
kIOHIDVendorIDKey as String: 0x0c45,
kIOHIDProductIDKey as String: 0x8009,
kIOHIDPrimaryUsagePageKey as String: 0xff13,
kIOHIDPrimaryUsageKey as String: 0x0001,
]
只有精确命中厂商私有控制集合(0xFF13:0x0001),并且在发现列表里恰好只有 1 个设备时才打开。既保证了不会串改其他 USB 设备,也保证了绝不干涉正常打字。
六、交付体验:不仅有 GUI,还有 CLI 与 LaunchAgent
做硬件伴侣工具,最怕做成“必须天天开着吃内存的 Electron 怪物”。
这个项目交付了两个形态:
-
双模运行的单文件二进制: 通过
EntryPoint.swift判定运行上下文:如果是双击 App 包或带-psn_参数启动,进入 SwiftUI 编写的控制中心;如果在终端里直接敲ak35i,则提供完整的命令行子命令:ak35i time sync --utc8 --apply # 立即对齐北京时间 ak35i time sync --local --apply # 对齐 Mac 当前系统时区 ak35i preview time --utc8 # 查看即将发送的 4 组报文,不写入 ak35i status --json # 输出当前识别到的通道 JSON -
LaunchAgent 静默自愈: 既然键盘有 RTC(实时时钟),但走时不够精确(没有网络授时),在控制中心里勾选“每日自动校时”后,程序会在
~/Library/LaunchAgents/下生成一个轻量 plist:<key>ProgramArguments</key> <array> <string>/Applications/AK35i Control Center.app/Contents/MacOS/ak35i</string> <string>time</string> <string>sync</string> <string>--utc8</string> <string>--apply</string> </array> <key>StartInterval</key> <integer>86400</integer>每天由 macOS launchd 在后台静默执行一次,执行完毕立刻退出,耗时 50 毫秒,不占任何常驻内存。
七、复盘总结:逆向与自制工具的价值边界
整个项目算上单元测试 1297 行 Swift,从逆向 Windows 驱动到发布 0.1.0-arm64.dmg,花了一个周末的时间。
回过头看,这个项目最值得留存的经验不是如何写 IOKit 的 C 指针桥接,而是:
- 面对未知硬件,克制比全能更重要:厂商没有公开规格,逆向只给了静态线索。在缺乏“读取配置 + 校验和算法 + 确认应答 + 回滚路径”四重证据前,任何写操作都是在赌博。把危险功能锁定,并诚实地告诉用户“等待抓包”,比提供一个可能把键盘刷成砖的按钮更有价值。
- 读懂协议规范中的平台差异:Windows 的 65 字节 unnumbered report 与 macOS IOKit 的 64 字节载荷剥离,是很多跨平台硬件移植中最隐蔽的暗礁。
- 保持纯净与最小依赖:不引入庞大的第三方驱动框架,用系统原生的 IOKit 与 SwiftUI,产物只有几百 KB,启动即用,完工即退。
硬件逆向不是为了和厂商对抗,而是把属于用户自己的硬件能力,安全、干净地还给用户。