Reverse Engineering keyboard firmware with Ghidra - Part 2

Last time, in Part 1, we found out the super-secret XOR key for the Ducky One firmware updater and used it to obtain its file header describing the firmware version and keyboard layout.

The next missing piece to find is the size of the firmware image, which will tell us which part of the .exe file contains the firmware.

To find out where to look, we need to go back to the xx_get_fw function, which takes the firmware size as a parameter:

xx_get_fw

Unfortunately, if we look specifically at the xx_internal_size parameter, Ghidra only finds read accesses and no writes. The two unnamed references are the actual write and verify operations:

xx_internal_size

At this point, I have a confession and apology to make: In the previous post, I was working on the V1.01 firmware update, and in this post we’ve switched to V1.03. This is for reasons which will be discussed later, but for now, they are similar enough that everything from last time still applies.

Given that xx_internal_size isn’t in the initialised data section of the .exe, it must be initialised at runtime, which means it’s probably read from some section near the end of the file just like the file header and firmware are. Given that the header and the firmware both also go through the “secret key” decryption process, let’s take a look at xx_secret_key again:

xx_secret_key

It’s accessed in three places: xx_get_fw, where we’ve already established it’s used to “decrypt” the firmware image, xx_read_key which is the function that reads the key and file header, and FUN_005537c0. So, let’s take a look at that one.

Chunks 1 and 2

void FUN_005537c0(void *param_1,void *param_2)
{
	byte *pbVar1;
	DWORD DVar2;
	uint uVar3;
	byte bVar4;
	uint uVar5;
	FILE *local_214;
	wchar_t local_210 [260];
	uint local_8;

	local_8 = DAT_005c14f0 ^ (uint)&stack0xfffffffc;
	DVar2 = GetModuleFileNameW((HMODULE)0x0,local_210,0x104);
	if (DVar2 != 0) {
		__wfopen_s(&local_214,local_210,L"rb");
		FUN_00553630(local_214);
		FUN_0053ab84(local_214,0xfffffd68,2);
		_fread(param_1,1,0x10,local_214);
		uVar5 = 0;
		do {
			pbVar1 = (byte *)((int)param_1 + uVar5);
			uVar3 = uVar5 & 3;
			bVar4 = (byte)uVar5;
			uVar5 = uVar5 + 1;
			*pbVar1 = (&xx_secret_key)[uVar3] ^ *pbVar1 ^ bVar4;
		} while (uVar5 < 0x10);
		FUN_0053ab84(local_214,0xfffffd58,2);
		_fread(param_2,1,0x10,local_214);
		uVar5 = 0;
		do {
			pbVar1 = (byte *)((int)param_2 + uVar5);
			uVar3 = uVar5 & 3;
			bVar4 = (byte)uVar5;
			uVar5 = uVar5 + 1;
			*pbVar1 = (&xx_secret_key)[uVar3] ^ *pbVar1 ^ bVar4;
		} while (uVar5 < 0x10);
		_fclose(local_214);
		FUN_00538ac2();
		return;
	}
	FUN_00538ac2();
	return;
}

It’s a familiar pattern at this point. Open the file, seek to somewhere near the end, read and “decrypt” some data. It does this twice for two different “chunks”.

The first chunk is at 664 bytes from the end of the file (0xfffffd68 is -664 in two’s complement, which is how all modern computers represent signed integers), and the second is 16 bytes earlier at 0xfffffd58 or -680.

This is where a slight difference between V1.01 and V1.03 arises - in V1.03, -664 is exactly 16 bytes before the start of the “header + key” block. In V1.01 the header is a little smaller.

So, in summary what we see here are two 16-byte chunks being read (for the sake of argument, xx_chunk1 and xx_chunk2. If we label those up, and check where they are in the memory map, we can see that xx_internal_size is bytes 4-7 of xx_chunk1!

xx_chunks

Copying the “read two chunks” code into my C test program, I can read and decode the two chunks and get a value for xx_internal_size: 0x56e0, or 22240 bytes.

Firmware blob

This finally tells us that the firmware is 22240 bytes long, and starts at (22240 + 680) bytes from the end of the file. In a hex editor, this looks promising. The suspected firmware is preceeded by a string of zeroes which looks a lot like padding:

FW Start

In fact, in the V1.01 file, instead of zeroes it literally contains the letters “PADDINGXX”:

Padding

Now we can put the xx_get_fw code into the test program, and read and “decrypt” the firmware. Giving us a blob of data which starts like this:

00000000: 84be c2c7 450a 0879 6c0a d553 51ce 1efc  ....E..yl..SQ...
00000010: fe5b e848 e9c1 3c77 3b74 48b7 768c cbd9  .[.H..<w;tH.v...
00000020: c68b 8c2a a8ad 6709 5c0f 52d4 f666 c3d0  ...*..g.\.R..f..
00000030: a8d0 c0ea 7494 c2e7 7f0a 0879 7c0a d553  ....t......y|..S
00000040: 41ce 1efc e85b e848 fdc1 3c77 2174 48b7  A....[.H..<w!tH.
00000050: 05cd cbd9 b5ca 8c2a dbec 6709 2f4e 52d4  .......*..g./NR.
00000060: ee66 c3d0 b6d0 c0ea 07d5 c2e7 630a 0879  .f..........c..y
00000070: 7e0a d553 41ce 1efc e85b e848 fdc1 3c77  ~..SA....[.H..<w
00000080: 2174 48b7 05cd cbd9 b5ca 8c2a dbec 6709  !tH........*..g.
00000090: 2f4e 52d4 ee66 c3d0 b6d0 c0ea 07d5 c2e7  /NR..f..........
000000a0: 630a 0879 7e0a d553 328f 1efc e85b e848  c..y~..S2....[.H
000000b0: fdc1 3c77 2174 48b7 05cd cbd9 b5ca 8c2a  ..<w!tH........*
000000c0: dbec 6709 2f4e 52d4 ee66 c3d0 b6d0 c0ea  ..g./NR..f......
000000d0: 07d5 c2e7 104b 0879 0d4b d553 328f 1efc  .....K.y.K.S2...
000000e0: 9b1a e848 afc6 3c77 7772 48b7 05cd cbd9  ...H..<wwrH.....
000000f0: b5ca 8c2a dbec 6709 2f4e 52d4 ee66 c3d0  ...*..g./NR..f..

This is not what I was hoping to see.

You see, firmware for the Cortex-M3 microcontroller in the keyboard usually starts with a special layout:

00000000: Initial stack pointer
00000004: Reset vector
00000008: NMI vector
0000000C: Hard fault
00000010: Memory management fault
00000014: Bus fault
00000018: Usage fault
0000001C: Reserved
...

This is the vector table, and is a list of pointers to interrupt service routine functions which should happen on “Reset”, non-maskable interrupt (“NMI”), “Hard fault” etc. The very first 4-byte world should be the initial stack pointer - and in most Cortex-M3 designs, RAM starts at 0x20000000, so we’d expect to see something like 0x20 in the fourth byte. This is not what we see.

To show what it should look like, we can look at the start of some Cortex-M3 firmware that I compiled myself. This clearly shows the initial stack pointer (0x20005000) followed by a very structured table of pointers to the various interrupt service routines.

00000000: 0050 0020 d112 0008 cf12 0008 410a 0008  .P. ........A...
00000010: cd12 0008 470a 0008 4d0a 0008 0000 0000  ....G...M.......
00000020: 0000 0000 0000 0000 0000 0000 cf12 0008  ................
00000030: cf12 0008 0000 0000 cf12 0008 bd0a 0008  ................
00000040: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
00000050: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
00000060: cd12 0008 cd12 0008 6907 0008 cd12 0008  ........i.......
00000070: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
00000080: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
00000090: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000a0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000b0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000c0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000d0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000e0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
000000f0: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................
00000100: cd12 0008 cd12 0008 cd12 0008 cd12 0008  ................

Furthermore, just throwing the extracted binary into a disassembler, it doesn’t really look like sensible Arm (Thumb) code - the very first instruction is allegedly a breakpoint, which I’m not sure you’d ever expect to see in compiler output, and there are a very high number of UNDEFINED an UNPREDICTABLE instructions:

       0:       be84            bkpt    0x0084
       2:       c7c2            stmia   r7!, {r1, r6, r7}
       4:       0a45            lsrs    r5, r0, #9
       6:       7908            ldrb    r0, [r1, #4]
       8:       0a6c            lsrs    r4, r5, #9
       a:       53d5            strh    r5, [r2, r7]
       c:       ce51            ldmia   r6, {r0, r4, r6}
       e:       fc1e 5bfe       ldc2    11, cr5, [lr], {254}    ; 0xfe  ; <UNPREDICTABLE>
      12:       48e8            ldr     r0, [pc, #928]  ; (0x3b4)
      14:       c1e9            stmia   r1!, {r0, r3, r5, r6, r7}
      16:       773c            strb    r4, [r7, #28]
      18:       743b            strb    r3, [r7, #16]
      1a:       b748                            ; <UNDEFINED> instruction: 0xb748
      1c:       8c76            ldrh    r6, [r6, #34]   ; 0x22
      1e:       d9cb            bls.n   0xffffffb8
      20:       8bc6            ldrh    r6, [r0, #30]
      22:       2a8c            cmp     r2, #140        ; 0x8c
      24:       ada8            add     r5, sp, #672    ; 0x2a0
      26:       0967            lsrs    r7, r4, #5
      28:       0f5c            lsrs    r4, r3, #29
      2a:       d452            bmi.n   0xd2
      2c:       66f6            str     r6, [r6, #108]  ; 0x6c
      2e:       d0c3            beq.n   0xffffffb8
      30:       d0a8            beq.n   0xffffff84
      32:       eac0 9474                       ; <UNDEFINED> instruction: 0xeac09474

It’s perfectly possible for the UNDEFINED things to simply be constants but the sheer number of them seems rather high. At this point, I’m thinking that this isn’t plain Thumb code, and something more must be going on.

Digging more

I spent a bunch more time digging through the decompilation, and wasn’t able to find anything which looked like it would further “decrypt” the firmware - as far as I could tell, the data which I’ve shown above looked like what was going to get sent over USB.

I was surprised that it didn’t look like plaintext firmware - given that it looks like the normal Holtek ISP commands are being used, I would expect the data to be a ready-to-run chunk of code.

What I did determine is the format of USB commands used. They all follow a common pattern:

Description Byte 0 (Command) Byte 1 (Sub-command) Byte 2 Byte 3 Byte 4 … Byte 63
Erase All 0x00 0xA CRC Low CRC High Don’t Care
Erase Range 0x00 0x8 CRC Low CRC High Start Addr (32-bit), End Addr (32-bit), Don’t Care
Verify 0x01 0x0 CRC Low CRC High Start Addr (32-bit), End Addr (32-bit), Data
Program 0x01 0x1 CRC Low CRC High Start Addr (32-bit), End Addr (32-bit), Data
Read 0x01 0x2 CRC Low CRC High Start Addr (32-bit), End Addr (32-bit), Don’t Care
Get Information 0x03 0x0 CRC Low CRC High Don’t Care
Reset to IAP 0x04 0x1 CRC Low CRC High Don’t Care
Reset to Normal 0x04 0x0 CRC Low CRC High Don’t Care

And guess what: It’s the same command-set which Sprite_tm found for his Coolermaster!

Annoyingly, the packets all include a CRC, which we’ll need to figure out in order to write our own.

To further confirm that I hadn’t done something wrong, I decided to try two further approaches: I’d use WinDbg on a Windows virtual machine to actually run the firmware updater and trace what happened, and use Wireshark to capture all the USB traffic during the firmware update.

This is the reason that I had to switch from V1.01 to V1.03 - the V1.01 updater I have is for a 108-key US keyboard, and I have an EU layout, so the updater refused to run, and I needed to use the V1.03 EU version.

WinDbg

WinDbg is a debugger from Microsoft, which I’d never used before but works more-or-less the same as gdb just with different commands.

Loading the firmware updater into WinDbg, the first issue I hit was that none of the addresses I saw in Ghidra were valid. The problem turns out to be that the image load address isn’t the 0x00400000 which Ghidra assumes, it was 0x009d0000 (shown in WinDbg via the lm command). I’m assuming this is due to Address Space Layout Randomisation (ASLR). The whole image is shifted, so in Ghidra’s memory map screen we can just move the base address to 0x009d0000 and we won’t need to do any translation by hand.

Memory Map

Running through the program, placing breakpoints at various places, I was pleased to see that all of my conclusions so far had been correct. The secret key, image size, chunks and even firmware contents was exactly what I had identified.

What was most interesting to me was that the nonsense binary data above is exactly what is written to the USB file.

Wireshark

I am running Windows in a virtual machine under Linux, which means that all of the actual USB traffic is going through the Linux kernel. We can use Wireshark to capture all the USB traffic, to be absolutely certain what’s being sent to the keyboard.

This was straightforward, and I captured a full firmware update session.

We can see final confirmation that the “nonsense” firmware data is exactly what’s being transferred over USB to the keyboard (also confirming my command format decoding from above). Here we see the first “Program” packet:

Wireshark

Which is decoded like so:

0x0000: 0x01 # Command - Program
0x0001: 0x01 # Sub-command - Write (not verify)
0x0002: 0x22d6 # CRC
0x0004: 0x00004000 # Start address
0x0008: 0x00004033 # End address
0x000C: 84 be c2 c2 c7 ... # Data

We can see that the data 84 be c2 c7 matches what we pulled out from the .exe. Also of note is the start address - 0x4000. This is queried from the keyboard with ISP_GetInformation(), which tells us the chip is a Holtek HT32F1654, and that the IAP/ISP start address is 0x4000. This isn’t the start of flash (which the HT32F1654 Users Manual says is 0x00000000, as you’d normally expect). This could explain why the “firmware” doesn’t start with a vector table, because it’s not the very start of the real firmware, but it doesn’t explain why it doesn’t look like valid Thumb code.

This must mean that the keyboard itself is somehow decoding the data it receives before writing it into flash.

Testing for myself

Confident that I’d correctly determined the parts of the USB protocol that I needed, I wrote a Python script to attempt to execute some of the ISP commands myself - starting with ISP_ResetToIAP(), which puts the keyboard into programming mode. During the earlier investigations with the decompiler and WinDbg I’d found out that these bits of the firmware header we extracted in Part 1 are actually the USB Vendor ID and Product IDs used by the keyboard:

0x000000: 30 34 44 39 00 00 00 00 30 31 38 38 00 00 00 00 04D9....0188....
0x000010: 31 00 00 00 30 34 44 39 00 00 00 00 31 31 38 38 1...04D9....1188

The keyboard in normal mode has VID:PID 0x04D9:0x0188, and in programming mode, 0x04D9:0x1188. I’d observed when running the .exe in my virtual machine, that when it switches to programming mode the device reset, gets a new VID/PID and the LEDs turn off. So my first goal was to reproduce this behaviour.

The first thing to figure out was which USB endpoints to talk to. In the Windows code, the device is accessed via something called the HID Top-Level Collection, which frankly I couldn’t be bothered to learn about. Using lsusb on Linux, I can see the device has 3 interfaces. I’m expecting there to be some “standard” ones (because this is a standard HID keyboard) and some custom ones for the extra custom functionality. I don’t know much about USB, but made some guesses:

$ lsusb -v -s 46
...
    Interface Descriptor:
      ...
      bInterfaceNumber        0
      bAlternateSetting       0
      bNumEndpoints           1
      bInterfaceClass         3 Human Interface Device
      bInterfaceSubClass      1 Boot Interface Subclass
      bInterfaceProtocol      1 Keyboard
        ...
    Interface Descriptor:
      ...
      bInterfaceNumber        1
      bAlternateSetting       0
      bNumEndpoints           2
      bInterfaceClass         3 Human Interface Device
      bInterfaceSubClass      0
      bInterfaceProtocol      0
      ...
    Interface Descriptor:
      ...
      bInterfaceNumber        2
      bAlternateSetting       0
      bNumEndpoints           1
      bInterfaceClass         3 Human Interface Device
      bInterfaceSubClass      0
      bInterfaceProtocol      0
      ...

lsusb tells me that the first interface (bInterfaceNumber = 0) has bInterfaceProtocol = Keyboard, which must be the standard keyboard interface.

bInterfaceNumber = 1 has two endpoints, and a bInterfaceProtocol which lsusb doesn’t know. The endpoints have 64-byte transfer sizes which matches what I see in my reverse engineering efforts, so my guess was Interface 1 is the one I want.

Firstly, I just wanted to “replay” one of the packets captured in Wireshark (which I know contains a valid CRC). It looks something like this:

import usb.core
import usb.util

dev = usb.core.find(idVendor=0x04d9, idProduct=0x0188)
if dev.is_kernel_driver_active(1):
    dev.detach_kernel_driver(1)

usb.util.claim_interface(dev, 1)

cfg = dev.get_active_configuration()
intf = cfg[(1,0)]

epout = usb.util.find_descriptor(
    intf,
    # match the first OUT endpoint
    custom_match = \
    lambda e: \
        usb.util.endpoint_direction(e.bEndpointAddress) == \
        usb.util.ENDPOINT_OUT)

# Command 0x04 = Reset
# Sub-command 0x01 = IAP
epout.write([
  0x04, 0x01, 0xe0, 0x1b, 0x3f, 0x46, 0x30, 0xb4, 0x62, 0xdc, 0x03, 0xa9,
  0xa8, 0xd3, 0x92, 0x34, 0x21, 0x61, 0x46, 0xba, 0xfa, 0x97, 0x56, 0x94,
  0xf3, 0x99, 0xf0, 0xa1, 0xf5, 0x62, 0x47, 0x32, 0x1b, 0xbe, 0x5f, 0x28,
  0xa1, 0x04, 0xcc, 0xda, 0x15, 0x23, 0xb7, 0x0d, 0x2d, 0xa2, 0x54, 0x9d,
  0xa6, 0x17, 0xb3, 0xa3, 0x9f, 0xad, 0x84, 0x33, 0x32, 0x5f, 0x3e, 0xfa,
  0x59, 0x59, 0x40, 0xe8
]

When I ran this, to my surprise, it worked! The keyboard reset and came back with no LEDs and a VID:PID of 0x04D9:0x1188!

The next step was to figure out how to make my own packets, which means figuring out how to make a valid CRC.

I’d identified the CRC routine in the decompilation, which looks like this:

uint xx_crc_one_val(uint param_1)

{
  uint uVar1;
  uint uVar2;
  
  uVar1 = -(uint)((param_1 & 0x80) != 0) & 0x1021;
  uVar2 = uVar1 * 2;
  if (((uVar1 ^ param_1 << 9) & 0x8000) != 0) {
    uVar2 = uVar2 ^ 0x1021;
  }
  uVar1 = uVar2 * 2;
  if (((uVar2 ^ param_1 << 10) & 0x8000) != 0) {
    uVar1 = uVar1 ^ 0x1021;
  }
  uVar2 = uVar1 * 2;
  if (((uVar1 ^ param_1 << 0xb) & 0x8000) != 0) {
    uVar2 = uVar2 ^ 0x1021;
  }
  uVar1 = uVar2 * 2;
  if (((uVar2 ^ param_1 << 0xc) & 0x8000) != 0) {
    uVar1 = uVar1 ^ 0x1021;
  }
  uVar2 = uVar1 * 2;
  if (((uVar1 ^ param_1 << 0xd) & 0x8000) != 0) {
    uVar2 = uVar2 ^ 0x1021;
  }
  uVar1 = uVar2 * 2;
  if (((uVar2 ^ param_1 * 0x4000) & 0x8000) != 0) {
    uVar1 = uVar1 ^ 0x1021;
  }
  uVar2 = param_1 << 0xf ^ uVar1;
  if ((uVar2 & 0x8000) != 0) {
    return uVar1 * 2 ^ 0x1021;
  }
  return uVar2 & 0xffff0000 | (uint)(ushort)((short)uVar1 * 2);
}

We see the number 0x1021 repeated over and over, which is the CRC polynomial. I’m no expert, but looking through the Python CRC module’s predefined CRC algorithms, I see a few with similar-looking polynomials. I hadn’t seen any non-zero inital value in the code, so with a bit of trial and error I determined that the CRC used is xmodem.

Pulling this into my Python code, I was able to recreate my own ISP_ResetToIAP() packet, and get the keyboard to reset.

There’s one last quirk to the protocol, which is that in IAP mode, the CRC is actually the CRC of the packet itself, and a chunk of extra data from the firmware header. So the full code to generate your own packets in IAP mode is:

extra_crc_data = [0x02, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x20,
                    0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
                    0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f,
                    0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f]
crcfunc = crcmod.predefined.mkPredefinedCrcFun('xmodem')

def gen_packet(data):
    data.extend([0] * (4 - len(data)))
    data.extend([0xff for i in range(64 - len(data))])
    crc = crcfunc(bytearray(data))
    crc = crcfunc(bytearray(extra_crc_data), crc=crc)
    data[2] = crc & 0xff
    data[3] = (crc >> 8) & 0xff
    return data

The original code pads the packet out with random data, but I just use 0xFF for simplicity - it doesn’t seem to make a difference so long as the CRC is correct.

Trying to dump FW from the keyboard

Knowing that I could now write my own ISP commands, I wanted to try and use ISP_ReadData() to dump the firmware from the keyboard. If successful, this would give me a “backup” which I could always flash back to the keyboard if I messed something up. It also might help me figure out how the “firmware” blob is encoded.

Sadly, and again the same as Sprite_tm found for his Coolermaster the keyboard firmware doesn’t allow reading of arbitrary chunks of memory - I can only read the tiny section which holds the firmware version:

00000000:  56 32 2e 31 2e 30 33 ba  ff ff ff ff ff ff ff ff  |V2.1.03.........|
00000010:  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  |................|
00000020:  ff ff ff ff ff ff ff ff  ff ff ff ff 03 02 00 0f  |................|
00000030:  17 00 00 00 ff ff ff ff  ff ff ff aa 07 00 00 00  |................|

It looks like my keyboard and the Coolermaster share the same pedigree - with the same MCU, the same ISP command set, and relatively similar firmware update programs.

Now what?

So with my attempts to dump the firmware thwarted, what options do I have left?

Well, technically I have what I need to meet my original goal - with everything I’ve learned, I should have enough to program the (encoded) firmware from a Python script. The problem is, I’m too chicken to try it, because I don’t have a backup to restore in case something goes wrong. Without a full backup, I can’t do a full erase and restore over SWD, which means I can only do updates in the same way as the firmware update program, and without being able to see exactly what the firmware is doing, I’m not confident enough that I won’t brick the keyboard.

I’ve been trying to avoid physically soldering onto the MCU to use SWD or similar to dump/program it, and from Sprite_tm’s work my guess is that the MCU has the flash protection bit set anyway, which means I won’t be able to dump the flash over SWD.

So this leaves my only option as trying to figure out the mangled firmware blob and seeing if I can recreate Sprite_tm’s flash read check bypass.

Looking closer at my data, I can see that it has a repeating pattern every 52 bytes (look for c2e7):

00000000: 84be c2c7 450a 0879 6c0a d553 51ce 1efc  ....E..yl..SQ...
00000010: fe5b e848 e9c1 3c77 3b74 48b7 768c cbd9  .[.H..<w;tH.v...
00000020: c68b 8c2a a8ad 6709 5c0f 52d4 f666 c3d0  ...*..g.\.R..f..
00000030: a8d0 c0ea 7494 c2e7 7f0a 0879 7c0a d553  ....t......y|..S
00000040: 41ce 1efc e85b e848 fdc1 3c77 2174 48b7  A....[.H..<w!tH.
00000050: 05cd cbd9 b5ca 8c2a dbec 6709 2f4e 52d4  .......*..g./NR.
00000060: ee66 c3d0 b6d0 c0ea 07d5 c2e7 630a 0879  .f..........c..y
00000070: 7e0a d553 41ce 1efc e85b e848 fdc1 3c77  ~..SA....[.H..<w
00000080: 2174 48b7 05cd cbd9 b5ca 8c2a dbec 6709  !tH........*..g.
00000090: 2f4e 52d4 ee66 c3d0 b6d0 c0ea 07d5 c2e7  /NR..f..........
000000a0: 630a 0879 7e0a d553 328f 1efc e85b e848  c..y~..S2....[.H
000000b0: fdc1 3c77 2174 48b7 05cd cbd9 b5ca 8c2a  ..<w!tH........*
000000c0: dbec 6709 2f4e 52d4 ee66 c3d0 b6d0 c0ea  ..g./NR..f......
000000d0: 07d5 c2e7 104b 0879 0d4b d553 328f 1efc  .....K.y.K.S2...
000000e0: 9b1a e848 afc6 3c77 7772 48b7 05cd cbd9  ...H..<wwrH.....
000000f0: b5ca 8c2a dbec 6709 2f4e 52d4 ee66 c3d0  ...*..g./NR..f..

There’s a good bet that 07d5 c2e7 is somehow involved as another XOR-based key for de-mangling this data.

52 is significant - each USB packet is 64 bytes, and contains 12 bytes of “header”: Command (1), Sub-command (1), CRC (2), start address (4), end address (4). This indicates to me that each packet of the firmware is treated in isolation. I also haven’t found anything in the code or the USB traffic which would give the keyboard information about any “decryption key” which might vary with time. So, my guess is that whatever “de-mangling” is going on is happening for each packet individually, and is probably the same for all of them. This also sounds familiar from the Coolermaster:

The mangling, again, wasn’t very hard: it’s just an XOR with a 52-byte key, plus some swapping of the bytes in each 32-bit word which is dependent on the previously-mentioned flash packet counter.

So that’s my plan. “Not very hard” for Sprite_tm might prove to be hard for me, hopefully we’ll find out in Part 3!