Cliquez ici pour la version française de l'article
If you tell most malware experts that you use Python for malware, you'll be seen as a fool or as a script skiddy (and probably rightfully so). Indeed, Python is generally the first language people use because it's high level and the syntax is incredibly easy. In fact, it's almost the complete opposite of what you would want for malware. A lot of abstraction layers generally means less freedom, malware developers usually do a lot of low-level memory manipulation, run machine code in injection scenarios, need complete freedom on the Windows API for dynamic DLL loading, system calls... This is why people in the field generally use compiled languages like C. But what if I told you Python does have advantages in malware development?Python compile()
In malware development, there is this concept called “code virtualization”, where the binary carries its own virtual machine, a custom bytecode interpreter that will read the payload (a payload that statistical analysis have a hard time with, since it’s a custom bytecode).
Well, this concept is obviously not only used for malware, you can find it in high level languages like Java (JVM) and… Python (CPython).
The bytecode gets executed by ceval.c, the interpreter happily runs the .pyc without ever seeing your source code!
You can control the Python compiler during runtime using the compile() function:
1 | source = r"print('hello peeps')" |
That means you can absolutely run code that isn’t written in the source / directly visible in static analysis.
Here is a simple example using base64:
1 | import base64 |
Now, if you believe there will be some constant folding / propagation going on… Well, here is the disassembly of the .pyc Python generated for our simple script:
1 |
|
That’s a good reminder that Python is slow, lazy and inefficient I believe. The compiler barely does any work for you at compile time.
Here for simplicity, we simply encoded the payload in base64. But with a more complex stub (XOR, junk code, non-deterministic brute force, hashing) it can becomes extremely hard to know what the script output will be without dynamic analysis.
So that’s that. We will see what we can do with this.
PyInstaller
PyInstaller is that dirty tool most people who know Python have used. I personally used it a lot to run my Python scripts on non tech savvy people computers so they don’t have to install a Python interpreter.
Indeed, PyInstaller in --onefile mode just creates a PE with the whole runtime compressed inside, the interpreter DLLs, your modules, your code. A single hello world is more than 10 MB in Python…
It’s junk code except it’s actually not, most AV vendors don’t have a custom Python unpacking mechanism, and never actually analyze the code that matters.
Going further with a demo of polymorphic code
Here is an example of how I would use this mechanism to make a .pyc very painful to analyse in static analysis for reverse engineers and AVs:
1 | #!/usr/bin/env python3 |
All of this is to open calc.exe by the way.
This is no where near what people do with LLVM obfuscation passes, but it’s annoying to reverse, which is already something. Remember this is just Python.
There are multiple projects like PyArmor, pyobfus, VMP-Protector, CodeEnigma doing this automatically with more complexity, but this just shows how fun and simple it can be to make your custom one (which makes it harder to fingerprint, there might be YARA rules for known library)
Stepping up a notch a real usecase of Python compile() : Encrypted code that only gets decrypted on specific machine / environment
This is all interesting, but none of it is truly original. I could stop here like most articles be like : “There you go, compiling custom bytecode” but instead, let me show a real use case: how this could be weaponized in real life, and the interesting things we can do with it.
My first idea was to make Python code that could only run on a specific machine. Not only that the code would be nearly undecryptable if it doesn’t run on the intended machine. Probably the worst nightmare for a malware analyst.
The following script demonstrate it, it’s “impossible” to know what the script is doing as long as you are not the target, this script compares the device MAC address with a SHA256 of the target MAC, if it matches it decrypt the AES128 payload with a key derived from the MAC address, and exec(compile()) allows us to run this decrypted code!
1 | #!/usr/bin/env python3 |
I find the simplicity of that code fascinating.
Python makes it so easy, any python beginner could understand this code after learning what compile() does, which might make Python a great language to draft / demonstrate / showcase complex malware mechanism in some cases.
This example is not “unbreakable”. While there is 2^46 ( = 281,474,976,710,656) possible MAC addresses, the key is only 128bit and on modern hardware it’s not that challenging. But you could absolutely make the ciphertext nearly impossible to crack by deriving the key using known environment variable: the content of a specific file the target has, GUID, hardware fingerprinting, IP address, folder / work space strucutre possiblities are simply endless.
“It’s just Python, it’s just Python”, yet even an expert researcher in front of the source code cannot tell you what the output of this script will be on the target machine, think about it.
A second real usecase example
So I believe this article is getting interesting, but it’s still not the “weaponization” I promised, still very far from anything that “could be used as an attack vector”. So here is another way, completely different yet very interesting, to take advantage of Python’s compile().
The idea: hide a payload in plain sight, directly in the source code for anyone to see, and ship the .py file, yes we are going to use unicode stegnography.
Unicode is a complex system with SO MANY code points. What I did one day is I prompted my AI agent to give me all printable but tiny / zero-width / invisible Unicode characters, with one idea in mind: if I could place these characters in a Python source file to represent binary data, then decode them, I could use compile() to run the result, essentially executing code that is literally invisible to the user (only the decoding logic and the compile() statement would be visible).
My AI agent came up with multiple candidates, but only variation selectors stood out (U+FE00 -> U+FE0F and U+E0100 -> U+E01EF).
That’s 16 + 240 = 256 invisible characters that Python happily accepts in your code, since these characters are printable Python will let you use them in your code without throwing any errors!
This might remind you of the “ASCII smuggling” attacks on LLMs / prompt injections, which used a similar technique, except here we use this steganography to hide the code in order to run it.
With that, I designed an encoder that takes text as bytes, variation selectors have enough possible values:
1 | def encode(text): |
The decoder is straightforward:
1 |
|
In Visual Studio code hidden char are highlighted in yellow with a warning, but for the non attentive programmer along with some social engineering (code painful to read, ASCII art inside the code that might make a single yellow block look normal) this can easily be ignored.
I tested it with import os; os.system('msg * test') (message box through windows CMD), in Visual Studio Code this is what it looks like.
Booom!!!!
It nonetheless looks impressive, the import os is encoded with the os.system() call, completely invisible
Now we have essentially achieved a "backdoor" in plain sight: the .py source code is there, yet it runs something we CANNOT SEE.
Obviously it does not work in any terminal and text editor it requires the correct encoding and code page...I think I have already done enough, this article is already pretty long and we explored very “fancy” stuff that we can do using the Python compile() function.
If you want to try it out for yourself, the whole code for generating “hidden unicode” payloads is in this GitHub repository : https://github.com/0xWill-A/python-invisible-code
Conclusion
I hope this article made Python less uninteresting. I’m not trying to say that Python is a good language for malware or that it will become a major attack vector, it’s just interesting to see the underlying concepts and see how the possibilities aren’t limited, in a language that is itself so limiting.
I simplified a lot of part of the article to only talk about what truly matters, for the polymorphic example it would have been better to heavily rely on AST manipulation and pre-compiled code objects: polymorphic code object would bypass AMSI for example.
I find also fascinating the contrast with compiled languages, how easy running raw instructions and manipulating them becomes. In C you would have to generate the machine code, remove null bytes, use VirtualAlloc() etc. In Python you can just compile VM instructions on the fly.
I’m currently looking for a job by the way, please hire me!