A deobfuscator for PyArmor.
There are 3 different methods for unpacking PyArmor, in the methods folder in this repository you will find all the files needed for each method. Method 3 is the preferred method for Python 3.9. Below you will find a detailed write-up on how I started all the way to the end product. I hope more people actually understand how it works this way rather than just using the tool.
This is a list of all the known issues/missing features. I don't have enough time to fix them myself so I heavily rely on contributors.
Issues:
Missing features:
IMPORTANT: USE THE SAME PYTHON VERSION EVERYWHERE, LOOK AT WHAT THE PROGRAM YOU ARE UNPACKING IS COMPILED WITH. If you don't, you will face issues.
method_1.py filerun.pydumps directory you can find the fully unpacked .pyc file.NOTE: Don't use the static unpacker for anything below version 3.9.7, The marshal.loads audit log was only added in and after 3.9.7. Any contributors are welcome to add support
python3 bypass.py filename.pyc (replace filename.pyc with the actual filename, obviously)dumps directory you can find the fully unpacked .pyc file.Contributions are really important. I don't have enough time to fix all the issues listed above. Please contribute if you can.
If you like you can also sponsor me on Github: https://github.com/sponsors/Svenskithesource/
This is the long-awaited write-up about the full process I went through to deobfuscate or rather unpack PyArmor, I will go through all the research I did and at the end give 3 methods for unpacking PyArmor, they are all unique and applicable in different situations. I want to mention I didn’t know a lot about Python internals so it took a lot longer for me than for other people with more experience in Python internals.
PyArmor has a very extensive documentation on how they do everything, I would recommend you read that fully. PyArmor essentially loops through every code object and encrypts it. There is a fixed header and footer though. This depends on if the “wrap mode” is enabled, it is by default.
wrap header:
LOAD_GLOBALS N (__armor_enter__) N = length of co_consts
CALL_FUNCTION 0
POP_TOP
SETUP_FINALLY X (jump to wrap footer) X = size of original byte code
changed original byte code:
Increase oparg of each absolute jump instruction by the size of wrap header
Obfuscate original byte code
...
wrap footer:
LOAD_GLOBALS N + 1 (__armor_exit__)
CALL_FUNCTION 0
POP_TOP
END_FINALLY
From the PyArmor docs
In the header there is a call to the __armor_enter__ function, which will decrypt the code object in memory. After the code object has finished the __armor_exit__ function will be called which will re-encrypt the code object again so no decrypted code objects get left behind in memory.
When we compile a PyArmor script we can see there is the entry point file, and a pytransform folder. This folder contains a dll and an __init__.py file.
dist
│ test.py
└───pytransform
| _pytransform.dll
| __init__.py
The __init__.py file doesn’t have to do much with decrypting the code objects. It is mostly used so that we can import the module.
It does some checks like what OS you're using, if you’d like to read it, it’s open source so you can just open it like a normal Python script.
The most important thing it does is loading the _pytransform.dll and exposing its functions to the Python interpreter’s globals. In all the scripts we can see that from pytransform it imports pyarmor_runtime.
from pytransform import pyarmor_runtime
pyarmor_runtime()
__pyarmor__(__name__, __file__, b'\x50\x59\x41\\x5...')
This function will create all the functions necessary to run PyArmor scripts, like the __armor_enter__ and __armor_exit__ function.
The first resource I found was this thread on the forum tuts4you, here the user extremecoders wrote a few posts on how he unpacked the PyArmor protected file. He edited the CPython source code to dump the marshal of every code object that gets executed.
While this method is great for exposing all the constants, it’s less ideal if you want to get the bytecode, this is because:
__armor_enter__ function gets called, which is at the start of the code object. Since the __armor_enter__ function decrypts it in memory it won’t get dumped by CPython.There are some people that have experimented with dumping the decrypted code objects from memory by injecting Python code.
In this video someone demonstrates how he disassembles all the decrypted functions in memory.
However, he hasn’t found out yet how to dump the main module, only functions. Thankfully he published his code he used to inject Python code. On the GitHub repository we can see that he creates a dll in which he calls an exported function from the Python dll to execute simple Python code. Currently he has only added support for finding the Python dlls for versions 3.7 to 3.9 but you can easily add more versions by modifying the source and recompiling it. He made it so it executes the code found in code.py, this way it’s easy to edit the Python code without having to rebuild the project every time.
In the repository he includes a Python file which will dump all the function’s names to a file with their corresponding address in memory, if there is no memory found it means it hasn’t been called yet so it also hasn’t been decrypted yet.
…
In the code you can see he added a comment saying the problem he has is that he can’t access the main module code object.
After a lot of Googling I was stumped, unable to find anything about how to get the current running code's object. Sometime later in an unrelated project I saw a function call to sys._getframe(). I did some research on what it does, it gets the current running frame.
You can give an integer as an argument which will walk up the call stack and get the frame at a specific index.
sys._getframe(1) # get the caller's frame
Now the reason that this is important is because a frame in Python is basically just a code object but with more information about its state in memory. To get the code object from a frame we can use the .f_code attribute, you will also be familiar with this if you have created a custom CPython version which dumps the code objects that get executed as we also get the code object from a frame there.
...
1443 tstate->frame = frame;
1444 co = frame->f_code;
...
From my custom CPython version
So now we have figured out how to get the current running code object, we can simply walk up the call stack until we find the main module, which will be decrypted.
Now we’ve pretty much figured out the main idea of how to unpack PyArmor. I’ll now show 3 methods of unpacking that I have personally found useful in different situations.
The first one requires you to inject Python code, so you’ll have to run the PyArmor script. When we dump the main code object like I explained above the main problem will be that some functions will still be encrypted, thus the first method invokes the PyArmor runtime function so that all the functions needed to decrypt the code objects are loaded, like __armor_enter__ and __armor_exit__.
This seems like a pretty simple thing to do but PyArmor did think of this, they implemented a restrict mode. You can specify this when compiling a PyArmor script, by default the restrict mode is 1.
I haven’t tested every restrict mode out but it works for the default one.
When we try to run this code in our REPL you will get the following error:
>>> from pytransform import pyarmor_runtime
>>> pyarmor_runtime()
Check bootstrap restrict mode failed
This prevents us from being able to use the __armor_enter__ and __armor_exit__.
So the next step I took was contacting extremecoders on tuts4you. He helped me by mentioning that I could natively patch the _pytransform.dll. I also want to thank him for giving me the solution on doing this solely in Python.
If we open the _pytransform.dll in a native debugger, I chose x64dbg, we will look for all the strings in the current module.
If we filter this now by searching for "bootstrap", we will get the following.
When we watch the disassembly on the first search result you see that there is a reference of _errno indicating that there could be some error raised, a few lines below that we can see the error that we get in Python.
When we just NOP everything from the point of the jump that jumps over the code that triggers the error to the return there is no way that the error could be raised.
Now if we save this and replace the `_pyt
No open issues yet, or sync has not completed.