Snorex 2K CCTV

Writeup for Snorex 2K Camera (Pwn) - NahamCon Winter CTF (2025) 💜

Description

I bought this second-hand CCTV camera but I seem to be locked out, can you help me? Apparently it's a "Snorex 2K Indoor Wi-Fi Security Camera"

Solution

I made this [baby] pwn chall (OOB heap read), with some inspiration from one of the steps in the Lorex CCTV Pwn2Own Research (2024) discovered by Stephen Fewer. The original challenge design was closer to the actual research, but I simplified it due to constraints (dev/test time, single random port requirement) and the event type (24 hours, audience mostly bug bounty / web-focused). Regardless, I think the best CTF challenges take some inspiration from real world vulnerabilities/attacks, and at the very least, it might encourage some players to learn about another cool Pwn2Own chain!

Basic File Checks

Checking the binary protections, we'll see from the checksec output that all security mechanisms are enabled. The binary is not stripped, and I even include debug info to make reversing as painless as possible 😇

file snorex_sonia; checksec --file snorex_sonia

snorex_sonia: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=099466942a10f6753c1177645a57c127b73c86bb, for GNU/Linux 3.2.0, with debug_info, not stripped

[*] '/home/crystal/Desktop/snorex_sonia'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

If we run the binary, it creates an RPC listener on port 3500 (different for remote).

We could try and connect with netcat, but there is no output on the client or server-side. Let's disassemble the binary with Ghidra.

Static Analysis

The main function begins by calling a load_config function which:

  • Loads some environment variables (serial number + MAC address) into a global config structure ([11-19])

  • Allocates a heap descriptor via usrMgr_getEncryptDataStr() and stores the pointer in g_usr_ctx.encrypt_data under a mutex ([22])

Inside usrMgr_getEncryptDataStr looks like this. It allocates a fixed-size heap chunk (0x108), writes a constant marker tag ("SNOREX1"), then builds a newline-delimited string that includes the device serial, a timestamp, the device MAC, and 15 bytes of random data encoded as 30 hex characters.

After this initial setup, the rpc_server_thread listens/accepts, then spawns a client_thread per connection. The core behaviour is inside handle_request which:

  1. reads a request header [1]

  2. dispatches based on a numeric command ID [2]

  3. sends a response [3]

Request Handling

[1] -> Reading a request header

Each request starts with an 8-byte fixed header (hdr[2]):

There's a cap on payload size:

If len is non-zero, the server allocates a buffer and reads the payload:

[2] -> Dispatching a command

There are three possible command IDs (0, 1, 6), but only two handlers (auth / IQ):

[3] -> Sending a response

All responses follow the same pattern (built inside the relevant handler):

  • write an 8-byte header [status][length]

  • write length bytes of body (optional)

Authentication (cmd = 0 / cmd = 1)

The auth side lives in handle_auth(cmd, buf, len, fd). There are two code paths:

  • cmd = 0 -> regenerate / refresh the global descriptor

  • cmd = 1 -> compute expected auth code and compare it to what we sent

cmd = 0 (regenerate descriptor)

This path calls usrMgr_getEncryptDataStr(), stores the returned heap pointer in g_usr_ctx.encrypt_data, then replies with a header-only response.

In usrMgr_getEncryptDataStr(), you can see the exact structure size and the string formatting.

cmd = 1 (check auth code -> flag gate)

The cmd=1 path holds the "win" condition: it computes the expected code and compares it against our input. If they match, we get the flag (maybe you searched for this string at the beginning 😉).

So, what does PasswdFind_getAuthCode() actually do? It retrieves the encrypt_str, MD5 hashes it and extracts the first 8 bytes of the digest (16 hex chars).

IQ Service (cmd = 6)

cmd=6 goes through handle_iq(), which allocates a fixed-size heap buffer, lets MI_IQSERVER_GetApi() fill it, then returns out.curr_length bytes back to the client.

The important detail is that the IQ heap buffer is only 0x100 bytes, but the length returned to the client is derived from attacker-controlled input.

So what happens in MI_IQSERVER_GetApi?

First, it sanity-checks the request and enforces an IQ ID (0x2803) in the first two bytes of the payload:

It then reads max_word from the payload and uses it to compute the output length:

Crucially, out->curr_length is never compared against out->max_length.

After that, the function initialises exactly 0x100 bytes of data:

No authentication data is copied here, the buffer contains only header data and random bytes.

Attacker-controlled length

The IQ request includes a 16-bit max_word field. MI_IQSERVER_GetApi() uses it to calculate the response length:

The problem is that handle_iq() always allocates exactly 0x100 bytes:

So once max_word goes past 0x3E, out->curr_length becomes larger than 0x100 (up to a max of 0x400), and the server sends an out-of-bounds read back to the client.

Parsing the leak

The first 0x100 bytes are just the IQ header plus rand() output. The useful part is whatever comes after that, when the server reads beyond the chunk boundary.

We want the USR_MGR_ENCRYPT_DATA structure, because it starts with the tag marker "SNOREX1" and the bytes after it are the newline-delimited encrypt_str. If the tag does not show up in a given response, run it again (sending cmd = 0 in between helps move that descriptor around).

Heap OOB Read

From the client side, exploitation is trivial:

  1. Send cmd = 0 to force regeneration of encrypt_str

  2. Send cmd = 6 with a large max_word

  3. Read the returned buffer and scan for the marker string "SNOREX1"

  4. If the tag is not present, repeat from step 1

Example leaked data:

Completing authentication

Once encrypt_str is known, the auth token is computed locally:

Sending this back via cmd = 1 passes PasswdFind_getAuthCode and returns the flag.

Exploit (PoC)

It won't work on the first try (because..)

The second run gets the flag 😌

Flag: flag{h3y_7h47_w4s_7074lly_0u7_0f_b0unds}

Debugging

For those interested, I did a little debugging to try and work out what was going on. I could have coded the challenge better tbh, I was testing/making changes up to the last minute 🤦‍♂️ Anyway, if you open the binary with pwndbg:

Then hit ctrl + C and set a breakpoint in handle_iq, right after the MI_IQSERVER_GetApi call returns, and continue.

Initial Run (fail)

We run the exploit and hit the breakpoint. At this point in our PoC, we already sent cmd=0 in handle_auth (populated auth data chunk), and the IQ buffer chunk has been filled with random data. Let's check the heap:

We dump the descriptor, confirming the chunk @ 0x7fffe8000b60 contains the auth data:

However, we don't see another chunk with the IQ data 🤔 If we dump the descriptor, we'll see the address range (0x55555555) doesn't match the auth chunk (0x7fffe800):

Inspecting the arenas confirms that the IQ buffer chunk is in the main_arena, while the auth chunk is in a non-main arena:

Finally, let's visualise the heap for a clear picture why the PoC will fail:

It returns the following error, which makes sense considering the two chunks are nowhere near each other! 😅

The reasoning behind this is the load_config runs in the main thread, so its allocations are from the main arena (normal [heap] region). Subsequent allocations occur within a worker thread, and land in a non-main arena. Since load_config on initialisation, this won't impact future runs, as we'll see now.

Second Run (success)

Run the PoC and check the heap again. This time there is an extra chunk of the same size (0x110), because they are both in the same arena! Last time 0x7fffe8000b60 chunk held the auth data, but on this connection attempt, it holds the IQ buffer data.

We can confirm with vis:

The IQ buffer we just allocated sits before the auth data chunk, so the layout is effectively:

The OOB read starts at the IQ chunk and reads forward, dumping the encrypted data 🙏

Third Run (failure)

Let's run the PoC a final time and check the heap. We'll see that the two chunks have swapped places!

You know what that means? Our IQ buffer chunk comes after auth data chunk this time, as visualised:

The OOB read starts at the IQ chunk and reads forward, missing the encrypted data 😤

The reasoning behind this is simple: same size + repeated allocate/free loop = allocator reuse roulette, so the IQ chunk and the "SNOREX1" chunk swap places between runs.

y u do dis?

I thought it would make the challenge more fun! Nah, as I said at the beginning, I left testing this challenge kinda late (spending too much time on game graphics 😆) and just about fixed some unintended solutions, random bugs etc before the event started. Ideally, I would have corrected this and made the challenge closer to the Pwn2Own chain like I did with Mother Printers. The circumstances for that challenge were different though; players didn't have 24 hours to solve it (along with many other challs), the infra was more flexible (multi-container, multi-port) and under less stress (not thousands of players at once) etc.

Conclusion

If conditions would have allowed, I would have liked to make the challenge closer to the Lorex chain - it is really crazy! The Snorex challenge is only inspired by a small part:

Even then, it's vastly over simplified due to reasons.. Anyway, I hope you enjoyed it all the same - see you for the next one 😺

Last updated