Obscurcissement de code Python

taking the snake to the dark side

Always two there are, no more, no less:
an apprentice Nicolas Szlifierski [Quarkslab, Telecom Bretagne]
and a master Serge Guelton [Quarkslab, Telecom Bretagne]

/me

Nicolas Szlifierski

  • Étudiant à Télécom Bretagne
  • Stagiaire chez QuarksLab
  • nicolas.szlifierski@telecom-bretagne.eu

Le Bytecode ?

>>> def foo:
...     print "hello world"
...
>>> dis.dis(foo)
 2  0 LOAD_CONST    1 ('hello world')
    3 PRINT_ITEM
    4 PRINT_NEWLINE
    5 LOAD_CONST    0 (None)
    8 RETURN_VALUE
                    

Le bytecode Python est simple à reverser

$ echo "print('hello world')" > hello.py
$ python -m py_compile hello
$ pycdc hello.pyc
# Source Generated with Decompyle++
# File: hello.pyc (Python 2.7)

print 'hello world'

Avec des optimisations ?

$ printf "a = 1\nif a: print(a + 2)" > dce.py
$ python -O -m py_compile dce
$ pycdc dce.pyo
# Source Generated with Decompyle++
# File: dce.pyo (Python 2.7)

a = 1
if a:
    print a + 2
L'option -O de CPython ne réalise quasiment aucune optimisation

Solutions pour l'obscurcissement

  • Modification du code source
  • Modification du bytecode
  • Modification de l'interpréteur

Modification du code source

Les transformation source à source en Python sont difficile, à cause du liaison retardé et du polymorphisme:

for i in range(10):
    s += hex(i)

range = lambda *args: args
__builtin__.hex, __builtin__.oct =  __builtin__.oct, __builtin__.hex

Obscurcissement du flux d'exécution

  1. Transformation des instructions en fonctions qui modifient un dictionnaire représentant la mémoire
  2. Enchainement de ces fonctions en les composant
  3. Transformation des définitions de fonctions en fonctions lambda

Un aperçus du challenge HITB

(lambda g, c, d: (lambda _: (_.__setitem__('$', ''.join([(_['chr'] if ('chr'
in _) else chr)((_['_'] if ('_' in _) else _)) for _['_'] in (_['s'] if ('s'
in _) else s)[::(-1)]])), _)[-1])( (lambda _: (lambda f, _: f(f, _))((lambda
__,_: ((lambda _: __(__, _))((lambda _: (_.__setitem__('i', ((_['i'] if ('i'
in _) else i) + 1)),_)[(-1)])((lambda _: (_.__setitem__('s',((_['s'] if ('s'
in _) else s) + [((_['l'] if ('l' in _) else l)[(_['i'] if ('i' in _) else i
)] ^ (_['c'] if ('c' in _) else c))])), _)[-1])(_))) if (((_['g'] if ('g' in
_) else g) % 4) and ((_['i'] if ('i' in _) else i)< (_['len'] if ('len' in _
) else len)((_['l'] if ('l' in _) else l)))) else _)), _) ) ( (lambda _: (_.
__setitem__('!', []), _.__setitem__('s', _['!']), _)[(-1)] ) ((lambda _: (_.
__setitem__('!', ((_['d'] if ('d' in _) else d) ^ (_['d'] if ('d' in _) else
d))), _.__setitem__('i', _['!']), _)[(-1)])((lambda _: (_.__setitem__('!', [
(_['j'] if ('j' in _) else j) for  _[ 'i'] in (_['zip'] if ('zip' in _) else
zip)((_['l0'] if ('l0' in _) else l0), (_['l1'] if ('l1' in _) else l1)) for
_['j'] in (_['i'] if ('i' in _) else i)]), _.__setitem__('l', _['!']), _)[-1
])((lambda _: (_.__setitem__('!', [1373, 1281, 1288, 1373, 1290, 1294, 1375,
1371,1289, 1281, 1280, 1293, 1289, 1280, 1373, 1294, 1289, 1280, 1372, 1288,
1375,1375, 1289, 1373, 1290, 1281, 1294, 1302, 1372, 1355, 1366, 1372, 1302,
1360, 1368, 1354, 1364, 1370, 1371, 1365, 1362, 1368, 1352, 1374, 1365, 1302
]), _.__setitem__('l1',_['!']), _)[-1])((lambda _: (_.__setitem__('!',[1375,
1368, 1294, 1293, 1373, 1295, 1290, 1373, 1290, 1293, 1280, 1368, 1368,1294,
1293, 1368, 1372, 1292, 1290, 1291, 1371, 1375, 1280, 1372, 1281, 1293,1373,
1371, 1354, 1370, 1356, 1354, 1355, 1370, 1357, 1357, 1302, 1366, 1303,1368,
1354, 1355, 1356, 1303, 1366, 1371]), _.__setitem__('l0', _['!']), _)[(-1)])
                ({ 'g': g, 'c': c, 'd': d, '$': None})))))))['$'])

Intéressé ? http://blog.quarkslab.com

Modification du bytecode

Plusieurs possibilités ici !

  • Mélanger les opcodes (ce que fait Dropbox)
  • Ajouter des nouveaux opcodes
  • Utiliser des séquences d'opcodes non standard

Mélange des opcodes

Modification de l'interpréteur afin que ceci :

>>> import dis
>>> print dis.opmap['BINARY_ADD']
23

Devienne cela :

>>> import dis
>>> print dis.opmap['BINARY_ADD']
62

Contraintes

  • Distinction entre les opcodes avec et sans argument
  • Certains opcodes doivent être contigus
  • Voir python/Include/opcode.h

On peut mélanger les opcodes par groupes pour générer un nouvel interpréteur

Génération de nouveaux opcodes

Un opcode est stocké sur un char mais seulement 112 sont utilisés !

  • Création d'alias sur des opcodes existants (facile)
  • Création de nouveaux opcodes agissant comme une séquences d'opcodes (plus intéréssant)
  1. Trouver les suites d'opcodes les plus fréquentes
  2. Les transformer en un unique opcode
  3. Avec une extension pour gérer les opcodes ayant plus de deux arguments

Opcodes fréquemment utilisés

  1. Parcours récursif des fichiers .pyc et construction de l'histogramme, en utilisant marshal.loads et inspect.iscode
  2. Sélection des suites d'opcodes fréquemment utilisés
  3. Substitution des opcodes (attention à la gestion des exceptions et aux jumps) [.pyc.pyc]

Par exemple :

LOAD_FAST                0
LOAD_CONST               n

Devient :

LOAD_FAST_LOAD_CONST     O
ANY_OPCODE_WITH_ARG      n

Suites d'opcodes non standard

Certains décompilateur font des hypothèses sur l'ordre des bytecodes (certains pensent que la décompilation ~= pattern matching)

LOAD_FAST 0
LOAD_FAST 1
BUILD_MAP 0
ROT_THREE
BINARY_ADD
ROT_TWO
POP_TOP

Est équivalent à

LOAD_FAST 0
LOAD_FAST 1
BINARY_ADD

Avec ce code uncompyle crash ! Mais pas pycdc...

Chiffrement des constantes


>>>def foo(): return "pyconfr"
>>>import dis
>>>dis.dis(foo)
1           0 LOAD_CONST               1 ('pyconfr')
            3 RETURN_VALUE


Les chaînes de caractères sont chargées avec LOAD_CONST, on peut donc

  1. Chiffrer toute les chaînes de caractères
  2. Modifier LOAD_CONST pour déchiffrer les chaînes de caractère à la volée



proof of concept... avec un rot13

Plongeons dans CPython

Du code se modifiant lui-même automatiquement ?

Les fonctions embarquent parmis leurs attributs, leurs bytecodes en tant que chaîne de caractères :-)

Mais les chaînes de caractères sont immutables en Python :-(

Sauf si on les modifie depuis un module natif ;-)

Code auto-modifiant

static PyObject* this_function_modifies_its_caller() {
  PyThreadState *tstate = PyThreadState_GET();
  if (NULL != tstate && NULL != tstate->frame) {
    PyFrameObject *frame = tstate->frame;

    int instr = frame->f_lasti;
    unsigned char* bytes = (void*)PyString_AS_STRING(frame->f_code->co_code);
    bytes[instr + 10] = INPLACE_MODULO;
  }
  Py_INCREF(Py_None);
  return Py_None;
}
  1. Récupération du parent
  2. Récupération du bytecode de la fonction
  3. Transformation de l'opcode suivant en un modulo

Appelez cette fonction avant une opération binaire pour la transformer en un modulo !

Divers extra

  • Remplacement du « magic number » par une valeur aléatoire
  • Désactivation de l'introspection sur les objets code
  • Désactivation de dump[s]/load[s] du module marshal
  • Désactivation de la recompilation du bytecode lors de changement des sources

Points bonus

  • Utiliser un packer Python (e.g. pyinstaller) pour lier une application Python et l'interpréteur modifié dans un unique binaire
  • Utiliser un compilateur Python (e.g. numba, shedskin, pythran) pour transformer certain(e)s fonctions/modules en code natif
  • Utiliser un obfuscateur C pour obfusquer la partie obfuscation de l'interpréteur !

How To

$ ../configure --help
[...]
  --disable-marshal       hide marshal functions
  --disable-codeobject    hide codeobject functions
  --disable-recompilation disable recompilation of .pyc file when .py file is
  --enable-cipher-str     enable string litteral ciphering
  --enable-shuffle-opcode enable opcodes shuffling
  --enable-gen-opcode     enable generation of new opcodes

THE END

AUTEURS
Nicolas Szlifierski et Serge Guelton
DÉPOT GIT
https://github.com/quarkslab/cpython
branch obfuscated/2.7
SLIDES
https://github.com/nvcs/talks