Bits and Bytes Security

Cyber Apocalypse 2024 CTF Flashing Logs

This is a write-up for the Cyber Apocalypse 2024 Flashing Logs challenge from the hardware category. It was classified as a “hard” challenge. Nevertheless, I ended up spending more time on another challenge (Prom) due to a strange bug in that challenge.

The challenge description is as follows:

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.

OK, let’s look at the files provided:

  • client.py
  • log_event.c

The client.py is the code that allows us to interact with the device. It is like an IP network-accessible FTDI device. The exchange function does the following:

  • Makes a TCP connection to the challenge host/port;
  • Sends a command_data packet containing a hex-encoded payload provided in the function arguments;
  • Receives a byte array of a specific size, specified by the second argument of the function “value”;
  • Decodes the byte array.

Finally, the code has an example command exchange:

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

Typically, hardware devices exchange information using address writes and reads. By address, one can see a simple numeric value used to distinguish between different data buckets. There is no REST API in hardware devices. Each address corresponds to a specific function according to the device documentation.

Flash Memory Diagram

For example, a device documentation could say that writing byte 0x01 to a specific address 0x1234 will activate a specific function or perform a specific operation.

The example command from the client.py file reveals that a JEDEC-compatible interface is used with the flash peripheral. The RDID command (JEDEC 9Fh) is a common instruction used in SPI flash memory devices to retrieve the device’s identification data, documented in the JEDEC Common Flash Interface specification.

I started wondering… what other commands are there? What other commands does this particular flash support? Anyway, must keep the focus… remember the idea is to solve the challenge.

One key thing we need to understand is how the logs are stored in flash memory. Once we know how that works, we may attempt to change the log entries. To learn about how logs are stored in flash memory, we will need to analyze the log_event.c source file.

Log Event Source

Let’s look at the log_event.c file. Right from the start, we see the comment: Our custom chip is compatible with the original W25Q128XX design. Thus, if we look for W25Q128XX datasheets, it will likely have similar or equal commands.

Analyzing the log_event.c source file, we see the root function is the `log_event function.

int log_event(const SmartLockEvent event, uint32_t sector, uint32_t address)
{
    bool memory_verified = false;
    uint8_t i;
    uint16_t n;  
    uint8_t buf[256]; 
    ...

The log event function prepares an event data structure SmartLockEvent with a CRC appended and calls write_to_flash to write the raw data to the flash memory.

The write_to_flash function encrypts the data using a simple XOR cipher and uses an undocumented function, W25Q128_pageWrite, which from the name, we could infer it’s to write the raw page data to the flash memory.

Let’s pause for a moment and try to understand how this flash memory data is structured. This flash chip, model W25Q128FW, holds 16MB of data. It has 65535 programmable pages, each 256 bytes long. The pages are grouped into sectors of 4kB each.

Flash Memory Pages

Looking at the log event structure:

// 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;

Each log event is 4+1+2+1+1 = 9 bytes long. This structure members will be padded (more on that later!) In addition, each log event is followed by a CRC checksum of 32 bits. But this means we’ll have multiple log events on each page. Here’s a diagram to help:

Log Events Structure

We know that the log event data is encrypted with an XOR cipher. The key, from the encrypt_data function analysis, comes from an undocumented function called read_security_register:

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

What are these security registers?

The security registers are designed to enhance the security of the data stored on the chip. These registers typically provide functionality such as locking certain memory areas to prevent unauthorized reading or writing and enabling or disabling write protection for specific sectors. In the case of this flash memory, they have just been used to store sensitive information. So, the security register in this case is a blob of 256 bytes.

The register_number value passed from `write_to_flash is 1. Looking at the datasheet of W25Q128FW (image below), we see the flash memory has three security registers of 256 bytes each.

Security Registers Datasheet

So, the first thing I did was to read the key to decrypt the log event messages.

Reading The Key

From the read_security_register function name, we can infer the key is stored in the flash security registers. How can we read these security registers? from the flash memory datasheet (image above) we see the registers are mapped to specific regions of the memory. The address bits are described in the table from the image.

For example, the address range for the first security register is the following.

Security Register 1

To read the security register we need to send a security register read command (0x48) to the memory flash (as you can see from the datasheet capture above.) From the datasheet we also know that we need to pass the address right after the 0x48 command byte:

The instruction is initiated by driving the /CS pin low and then shifting the instruction code “48h” followed by a 24-bit address (A23-A0) and eight “dummy” clocks into the DI pin.

Thus, the code I used to read the first security register contents is the following.

# read security register
sreg = exchange([0x48, 0x0, 0x10, 0x00], 256)
pprint(sreg)

This will give:

[255, 18, 202, 110, 110, 36, 217, 65, 183, 95, 250, 179, 131, 114, 253, 97,
220, 170, 149, 13, 65, 141, 17, 239, 203, 225, 173, 230, 152, 41, 59, 138, 
248, 141, 30, 134, 189, 178, 0, 32, 254, 113, 86, 214, 48, 61, 236, 57, 137,
...
155, 248, 96, 123, 177, 144, 164, 146, 215, 196, 59, 139, 131, 43, 15]

A blob of 256 random bytes. The key is 12 bytes long, so it must be within this blob of bytes. From the read_security_register function call we see the second argument that could potentially be the offset where the key is, 0x52:

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

This is just an hypothesis. We can only confirm after we decrypt some log events with the resulting key. The 12 bytes at offset 0x52 is:

0000  ff 12 ca 6e 6e 24 d9 41 b7 5f fa b3 83 72 fd 61   ...nn$.A._...r.a
0010  dc aa 95 0d 41 8d 11 ef cb e1 ad e6 98 29 3b 8a   ....A........);.
0020  f8 8d 1e 86 bd b2 00 20 fe 71 56 d6 30 3d ec 39   ....... .qV.0=.9
0030  89 ff 72 7c c1 e4 8c d1 25 d8 3e eb 41 31 26 6f   ..r|....%.>.A1&o
0040  2d f6 1b 9b 86 83 e4 ea 5c 3d d8 ef b0 03 2b ca   -.......\=....+.
0050  41 64 39 de 6a 99 26 5d fe 89 6c 06 48 53 cd 38   Ad9.j.&]..l.HS.8
0060  14 1c 90 f2 19 18 d9 cb 07 4e 14 01 47 46 06 01   .........N..GF..
0070  a9 3d 64 1d 2a 4d ae 93 82 84 15 b5 39 1a d1 f0   .=d.*M......9...
0080  ff 1f 58 b2 b6 15 71 b5 16 11 26 19 ae 39 46 7c   ..X...q...&..9F|
0090  1a 64 63 bf 01 ce 21 76 fe c2 ee ad 31 8e 06 67   .dc...!v....1..g
00a0  cc b7 5f a3 f4 11 e0 f5 ac f6 c3 4b 85 64 4f 66   .._........K.dOf
00b0  2d 96 27 9c ac 7b 80 a9 80 15 88 8b 4a 6f 57 51   -.'..{......JoWQ
00c0  76 63 ad 41 76 40 f7 57 b4 3a 1e ac 5b c8 29 59   [email protected].:..[.)Y
00d0  31 5f b6 f7 e2 a8 23 eb 32 ad c2 62 a9 8d 95 bf   1_....#.2..b....
00e0  d3 05 57 07 d9 a2 7b d5 89 74 37 ee 11 d7 e0 32   ..W...{..t7....2
00f0  16 9b f8 60 7b b1 90 a4 92 d7 c4 3b 8b 83 2b 0f   ...`{......;..+.
key=
39 de 6a 99 26 5d fe 89 6c 06 48 53 

Now with a potential key we can attempt to read the log events.

Reading The Logs

Extracting the 12-byte key from the security section and trying to decrypt the first page, doesn’t match the CRC of the cleartext event. This would be a way we could use to validate that our key is correct; by verifying the log event integrity checksum. To overcome this I tried to sweep all keys from the security section (from 0 to 256-12) and none resulted in a matching CRC.

PAGE 0:
j=00 key=[255, 18, 202, 110, 110, 36, 217, 65, 183, 95, 250, 179]
Event 1:
  TS=0x92126678, Type=0x4D, UserID=0x1879, Method=0xCA,
  Status=0xDA, CRC=0x98534807, Calculated CRC=0xE86B63D8
j=01 key=[18, 202, 110, 110, 36, 217, 65, 183, 95, 250, 179, 131]
Event 1:
  TS=0x92B6BE95, Type=0x07, UserID=0x8084, Method=0x3C,
  Status=0x32, CRC=0x98534807, Calculated CRC=0x3C76D41B
j=02 key=[202, 110, 110, 36, 217, 65, 183, 95, 250, 179, 131, 114]
Event 1:
  TS=0xD8B61A4D, Type=0xFA, UserID=0x761C, Method=0xD4,
  Status=0x97, CRC=0x98534807, Calculated CRC=0x5A6F8B00
j=03 key=[110, 110, 36, 217, 65, 183, 95, 250, 179, 131, 114, 253]
Event 1:
  TS=0x25FC1AE9, Type=0x62, UserID=0x9EEA, Method=0x71,
  Status=0xDE, CRC=0x98534807, Calculated CRC=0x93AA6D3A
j=04 key=[110, 36, 217, 65, 183, 95, 250, 179, 131, 114, 253, 97]
Event 1:
  TS=0xBD0150E9, Type=0x94, UserID=0x3B02, Method=0x38,
  Status=0xEE, CRC=0x98534807, Calculated CRC=0xD305EB69
j=05 key=[36, 217, 65, 183, 95, 250, 179, 131, 114, 253, 97, 220]
Event 1:
  TS=0x4B99ADA3, Type=0x7C, UserID=0x72A7, Method=0x08, 
  Status=0x1F, CRC=0x98534807, Calculated CRC=0xEB9D862B
j=...

The calculated CRC was always different to the CRC from the log event. Something must be wrong… I looked at multiple things and started wondering if it had to do with the C structure padding. The mapping from the C structure declaration and the memory binary data may not be byte-sequential due to alignment of the structure members. Which of the following is right mapping?

Struct Memory Mapping

After some trial and error in changing the fields offsets.. it turned out to be:

Struct Memory Mapping

  • The timestamp is at offset 0;
  • The eventType is one byte at offset 4;
  • The userId is two bytes at offset 6 and 7;
  • The method is one byte at offset 8;
  • The status is one byte at offset 9;
  • There is a prologue padding at bytes 10 and 11.

This means that the CRC will start at offset 12 and using 4 bytes. With this correction of the structure mapping to binary, we can start to see the event logs and the CRCs will match.

Event 1: 
    Timestamp=0x65B2AABE, Type=0x05, UserID=0x3F00, Method=0x02, 
    Status=0x01, Stored CRC=0x175DD498, Calculated CRC=0x175DD498

Event 2: 
    Timestamp=0x65B37E1D, Type=0xF9, UserID=0x6F00, Method=0x03, 
    Status=0x02, Stored CRC=0xC3EECDDB, Calculated CRC=0xC3EECDDB

Event 3: 
    Timestamp=0x65B46C0E, Type=0x6C, UserID=0x1C00, Method=0x00, 
    Status=0x03, Stored CRC=0xB9E44CEB, Calculated CRC=0xB9E44CEB

Event 4: 
    Timestamp=0x65B476C0, Type=0x05, UserID=0xB800, Method=0x03, 
    Status=0x02, Stored CRC=0xFE1D2E77, Calculated CRC=0xFE1D2E77

Event 5: 
    Timestamp=0x65B4E396, Type=0x6C, UserID=0x1C00, Method=0x00, 
    Status=0x02, Stored CRC=0x8F921E35, Calculated CRC=0x8F921E35
...

OK great! we got the log events… now it’s time to look for the events of a particular user ID and patch them.

Patching Logs

To patch the events in the logs we need to first locate them in the different pages. Looking at the different event pages, we find the userID 0x7601 events on page 9, event 12, 13, 14, and 15.

We just need to patch the “userId” of these events to another one, update the CRC32, re-encrypt it, and update the page in the flash.

Based on information from the datasheet, we must use the “Page Program” command (0x02), to write a page in the flash memory. Before the page program, we need to send a “Write Enable” command (0x06). I’ve written a function to do this.

def write_page(page_number, page_data):
    if len(page_data) != 256:
        raise ValueError("Page data must be exactly 256 bytes")
    
    address = page_number * 256  # Calculate address from page number
    command = [0x02]  # Page Program command
    address_bytes = [(address >> (8 * i)) & 0xFF for i in range(2, -1, -1)]

    write_data = command + address_bytes + list(page_data)
    
    # Send Write Enable
    response = exchange([0x06], 0)

    print("writing page command:")
    hex_dump(write_data)

    # Send the write command and data
    response = exchange(write_data, 0)
    
    return response

But I faced a problem. The writes were not working as expected. After some searching, I realized that I needed to first erase the sector. Flash memory pages must be erased before they can be written or reprogrammed (see reference 2). This characteristic stems from how data is stored in flash memory cells. In a typical flash memory cell, programming (writing) changes bits from ‘1’ to ‘0’. However, once a ‘0’ has been written, it cannot be changed back to ‘1’ except by erasing the entire sector to which the page belongs.

In fact, if we read the datasheet carefully we see in the “Page Program” section:

The Page Program instruction allows from one byte to 256 bytes (a page) of data to be programmed at previously erased (FFh) memory locations.

To help with the patching I used a function which could be refactored into multiple functions. To make it easier to understand, I’m sharing the refactored version.

def patch_page_events(page_id, page_data, key):
    events = bytearray()
    for i in range(len(page_data) // EVT_SIZE):
        offset = i * EVT_SIZE

        # Extract raw event data
        raw_evt = page_data[offset:offset + EVT_SIZE - 4]
        
        # Decrypt event data
        evt = decrypt(raw_evt, key)

        # Calculate event CRC after decryption
        crc_calc = zlib.crc32(evt) & 0xFFFFFFFF

        # Extract the CRC stored in the event
        crc_stored = int.from_bytes(page_data[offset+12:offset+16], 'little')

        # Patch the userId if it's equal to 0x5244
        ts, et, uid, meth, stat = parse_evt(evt)
        if uid == 0x5244:
        	uid = REP_UID
        new_evt = evt_to_bytes(ts, et, uid, meth, stat)
        
        events += new_evt

    return events

Since events have all the same size, we can access the page as a list of events. It decrypts an event using decrypt function, and calculates the event data CRC using crc function.

We then parse the data into it’s multiple fields with parse_evt. The third field is the userId and we compare it to 0x5244 which is the one we want to change. If it’s the same, the uid is changed to REP_UID value.

Regardless of the userId, the loop is building a list of events. The reason is that with the sector erase, we will loose all the events. Thus, we need to store them even if they don’t match the target userId.

The functions used are something like:

def decrypt(data, key):
    if len(key) != 12:
        raise ValueError("Key must be 12 bytes long.")
    return bytearray(b ^ key[i % len(key)] for i, b in enumerate(data))

def evt_to_bytes(ts, et, uid, meth, stat):
    data = ts.to_bytes(4, 'little') + et.to_bytes(1, 'little')
    data += b'\0' + uid.to_bytes(2, 'little') + meth.to_bytes(1, 'little')
    data += stat.to_bytes(1, 'little') + b'\0\0'
    return data + zlib.crc32(data).to_bytes(4, 'little')

def parse_evt(data):
    return (
        int.from_bytes(data[:4], 'little'),  # Timestamp
        data[4],                              # EventType
        int.from_bytes(data[6:8], 'little'),  # UserID
        data[8],                              # Method
        data[9]                               # Status
    )

OK, now the missing piece is the code that calls the patch_page_events function. It needs to read the pages first. From previous dumps I learned that only 10 pages of logs exist in the flash, the other ones will be filled with 0xFF.

pages_data = read_multiple_pages(0, 10)
key = sreg[0x52:0x52+12]
for page, data in enumerate(pages_data):
    if page == 9:
        pages_data[page] = patch_page_events(page, bytearray(data), key)
    else:
        pages_data[page] = bytearray(data)

The code is self-explanatory. I only care about patching events from page 9; I know it’s the only one to contain the userId events we’re interested. The other pages, we just keep the data as-is. Now, we need to enable write and erase the sector:

# Send Write Enable
response = exchange([0x06], 0)

# Erase sector
response = exchange([0x20], 0)

Finally, we can write all the pages, including the patched ones:

for page, data in enumerate(pages_data):
    print(f"Writing page {page} ...")
    write_page(page, data)

OK, logs have been patched, now where do we get the flag? Looking at the client.py we see at the beginning:

import socket
import json
import struct
import zlib


FLAG_ADDRESS = [0x52, 0x52, 0x52]
...

The FLAG_ADDRESS and three bytes. This is a flash device, so let’s read what’s in that address, using the same commands for page reads.

flag = exchange([0x03, 0x52, 0x52, 0x52], 256)
print(flag)

Which gives:

0000  48 54 42 7b 6e 30 37 68 31 6e 39 5f 31 35 5f 35   HTB{n07h1n9_15_5
0010  33 63 75 32 33 5f 77 31 37 68 5f 70 68 79 35 31   3cu23_w17h_phy51
0020  63 34 31 5f 34 63 63 33 35 35 21 40 7d ff ff ff   c41_4cc355!@}...

References

  1. https://www.winbond.com/hq/product/code-storage-flash-memory/serial-nor-flash/?__locale=en&partNo=W25Q128FW

  2. https://www.renesas.com/us/en/document/apn/nor-flash-memory-erase-operation