Advent of Cyber Side Quest
3 December 2024
I discovered that TryHackMe has an annual "Advent of Code"-style event, except theirs is called Advent of Cyber and it's a cybersecurity challenge. This is much more up my alley than Advent of Code. There's nothing wrong with Advent of Code per se, it's just that the nature of the problems are not the sort of thing I find fun.
Side Quest?
The Advent of Cyber (hereafter AoC) has one challenge per day, and the challenges are generally very straightforward. The challenge descriptions hold your hand a lot of the way, and there's not much to it.
But.
Hidden inside some of the main challenges one can find five Key Cards, T1 through T5, and they contain passwords that unlock machines in the Side Quest room. The Side Quest is much more difficult and there is no guidance at all. In the discord channel there is a strictly-enforced "no hints" policy. Difficult hacking challenges with secret unlock codes??? Sign me up! The embargo lifted at 00:00 GMT on the 31st of December so I can publish these now.
The Keycard
The first day of the main quest turned out to have the first keycard.
In the main quest we're asked to investigate a github repository to
teach a lesson about poor opsec. Well, if we explore the user's github
repositories we see there's a C2 server
flask app. Interesting. Running nmap
on the main quest
machine reveals there's something running on port 8080. Could it be the
C2 server?
The default credentials didn't work, but a careful examination of the code reveals this snippet:
@app.route("/dashboard")
def dashboard():
if "logged_in" not in session:
return redirect(url_for("login"))
return render_template("dashboard.html")
So, it only checks if the session cookie contains the "logged in"
key. If the owner/attacker didn't change the application secret then we
can forge session cookies and bypass the login. Fortunately there's a
tool for that called flask-unsign. A
quick git clone
later and we were into the C2 server's
dashboard, which contained the keycard:
T1: Operation Tiny Frostbite
Perfect! Now we can start the sidequest machine, which contains only
a single zip file for download. The password from the keycard allows us
to open the zip, which contains a pcap
file.
pcap
files are packet captures, and this one had quite a
bit of traffic. I spent many hours poking around, and eventually noticed
a few things:
- some mysql traffic
- some encrypted traffic on port 9001
- http endpoints serving 200 OK on
/ff
and/exp_file_credential
- the password that serves as the first flag
- another passworded zip being transferred
The http endpoints at /exp_file_credential
and
/ff
serve binaries. exp_file_credential
looked
interesting but I couldn't get anywhere with it. The "ff binary",
according to virustotal, was malware. Specifically, a tinyshell backdoor from the
"rekoobe"/"rekobee"
attack. Running strings
on the ff binary revealed the
string "SuP3RSeCrEt".
Don't send this file to anyone on discord, or else you will trip their heuristics and get permanently banned. Ask me how I know.
Anyways, rekoobe implements a custom cryptosystem using two AES-CBC-128 ciphers, one for client-to-server and one for server-to-client. I know how to attack AES CBC under the right conditions but bitflipping attacks didn't seem feasible here. Instead, it was a matter of re-implementing the tinyshell cryptosystem in python and decrypting the packet stream:
from Crypto.Cipher import AES
from Crypto.Hash import SHA1, HMAC
import binascii
import struct
from scapy.all import *
class ShittyCrypto:
def __init__(self, key):
self.key = key.encode()
self.initial_iv = None
def setup_context(self, iv):
self.initial_iv = iv
= SHA1.new()
sha1 self.key)
sha1.update(
sha1.update(iv)= sha1.digest()
key_material
self.aes_key = key_material[:16]
self.k_ipad = bytearray([0x36] * 64)
self.k_opad = bytearray([0x5C] * 64)
for i in range(20):
self.k_ipad[i] ^= key_material[i]
self.k_opad[i] ^= key_material[i]
self.last_ct = iv[:16]
self.packet_counter = 0
self.consecutive_failures = 0
def decrypt_block(self, ciphertext):
= AES.new(self.aes_key, AES.MODE_ECB)
cipher = self.last_ct[:]
temp self.last_ct = ciphertext[:16]
= cipher.decrypt(ciphertext)
plaintext return bytes(a ^ b for a, b in zip(plaintext, temp))
def verify_hmac(self, ciphertext, hmac_tag):
= struct.pack('>I', self.packet_counter)
counter_bytes
= SHA1.new()
inner bytes(self.k_ipad))
inner.update(
inner.update(ciphertext)
inner.update(counter_bytes)= inner.digest()
inner_hash
= SHA1.new()
outer bytes(self.k_opad))
outer.update(
outer.update(inner_hash)
= outer.digest()
calculated_hmac return calculated_hmac == hmac_tag
def try_decrypt_with_counter(self, encrypted_data, counter):
if len(encrypted_data) < 36:
return None
= encrypted_data[:-20]
ciphertext = encrypted_data[-20:]
hmac_tag
# Save current counter
= self.packet_counter
old_counter self.packet_counter = counter
if self.verify_hmac(ciphertext, hmac_tag):
= self.decrypt_block(ciphertext[:16])
first_block = (first_block[0] << 8) | first_block[1]
msg_len
if msg_len > 0 and msg_len <= 8192:
= first_block[2:16]
plaintext for i in range(16, len(ciphertext), 16):
= self.decrypt_block(ciphertext[i:i+16])
block += block
plaintext
self.packet_counter = counter + 1
self.consecutive_failures = 0
return plaintext[:msg_len]
# Restore counter if decryption failed
self.packet_counter = old_counter
return None
def decrypt_message(self, encrypted_data):
# Try current counter first
= self.try_decrypt_with_counter(encrypted_data, self.packet_counter)
result if result:
return result
# For client packets, try a wider range when stuck
= 20 if self.packet_counter <= 2 else 5
search_range
# Try counter values both forward and backward
for i in range(self.packet_counter - search_range, self.packet_counter + search_range):
if i >= 0: # Ensure counter doesn't go negative
= self.try_decrypt_with_counter(encrypted_data, i)
result if result:
return result
# If still failing, try resetting counter
if self.consecutive_failures > 5:
self.packet_counter = 0
self.consecutive_failures = 0
= self.try_decrypt_with_counter(encrypted_data, 0)
result if result:
return result
return None
def find_ivs(packets):
for i, pkt in enumerate(packets):
if IP in pkt and TCP in pkt and Raw in pkt:
if pkt[IP].src == "10.13.44.207" and pkt[TCP].dport == 9001:
= bytes(pkt[Raw].load)
payload if len(payload) == 40:
= payload[:20]
client_iv = payload[20:]
server_iv print(f"Found IVs in packet {i+1}")
print(f"Client IV: {client_iv.hex()}")
print(f"Server IV: {server_iv.hex()}")
return client_iv, server_iv, i
return None, None, 0
def process_pcap(pcap_file, password):
print(f"Reading pcap file: {pcap_file}")
= rdpcap(pcap_file)
packets print(f"Found {len(packets)} packets in pcap")
= find_ivs(packets)
client_iv, server_iv, start_packet if not client_iv or not server_iv:
print("Could not find IVs")
return
= ShittyCrypto(password)
client_crypto = ShittyCrypto(password)
server_crypto
client_crypto.setup_context(client_iv)
server_crypto.setup_context(server_iv)
print("\nProcessing packets:")
= []
successful_decryptions
for i, pkt in enumerate(packets[start_packet+1:]):
if IP in pkt and TCP in pkt and Raw in pkt:
= i + start_packet + 2
pkt_num print(f"\nPacket {pkt_num}:")
print(f"Source: {pkt[IP].src}:{pkt[TCP].sport}")
print(f"Dest: {pkt[IP].dst}:{pkt[TCP].dport}")
= bytes(pkt[Raw].load)
encrypted_data print(f"Payload length: {len(encrypted_data)}")
= ""
direction = None
crypto
if pkt[IP].src == "10.13.44.207" and pkt[TCP].dport == 9001:
= "C->S"
direction = client_crypto
crypto # Reset client counter if stuck at 2 for too long
if crypto.packet_counter == 2 and crypto.consecutive_failures > 3:
= 0
crypto.packet_counter = 0
crypto.consecutive_failures elif pkt[IP].src == "10.10.103.220" and pkt[TCP].sport == 9001:
= "S->C"
direction = server_crypto
crypto
if crypto and direction:
print(f"Attempting to decrypt {direction} packet (counter: {crypto.packet_counter})")
= crypto.decrypt_message(encrypted_data)
decrypted if decrypted:
try:
= decrypted.decode('ascii', errors='replace')
decoded print(f"Successfully decrypted: {decoded}")
f"Packet {pkt_num} {direction}: {decoded}")
successful_decryptions.append(except:
print(f"Successfully decrypted (hex): {decrypted.hex()}")
f"Packet {pkt_num} {direction} (hex): {decrypted.hex()}")
successful_decryptions.append(else:
print("Decryption failed")
else:
print("Packet direction not matched")
print("\nAll successful decryptions:")
for msg in successful_decryptions:
print(msg)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python3 decrypt.py <pcap_file>")
1)
sys.exit(
= "SuP3RSeCrEt"
password = sys.argv[1]
pcap_file
process_pcap(pcap_file, password)
Once that python code was written, it was a matter of determining
which packets to feed it. Fortunately the rekoobe attack starts by
feeding the backdoor two 20-byte AES initialization vectors ("IVs"), and
there was only one packet with tcp.len == 40
in the entire
packet capture. This not only gives us the start of the TCP stream but
also gives us the IVs we need to decrypt the AES traffic, using
"SuP3RSeCrEt" as the key.
With that done we can see the command that the attacker sent to zip up the exfiltrated data, which means we can also now open the second zip that we found. That final zip contained an SQL dump which contained the final flag.
The worst part? After getting the final flag someone in a Discord private chat linked me to a purpose-built rekoobe analyzer tool that performs the decryption automatically, if you feed it a pcap. Unfortunately the author seems to have taken it down and at the time of this writing I haven't been able to find a copy anywhere.
Onto Task 2!