在桌上放一把带 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 的示例动图

这里马上发现了两个事实:

  1. 公版方案与多型号共用:驱动包目录里躺着 AK820Pro 的示例 GIF,外层 zip 叫 MAX V3,内部解出来的 PE 文件名和窗口标题却写着 AK35I PRO V3。这说明厂商使用的是深圳常见的公版键盘驱动开发平台,同一套代码通过 config.xml 配置不同的 VID/PID、按键矩阵和屏幕尺寸。
  2. config.xml 敲定了物理身份
    <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>
    USB 模式下的设备正是 0C45:8009,与我们在 Mac 上通过系统报告看到的 USB 设备一模一样。

二、从二进制看出的通信脉络

顺着 DeviceDriver.exe 往下摸导入表,程序并没有静态链接特殊的内核过滤驱动,而是动态加载 hid.dllsetupapi.dll,调用标准的 Windows HID API:

  • HidD_GetFeature
  • HidD_SetFeature
  • HidP_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 的机械键盘:

  1. 不可预期的键位死锁:发送一条语义错误的 Keymap 写入包,可能让键盘固件将某些按键映射到未定义扫描码,甚至覆盖 Bootloader 触发保护模式。
  2. Flash 扇区破坏:屏幕图片、宏和按键配置通常共用板载 Flash 的不同扇区。盲发未验证的写指令可能擦除相邻扇区,把不可恢复的字库或出厂校准抹掉。
  3. 不可逆性: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 接口:

  1. 标准键盘(Usage Page 0x0001, Usage 0x0006)——这是你平时打字用的接口;
  2. 媒体按键(Usage Page 0x000C, Usage 0x0001);
  3. 厂商私有控制通道(Usage Page 0xFF13, Usage 0x0001);
  4. 屏幕数据通道(Usage Page 0xFF68, Usage 0x0061)。

如果你只用 VID/PID 进行匹配,IOHIDManagerCopyDevices 会返回 4 个设备。如果你的代码不加甄别地执行 IOHIDDeviceOpen(device, kIOHIDOptionsTypeNone),甚至尝试获取独占访问,你的键盘打字功能会当场失灵,必须拔插才能恢复。

HIDDiscovery.swiftHIDClockTransport.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 怪物”。

这个项目交付了两个形态:

  1. 双模运行的单文件二进制: 通过 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
  2. 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 指针桥接,而是:

  1. 面对未知硬件,克制比全能更重要:厂商没有公开规格,逆向只给了静态线索。在缺乏“读取配置 + 校验和算法 + 确认应答 + 回滚路径”四重证据前,任何写操作都是在赌博。把危险功能锁定,并诚实地告诉用户“等待抓包”,比提供一个可能把键盘刷成砖的按钮更有价值。
  2. 读懂协议规范中的平台差异:Windows 的 65 字节 unnumbered report 与 macOS IOKit 的 64 字节载荷剥离,是很多跨平台硬件移植中最隐蔽的暗礁。
  3. 保持纯净与最小依赖:不引入庞大的第三方驱动框架,用系统原生的 IOKit 与 SwiftUI,产物只有几百 KB,启动即用,完工即退。

硬件逆向不是为了和厂商对抗,而是把属于用户自己的硬件能力,安全、干净地还给用户。