Hackvent 2024: Day 21
[HV24.21] Silent Post
The elves have launched a new service to transmit the most valuable secrets, like the naughty lists, over a secure line. Using Silent Post, secrets get encrypted, and the decryption key is right in the link. How clever! Sadly, one elves has lost the link to one of the lists. Can you help him recover the list?
Start the website and get the flag.
Flag format: HV24{}
This challenge was written by rtfmkiesel. That's what I call the cool version of Pastebin.
Solution
Initial Analysis
After launching the Docker container for the challenge, we are presented with a website called Silent Post which looks a lot like Pastebin.
The website allows you to enter some plaintext and have it encrypted. When you click Generate link
, a /api/new
endpoint is called and some encrypted value is sent to the server.
The website then gives us a link that looks like this:
https://2f7453af-f15c-408e-a40e-58cb1719440e.i.vuln.land/133#MDc4NjY3NmE5YzJmN2Y0MDgzNzM3OTZkNWQ5N2E1OWJhOTgxYzA4YQ==
We are told this URL will only work once. We validate this behaviour and confirm that after a single visit the URL will no longer successfully retrieve the plaintext.
The URL itself has this format: {BASE_URL}/{ID}#{KEY}
The ID is a simple integer which appears to increment sequentially. The first plain text we encrypted resulted in an ID of 133
. Therefore, we make the reasonable deduction that has been previous generations from IDs 0
to 132
inclusive.
The webpage is also sourcing a app-view.js
:
document.addEventListener('DOMContentLoaded', function() {
var outputfield = document.getElementById('secret-output');
var id = window.location.pathname.split('/')[1];
var key = window.location.hash.substring(1);
if (!id || !key) {
outputfield.innerText = 'Invalid link';
return;
}
fetch('/api/fetch/' + id, {
method: 'GET',
}).then(function(response) {
if (response.ok) {
response.json().then(function(data) {
var decrypted = decrypt(data.value, key);
outputfield.innerText = decrypted;
});
} else {
outputfield.innerText = 'An error occurred';
}
});
});
This code is responsible for showing the original plaintext when a link is loaded. It calls the /api/fetch/{ID}
endpoint which returns a value
which looks like this:
{"value":"EVdBRQ=="}
This value is then passed to a decrypt
function alongside the key
from the URL to obtain the original plaintext. We also see the webpage is sourcing a crypto.js
file which has been heavily obfuscated. This file must contain the decrypt
function as it cannot be anywhere else. Luckily, we use Chrome's dev tools to easily de-obfuscate this code. We type out the function name in the console, right click and press Show function definition
.
We then see the de-obfuscated crypto.js
file contents:
function encrypt(text) {
const key = generateKey();
let encrypted = '';
for (let i = 0; i < text.length; i++) {
encrypted += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return [btoa(encrypted), btoa(key)];
}
function decrypt(encrypted, key) {
encrypted = atob(encrypted);
key = atob(key);
let decrypted = '';
for (let i = 0; i < encrypted.length; i++) {
decrypted += String.fromCharCode(encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return decrypted;
}
function generateKey() {
const timestamp = Math.floor(Date.now() / 1000);
const shaObj = new jsSHA("SHA-1","TEXT",{
encoding: "UTF8"
});
shaObj.update(timestamp.toString());
const hash = shaObj.getHash("HEX");
return hash;
}
Finding the encrypted text
The challenge description hints to us to find the lost link. We can utilise the /api/fetch/{ID}
endpoint to look for possible IDs that have not been used yet.
We write the following script to brute-force IDs 0
to 132
:
# Hackvent 2024 - Day 21
# Mo Beigi
#
# Brute-force to search for valid link IDs.
import requests
BASE_URL = 'https://2f7453af-f15c-408e-a40e-58cb1719440e.i.vuln.land'
MIN_ID = 0
MAX_ID = 132
def check_url(id):
url = f"{BASE_URL}/api/fetch/{id}"
response = requests.get(url)
if response.status_code == 200 and "record not found" not in response.text:
print(f"Valid record found for ID {id}:")
print(response.json())
else:
print(f"Error or record not found for ID {id}")
def main():
for id in range(MIN_ID, MAX_ID + 1):
check_url(id)
if __name__ == "__main__":
main()
This spits out the following results:
$ py -3 fetch.py
Error or record not found for ID 0
Error or record not found for ID 1
Error or record not found for ID 2
Error or record not found for ID 3
...
Error or record not found for ID 85
Valid record found for ID 86:
{'value': 'fmELB0hHVlsDQgxVChFoEAcOUWwMFm5cUksY'}
Error or record not found for ID 87
...
Error or record not found for ID 131
Error or record not found for ID 132
Nice! All IDs resulted in an error except for ID 86
which gives us the value fmELB0hHVlsDQgxVChFoEAcOUWwMFm5cUksY
. This is our base64 encrypted text which contains the flag.
Finding the key
Recall how the generateKey()
function is based on const timestamp = Math.floor(Date.now() / 1000)
. This means that we need to know the original Date.now()
value used in order to generate a valid key. Since we spawn this Docker container on demand, we assume the timestamp used to encrypt ID 86
is close to the spawn time of the container running the challenge. We recorded the timestamp the website became available after starting the challenge as 1734747825
.
Therefore, we write a script which will brute-force a range of possible timestamps near this timestamp. It will then generate a key by generating the SHA-1
sum of the timestamp and then base64
encoding that result. Finally, it will call the decrypt
function passing in both the encrypted text from before and the candidate key. Finally, we check to see if the result contains HV24
which is the flag for our flag.
# Hackvent 2024 - Day 21
# Mo Beigi
#
# Find plaintext from encrypted text by brute-forcing timestamp ranges.
import hashlib
import base64
def generate_key(timestamp):
sha_obj = hashlib.sha1()
sha_obj.update(str(timestamp).encode('utf-8'))
return sha_obj.hexdigest()
# Python version of: function decrypt(encrypted, key)
def decrypt(encrypted, key):
encrypted_bytes = base64.b64decode(encrypted)
key_bytes = base64.b64decode(key)
decrypted = ''.join(
chr(encrypted_bytes[i] ^ key_bytes[i % len(key_bytes)]) for i in range(len(encrypted_bytes))
)
return decrypted
def main(encrypted_text):
timestamp_min = 1734747700
timestamp_max = 1734750900
for timestamp in range(timestamp_min, timestamp_max + 1):
# Generate the base64 encoded key
hex_key = generate_key(timestamp)
base64_key = base64.b64encode(hex_key.encode('utf-8')).decode('utf-8')
# Decrypt the text
decrypted_text = decrypt(encrypted_text, base64_key)
# Check if the decrypted text contains flag
if "HV24" in decrypted_text:
print(f"Decrypted Text Found for Timestamp {timestamp}: {decrypted_text}")
if __name__ == "__main__":
encrypted_text = 'fmELB0hHVlsDQgxVChFoEAcOUWwMFm5cUksY'
main(encrypted_text)
Running this script produces the following output which contains our daily flag:
$ py -3 brute.py
Decrypted Text Found for Timestamp 1734747795: HV24{s0metim3s_t1me_is_k3y}
Flag:
HV24{s0metim3s_t1me_is_k3y}