This is a detailed walkthrough to Disobey 2024 hacker puzzle.
The puzzle itself was both fun and challenging, I’d say that difficulty-wise the puzzle was very close to the last year’s level, but the story was especially awesome and well constructed. Solving the puzzle actually felt like being a part of a story rather than doing a regular ctf. Big thanks to everyone involved!
We solved the puzzle together with my friends and colleagues Harri Hietaranta and Riku Järvinen.
Stage 1 – kouvostopankki.fi
The puzzle begins with a link to Kouvostopankki’s home page, which has been defaced by a hacker group 4hv3n.
Inspecting the page source code reveals some cool ASCII art and a JavaScript file that has been included from https://4hv3n.fi.
<!-- DEFACED BY AHVEN
/`·.¸
/¸...¸`:·
¸.·´ ¸ `·.¸.·´)
: © ):´; ¸ {
`·.¸ `· ¸.·´\`·¸)
`\\´´\¸.·´
WE WILL CONCUR KOUVOSTOLIITTO
-->
<script src="https://4hv3n.fi/script.js"></script>
The included JavaScript contains obfuscated code that can easily be deobfuscated using numerous available tools, for example de4js.
Code after deobfuscation:
const y5t4r3e2w1 = '.';
const _0x221444 = _0x422f;
function _0x422f(_0x3c0471, _0x512f21) {
const _0x3fbac6 = _0x3fba();
return _0x422f = function (_0x422f3f, _0x182502) {
_0x422f3f = _0x422f3f - 0xdd;
let _0x5095ca = _0x3fbac6[_0x422f3f];
return _0x5095ca;
}, _0x422f(_0x3c0471, _0x512f21);
}(function (_0x4a9e40, _0x2333e2) {
const _0x37a810 = _0x422f,
_0x156760 = _0x4a9e40();
while (!![]) {
try {
const _0x5402dc = -parseInt(_0x37a810(0xef)) / 0x1 + parseInt(_0x37a810(0xee)) / 0x2 * (parseInt(_0x37a810(0xeb)) / 0x3) + parseInt(_0x37a810(0xe6)) / 0x4 * (parseInt(_0x37a810(0xe1)) / 0x5) + -parseInt(_0x37a810(0xea)) / 0x6 + parseInt(_0x37a810(0xe9)) / 0x7 * (parseInt(_0x37a810(0xed)) / 0x8) + -parseInt(_0x37a810(0xdf)) / 0x9 + -parseInt(_0x37a810(0xe4)) / 0xa;
if (_0x5402dc === _0x2333e2) break;
else _0x156760['push'](_0x156760['shift']());
} catch (_0x222cbf) {
_0x156760['push'](_0x156760['shift']());
}
}
}(_0x3fba, 0x5c9b9));
function _0x3fba() {
const _0x13bed0 = ['pink', '5358771AxcgGl', 'length', '25ALkAjP', 'body', 'purple', '7802450qkYNbO', 'grey', '550040YFvrEq', 'red', 'floor', '440797EoQVCw', '798624SYQaLW', '9GhsrtN', '4hv3n.fi', '88cwkqba', '417958IyOZao', '119079hGwgmL', 'backgroundColor', 'brown'];
_0x3fba = function () {
return _0x13bed0;
};
return _0x3fba();
}
const hjkadshjkdsa = 'black',
poiuytgvbnmk = 'orange',
lkjhgf = _0x221444(0xde),
wertyuio = _0x221444(0xdd);
function q1w2e3r4t5() {
const _0xd18e56 = _0x221444,
_0x4063be = [_0xd18e56(0xe7), hjkadshjkdsa, _0xd18e56(0xe3), poiuytgvbnmk, _0xd18e56(0xe5), wertyuio, lkjhgf],
_0x5b55df = _0x4063be[Math[_0xd18e56(0xe8)](Math['random']() * _0x4063be[_0xd18e56(0xe0)])];
document[_0xd18e56(0xe2)]['style'][_0xd18e56(0xf0)] = _0x5b55df;
}
setInterval(q1w2e3r4t5, 0x3e8);
const rect = 'w3 ar3 l00k1ng f0r n3w h4ck3rs t0 j0in uS, 4pply t0 J01n 4hv3n: ',
rtyuio = _0x221444(0xec),
sdfghjklö = hjkadshjkdsa + hjkadshjkdsa + lkjhgf + wertyuio + y5t4r3e2w1 + rtyuio;
At the end of the code there seems to be some kind of recruitment ad for people looking to join the group. We can run the code in browser or jfiddle and print out the last line:
console.log(hjkadshjkdsa + hjkadshjkdsa + lkjhgf + wertyuio + y5t4r3e2w1 + rtyuio);
>"blackblackpinkbrown.4hv3n.fi"
Nice. We got a subdomain for 4hv3n.fi. Let’s check it out.
Site root of https://4hv3n.fi/ contains only a logo with an image of a fish, presumably a perch.
There seems to be nothing more here, and since we already solved (presumably) the first part of the puzzle, we should proceed straight to blackblackpinkbrown.4hv3n.fi.
Curling the site returns 403:
Let’s use FFUF to discover content:
We get a hit on number 4. Enumerating further we discover an URL http://blackblackpinkbrown.4hv3n.fi/4/h/v/3/n/:
From there we can download cta.mp4 and proceed to stage 2.
Stage 2 – cta.mp4
The person in the video tells us that they have hidden credentials to Kouvostopankki’s IDS backup file server in the video. They also tell that all we need to do is to log in to their intranet.
Just by watching the video we’ll discover a “weird” frame at approximately 0:28. Watching the video frame by frame a username is discovered.
Going through a few common mp4 steganography tricks, the password can finally be found from the spectogram of the audio track:
Username: KKP_IDS_ADMIN
Password: THIS_PASSWORD_IS_SECURE
Now we only need to find the right place where to put the password. The video hints towards “ids backup file server”, so let’s do some content discovery on kouvostpankki.fi. With FFUF we quickly uncover a protected directory https://kouvostopankki.fi/backup/~operator/ids. The directory can be accessed using the credentials from the video file:
We can now download capture.pcap and move on to stage 3.
Stage 3 – capture.pcap
Let’s use Wireshark’s conversations -view to display all conversations in the capture file. By analyzing the conversations one by one we’ll discover a lot of interesting information.
1. Someone accessed Kouvostopankki’s systems and opened a reverse shell:
2. There’s an email conversation between Amadea Harjumotto and Pinja Pirivirkkala, where Amadea is noticed that their account could be compromised and is urged to change their password. Amadea also asks for an URL to intranet, and Pinja promises to send the link (there are also credentials for Amadea’s account in base64 format at the beginning of the conversation):
3. There is a longer conversation where the attacker downloads a ransomware and encrypts(?) couple of files with it (employees.csv, sadevesikouruinsinoori.txt and mfa.apk). The contents of the files are also printed to terminal in base64 format, so we can easily grab them:
4. Access to a subdomain intra.kouvostopankki.fi:
5. The executable “ransomware.bin” that was downloaded by the attacker:
6. And finally a conversation where the victim (Amadea) interacts with an unknown service called I.S.U.C.K:
Newly acquired URL intra.kouvostopankki.fi does not work, although all other subdomains (including https://intranet.kouvostopankki.fi) seem to redirect to the intranet that was mentioned in the video. Unfortunately the captured credentials from the email conversation do not work, which was of course to be expected.
After exporting/downloading all the files from the pcap, we are ready to move forward.
Stage 4 – ransomware.bin
To move forward, we need to decrypt the files that were encrypted with the ransomware. From the pcap we see that the files were encrypted using a random key, so there is no point in trying to brute force or guess the key. Instead, we need to reverse the algorithm and try to find some kind of weakness from it.
Let’s analyze the the ransomware.bin that we exported from the pcap -file:
It’s a regular 64-bit elf, so we can just run it (yay, no qemu this year!).
Let’s run the file:
And run it again with some input:
We can immediately see that our 24 byte input generated a 32 bit seemingly random output. It means we are most likely dealing with a block cipher.
This can be confirmed (and block size determined) by running the software again with a shorter input:
As we see, our 1 byte input generated 16 byte output. We are indeed dealing with a block cipher and 16-byte blocks.
Next we want to do some dynamic analysis to get a clear picture of how the program works and how it manipulates our input.
Also, remember to disable ASLR to make the debugging easier:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
Next, lets open radare2 and do some basic analysis. First continue execution until the entry of the executable, then analyze all and list functions:
r2 -d ./ransomware.bin msg.txt CCCCCCCCCCCCCCCC
>dcu entry0
>aaaa
>afl
Function listing does not provide anything interesting. Let’s move forward and try to find the actual encryption function using break points.
When analyzing encryption algorithms, I usually like to initially set a watch point to a memory location containing the encryption key. This is a good way to find the part of the executable where the encryption is done, and it often saves a lot of time from the static analysis.
So, lets search for our key and set a read watch point to it’s memory location. First we set the search space to whole memory mappings (e.search.in=dbg.maps), then search for our key (“/ CCCC…”), and finally set a read watch point using “dbw [address] [r/w]”:
>e.search.in=dbg.maps
>/ CCCCCCCCCCCCCCCC
After continuing the execution with “dc”, we hit our watch point. Let’s hit “vv” and examine the situation in visual view:
Since the code shown here is accessing the encryption key, we are most likely in a function that does the encryption. We are especially interested in finding memory locations that contain the plaintext and the key, and the location where the ciphertext will be written.
Let’s check source and destination indexes first, since they are usually used when moving byte arrays around:
It seems we found our plaintext from @RSI.
We can also see that there is a \x07 added before our plaintext and \x0a\x30\x30\x30\x30\x30\x30 at the end of it.
If you are familiar with block ciphers, you already know that some kind of padding is usually added to the last block, since it’s size needs to match the block size of the cipher algorithm.
The thing is, a predictable padding can make the whole cipher vulnerable, and the padding of \x30’s does not seem random at all. However, the padding might still be affected by the key or the plaintext.
Lets verify this by running the program again with a different message and key:
Repeat the commands, find the memory location of the key, set a watch point and hit dc:
Nice. We notice that our plaintext is once again padded with \x30’s. This should come in handy.
Let’s jump off of debugging for a while and dig deeper into analyzing the inputs and outputs of the binary.
A common principle in block ciphers is to properly encrypt the first block and then use it in combination with the key to encrypt the next block. We could try to use this technique to decrypt some blocks from a ciphertext when we know the key.
Let’s encrypt a longer text with the ransomware:
Then let’s put the second and the third block into CybefChef along with the key and do a simple XOR between the three of them:
And we see plaintext. It seems we can indeed easily decrypt the file (except for the first block) if we just know the key.
But do we actually need the key?
Now that we know that block_n XOR (block_n-1) XOR key == plaintext_n, we can leverage the known bytes in padding to calculate the key (or at least a part of it). All we need is a file with enough padding to calculate a long enough part of the key. That part can then hopefully be used along with sadevesikouruinsinoori.txt or employees.csv to guess the rest of the key.
If we jump back and check the part of the pcap file where the ransomware was used, we can also see that the sizes of the plaintext files are printed in the terminal:
We can now compare the sizes of the plaintext files to the sizes of the encrypted files. Lets start with sadevesikouruinsinoori.txt. Grab the b64 version of the file from the pcap and decode it to “sadevesikouruinsinoori.enc”:
Once again we are lucky. The encoded file is 16 bytes larger, so there is most likely a full 16 bytes of padding added. We should be able to calculate the whole key with this.
Lets put the last and the second last blocks from the encoded file into CyberChef, and use \x30’s as the key:
Calcuated key is 8f 75 41 db 1d d0 6c 03 50 16 a2 59 d9 7b 68 7d.
Lets change the parameters and put second-to-last block of the file along with the 3rd last block of the file into CyberChef along with the newly calculated key:
And we see some plaintext.
We can now easily decrypt the file.
Lets write a script to decrypt:
import sys
target = sys.argv[1]
key = b"\x8f\x75\x41\xdb\x1d\xd0\x6c\x03\x50\x16\xa2\x59\xd9\x7b\x68\x7d"
with open(target, "rb") as infile:
with open("pwned", "wb") as outfile:
last_bytes = b''
while True:
b = infile.read(16)
if len(b)<1:
break
if (last_bytes == b''):
last_bytes = b
continue
xorred = bytes([aa ^ bb for aa, bb in zip(last_bytes, b)])
decrypted = bytes([aa ^ bb for aa, bb in zip(xorred, key)])
outfile.write(decrypted)
last_bytes = b
And use it to decrypt sadevesikouruinsinoori.txt:
The text is readable, but there is still something off. This is because one byte was added to the beginning of the file before encryption, which means that the padding in this case is actually only 15 bytes long. However it’s easy to determine the missing byte correctly by guessing the corresponding characters from the plaintext.
Haportti: SadeveIikouruinsinööHin palkkaaminen0
We can easily see that the first “H” should actually be “r”, and proceed to calculate the missing byte, which is \xb5.
We now have the full key:
b5 75 41 db 1d d0 6c 03 50 16 a2 59 d9 7b 68 7d
…and can proceed to decrypt employees.csv and mfa.apk:
Employees.csv yields us with details of two employees.
Lets move on to mfa.apk:
It’s still only data. This is because we could not decrypt the first block of the encrypted file. However, this should be very easily fixed. Since we know that the file is (or should be) an APK, and APKs begin with a regular zip-header, we can try copying an existing header from another APK to the beginning of the file.
We also need to skip the first byte of the decrypted file. Remember, one byte of “padding” was added to the beginning of the plaintext before encryption – this means that the first block of the decrypted file actually begins with the 16th byte of the original file, even though it should be the 17th byte. This will mess up our reconstructed zip-headers if not addressed correctly:
We now have a working APK andcan proceed forward.
Decompiling the APK with Jadx, we can quickly identify an interesting class: kkp.mfa.MainActivity:
The class seems to make an API-call using encrypted parameters. Luckily enough, the decryption tool is provided in class decryptstringmanager.DecryptString.
Lets copy & compile the source code of the DecryptString, and run it locally to decrypt the parameters in API-call:
And we get the parameters in plaintext. The call seems to fetch some kind of code, and it’s also missing a value for parameter “id”.
Let’s use Amadea’s employee id from the decrypted employees.csv, and hit curl to test it out:
And we get an mfa-token for Amadea.
We now have a username and a method to pass mfa. All we need now is the correct password.
Stage 5 – phishing email
In the PCAP, there is a mail conversation between Amadea and Pinja about phishing emails. At the end of the chain, Pinja promises to send Amadea the Intranet address, because she lost her bookmarks. This is our tip on what to do for the final puzzle. Send Amadea the “intranet address”.
Lets quickly enumerate the mailserver mx.kouvostopankki.fi (URL was also aquired form the pcap-file):
Seems like a regular mail server with the relevant ports (except 25) open. After switching to company internet we were also able to interact with port 25 (note: apparently some ISPs can block access to port 25 from home networks).
Next let’s try the I.S.U.C.K -service with our own IP-address (service address and port were obtained from the pcap):
As we see the service just sends the raw data to the IP and port we tell it to.
We will use this service to interact with the SMTP server and send our malicious email. Hopefully using an internal service will make the server to accept our email, and lower the spam score enough for it to pass the spam filters.
To make the phishing mail look convincing, we will reply to the same email chain the conversation has happened in. Most likely only some of the reference message ids would be needed to satisfy the puzzle logic, but its easier to just reply to whole email chain.
The email conversation can be exported from Wireshark in an eml-format. After this we can import the email to any email client and just hit the reply button. Alternatively we can construct the phishing mail manually.
Let’s use Thunderbird and set up an account with Pinja’s email address. We’ll then use that account to reply to the exported email, and get a proper template for our phishing mail:
We can then export the mail as eml, which will maintain the proper format and contain all necessary headers and references. Note that SMTP servers also understand eml in the DATA segment, so we can just use the exported file when communicating with the server.
Since SMTP server requires a few commands before we can send the DATA section of our email, we’ll tweak our payload a bit. Lets add EHLO, MAIL FROM, RCPT TO and DATA lines to the beginning of our eml file:
EHLO kouvostopankki.fi
MAIL FROM: pinja.pirivirkkala@kouvostopankki.fi
RCPT TO: amadea.harjumotto@kouvostopankki.fi
DATA
X-Mozilla-Status: 0011
X-Mozilla-Status2: 00000000
Message-ID: <8b210fbc-8275-44e9-9253-da3420b9693c@kouvostopankki.fi>
Date: Sun, 17 Sep 2023 10:28:57 +0300
MIME-Version: 1.0
User-Agent: Mozilla Thunderbird
Subject: Re: Spam issues
From: Pinja Pirivirkkala <pinja.pirivirkkala@kouvostopankki.fi>
To: =?UTF-8?Q?Amadea_Harjum=C3=B6tt=C3=B6?=
...
Now we can write a script to interact with I.S.U.C.K:
import socket
host = "kouvostopankki.fi"
port = 42851
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
data = ""
while data != "\n\n\nTarget address: ":
data = s.recv(1024).decode()
print(data, end = "")
s.sendall(b"mx.kouvostopankki.fi\n")
print(s.recv(1024).decode())
s.sendall(b"25\n")
print(s.recv(1024).decode())
with open('payload.eml', 'r') as f:
while True:
line = f.readline()
if not line:
break
s.sendall(bytes(line, 'utf-8'))
print(s.recv(1024).decode())
s.sendall(b'\nHUPS\n')
s.close()
We also need to set-up a proper phishing site where Amadea is comfortable putting their credentials. Lets use Social-Engineer Toolkit to clone the intranet login page and serve our phishing site.
After setting up setoolkit, we launch our python script and send the phishing mail through I.S.U.C.K.
As we see the payload worked and we snatched Amadea’s credentials.
Username: amadea.harjumotto@kouvostpankki.fi
Password: hitman_Vic_Le_Big_Mac_020120
We can now use the credentials in combination with the mfa-token to login to https://intranet.kouvostopankki.fi:
After a successful login the puzzle is finally solved, and we are redirected to a web shop to buy the hacker tickets.
Happy hacking, and see you at Disobey 2024 :-)