I was working on a reversing challenge recently and I was given an odd binary file.
I say odd because it had a prompt that said “enter username” and such but the string username was not to be found in the binary. Something was up.
So I looked at it from a few angles:
First, I knew it was an ELF(64) (file told me so) so I opened it in Ghidra & Cutter-radare2 w/ the Ghidra plugin(thanks NSA for the free stuff!). The code made little sense but it did give me a hint, it was setting an env var _MEIPASS2.
Next, I ran it against ltrace and noticed it was carving pieces of itself and extracting them in a folder in /tmp. OK, so it was one of those self extracting binaries. Towards the end it fork()ed and launched a new binary. Now this binary was not found in the /tmp folder, that only contained support files: they were python libs, specifically python3.6 libs(this is relevant).
fopen("/home/cristian/Desktop/Workspace"..., "rb") = 0x1601340 fseek(0x1601340, 0x424e96, 0, 15) = 0 malloc(8307) = 0x1602bc0 fread(0x1602bc0, 8307, 1, 0x1601340) = 1 zlibVersion(0x1601420, 0x1601520, 0, 0x7f9a220b6ab2) = 0x7f9a221a2034 malloc(24968) = 0x1604c40 inflateInit_(0x7ffd58a73c80, 0x40544a, 112, 0x160adc0) = 0 inflate(0x7ffd58a73c80, 4, 0x160add0, 0x160b328) = 1 inflateEnd(0x7ffd58a73c80, 0, 0x3f50, 0xe800) = 0 free(0x1602bc0) = <void> fclose(0x1601340) = 0 strncpy(0x7ffd58a71d00, "/tmp/_MEIpqGisz", 4096) = 0x7ffd58a71d00 strncpy(0x7ffd58a72d00, "termios.cpython-36m-x86_64-linux"..., 4096) = 0x7ffd58a72d00 strtok("termios.cpython-36m-x86_64-linux"..., "/") = "termios.cpython-36m-x86_64-linux"... strlen("termios.cpython-36m-x86_64-linux"...) = 39 __strcpy_chk(0x7ffd58a71d10, 0x7ffd58a72d00, 4096, 0x7ffd58a71d0f) = 0x7ffd58a71d10 strtok(nil, "/") = nil __xstat(1, "/tmp/_MEIpqGisz/termios.cpython-"..., 0x7ffd58a71c70) = -1 fopen("/tmp/_MEIpqGisz/termios.cpython-"..., "wb") = 0x1601340 fwrite("\177ELF\002\001\001", 24968, 1, 0x1601340) = 1 fileno(0x1601340) = 3 fchmod(3, 0700) = 0 fclose(0x1601340) = 0 free(0x1604c40)
A few fun facts: it opens itself and fseeks at different offsets from which it slices off the different bits, and also manually adds the ELF magic bytes at the beginning of files(this is another important point: binary headers are missing)
Now, I’ll be honest, I googled _MEIPASS2 and found this: https://medium.com/@alexskalozub/solving-the-malwarebytes-crackme-2-6ba23a7c5b56
This was the final hint that this is in fact a PyInstaller packed binary.
For Windows there is a tool to extract PyInstaller Windows executable files but for Linux things are trickier.
Initially I did not know this was built with python3.6 so I fumbled a bit.
The first step is to get pyinstaller installed and use it to get files from the binary:
pip install pyinstaller # this is installs it for python3 on my system, on older distros you might need to use pip3
Then use it to view and extract files:
#!/bin/bash pyi-archive_viewer ./my-sneaky-binary
You will be prompted with a list of files and offsets. I initially went on a false track and extracted the PYZ-00.pyz file which is just a bundle of python runtime files. The file you need will be marked as ‘s’ -> for script. Extract that to a file called code.pyc or something similar.
Now, the header is stripped so you need to append it back. After I knew that this was built for python3.6 I ran a docker with python3.6(I have python 3.7 on my system) to be able to decompile the .pyc file. I spent a lot of time fumbling with this. Simply put, bytecode generated for a specific version of python is not really forwards-compatible or python 3.7/3.8/3.9 is not binary backwards compatible. And why would it be, you are not supposed to distribute .pyc compiled files.
docker run -v `pwd`:/data --rm -it python:3.6-buster /bin/bash
Now, we need to get a sane header, so just make some dummy python file and compile to get a valid header:
python -m compileall test.py # result is put in __pycache__/test.cpython-36.pyc
Slice off the first 16 bytes from the resulting file:
And add them to beginning of the previously extracted file code.pyc.
Now, still in the docker we need to get uncompyle6(for python3.6) so do:
pip install uncompyle6
If you have problems then you are using the wrong python version.
And then just uncompile it!
uncompyle6 -o . code.pyc
Aaaand done, silly Python, do you call THAT compiling?
This was a fun challenge. I did my best not to share any details that would help others identify what challenge I was solving, I just wanted to talk about the tools to address this on Linux.
1 thought on “Reverse Engineering a PyInstaller Executable on Linux”
Thank you very much!