Cyber Apocalypse 2024: Hacker Royale: Flash-ing Logs

Time 10 minute read

“Read and decipher the encrypted memory of a W25Q128 flash memory chip, parse custom event logs and patch the memory to erase all traces of our mischief.”

This was the last challenge of the hardware category of the 2024 Hackthebox Cyberapocalypse (Hacker Royale), rated hard. I played this CTF with the GCC team. We ended up 31fst out of 5693 teams, and the third french team. Shout out to the two other teams, Synacktiv (3rd) and EDFunit61000 (27th).

I’m usually not really into hardware, but somebody had to do it (and it ended up being a very fun challenge to solve).

After deactivating the lasers, you approach the door to the server room. It seems there’s a secondary flash memory inside, storing the log data of every entry. As the system is air-gapped, you must modify the logs directly on the chip to avoid detection. Be careful to alter only the user_id = 0x5244 so the registered logs point out to a different user. The rest of the logs stored in the memory must remain as is.

We are provided with 2 files:

You can download the files yourself just above, I will only put snippets of code throughout the article.

The goal is to read the flag at the address [0x52, 0x52, 0x52] (given in the client.py file) once we were able to replace our userID by someone elses, while leaving the rest of the memory intact.

In the client file, we have a function to send commands to the chip, and one usage example:

# Example command
jedec_id = exchange([0x9F], 3)
print(jedec_id)

For the rest of the challenge, we are going to base all our commands on this documentation : https://www.pjrc.com/teensy/W25Q128FV.pdf

After a few tests, we can deduce the correct command format:

exchange(array_of_bytes_composing_the_command, number_of_bytes_to_read)

That means that if we have to send a command ID, and some parameters, it will be within the same byte array. This will be more clear as we go on to read the memory in the next section.

In this section, we will focus on how we dumped the memory, read the encryption key and used it to decrypt the memory.

It’s time to take a look at the functions responsible for serializing a log event and encrypting it. For more clarity, I removed all the debug prints.

The function log_event takes a SmartLockEvent to write, and the address to write it to. encrypt_data takes as parameters the data to encrypt, its length, register number and address.

int log_event(const SmartLockEvent event, uint32_t sector, uint32_t address) {
    uint8_t buf[256]; 
    uint8_t buffer[sizeof(SmartLockEvent) + sizeof(uint32_t)]; // Buffer for event and CRC
    uint32_t crc;
    memset(buffer, 0, sizeof(SmartLockEvent) + sizeof(uint32_t));
    memcpy(buffer, &event, sizeof(SmartLockEvent)); // Serialize the event
    crc = calculateCRC32(buffer, sizeof(SmartLockEvent)); // Calculate CRC for the serialized event
    memcpy(buffer + sizeof(SmartLockEvent), &crc, sizeof(crc)); // Append CRC to the buffer

    write_to_flash(sector, address, buffer, sizeof(buffer)); // This calls encrypt_data

    return 1
}

void encrypt_data(uint8_t *data, size_t data_length, uint8_t register_number, uint32_t address) {
    uint8_t key[KEY_SIZE];

    read_security_register(register_number, 0x52, key); // register, address

    // Apply encryption to data, excluding CRC, using the key
    for (size_t i = 0; i < data_length - CRC_SIZE; ++i) { // Exclude CRC data from encryption
        data[i] ^= key[i % KEY_SIZE]; // Cycle through  key bytes
    }
}

The code above tells us that the encryption key is read from a so called “Security Register”, at address 0x52. Next the data is xored with that key. However, the CRC that is present at the end of the data is not encrypted.

This function is called only one time.

encrypt_data(data, length, 1, address); 

This means that the key is read from the register number one (the value is hardcoded). Based on the documentation, the command 0x48 (Read Security Registers) has the following format.

Read Security Registers
Read Security Registers

In our case the command would be the following:

KEY_SIZE = 12
key = exchange([0x48, 0x0, 0x10, 0x52], KEY_SIZE)

It’s worth noting that those registers are “One Time Programmable Memory” slots (OTP), which means that they can’t be overwritten like the rest of the chip’s memory.

Reading the memory is done through the 0x03 (Read Data) command. This one takes the start address as a parameter, and the amount of data to read.

After some experimenting we noticed that only the first 2560 bytes are populated (10 pages of 256 bytes of memory). After that, everything is set to 0xFF, which is an empty memory cell.

PAGE_SIZE = 256
encrypted_memory = exchange([0x03, 0x0, 0x0, 0x0], PAGE_SIZE*10)

Here, we read 2560 first bytes, starting at address 0x000000.

Now decrypting is fairely easy, IF you read the source code with attention.

My first attempt was the following:

decrypted_memory = []
for i, d in enumerate(encrypted_memory):
  decrypted_memory.append(d ^ key[i % KEY_SIZE])

This would work fine if the entire memory was encrypted as one block. But as we saw earlier, the encryption happens individually for each log event, and only encrypts the log data, not the CRC checksum.

Let’s first check the size of one block. One log event is defined as follows:

// SmartLockEvent structure definition
typedef struct {
    uint32_t timestamp;   // Timestamp of the event
    uint8_t eventType;    // Numeric code for type of event // 0 to 255 (0xFF)
    uint16_t userId;      // Numeric user identifier // 0 t0 65535 (0xFFFF)
    uint8_t method;       // Numeric code for unlock method
    uint8_t status;       // Numeric code for status (success, failure)
} SmartLockEvent;

The total size is 9 bytes. However, C has a padding mechanism for datastructures, to align data in memory. My knowledge on the matter is not very deep, so what I did was simply compile a little program with an instance of the SmartLockEvent struct, and check the content. It turns out the total structure size is 12 bytes, as one byte of padding is inserted between the eventType and the userId, and two others at the end after the status attribute.

That means our key is the same size as one event log block.

STRUCT_SIZE = 12
CRC_SIZE = 4
BLOCK_SIZE = STRUCT_SIZE + CRC_SIZE
decrypted_memory = []
for i in range(0, len(encrypted_memory), BLOCK_SIZE):
    for j in range(STRUCT_SIZE):
        decrypted_memory.append(encrypted_memory[i+j] ^ key[j])
    decrypted_memory += encrypted_memory[i+STRUCT_SIZE:i+BLOCK_SIZE]

This is my solution to decrypt correctly the memory.

At this point we have access to the un-encrypted memory of the flash chip. We now have to parse that memory to extract the log entries and patch the relevent entries.

Here are the steps to follow :

  1. Loop on the decrypted memory by steps of 16 bytes
  2. Parse the block to extract all the fields of the log entry and the CRC checksum
  3. Identify the ones relative to our user ID (0x5244)
  4. Change the user ID
  5. Recompute the CRC checksum
  6. Encrypt the log entry
  7. Replace the log entry inside the encrypted byte array

This is how I parsed the log entries.

data = bytes(decrypted_memory[i:i+BLOCK_SIZE])
timestamp, event_type, pad1, user_id, method, status, pad2 = struct.unpack("<I B B H B B H", data[:STRUCT_SIZE])
crc, = struct.unpack("<I", data[STRUCT_SIZE:])
computed_crc = calculate_crc32(data[:STRUCT_SIZE])

assert(crc == computed_crc)

The calculate_crc32 function is re-implementation of the CRC function from the original source code, in python.

def calculate_crc32(data):
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0xEDB88320
            else:
                crc >>= 1
    return ~crc & 0xFFFFFFFF

If the userID we extracted matches our own (0x5244), we forge the entry with a modified user ID like this:

fake_user_id = 0x5244 + 1
fake_data = struct.pack("<I B B H B B H", timestamp, event_type, pad1, fake_user_id, method, status, pad2)
fake_crc = (struct.pack("<I", calculate_crc32(fake_data)))

The encryption process is a simple xor of the first 12 bytes with the key. We finally add the CRC at the end.

encrypted_fake_data = []
for j, d in enumerate(fake_data):
    encrypted_fake_data.append(d ^ key[j])
encrypted_fake_data += fake_crc

What we call patching is simply replacing the log entry in the encrypted_memory with our new one.

encrypted_memory = encrypted_memory[:i] + encrypted_fake_data + encrypted_memory[i+BLOCK_SIZE:]

Now we just repeat this process for each 16 byte block!

Now that we have an array of bytes containing the patched memory, we only have to write it onto the chip and get our flag.

Writing can be done over the 0x02 (Page Program) command, that allows us to write up to 256 bytes at a certain address. This command takes as parameters a 3 bytes address, followed by the data to write. However, writing only works properly on a empty memory cell.

There are several commands that could be used to erase the memory region we need (since only a little area of the memory is populated, we don’t have to worry about deleting data). I used the 0x20 (Sector Erase) command, that erases 4096 bytes of data at a given address.

Once this is done, I called upon the Page Program command 10 times, to write the entire patched memory.

Finally, we read the flag at the address given by the challenge.

PAGE_SIZE = 256

# Wipe chip memory
exchange([0x06])
exchange([0x20, 0x0, 0x0, 0x0])
print("[*] Memory wiped")

# Write memory with patched data
for i in range(0, len(encrypted_memory), PAGE_SIZE):
    data_page = encrypted_memory[i:i+PAGE_SIZE]
    exchange([0x06])
    exchange([0x02] + [0x0, i//PAGE_SIZE, 0x0] + data_page)
    print(f"[*] Writing page {i//PAGE_SIZE + 1}")

flag = exchange([0x03] + FLAG_ADDRESS, 0x64)
print("[+] Flag: ", end="")
for l in flag:
    if l == 255:
        break
    print(chr(l), end="")

As you can see, before each writing operation (erasing data is a writing operation), I called the 0x06 (Write Enable) command. This puts the “Write Enable Latch” bit in the Status Register to 1. This is medatory for any write operation to be successful. You have to do it before each operation, since after a writing operation, the WEL bit is cleared to 0.

import json
import struct
from pwn import *
import pickle

context.log_level = "ERROR"

FLAG_ADDRESS = [0x52, 0x52, 0x52]
KEY_SIZE = STRUCT_SIZE = 12
CRC_SIZE = 4
BLOCK_SIZE = STRUCT_SIZE + CRC_SIZE
PAGE_SIZE = 256
HOST = '83.136.254.223'
PORT = 45255

def exchange(hex_list, value=0):

    command_data = {
        "tool": "pyftdi",
        "cs_pin":  0,
        "url":  'ftdi://ftdi:2232h/1',
        "data_out": [hex(x) for x in hex_list],
        "readlen": value
    }
    
    s = remote(HOST, PORT)
    s.send(json.dumps(command_data).encode('utf-8'))
    data = b''
    while True:
        chunk = s.recv(1024)
        data += chunk
        if data.endswith(b']'):
            break
    s.close()

    response = json.loads(data.decode('utf-8'))
    return response

def calculate_crc32(data):
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0xEDB88320
            else:
                crc >>= 1
    return ~crc & 0xFFFFFFFF

# Read encryption key
key = exchange([0x48, 0x0, 0x10, 0x52], KEY_SIZE)

# Read memory
encrypted_memory = exchange([0x03, 0x0, 0x0, 0x0], PAGE_SIZE*10)

# Decrypt memory by skipping the CRC
decrypted_memory = []
for i in range(0, len(encrypted_memory), BLOCK_SIZE):
    for j in range(STRUCT_SIZE):
        decrypted_memory.append(encrypted_memory[i+j] ^ key[j])
    decrypted_memory += encrypted_memory[i+STRUCT_SIZE:i+BLOCK_SIZE]

for i in range(0, len(encrypted_memory), BLOCK_SIZE):
    # Extract fields of the log entry and compute crc for data integrity check
    data = bytes(decrypted_memory[i:i+BLOCK_SIZE])
    timestamp, event_type, pad1, user_id, method, status, pad2 = struct.unpack("<I B B H B B H", data[:STRUCT_SIZE])
    crc, = struct.unpack("<I", data[STRUCT_SIZE:])
    computed_crc = calculate_crc32(data[:STRUCT_SIZE])

    assert(crc == computed_crc)

    # If the user_id matches our ID
    if user_id == 0x5244 :
        print(f"[*] Found log entry for user 0x5244")
        print(f"\tTimestamp: {timestamp}")
        print(f"\tEvent Type: {event_type}")
        print(f"\tUser ID: {hex(user_id)}")
        print(f"\tMethod: {method}")
        print(f"\tStatus: {status}")
        print(f"\tCRC: {crc}")
        print()

        # Forge fake entry
        fake_user_id = 0x5244 + 1
        fake_data = struct.pack("<I B B H B B H", timestamp, event_type, pad1, fake_user_id, method, status, pad2)
        fake_crc = (struct.pack("<I", calculate_crc32(fake_data)))
        encrypted_fake_data = []
        for j, d in enumerate(fake_data):
            encrypted_fake_data.append(d ^ key[j])
        encrypted_fake_data += fake_crc

        # Patch original memory array
        encrypted_memory = encrypted_memory[:i] + encrypted_fake_data + encrypted_memory[i+BLOCK_SIZE:]

# Wipe chip memory
exchange([0x06])
exchange([0x20, 0x0, 0x0, 0x0])
print("[*] Memory wiped")

# Write memory with patched data
for i in range(0, len(encrypted_memory), PAGE_SIZE):
    data_page = encrypted_memory[i:i+PAGE_SIZE]
    exchange([0x06])
    exchange([0x02] + [0x0, i//PAGE_SIZE, 0x0] + data_page)
    print(f"[*] Writing page {i//PAGE_SIZE + 1}")

flag = exchange([0x03] + FLAG_ADDRESS, 0x64)
print("[+] Flag: ", end="")
for l in flag:
    if l == 255:
        break
    print(chr(l), end="")

HTB{n07h1n9_15_53cu23_w17h_phy51c41_4cc355!@}

A big thanks to the challenge maker, the emulation of the chip was really on point. Another thanks goes to my team, especially to Drahoxx who helped me out on the challenge when I was really stuck.

That’s just a MVP flex x)