Cracking the ZIP file passwords of the Minecraft Launcher

2024-02-18 20:45:00 | mr

TL;DR

I used GDB to find the function call to the CEF ZIP library and extracted the password when the file is opened.

Intro

Have you ever taken a closer look at the .minecraft folder? More specifically, have you ever wondered what's inside the Minecraft launcher asset files in launcher/media and wondered why they are encrypted and what the password is?
If you have, then you're in luck, because I'm going to tell you how to find out!

In this blog post, I will guide you through my steps to eventually get the password for these files and my takeaways from this endeavour.

Initial attempts

I came across the idea of looking into the Minecraft launcher when I talked to one of my friends who also showed an interest in investigating its asset files. It seems bizarre that nobody on the entire internet had any idea of what the password was, and at the time of writing, there is still only one Reddit post about the topic with no actual answer. So I decided to investigate.

I booted up a new virtual machine running Fedora Linux (which is actually the same distro I use as my daily driver) and installed some basic tooling such as Wireshark and the C/C++ development toolchain including the GDB debugger.
The reason I didn't just do the debugging on my host system is because I wanted to make sure that I have a clean install of the Minecraft launcher. This is important, because the launcher might be doing some interesting things to create the ZIP files on first launch.

At first, I just looked at the Wireshark logs to see where the launcher got the files from. It seems that (for the newest version at the time) it just gets the list for all of them from an index url. This links to the list of files the launcher downloads during install.

Looking at the index, the background.zip, common.zip, ... files appear here as well. Sadly, when you download them, they are already encrypted, so this path didn't really lead anywhere. However, it also told me that there is no point in trying to debug the launcher install process, because it would most likely just download the files without actually creating them from the individual contents.

Doing things "the hard way"

Before I get into this, I have to clarify: I don't have a lot of experience with reverse engineering binaries. However, I did take a university class on the topic recently, and thus I had the motivation to put my newly learned skills to the test.

When reverse engineering unknown binaries, it might be useful to disable ASLR (Address space layout randomization). This feature is usually enabled for security reasons, as it makes addresses of functions and data on the stack unpredictable. However, since I might need to break at specific points in the code and don't have the luxury of included function names, I disabled it using the command:

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

I then took a look at the GDB documentation to see how I would go about debugging the launcher and breaking at the interesting function calls. I assumed that breaking at the fopen function or possibly the open/openat syscalls would be useful, since that would automatically put me somewhere near the code that decrypts the ZIP file contents. However, when I first did this, the program would constantly trigger the breakpoint. Turns out that the launcher opens a lot more files than just the ones I'm interested in, so I need to make sure that I only break on the calls where one of the interesting files is opened.

To figure out which file would be a good breakpoint, I used strace. strace is a quite useful command line tool to trace the syscalls a program makes. In my case, it means that I can tell it to trace all of the open and openat syscalls to see which files the launcher would open first:

strace -i -e open -f ./minecraft-launcher 2> trace.txt

-i prints the instruction pointer for the calls (could come in handy when debugging using GDB)
-e open,openat prints all calls to open and openat
-f follows forks (because the call might happen in some sub-process or thread)
The 2> trace.txt simply redirects all of the output on stderr to a file named trace.txt. strace prints all of the trace logs on stderr by default.

Running that, it produces quite a lot of logs (around 20 KiB in my case). However, the interesting parts are:

[pid  5428] [00007f2e08d25905] openat(AT_FDCWD, "/home/mr/.minecraft/launcher/media/background.zip", O_RDONLY|O_CLOEXEC) = 86
...
[pid 5428] [00007f2e08d25905] openat(AT_FDCWD, "/home/mr/.minecraft/launcher/media/common.zip", O_RDONLY|O_CLOEXEC) = 86

It appears that the only two ZIP files opened by the launcher when it is started (without being logged in) are background.zip and common.zip. So I investigated these further.

The syntax for conditional breakpoints in GDB is a bit weird, especially when you are working with strings. What I eventually settled on after a lot of trying is:

(gdb) break __libc_open64 if $_streq((char *) $rdi, "/home/mr/.minecraft/launcher/media/background.zip")

Let's break this down. I decided to set a breakpoint in the __libc_open64 function. This is the function that the libc implementation of open calls on Linux. The reason I didn't just break at open is that doing this would also include functions in the C++ standard library, which are problematic, since the first argument is usually a this pointer in that case, which messes up the breakpoint condition (There's probably a way around that, but just changing the function to break at is easiest).
$_streq is a function built into GDB that checks if the two string (char *) arguments that are passed to it are equal.
rdi is the register that holds the first argument in the System V calling convention, which is the default calling convention on 64-bit Linux. As the function takes the path as its first argument, it can be safely cast to a char *, which $_streq expects.
And finally, I pass the file path I observed earlier as the second argument to $_streq.

However, if you just execute this command and then run the program as usual, the breakpoint will not get triggered. This puzzled me for a bit.

Multi-process shenanigans

Remember how I mentioned sub-processes when executing strace? This is where that comes into play. By default, GDB only debugs one process. This means that it will only look at function calls from the main process and since the launcher spawns a bunch of other sub-processes which then load the file at some point, they will just run without the breakpoints, so the breakpoint will never be hit.

It took some investigation and trial and error for me to figure out how to get GDB to reliably debug multiple processes, but in the end I used the following commands:

(gdb) set detach-on-fork off
(gdb) set schedule-multiple on

The first command simply tells GDB to not detach from the child process when it is spawned and to instead suspend it until you manually select it using the inferior <id> command.
The second command is to resume all child processes' threads when you type continue instead of just the ones of the current process. This makes it easier to debug the sub-processes without manually having to resume each one of them individually.

Note: I'm still not 100% certain I fully understand these options. If you want to know more about them, I suggest reading the GDB documentation on forks and the all-stop mode.

This setup is still not perfect, since sometimes you might still need to manually switch processes using the inferior command to resume them (I'm not fully certain why, I suspect it's because the currently selected sub-process/thread exits, which causes GDB to halt program execution), but eventually got me to the function call I was looking for.

Simplifying the process

However, at this point, the amount of commands you need to manually enter each time you want to start the Minecraft launcher is quite a lot. So I made use of two useful features GDB has - Custom functions and the .gdbinit file.

To make life easier, GDB allows you to create custom function using the define keyword. You can either do this directly in the command line (type define <name>, enter some commands, then type end), however putting them in the .gdbinit file in your home directory makes sure to automatically define it every time you start GDB.

So I put all of the current configuration in my .gdbinit like this:

define mcl
set breakpoint pending on
set detach-on-fork off
set schedule-multiple on
break __libc_open64 if $_streq((char *) $rdi, "/home/mr/.minecraft/launcher/media/background.zip")
run
end

set breakpoint on tells GDB to set breakpoints for functions that have not yet been loaded (so essentially functions from dynamic libraries) once the libraries that define them get loaded. This is what you usually get prompted for when typing the break command manually.

Like this, I only had to type mcl once when I started GDB and the settings I need got applied automatically.

Investigating libraries

There is still one big problem - even though I now had the point in the code where open was called for the ZIP file, I still didn't really know where to go from here. Single stepping through a bunch of assembly blindly doesn't seem like the greatest idea (even though I did try that). So I had to change my approach a bit: Instead of looking at the open function, maybe I should find something more high level.

A good place to start when debugging complex programs like this is to look at the libraries they include. Since the Minecraft launcher is based on the Chromium embedded framework (it's basically a glorified web browser running as a desktop app, similar to Electron), I was fairly certain it would use some kind of ZIP library to open the password-protected file. Knowing which function in the library is likely called would make it a lot easier to get the password from it.

I had a look at which libraries the launcher used using ldd. There were a few promising ones, like libz, libcef (the Chromium embedded framework library), liblz4, libbz2, liblzma (even though those last three are most likely just used for the compression algorithms and not loading the ZIP files themselves). I'll cut it short - libz does not seem to provide a simple way to load password-protected ZIP files, while libcef has a ZipReader that seems perfect for this use-case.

So I opened the repository for the library to figure out how it is implemented, since it has both a C++ and a C library. However, the C++ library just seems to call to the C library (and debugging a C library is quite a lot easier), so that is the best place to investigate.

The most interesting function here is cef_zip_reader_create, since it creates the actual ZIP reader (cef_zip_reader_t) with all of the callbacks for various purposes. There is one important callback in particular - open_file, which takes the password for the ZIP file.

Now I don't actually need a conditional breakpoint anymore. I simply decided to break at the cef_zip_reader_create function and then finish it (execute it until it has returned). I then took a look at the memory at the location the rax register points to (which contains the return value of the function):

Thread 2.36 "ThreadPoolSingl" hit Breakpoint 1.1, 0x00007fffca6c61d0 in cef_zip_reader_create@plt () from /home/mr/.minecraft/launcher/liblauncher.so
(gdb) finish
Run till exit from #0 0x00007fffca6c61d0 in cef_zip_reader_create@plt () from /home/mr/.minecraft/launcher/liblauncher.so
0x00007fffca8e5c9c in ?? () from /home/mr/.minecraft/launcher/liblauncher.so
(gdb) x/20gx $rax
0x7fff60002df0: 0x0000000000000088 0x00007fffba9a9970
0x7fff60002e00: 0x00007fffba9a9990 0x00007fffba9a99b0
0x7fff60002e10: 0x00007fffba9a99d0 0x00007fffba9a9e40
0x7fff60002e20: 0x00007fffba9a9eb0 0x00007fffba9f2890
0x7fff60002e30: 0x00007fffba9b95d0 0x00007fffba9b6970
0x7fff60002e40: 0x00007fffba9aa220 0x00007fffba9f2980
0x7fff60002e50: 0x00007fffba9f2a20 0x00007fffba9c73d0
0x7fff60002e60: 0x00007fffba9f2b00 0x00007fffba9d1fb0
0x7fff60002e70: 0x00007fffba9f2ba0 0x0000000000000001
0x7fff60002e80: 0x0000000000000000 0x0000000000000051

x/20gx $rax prints 20 qwords (quad/giant word, 8 bytes each), starting from the location rax points to.

Looking at the source code, the _cef_zip_reader_t struct consists of various fields. A _cef_base_ref_counted_t struct which comes out to 5 qwords in size, and then there's another 8 qwords before the relevant pointer to the open_file function. That means I have to look at the 13th qword in the struct.

After that, I simply have to break at that address (0x00007fffba9f2a20 in this case) and when that breakpoint is hit, I have everything I need. A quick look at the cef_string_t (cef_string_utf8_t/cef_string_utf16_t) struct reveals that a pointer to the relevant string is stored at the beginning. Examining it as a UTF-8 string doesn't yield anything useful, but as a UTF-16 string it reveals the password. Finally, I could extract it using:

(gdb) x/sh *((long *)$rsi)
0x7fff60002780: u"0E05D20E-74FB-45CD-B90A-DE57A3163D10"

rsi holds the second parameter of the function call.
*((long *) $rsi) this casts the pointer stored in rsi to a long pointer (in this case a long is 64 bit, so an address fits into it. Casting to void ** is fine as well). It is then dereferenced, giving the actual address of the string.
x/sh, similar to x/h, prints the memory as a string of "halfwords" (GDB words are 32 bit as opposed to the common 16 bit words), so effectively it prints a UTF-16 string.

And with that, I was done! 🥳

As far as I can tell, this appears to be the only password for all of the asset files in the launcher and it is the same for every system (both the Linux and the Windows version of the launcher).

Conclusion

Was using GDB total overkill for finding out these passwords? Probably yes. You could have most likely found them more easily by looking in the liblauncher.so file using a tool like Ghidra or IDA (or - with enough determination - just the strings command line tool).

I also cut a bunch of stuff out of this blog post to not make it too excessively long (even though it is still plenty long). For example, I tried to follow the process chain by breaking at calls to fork and comparing them to the strace output before I found out how to properly debug multiple processes. I also opened the launcher and its companion library in Ghidra/IDA (I should have looked at the strings more closely) and stepped through a bunch of assembly trying to find some interesting things, but didn't really get anywhere with that.

However, I did learn a lot during this project. I learned about dynamic debugging using GDB, debugging multi-threaded programs, reading library code and applying that knowledge to a binary file, and a bunch of other small things along the way.
In short, it was fun just looking at a small and very targeted reverse engineering challenge where you never know if you're going to succeed. However, when I did eventually succeed, it was all the more satisfying knowing I was one of the few people who know the password to these files. Plus, there are no wrong ways to learn something new if you're having fun!