Solving HTB Cyber Apocalypse Hardware challenges without any prior knowledge of hardware

Bill Elim

HackTheBox Cyber Apocalypse 2024 Banner

A couple days ago I was playing and competition is really fierce, in order to squeeze the last bit of points I have to solve the hardware challenges. Unfortunately, I have no experience whatsoever in hardware, thankfully I ended up solving all of the hardware challenge and I think it is a very fun learning process. I will try to walk you through from knowing absolutely so everyone can follow along.

In this writeup I will talk about 3 challenges, Rids, The PROM, and Flash-ing Logs . The other 2 challenges were labeled very easy and I think there are a lot of learning resource already

Rids

CHALLENGE NAME: Rids
Description: Upon reaching the factory door, you physically open the RFID lock and find a flash memory chip inside. The chip’s package has the word W25Q128 written on it. Your task is to uncover the secret encryption keys stored within so the team can generate valid credentials to gain access to the facility.

In this challenge, we are given a client.py file that can be used to communicate to a hardware, in the description, we know it is W25Q128, I don’t even know what that is, so let’s try googling it

The W25Q128 is a 128 megabit serial flash memory from Winbond Electronics

Okay, to be honest there are a lot of stuff here that I don’t understand, but for know all we know that it is a serial flash memory, let’s see what that means

Serial flash memory, also known as SPI (Serial Peripheral Interface) flash, is a non-volatile memory device that uses a serial communication protocol to transfer data.

Ah interesting, so it is a memory that we can communicate with, the objective of this challenge is not clear yet, but for now since it stores some data the first intuitive thing to do is to try to read it so lets try!

Thankfully we are given this client.py file that we can use to exchange information with the hardware

import socket
import json

def exchange(hex_list, value=0):

# Configure according to your setup
host = '127.0.0.1' # The server's hostname or IP address
port = 1337 # The port used by the server
cs=0 # /CS on A*BUS3 (range: A*BUS3 to A*BUS7)

usb_device_url = 'ftdi://ftdi:2232h/1'

# Convert hex list to strings and prepare the command data
command_data = {
"tool": "pyftdi",
"cs_pin": cs,
"url": usb_device_url,
"data_out": [hex(x) for x in hex_list], # Convert hex numbers to hex strings
"readlen": value
}

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))

# Serialize data to JSON and send
s.sendall(json.dumps(command_data).encode('utf-8'))

# Receive and process response
data = b''
while True:
data += s.recv(1024)
if data.endswith(b']'):
break

response = json.loads(data.decode('utf-8'))
#print(f"Received: {response}")
return response


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

Here we can see that we are provided with the exchange() function, it simply connects to the remote server and send some JSON data, we can also see that the value parameter is used as the readlen argument there on the JSON, so we can assume that the value parameter is how long we wanna read or see the returned result. Cool, let’s check the example command

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

So I guess this command are supposed to return the jedec_id, what even is jedec_id? and why is the command [0x9f], at this moment you might realize what’s going on, there are specific instruction to do specific things, like in this example, 9F means it will return the jedec_id (whatever that means lmao), and so first we must find the list of instruction

From some previous googling we know that W25Q128 is made by Winbond, so let’s google some documentation!

Googling for winbond documentation, the result returns documentatio for W25Q128JV, there are extra JV at the end
https://www.winbond.com/hq/product/code-storage-flash-memory/serial-nor-flash/?__locale=en&partNo=W25Q128JV

Hmmmm I don’t know about the additional JV at the end there, but nonetheless let’s try to read it!

Going to the first search result we can find the datasheet, which looks like a manual, this is what we need!

At page 20, we can see the list of instruction and their opcodes:

At page 20 of the datasheet, there are tables of instruction and the bytes used, according to the table JEDEC ID instruction uses byte 9F, so we are right

AHA! There they are, 9F as as JEDEC ID, so this is what we are looking for, I wonder what useful instruction can we do?

I can see the Read Data instruction there with opcode 03 , so I decided to just try it

# Example command
# jedec_id = exchange([0x9F], 3)
jedec_id = exchange([0x03], 3)
print(jedec_id)
The output: 72, 84, 66

Whoa, it returns something! In fact, if we convert this to ASCII, it’s actually read HTB , so this could be the flag? Let’s try reading more data and see

# Example command
flag = exchange([0x03], 100)
print(bytes(flag))
Running the python script gives us the flag

And we found the flag! But this is just the beginning, This client.py file will be used again in the challenge Flash-ing Logs, this challenges is here only to familiarize us with the client.py file and the manual. There are many things that we haven’t learned yet, like in the instruction, there are option to provide more than 1 bytes as the instruction, but at least our current knowledge is enough to solve this challenge

The PROM

CHALLENGE NAME: The PROM
Description: After entering the door, you navigate through the building, evading guards, and quickly locate the server room in the basement. Despite easy bypassing of security measures and cameras, laser motion sensors pose a challenge. They’re controlled by a small 8-bit computer equipped with AT28C16 a well-known EEPROM as its control unit. Can you uncover the EEPROM’s secrets?

This challenges uses a different hardware, that is AT28C16, an EEPROM, what is an EEPROM you ask? let’s see

EEPROM, or electrically erasable programmable read-only memory, is a type of non-volatile memory that can store small amounts of data in computers and other electronic devices. EEPROM chips are known as byte erasable chips because they allow individual bytes of data to be erased and reprogrammed. The stored data is non-volatile and can be erased on a byte-by-byte basis

Okay so it’s pretty much the same thing as serial flash memory? We will discover more about the difference soon, this time we are given a remote connection but without any attachment, connecting to the service looks like this

The display when connecting to the remote server, there are some manual on how to do certain things, like read byte, write byte, etc.

This is interesting, and most importantly look, there are read_byte(), and write_byte() function, the description says that read_byte() reads something from the current address, what is the current address? it is controlled by the set_address_pins() function… right? maybe? anyway I don’t know anything about this, so let’s once again search for documentation

Searching for AT28C16 EEPROM documentation gives us a PDF containing the documentation
http://cva.stanford.edu/classes/cs99s/datasheets/at28c16.pdf

cool, once again we found a PDF, let’s read it

The documentation for operating modes, it is a little bit confusing

Oh no…. what does this even mean

Let’s do it like the previous challenge and attempt to read a byte from the memory, how can we do that?

Let’s see the table right below that one

This clears things up a little, so in order to read, we have to set CE and OE pins to Input Low Voltage, meaning ≤ 0.8V, and set WE pins to Input High Voltage, that is > 2.0 V, but what there is 1 more column, the I/O column, what does D_out even mean? I can’t find it anywhere on the documentation, but let’s just skip it and see if it even matters

Alright, let’s try!

from pwn import *
# context.log_level = 'debug'
p = remote("94.237.57.59", 51883)

def read_mode():
p.sendline(f"set_ce_pin(0.8)".encode())
p.sendline(f"set_oe_pin(0.8)".encode())
p.sendline(f"set_we_pin(2.0)".encode())

def read_byte():
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

p.recvuntil(b"4.2])")
p.recvline()
p.recvline()

read_mode()
print(read_byte())
Error: Pins are not set correctly

Oh no this is bad, we set the pins as it should be, but it still doesn’t work.

Looking at the notes below the flag

VH = 12.0V

It says V_H is 12.0V, well its not exactly V_IH but what if we try? Also let’s lower the CE and OE pin a little bit, maybe if we put them in the middle it will be more stable?

from pwn import *
# context.log_level = 'debug'
p = remote("83.136.254.223", 52428)

def read_mode():
p.sendline(f"set_ce_pin(0.5)".encode())
p.sendline(f"set_oe_pin(0.5)".encode())
p.sendline(f"set_we_pin(12)".encode())

def read_byte():
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

p.recvuntil(b"4.2])")
p.recvline()
p.recvline()

read_mode()
print(read_byte())
Error: The chip is burned

Well that didn’t work…

Okay so at this point I was just playing around trying to see what is the limit of voltage I can supply before the chip is burned, and then, this happened

from pwn import *
# context.log_level = 'debug'
p = remote("83.136.254.223", 52428)

def read_mode():
p.sendline(f"set_ce_pin(0.5)".encode())
p.sendline(f"set_oe_pin(0.5)".encode())
p.sendline(f"set_we_pin(5)".encode())

def read_byte():
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

p.recvuntil(b"4.2])")
p.recvline()
p.recvline()

read_mode()
print(read_byte())
Read 0x0 at address 0x2ba

It works!

I set the WE pin to 5V and now we can read a byte, but we can see that it reads from an arbitrary address, how can we control the address?

Let’s read some more documentation

Okay so the location is determined by address pins, and we have the set_address_pins function, but how do we use it?

I tried to use it the same way to set the io pins but it shows the following error

Error: Incorrect number of address pins

Okay so looking at the description of the commands the set_address_pins function sets the address pins from A10 to A0, so there will be in total 11 pins, so let’s try it!

# I will now not write the previous functions again so the snippet wouldn't take too much space
p.recvuntil(b"4.2])")
p.recvline()
p.recvline()

p.sendline(b"set_address_pins([0,0,0,0,0,0,0,0,0,0,0])")
read_mode()
print(read_byte())

It works!

It also read at address 0, so I instinctively thinks this is binary, let’s try to read at index 1

p.sendline(b"set_address_pins([0,0,0,0,0,0,0,0,0,0,1])")
read_mode()
print(read_byte())

What?? its still read at address 0? How???

Learning from the previous mistake, we can try to increase the voltage again, and as expected somewhere around 10 volt will burn the chip, but setting it just right apparently allow us to now control the address

p.sendline(b"set_address_pins([0,0,0,0,0,0,0,0,0,0,5])")
read_mode()
print(read_byte())
Read 0x0 at address 0x1

Yeay, now we can control the address!

Now we will modify our read_byte function to read at specific address

def to_address(num):
address = bin(num)[2:].zfill(11)
address = address.replace('1', '5')
address = f"[{','.join(address)}]"
return address

def read_byte(addr):
address = to_address(addr)
p.sendline(f"set_address_pins({address})".encode())
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

So now where should we read? unfortunately even if you bruteforce the entire address range from 0, there is no flag written in the memory.

Let’s read more stuff from the documentation

DEVICE IDENTIFICATION: An extra 32 bytes of
 EEPROM memory are available to the user for device identification. By raising A9 to 12 ± 0.5V and using address
 locations 7E0H to 7FFH the additional bytes may be written
 to or read from in the same manner as the regular memory
 array

Okay, it looks like there are more stuff we can read by setting the A9 pins to 12V, let’s try it out!

We will add the new function to read from the secret address, This is what our script will look like

from pwn import *
# context.log_level = 'debug'
p = remote("83.136.254.223", 45188)

def read_mode():
p.sendline(f"set_ce_pin(0.5)".encode())
p.sendline(f"set_oe_pin(0.5)".encode())
p.sendline(f"set_we_pin(5)".encode())

def to_address(num):
address = bin(num)[2:].zfill(11)
address = address.replace('1', '5')
address = f"[{','.join(address)}]"
return address

def read_byte(addr):
address = to_address(addr)
p.sendline(f"set_address_pins({address})".encode())
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

def to_address_secret(num):
address = bin(num)[2:].zfill(5)
address = list(address.replace('1', '5'))
address = ['5','12','5','5','5','5'] + address
address = f"[{','.join(address)}]"
return address

def read_byte_secret(addr):
address = to_address_secret(addr)
p.sendline(f"set_address_pins({address})".encode())
p.sendline(b'read_byte()')
response = p.recvline().decode().strip()
return response

p.recvuntil(b"4.2])")
p.recvline()
p.recvline()

read_mode()
print(read_byte_secret(0x0))

Looks like we have some data now! in fact, 0x48 is the letter H, which is the start of our flag, we can continue reading more bytes and get the full flag

read_mode()
for i in range(32):
res = int((read_byte_secret(i)).split("Read ")[1].split()[0],16)
print(chr(res), end="")
print()

Flash-ing Logs

CHALLENGE NAME
 Flash-ing Logs
 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.

In this challenge we are back using W25Q128 and the client.py file from earlier. We are given the following C file

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <wiringPiSPI.h>
#include "W25Q128.h" // Our custom chip is compatible with the original W25Q128XX design

#define SPI_CHANNEL 0 // /dev/spidev0.0
//#define SPI_CHANNEL 1 // /dev/spidev0.1

#define CRC_SIZE 4 // Size of the CRC data in bytes
#define KEY_SIZE 12 // Size of the key



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


// Function Prototypes
int log_event(const SmartLockEvent event, uint32_t sector, uint32_t address);
uint32_t calculateCRC32(const uint8_t *data, size_t length);
void write_to_flash(uint32_t sector, uint32_t address, uint8_t *data, size_t length);

// CRC-32 calculation function
uint32_t calculateCRC32(const uint8_t *data, size_t length) {
uint32_t crc = 0xFFFFFFFF;
for (size_t i = 0; i < length; ++i) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; ++j) {
if (crc & 1)
crc = (crc >> 1) ^ 0xEDB88320;
else
crc >>= 1;
}
}
return ~crc;
}


bool verify_flashMemory() {
uint8_t jedc[3];
uint8_t uid[8];
uint8_t buf[256];
uint8_t wdata[26];
uint8_t i;

uint16_t n;

bool jedecid_match = true; // Assume true, prove false
bool uid_match = true; // Assume true, prove false

// JEDEC ID to verify against
uint8_t expectedJedec[3] = {0xEF, 0x40, 0x18};

// UID to verify against
uint8_t expectedUID[8] = {0xd2, 0x66, 0xb4, 0x21, 0x83, 0x1f, 0x09, 0x2b};


// SPI channel 0 at 2MHz.
// Start SPI channel 0 with 2MHz
if (wiringPiSPISetup(SPI_CHANNEL, 2000000) < 0) {
printf("SPISetup failed:\n");
}

// Start Flash Memory
W25Q128_begin(SPI_CHANNEL);

// JEDEC ID Get
//W25Q128_readManufacturer(buf);
W25Q128_readManufacturer(jedc);
printf("JEDEC ID : ");
for (i=0; i< 3; i++) {
printf("%x ",jedc[i]);
}

// Iterate over the array and compare elements
for (int i = 0; i < sizeof(jedc)/sizeof(jedc[0]); ++i) {
if (jedc[i] != expectedJedec[i]) {
jedecid_match = false; // Set match to false if any element doesn't match
break; // No need to check further if a mismatch is found
}
}

if (jedecid_match) {
printf("JEDEC ID verified successfully.\n");
} else {
printf("JEDEC ID does not match.\n");
return 0;
}

// Unique ID
// Unique ID Get
W25Q128_readUniqieID(uid);
printf("Unique ID : ");
for (i=0; i< 8; i++) {
printf("%x ",uid[i]);
}
printf("\n");

// Iterate over the array and compare elements
for (int i = 0; i < sizeof(uid)/sizeof(uid[0]); ++i) {
if (uid[i] != expectedUID[i]) {
uid_match = false; // Set match to false if any element doesn't match
break; // No need to check further if a mismatch is found
}
}

if (uid_match) {
printf("UID verified successfully.\n");
} else {
printf("UID does not match.\n");
return 0;
}

return 1;
}
// Implementations
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];


memory_verified = verify_flashMemory();
if (!memory_verified) return 0;

// Start Flash Memory
W25Q128_begin(SPI_CHANNEL);

// Erase data by Sector
if (address == 0){
printf("ERASE SECTOR!");
n = W25Q128_eraseSector(0, true);
printf("Erase Sector(0): n=%d\n",n);
memset(buf,0,256);
n = W25Q128_read (0, 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));

// Serialize the event
memcpy(buffer, &event, sizeof(SmartLockEvent));

// Calculate CRC for the serialized event
crc = calculateCRC32(buffer, sizeof(SmartLockEvent));

// Append CRC to the buffer
memcpy(buffer + sizeof(SmartLockEvent), &crc, sizeof(crc));


// Print the SmartLockEvent for debugging
printf("SmartLockEvent:\n");
printf("Timestamp: %u\n", event.timestamp);
printf("EventType: %u\n", event.eventType);
printf("UserId: %u\n", event.userId);
printf("Method: %u\n", event.method);
printf("Status: %u\n", event.status);

// Print the serialized buffer (including CRC) for debugging
printf("Serialized Buffer (including CRC):");
for (size_t i = 0; i < sizeof(buffer); ++i) {
if (i % 16 == 0) printf("\n"); // New line for readability every 16 bytes
printf("%02X ", buffer[i]);
}
printf("\n");


// Write the buffer to flash
write_to_flash(sector, address, buffer, sizeof(buffer));


// Read 256 byte data from Address=0
memset(buf,0,256);
n = W25Q128_read(0, buf, 256);
printf("Read Data: n=%d\n",n);
dump(buf,256);

return 1;
}


// encrypts log events
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

printf("Data before encryption (including CRC):\n");
for(size_t i = 0; i < data_length; ++i) {
printf("%02X ", data[i]);
}
printf("\n");

// Print the CRC32 checksum before encryption (assuming the original data includes CRC)
uint32_t crc_before_encryption = calculateCRC32(data, data_length - CRC_SIZE);
printf("CRC32 before encryption: 0x%08X\n", crc_before_encryption);

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

printf("Data after encryption (including CRC):\n");
for(size_t i = 0; i < data_length; ++i) {
printf("%02X ", data[i]);
}
printf("\n");


}

void write_to_flash(uint32_t sector, uint32_t address, uint8_t *data, size_t length) {
printf("Writing to flash at sector %u, address %u\n", sector, address);

uint8_t i;
uint16_t n;

encrypt_data(data, length, 1, address);

n = W25Q128_pageWrite(sector, address, data, 16);
printf("page_write(0,10,d,26): n=%d\n",n);

}

Time to actually learn about the instructions

Before we get into solving the challenge, we will learn how to read and write from the memory, as previously we are very lucky to be able to solve the challenge by barely knowing about the instructions, this time we really need to dive deep

The first step that we gonna do is reading from a particular address, at the previous challenge all we need to do is to read without specifying the location (presumably address 0), but this time let’s really try to understand how to do this properly

The only time consuming part about the challenge is just reading documentation again and again, I don’t think I need to go indepth on how to read documentation anymore, anyway we can see how we can read from a specific address here.

The Read Data instruction allows one or more data bytes to be sequentially read from the memory. The
 instruction is initiated by driving the /CS pin low and then shifting the instruction code “03h” followed by a
 24-bit address (A23-A0) into the DI pin.

So it’s not that difficult really, just 03 followed by 24 bit address, which is 3 byte

from pwn import *
import socket
import json

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

def exchange(hex_list, value=0):

# Configure according to your setup
host = '94.237.54.164' # The server's hostname or IP address
port = 37972 # The port used by the server
cs=0 # /CS on A*BUS3 (range: A*BUS3 to A*BUS7)

usb_device_url = 'ftdi://ftdi:2232h/1'

# Convert hex list to strings and prepare the command data
command_data = {
"tool": "pyftdi",
"cs_pin": cs,
"url": usb_device_url,
"data_out": [hex(x) for x in hex_list], # Convert hex numbers to hex strings
"readlen": value
}

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))

# Serialize data to JSON and send
s.sendall(json.dumps(command_data).encode('utf-8'))

# Receive and process response
data = b''
while True:
data += s.recv(1024)
if data.endswith(b']'):
break
# print(data)

response = json.loads(data.decode('utf-8'))
#print(f"Received: {response}")
return response

# ===== NEW ======
def read_data(addr, length, quiet=False):
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
res = exchange([0x03]+addr, length)
if not quiet:
print("read:",[hex(x) for x in res])
return res

print(bytes(read_data(FLAG_ADDRESS, 100)))
The bytes at the flag address are currently empty

curently, reading the flag address will not give us anything, this is because we haven’t done the objective yet

Now let’s try writing, for our experiment we will also try to write to the flag address, we will write some data and read it to see if it works

It’s so frustrating to not be able to find the words “Write Data” at the documentation, quick google search will say that write data uses opcode 02, which if we check on the datasheet is indeed writing data but have a very unintuitive name

The Page Program instruction allows from one byte to 256 bytes (a page) of data to be programmed at
 previously erased (FFh) memory locations. A Write Enable instruction must be executed before the device
 will accept the Page Program Instruction

Okay so in order to be able to write, the data must be erased (is 0xFF), and also we have to call a write enable instruction before doing it. Reading the documentation tells us that Write Enable instruction is at 0x06

def write_enable():
x = exchange([0x06], 100)
# print([hex(x) for x in x])
return

def write_data(addr, data):
write_enable()
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
x = exchange([0x02]+addr+data, 100)
print("write:",data)
return

print(bytes(read_data(FLAG_ADDRESS, 10)))
write_data(FLAG_ADDRESS, [0x42]*10)
print(bytes(read_data(FLAG_ADDRESS, 10)))

Cool! Now we can read and write data, but remember that the documentation said that we can only write when the memory is at 0xFF, a quick google search will tell you that it’s because the instruction is only turning off the on switch and not vice versa, so for example we can write 101 from 111 by turning off the second bit, but we can’t do it vice versa

So now the last basic instruction we need to learn about is the erase instruction

Looking at the datasheet, this is the only thing I can find about erasing

There are also block erase which does the same thing but for the range of 32KB, and 64KB which is just as bad

It appears that we can’t erase an individual memory, we have to erase the entire sector, which is the range of 4KB, but needless to say let’s give it a try

def erase_data(addr):
write_enable()
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
x = exchange([0x20]+addr, 10)
print("erase:",[hex(x) for x in x])
return

read_data(FLAG_ADDRESS, 10)
write_data(FLAG_ADDRESS, [0x42]*10)
read_data(FLAG_ADDRESS, 10)
erase_data(FLAG_ADDRESS)
read_data(FLAG_ADDRESS, 10)
The read write and erase instruction are succesful

Looks good to me!

Understanding the challenge

Now that we can manipulate the memory from the client, we can now starts to understand how the challenge is structured, from the description, it says that it write some logs into the memory, and we have to overwrite some logs to cover our tracks. but if you read the memory starting from address 0, it’s all gibberish, let’s see how the logs are written.

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


memory_verified = verify_flashMemory();
if (!memory_verified) return 0;

// Start Flash Memory
W25Q128_begin(SPI_CHANNEL);

// Erase data by Sector
if (address == 0){
printf("ERASE SECTOR!");
n = W25Q128_eraseSector(0, true);
printf("Erase Sector(0): n=%d\n",n);
memset(buf,0,256);
n = W25Q128_read (0, 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));

// Serialize the event
memcpy(buffer, &event, sizeof(SmartLockEvent));

// Calculate CRC for the serialized event
crc = calculateCRC32(buffer, sizeof(SmartLockEvent));

// Append CRC to the buffer
memcpy(buffer + sizeof(SmartLockEvent), &crc, sizeof(crc));


// Print the SmartLockEvent for debugging
printf("SmartLockEvent:\n");
printf("Timestamp: %u\n", event.timestamp);
printf("EventType: %u\n", event.eventType);
printf("UserId: %u\n", event.userId);
printf("Method: %u\n", event.method);
printf("Status: %u\n", event.status);

// Print the serialized buffer (including CRC) for debugging
printf("Serialized Buffer (including CRC):");
for (size_t i = 0; i < sizeof(buffer); ++i) {
if (i % 16 == 0) printf("\n"); // New line for readability every 16 bytes
printf("%02X ", buffer[i]);
}
printf("\n");


// Write the buffer to flash
write_to_flash(sector, address, buffer, sizeof(buffer));


// Read 256 byte data from Address=0
memset(buf,0,256);
n = W25Q128_read(0, buf, 256);
printf("Read Data: n=%d\n",n);
dump(buf,256);

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

printf("Data before encryption (including CRC):\n");
for(size_t i = 0; i < data_length; ++i) {
printf("%02X ", data[i]);
}
printf("\n");

// Print the CRC32 checksum before encryption (assuming the original data includes CRC)
uint32_t crc_before_encryption = calculateCRC32(data, data_length - CRC_SIZE);
printf("CRC32 before encryption: 0x%08X\n", crc_before_encryption);

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

printf("Data after encryption (including CRC):\n");
for(size_t i = 0; i < data_length; ++i) {
printf("%02X ", data[i]);
}
printf("\n");


}

void write_to_flash(uint32_t sector, uint32_t address, uint8_t *data, size_t length) {
printf("Writing to flash at sector %u, address %u\n", sector, address);

uint8_t i;
uint16_t n;

encrypt_data(data, length, 1, address);

n = W25Q128_pageWrite(sector, address, data, 16);
printf("page_write(0,10,d,26): n=%d\n",n);

}

The flow is not as difficult as it looks, here is some high level explanation

1. The logs are in a form of a struct
2. The struct are written into a buffer
3. Calculate the CRC of the buffer and append it to the end of the buffer
4. The buffer (not including the CRC) is xored by a static key taken from the security register
5. The buffer and the CRC is written to the memory in block of 16

If analyzing this feels hard for you, its okay, I myself spent hours just trying to understand the source code, I tried my best to summarize stuff along the way so we doesn’t waste too much time

Before we continue, we must learn another new instruction, that is the Read Security Register instruction, this instruction reads from a register, which probably has the same concept as regular executable register. Looking at the documentation we can see that the Read Security Register uses opcode 0x48

In the C file we know that we want to read from Security Register #1 from address 0x52, so lets do it

def read_security_register(addr, length):
x = exchange([0x48]+[0,0b0001_0000,addr], length)
print([hex(x) for x in x])
return x

key = bytes(read_security_register(0x52, 12))+b"\x00"*4

Remember that the CRC does not get XORed, thus I set the last 4 bytes of the key to be null

With all our knowledge now this has become a regular reversing challenge, all we need is just patients to slowly analyzing the code and implement it.

Now we can write a parser for the logs, it reads from the memory, XOR it with the key, and parse its struct structure and the CRC

def get_crc(data):
crc = 0xFFFFFFFF
for i in range(len(data)):
crc ^= data[i]
for j in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xEDB88320
else:
crc >>= 1
return crc ^ 0xFFFFFFFF

def parse_event(event):
timestamp = event[0:4]
eventType = event[4]
userId = event[6:8] # this might look odd, but after experimenting in C this is exactly how the struct is converted into the buffer, idk why
method = event[8]
status = event[9]
crcs = event[-4:]
computed_crc = get_crc(event[0:-4]).to_bytes(4, "little")
print({
"timestamp": (int.from_bytes(timestamp, "little")),
"eventType": eventType,
"userId": hex(int.from_bytes(userId, "little")),
"method": method,
"status": status
})
assert computed_crc == crcs
print("CRC OK")

def read_and_parse_event(idx, amount=1):
res = bytes(read_data(16*idx, 16*amount))
structs = (xor(res,key))
structs = [structs[i:i+16] for i in range(0, len(structs), 16)]
events = []
for _,i in enumerate(structs):
print(_, end=' ')
parse_event(i)
events.append(i)
return events

read_and_parse_event(0, amount=10)
The script now parses the logs correctly

This looks really really promising, the timestamp is strictly increasing, the status and method stays low, and the CRC matches. We are on the right path

Now it’s time to find which logs contains our userId, 0x5244. After reading at all the logs I finally found our user logs at the last 4 log, that is at index 156–160

read_and_parse_event(156, amount=4)

Putting Everything Together

So now all we need is to change this particular log, remember, we cannot change any other log, only this 4 logs, also there are CRC checking which means that we have to recalculate the CRC as well, this is no issue as we already implemented the CRC function ourself.

We already learn about reading, writing, and erasing data. Remember that erasing data can only be done in sectors. This means that we will clear the entire logs, rewrite old logs, and then write our new modified logs only at index 156–160

After putting everything together, this is what our final script will look like

from pwn import *
import socket
import json

FLAG_ADDRESS = [0x52, 0x52, 0x52]
START = 156
LOG_LEN = 16
LOGS_AMOUNT = 4

def exchange(hex_list, value=0):

# Configure according to your setup
host = '94.237.54.170' # The server's hostname or IP address
port = 54714 # The port used by the server
cs=0 # /CS on A*BUS3 (range: A*BUS3 to A*BUS7)

usb_device_url = 'ftdi://ftdi:2232h/1'

# Convert hex list to strings and prepare the command data
command_data = {
"tool": "pyftdi",
"cs_pin": cs,
"url": usb_device_url,
"data_out": [hex(x) for x in hex_list], # Convert hex numbers to hex strings
"readlen": value
}

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))

# Serialize data to JSON and send
s.sendall(json.dumps(command_data).encode('utf-8'))

# Receive and process response
data = b''
while True:
data += s.recv(1024)
if data.endswith(b']'):
break
# print(data)

response = json.loads(data.decode('utf-8'))
#print(f"Received: {response}")
return response

def write_enable():
x = exchange([0x06], 100)
# print([hex(x) for x in x])
return

def read_data(addr,length,quiet=False):
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
jedec_id = exchange([0x03]+addr, length)
if not quiet:
print("read:",[hex(x) for x in jedec_id])
return jedec_id

def erase_data(addr):
write_enable()
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
x = exchange([0x20]+addr, 10)
print("erase:",[hex(x) for x in x])
return

def write_data(addr, data):
write_enable()
if type(addr) != list:
# split the address into 3 bytes
addr = [addr >> 16, addr >> 8 & 0xff, addr & 0xff]
x = exchange([0x02]+addr+data, 100)
print("write:",data)
return

def read_security_register(addr, length):
x = exchange([0x48]+[0,0b0001_0000,addr], length)
print([hex(x) for x in x])
return x

def get_crc(data):
crc = 0xFFFFFFFF
for i in range(len(data)):
crc ^= data[i]
for j in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xEDB88320
else:
crc >>= 1
return crc ^ 0xFFFFFFFF

def parse_event(event):
timestamp = event[0:4]
eventType = event[4]
userId = event[6:8]
method = event[8]
status = event[9]
crcs = event[-4:]
computed_crc = get_crc(event[0:-4]).to_bytes(4, "little")
print({
"timestamp": (int.from_bytes(timestamp, "little")),
"eventType": eventType,
"userId": hex(int.from_bytes(userId, "little")),
"method": method,
"status": status
})
assert computed_crc == crcs
print("CRC OK")

key = bytes(read_security_register(0x52, 12))+b"\x00"*4
def read_and_parse_event(idx, amount=1):
res = bytes(read_data(16*idx, 16*amount))
structs = (xor(res,key))
structs = [structs[i:i+16] for i in range(0, len(structs), 16)]
events = []
for _,i in enumerate(structs):
print(_, end=' ')
parse_event(i)
events.append(i)
return events

def change_user_id_and_recalc_crc(event, new_id):
event = bytearray(event)
print("old", end=' ')
parse_event(event)
event[6:8] = new_id.to_bytes(2, "little")
event[-4:] = get_crc(event[0:-4]).to_bytes(4, "little")
try:
print("new", end=' ')
parse_event(event)
except:
print('MISMATCH!')
return event

print("Sanity check, reading the flag")
print(bytes(read_data(FLAG_ADDRESS, 100)))
print("---")

print("Sanity check, checking first event")
read_and_parse_event(0)
print("---")

old_data = read_data(0, START*LOG_LEN, quiet=True)
old_data = [old_data[i:i+128] for i in range(0, len(old_data), 128)]
to_write = read_and_parse_event(START, LOGS_AMOUNT)

input("Press enter to continue to recalculate crc")
for i in range(LOGS_AMOUNT):
to_write[i] = xor(change_user_id_and_recalc_crc(to_write[i], 0x1337), key)

input("Press enter to continue to write")
erase_data(0)
for i in range(len(old_data)):
write_data(i*128, list(old_data[i]))

print("Old data has been restored")
print("Sanity check, checking first event")
read_and_parse_event(0)

print("Now writing modified logs")
for i in range(LOGS_AMOUNT):
write_data((START+i)*LOG_LEN, list(to_write[i]))

print(bytes(read_data(FLAG_ADDRESS, 100)))
After running the script and waiting for the execution, we finally get the flag

And there you have it! We have conquered the entirety of hardware category without any prior knowledge of hardware. If you have any question feel free to reach out to me @wrth via discord :)

No responses yet

Write a response