Attention: contient la programmation du système. Oui, en substance, il ne contient rien d'autre.
Imaginons qu'on vous ait confié la tâche d'écrire un jeu de fantasy fantasy. Eh bien, à propos des elfes. Et sur la réalité virtuelle. Dès l'enfance, vous rêviez d'écrire quelque chose comme ça et, sans hésitation, d'accord. Bientôt, vous vous rendez compte que vous connaissez la plupart du monde des elfes grâce aux blagues de l'ancien bashorgh et d'autres sources disparates. Oups, un problème. Eh bien, là où le nôtre n’a pas disparu ... Enseigné par une riche expérience en programmation, vous allez sur Google, entrez la "spécification Elf" et suivez les liens. Oh! Celui-ci mène à une sorte de PDF ... donc ce que nous avons ici ... une sorte d' Elf32_Sword
- des épées elfes - cela semble être ce dont vous avez besoin. 32 est apparemment le niveau du personnage, et les deux quatre dans les colonnes suivantes sont probablement des dégâts. Exactement ce dont vous avez besoin, et en plus de la façon dont systématisé! ..
Comme indiqué dans une tâche de programmation de l'Olympiade, après quelques paragraphes d'un texte détaillé sur le sujet du Japon, des samouraïs et des geishas: "Comme vous l'avez déjà compris, la tâche ne sera pas du tout à ce sujet." Oh oui, le concours a bien sûr duré un moment. En général, je déclare clos cinq minutes de ténacité.
Aujourd'hui, je vais essayer de parler de l'analyse d'un fichier au format ELF 64 bits. En principe, ce qu'ils n'y stockent pas, ce sont des programmes natifs, des bibliothèques statiques, des bibliothèques dynamiques, chaque implémentation spécifique, comme les crashdumps ... Il est utilisé, par exemple, sur Linux et de nombreux autres systèmes de type Unix, oui, disent-ils, même sur les téléphones son support était auparavant bourré de firmware patché. Il semblerait que la prise en charge du format de stockage des programmes à partir de systèmes d'exploitation sérieux devrait être difficile. Alors j'ai pensé. Oui, c'est probablement le cas. Mais nous prendrons en charge un cas d'utilisation très spécifique: le chargement du bytecode eBPF à partir de fichiers .o
. Pourquoi Juste pour d'autres expériences, j'aurai besoin d'un bytecode multiplateforme sérieux (c'est-à-dire pas à hauteur du genou ), qui peut être obtenu à partir de C et non écrit manuellement, donc eBPF est simple et il y a un backend LLVM pour cela. Et j'ai juste besoin d'analyser ELF comme un conteneur dans lequel ce bytecode est placé par le compilateur.
Juste au cas où, je précise: l'article est une programmation exploratoire et ne prétend pas être un guide exhaustif. Le but ultime est de créer un chargeur de démarrage qui vous permette de lire les programmes C compilés dans eBPF en utilisant Clang - ceux que j'ai - dans un volume suffisant pour continuer les expériences.
Titre
À partir du décalage zéro dans l'ELF se trouve l'en-tête. Il contient les lettres E, L, F, qui peuvent être vues si vous essayez de l'ouvrir avec un éditeur de texte, et quelques variables globales. En fait, l'en-tête est la seule structure du fichier située à un décalage fixe, et il contient des informations pour trouver le reste de la structure. (Ci-après, je suis guidé par la documentation pour le format 32 bits et elf.h
, qui connaît les 64 bits. Donc, si vous remarquez des erreurs, n'hésitez pas à les corriger)
La première chose que nous rencontrons dans le fichier est le unsigned char e_ident[16]
. Vous vous souvenez de ces articles amusants de la série «toutes les affirmations suivantes sont fausses»? Ici, c'est à peu près la même chose: ELF peut contenir du code 32 ou 64 bits, Little ou Big Endian, et même une douzaine d'architectures de processeur. Vous allez le lire comme Elf64 sous Little endian - eh bien, bonne chance ... Ce tableau d'octets est une sorte de signature de ce qui est à l'intérieur et comment l'analyser.
Avec les quatre premiers octets, tout est simple - c'est [0x7f, 'E', 'L', 'F']
. S'ils ne correspondent pas, alors il y a des raisons de croire que ce sont de mauvaises abeilles. L'octet suivant contient la classe caractère Fichier: ELFCLASS32
ou ELFCLASS64
- profondeur de bits. Par souci de simplicité, nous ne travaillerons qu'avec des fichiers 64 bits (existe-t-il un eBPF 32 bits?). Si la classe s'est avérée être ELFCLASS32
, nous ELFCLASS32
simplement avec une erreur: tout de même, les structures «flotteront», et le contrôle de santé mentale ne fera pas de mal à le faire. Le dernier octet qui nous intéresse dans cette structure indique l'endianité du fichier - nous travaillerons uniquement avec l'ordre d'octets natif pour notre processeur.
Juste au cas où, je clarifierai: lorsque vous travaillez avec le format ELF en C, vous ne devez pas soustraire tous les int par le décalage calculé intelligemment - elf.h
contient les structures nécessaires, et même des nombres d'octets dans e_ident
: EI_MAG0
, EI_MAG1
, EI_MAG2
, EI_MAG3
, EI_CLASS
, EI_DATA
... pointeur vers les données lues ou mappées en mémoire depuis le fichier vers le pointeur vers la structure et lues.
En plus de e_ident
tête contient d'autres champs, certains que nous allons simplement vérifier, et certains seront utilisés pour une analyse plus approfondie, mais plus tard. A savoir, nous vérifions que e_machine == EM_BPF
(c'est-à-dire qu'il est "sous l'architecture du processeur eBPF"), e_type == ET_REL
, e_shoff != 0
. La dernière vérification a la signification suivante: un fichier peut contenir des informations pour la liaison (table de section et sections), pour le lancement (table de programme et segments), ou les deux. Avec les deux dernières vérifications, nous vérifions que les informations dont nous avons besoin (comme pour la liaison) sont dans le fichier. Vérifiez également que la version du format est EV_CURRENT
.
Immédiatement faire une réservation, je ne vérifierai pas la validité du fichier, en supposant que si nous le chargeons dans notre processus, alors nous lui faisons confiance. Dans le code du noyau ou d'autres programmes fonctionnant avec des fichiers non fiables, il est naturellement impossible de le faire dans tous les cas .
Table de coupe
Comme je l'ai dit, nous sommes intéressés par la vue de liaison du fichier, c'est-à-dire le tableau des sections et les sections elles-mêmes. Les informations sur où chercher le tableau des sections se trouvent dans l'en-tête. Sa taille y est également indiquée, ainsi que la taille d'un élément - il peut être plus grand que sizeof(Elf64_Shdr)
(car cela affectera le numéro de version du format, honnêtement, je ne sais pas). Certains numéros de section principaux sont réservés et ne sont pas réellement présents dans le tableau. Les référencer a une signification particulière. Nous ne sommes apparemment intéressés que par SHN_UNDEF
(zéro est également réservé - la section manquante; d'ailleurs, comme vous le savez, son titre dans le tableau est toujours là) SHN_ABS
. Le caractère "défini dans la section SHN_UNDEF
" n'est en fait pas défini, et dans SHN_ABS
il a en fait une valeur absolue et n'est pas déplacé. Cependant, SHN_ABS
ne semble pas non plus être SHN_ABS
moi.
Table de rang
Ici, nous rencontrons pour la première fois des tables de chaînes - des tables de chaînes utilisées dans un fichier. En fait, si const char *strtab
est une table de chaînes, alors le nom sh_name
est juste strtab + sh_name
. Oui, c'est juste une ligne commençant par un certain index et continuant à zéro octet. Les lignes peuvent se croiser (plus précisément, l'une peut être le suffixe de l'autre). Les sections peuvent avoir des noms, puis dans l'en-tête ELF, le champ e_shstrndx
pointera vers une section de la table de lignes (celle des noms de section, s'il y en a plusieurs), et le champ sh_name
de l'en-tête de section vers une ligne spécifique.
Le premier (zéro) et le dernier octet de la table de lignes contiennent des caractères nuls. Cette dernière comprend pourquoi: valeur-heure, termine la dernière ligne. Mais le décalage zéro spécifie un nom absent ou vide - selon le contexte.
Sections de chargement
Il y a deux adresses dans l'en-tête de chaque section: l'une, sh_addr
est l'adresse de chargement (où la section sera placée en mémoire), l'autre, sh_offset
est l'offset dans le fichier où se trouve cette section. Je ne sais pas comment les deux sont, mais chacune de ces valeurs individuellement peut être 0: dans un cas, la section "reste sur le disque", car il existe une sorte d’information de service. Dans un autre, la section n'est pas chargée à partir du disque , par exemple, il vous suffit de la sélectionner et de la marquer avec des zéros ( .bss
). Honnêtement, même si je n'ai pas eu à traiter l'adresse de téléchargement - où elle a été téléchargée, elle a été téléchargée :) Cependant, nous avons aussi des programmes spécifiques, franchement.
Délocalisation
Et maintenant, la chose intéressante: selon les mesures de sécurité, comme vous le savez, ils ne vont pas à la matrice sans qu'un opérateur reste à la base. Et puisque nous avons encore du fantasme ici, la connexion avec l'opérateur sera télépathique. Oh oui, j'ai annoncé cinq minutes de ténacité terminées. En général, nous discuterons brièvement du processus de liaison.
Pour mon expérience, j'ai besoin d'un morceau de code compilé dans un so-boot normal, chargé avec libdl
normal. Ici, je ne vais même pas décrire en détail - il suffit d'ouvrir dlopen
, de retirer les caractères via dlsym
, de le fermer avec dlclose
lorsque le programme dlclose
. Cependant, même ces détails d'implémentation ne sont pas liés à notre chargeur de fichiers ELF. Il y a simplement un certain contexte : la possibilité d'obtenir un pointeur par son nom.
En général, le jeu d'instructions eBPF est un triomphe de code machine aligné: une instruction prend toujours 8 octets et a une structure
struct { uint8_t opcode; uint8_t dst:4; uint8_t src:4; uint16_t offset; uint32_t imm; };
De plus, de nombreux champs dans chaque instruction spécifique ne peuvent pas être utilisés - économiser de l'espace pour un code "machine" ne nous concerne pas.
En fait, la première instruction peut suivre immédiatement la seconde, qui ne contient aucun opcode, mais étend simplement le champ immédiat de 32 à 64 bits. Voici un patch pour une telle instruction composée appelée R_BPF_64_64
.
Afin d'effectuer la relocalisation, nous allons une fois de plus regarder la table de section pour sh_type == SHT_REL
. Le champ sh_info
de l'en-tête indiquera quelle section nous sh_link
et sh_link
- de quelle table prendre une description des caractères.
typedef struct { Elf64_Addr r_offset; Elf64_Xword r_info; } Elf64_Rel;
En fait, il existe deux types de sections de relocalisation: REL
et RELA
- la seconde contient explicitement un terme supplémentaire, mais je ne l'ai pas encore vu, nous ajoutons simplement une affirmation au fait qu'il ne se réunit pas et nous le traiterons. Ensuite, je vais ajouter à la valeur qui est écrite dans les instructions, l'adresse du symbole. Et où l'obtenir? Ici, comme nous le savons déjà, des options sont possibles:
- Le symbole fait référence à la section
SHN_ABS
. Ensuite, prenez simplement st_value
- Le caractère fait référence à la section `SHN_UNDEF. Tirez ensuite le symbole extérieur
- Dans d'autres cas, il suffit de patcher le lien vers une autre section du même fichier`
Comment l'essayer vous-même
Tout d'abord, que lire? En plus de la spécification déjà spécifiée , il est logique de lire ce fichier , dans lequel l'équipe iovisor collecte des informations extraites du noyau Linux via eBPF.
Deuxièmement, comment, en fait, tout le monde devrait-il travailler avec cela? Vous devez d'abord obtenir le fichier ELF quelque part. Comme indiqué à StackOverfow , l'équipe nous aidera.
clang -O2 -emit-llvm -c bpf.c -o - | llc -march=bpf -filetype=obj -o bpf.o
Deuxièmement, vous devez en quelque sorte obtenir une analyse de référence du fichier en morceaux. Dans une situation normale, la commande objdump
nous aiderait:
$ objdump : objdump <> <()> <()>. : -a, --archive-headers Display archive header information -f, --file-headers Display the contents of the overall file header -p, --private-headers Display object format specific file header contents -P, --private=OPT,OPT... Display object format specific contents -h, --[section-]headers Display the contents of the section headers -x, --all-headers Display the contents of all headers -d, --disassemble Display assembler contents of executable sections -D, --disassemble-all Display assembler contents of all sections --disassemble=<sym> Display assembler contents from <sym> -S, --source Intermix source code with disassembly -s, --full-contents Display the full contents of all sections requested -g, --debugging Display debug information in object file -e, --debugging-tags Display debug information using ctags style -G, --stabs Display (in raw form) any STABS info in the file -W[lLiaprmfFsoRtUuTgAckK] or --dwarf[=rawline,=decodedline,=info,=abbrev,=pubnames,=aranges,=macro,=frames, =frames-interp,=str,=loc,=Ranges,=pubtypes, =gdb_index,=trace_info,=trace_abbrev,=trace_aranges, =addr,=cu_index,=links,=follow-links] Display DWARF info in the file -t, --syms Display the contents of the symbol table(s) -T, --dynamic-syms Display the contents of the dynamic symbol table -r, --reloc Display the relocation entries in the file -R, --dynamic-reloc Display the dynamic relocation entries in the file @<file> Read options from <file> -v, --version Display this program's version number -i, --info List object formats and architectures supported -H, --help Display this information
Mais dans ce cas, il est impuissant:
$ objdump -d test-bpf.o test-bpf.o: elf64-little objdump: UNKNOWN!
Plus précisément, il affichera des sections, mais le démontage est un problème. Ici, nous rappelons ce que nous avons collecté en utilisant LLVM. LLVM a ses propres analogues étendus d'utilitaires de binutils, avec des noms de la forme llvm-< >
. Ils comprennent, par exemple, le code binaire LLVM. Et ils comprennent également eBPF - cela dépend bien sûr des options de compilation, mais comme il a été compilé, il devrait probablement toujours être analysé. Par conséquent, pour plus de commodité, je recommande de créer un script:
vim test-bpf.c
Alors pour une telle source:
#include <stdint.h> extern uint64_t z; uint64_t func(uint64_t x, uint64_t y) { return x + y + z; }
Il y aura un tel résultat:
$ ./compile-bpf.sh test-bpf.o: file format ELF64-BPF Disassembly of section .text: 0000000000000000 func: 0: bf 20 00 00 00 00 00 00 r0 = r2 1: 0f 10 00 00 00 00 00 00 r0 += r1 2: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll 0000000000000010: R_BPF_64_64 z 4: 79 11 00 00 00 00 00 00 r1 = *(u64 *)(r1 + 0) 5: 0f 10 00 00 00 00 00 00 r0 += r1 6: 95 00 00 00 00 00 00 00 exit SYMBOL TABLE: 0000000000000000 l df *ABS* 00000000 test-bpf.c 0000000000000000 ld .text 00000000 .text 0000000000000000 g F .text 00000038 func 0000000000000000 *UND* 00000000 z
Code
Partie 1. QInst: il vaut mieux perdre une journée, puis voler en cinq minutes (écrire des instruments est trivial)