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
:
|
|
str_to_uint8
cannot return -1
, i.e. this error message is triggered by
is_port_allowed
returning -1
:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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?}
.