Le lecteur de badge vissé au mur, dehors, est la partie que personne n’est censé croire sur parole. C’est justement pour ça que le protocole l’autorise à parler au panneau resté à l’intérieur. Deux contrôles de bornes manquants transforment ce canal de réponse en une primitive de corruption mémoire contre le contrôleur qui ouvre la porte.

TL;DR

Deux failles dans LibOSDP, l’implémentation C multiplateforme d’OSDP, le protocole derrière la plupart des lecteurs de badge modernes. Même cause racine des deux côtés du bus : une longueur lue sur le fil et jamais confrontée à une destination de taille fixe.

L’écriture (CVE-2026-77273, GHSA-vcf2-q3rm-gfpq, CVSS 8.1 élevé, CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H, CWE-787). Dans cp_decode_response(), le panneau de contrôle recopie une réponse dans un champ fixe de 64 octets d’un struct osdp_event de 160 octets sur la pile, avec une longueur et un contenu entièrement contrôlés par l’attaquant. Une seule trame de réponse déborde le champ de bien plus de 100 octets, au-delà de l’objet event et dans la pile adjacente. Sur un contrôleur OSDP construit sans protections de pile, le cas courant pour ce matériel, nous poussons ce débordement jusqu’au bout, jusqu’au contrôle du compteur de programme du panneau, démontré contre la vraie bibliothèque. Sur un hôte durci, il retombe sur un crash fiable. Nous gardons la confidentialité notée à néant (C:N) : l’écriture elle-même ne renvoie rien à l’attaquant. C’est un choix délibérément conservateur. Un détournement de flux de contrôle sur un build non durci pourrait justifier C:H et un score de base proche de 9.0, mais nous nous en tenons au vecteur assigné par le CNA.

La lecture (CVE-2026-77275, GHSA-23h3-j4q9-8hf6, CVSS 6.5 moyen, CWE-125). Dans osdp_file_cmd_tx_decode(), une longueur 16 bits est lue telle quelle sur le fil et passée au handler d’écriture sans aucun contrôle contre les octets réellement reçus, lisant loin au-delà du buffer de réception.

  • Atteignable avant authentification dans la configuration par défaut : les deux chemins tournent avant Secure Channel quand il n’est pas forcé (OSDP_FLAG_ENFORCE_SECURE non défini, le défaut).
  • Transport : le bus série RS-485 d’OSDP. L’attaquant est sur le bus, un PD malveillant ou cloné, un fil de lecteur sur écoute, ou un pont série-vers-IP en coupure.
  • Affecté : LibOSDP >= 1.3.0 (écriture) / >= 2.0.0 (lecture). Corrigé : 3.2.4.

Ni l’un ni l’autre ne demande d’identifiants. Ni l’un ni l’autre ne demande à qui que ce soit de cliquer.


Contexte : qui parle à qui sur un bus OSDP

OSDP (Open Supervised Device Protocol, IEC 60839-11-5, maintenu par la Security Industry Association) décrit comment un panneau de contrôle (CP) de contrôle d’accès parle à un ou plusieurs périphériques (PD), les lecteurs de carte et claviers à la porte, sur un bus série RS-485 multipoint deux fils. Un Secure Channel optionnel ajoute le chiffrement et l’authentification par-dessus.

Le modèle de confiance est tout le sujet : le lecteur dehors est l’extrémité exposée, non fiable ; le panneau à l’intérieur est ce qui décide vraiment d’ouvrir. Dans le cycle de scrutation normal, le CP envoie une commande et le PD répond. Le CP parse chaque réponse reçue dans cp_decode_response().

OSDP sur le bus RS-485, et où se place l’attaquant

LibOSDP est l’implémentation open source des deux rôles, distribuée via Cargo, PyPI, Vcpkg, PlatformIO et le registre Arduino, ce qui la place dans beaucoup de contrôleurs embarqués DIY et intégrateurs, ESP32 et autres microcontrôleurs compris. Les gros éditeurs propriétaires font tourner leurs propres piles OSDP et ne sont pas concernés ; ce qui est concerné, c’est tout ce qui repose sur LibOSDP.

L’écriture : une réponse qui déborde la pile du panneau

cp_decode_response() décode chaque réponse du PD dans un struct osdp_event event local de taille fixe (160 octets) posé sur la pile du CP. Deux types de réponse portent des payloads de longueur variable vers des champs fixes de 64 octets de ce struct :

  • REPLY_KEYPAD : un octet de nombre de touches est lu directement sur le fil (0 à 255). Le seul garde-fou est un contrôle de cohérence, que le nombre déclaré égale le nombre d’octets de payload présents dans la trame. Il n’y a aucun contrôle que ce nombre tienne dans la destination, un tableau fixe de 64 octets (OSDP_EVENT_KEYPRESS_MAX_DATALEN).
  • REPLY_RAW : une longueur de données de carte sur 16 bits est lue sur le fil et convertie en nombre d’octets. Là encore, le seul garde-fou est la cohérence de longueur du payload, sans aucun contrôle contre la destination fixe de 64 octets (OSDP_EVENT_CARDREAD_MAX_DATALEN).

Une trame qui déclare plus de 64 octets de payload passe donc le contrôle de cohérence et déclenche un memcpy de bien plus de 100 octets dans le champ de 64, débordant l’objet event sur la pile. La borne haute est la longueur du paquet de couche physique, donc une seule trame atteint loin au-delà du champ, au-delà du struct de 160 octets, dans la pile adjacente, et le contenu recopié est entièrement contrôlé par l’attaquant, octets nuls compris.

Voici le sink lui-même, tel quel dans la version affectée. Les deux copies font confiance à une longueur lue sur le fil, et aucune ne la vérifie contre la destination fixe de 64 octets :

src/osdp_cp.c, cp_decode_response() dans LibOSDP 3.2.3 (vulnérable)
case REPLY_KEYPAD:
    if (len < REPLY_KEYPAD_DATA_LEN) {
        break;
    }
    event.type = OSDP_EVENT_KEYPRESS;
    event.keypress.reader_no = buf[pos++];
    event.keypress.length    = buf[pos++];        // octet de longueur lu direct sur le fil, 0..255
    if ((len - REPLY_KEYPAD_DATA_LEN) != event.keypress.length) {
        break;                                    // seulement un contrôle de cohérence du payload
    }
    memcpy(event.keypress.data, buf + pos, event.keypress.length);
    // ^ aucun bornage contre OSDP_EVENT_KEYPRESS_MAX_DATALEN (le champ de 64 octets)
    ...
case REPLY_RAW:
    ...
    event.cardread.length = bread_u16_le(buf, &pos);   // longueur 16 bits sur le fil
    t = (event.cardread.length + 7) / 8;               // convertie en octets
    if (t != (len - REPLY_RAW_DATA_LEN)) {
        break;                                    // là encore, seulement la cohérence
    }
    memcpy(event.cardread.data, buf + pos, t);
    // ^ aucun bornage contre OSDP_EVENT_CARDREAD_MAX_DATALEN (le champ de 64 octets)
    ...
case REPLY_MFGREP:                                // le handler frère, fait correctement
    ...
    event.mfgrep.length = len - REPLY_MFGREP_LEN;
    if (event.mfgrep.length > OSDP_EVENT_MFGREP_MAX_DATALEN) {
        break;                                    // borne d'abord contre sa destination
    }
    memcpy(event.mfgrep.data, buf + pos, event.mfgrep.length);

Le signe est là, dans la même fonction. Le handler frère à longueur variable, REPLY_MFGREP, borne sa longueur déclarée contre son propre MAX_DATALEN avant de copier. Les handlers KEYPAD et RAW omettent ce bornage. Un pickaxe sur l’historique de ces constantes ne montre que des renommages de macros, jamais un correctif de bornes à ces deux sinks : le bornage n’a jamais été là.

Du bornage manquant au panneau détourné

Le plancher de l’advisory, c’est la disponibilité : vise la pile avec le débordement et le panneau crashe, un déni de service reproductible du dispositif qui ouvre la porte. Ça, c’est certain. La question est de savoir si la primitive va plus loin, et ici oui, avec une réserve qui dépend entièrement de la façon dont le panneau a été construit.

Nous l’avons prouvé contre notre propre instance compilée d’un panneau de contrôle LibOSDP, jamais un déploiement tiers. Version testée : v3.2.3, la dernière version vulnérable avant le correctif 3.2.4.

La primitive. Un memcpy avant dans un champ fixe de 64 octets d’un local de pile de 160 octets, avec une longueur choisie par l’attaquant et un contenu entièrement arbitraire, octets nuls compris. Offset de sink fixe, longueur contrôlée par l’attaquant. C’est la forme propre qu’on veut : on écrit ce qu’on veut, aussi loin qu’on veut, et la destination se trouve sur la pile juste devant l’adresse de retour sauvegardée.

Le débordement : 192 octets dans un champ de 64, sur l’adresse de retour sauvegardée

De bout en bout, par le vrai chemin de réception. La preuve n’est pas un appel direct de cp_decode_response(). Un vrai CP est monté avec osdp_cp_setup() et animé par osdp_cp_refresh() dans une boucle de scrutation ; un faux PD enregistré sur le callback recv du canal répond à chaque scrutation par une réponse KEYPAD forgée, en flux d’octets bruts. La bibliothèque déroule alors son propre chemin, rien n’est appelé à la main :

osdp_cp_refresh
  -> cp_phy_state_update
  -> cp_process_reply            (osdp_cp.c:729)
  -> osdp_phy_check_packet       validation en-tête + CRC16 + séquence
  -> osdp_phy_decode_packet
  -> cp_decode_response          (osdp_cp.c:412)
  -> sink REPLY_KEYPAD           (osdp_cp.c:580)  memcpy -> écrasement de l'adresse de retour
  -> ret                         -> win()

L’exécution parle d’elle-même :

[+] WIN: CP program counter redirected to win() at 0x401790

suivie d’un _exit(42) propre. La bibliothèque n’a émis aucune erreur de CRC, de séquence ou de format : la trame a passé phy_check_packet et a été dispatchée comme une réponse légitime. Une seule trame KEYPAD d’apparence légale a pris le contrôle du compteur de programme du panneau.

Une réponse, par le propre chemin de la bibliothèque, jusqu’au compteur de programme

La trame sur le fil. C’est une réponse OSDP valide, pas un blob malformé que la pile rejette tôt :

53 e5 ca 00 04  53 00 c0  41 41 ... (184 x 0x41) ...  90 17 40 00 00 00 00 00  3c f8
  • 53 SOM, e5 adresse du PD (101 avec le bit de poids fort de réponse), ca 00 longueur = 202, 04 contrôle = séquence 0 avec le flag CRC
  • payload : 53 REPLY_KEYPAD, 00 numéro de lecteur, c0 longueur keypress = 192, puis 184 octets de remplissage, puis l’emplacement de 8 octets de l’adresse de retour 90 17 40 00 00 00 00 00 = l’adresse de win() (0x401790)
  • 3c f8 = CRC16 0xf83c, calculé avec le propre osdp_compute_crc16 de la bibliothèque, donc valide par construction, et le numéro de séquence reflète la commande sortante du CP pour que phy_check_packet l’accepte

Total 202 octets, sous les 256 de OSDP_PACKET_BUF_SIZE, donc transmissible sur un vrai segment RS-485. Le garde de longueur passe parce que la longueur keypress déclarée (192) égale les octets de payload présents, et 192 tient dans un octet de longueur unique. Le chemin vulnérable n’a pas de bornage supérieur, c’est tout ce qu’il vérifie.

Atteindre l’adresse de retour sur chaque ABI. L’adresse de retour sauvegardée se trouve à une distance fixe après le sink, et sur les trois cibles cette distance est sous le plafond de longueur d’un octet (255), donc toujours atteignable :

ABIBuildtaille eventsinkchamp vers RA sauvéestatut
x86-64-O2160frame+32184prouvé dynamiquement, ret contrôlé vers win, direct et de bout en bout
AArch64-O0160sp+176184mesuré statiquement sur la frame générée
ARMv7 (AArch32)-O0152sp+104180mesuré statiquement sur la frame générée

Sur ARM, l’épilogue pop {..., pc} charge l’emplacement corrompu directement dans le compteur de programme. Le sink REPLY_RAW donne une primitive équivalente avec une longueur 16 bits bornée seulement par le buffer de paquet de 256 octets. Les offsets dépendent du niveau d’optimisation ; ce sont les valeurs mesurées aux niveaux indiqués.

Ce qui l’arrête, et ce qui ne l’arrête pas. La même vraie fonction, quatre postures de build :

Build cibleRésultat
CP représentatif de l’embarqué (-fno-stack-protector -no-pie, pas d’ASLR, adresses fixes)RCE-FAISABLE. pd reste vivant en registre pendant la copie qui déborde, donc le déréférencement suivant ne faute pas avant le ret ; transfert contrôlé propre prouvé.
Hôte standard, stack canary (-fstack-protector-strong)DoS seulement. Le canary se place de manière prouvée entre le sink et la RA sauvée, stack smashing detected avant le ret.
Hôte standard, FORTIFY (-D_FORTIFY_SOURCE=2)DoS seulement, plus tôt. __memcpy_chk connaît la destination de 64 octets, buffer overflow detected à la copie, avant toute corruption.
ASLR, sans fuiteNEEDS-LEAK pour une cible non fixe ; non requis sur la flash embarquée sans ASLR.

Le partage est toute l’histoire. Sur un hôte glibc durci, c’est un déni de service et rien de plus. Sur le contrôleur OSDP réaliste, un microcontrôleur sans MMU, sans canary, sans FORTIFY, à adresses flash fixes, c’est un détournement du flux d’exécution du dispositif qui ouvre la porte. Ce n’est pas un pire cas rhétorique : c’est le build courant de cette classe de matériel.

Cadré honnêtement. Ce qui est prouvé, maintenant, sur x86-64 contre la vraie bibliothèque non modifiée : détournement complet du flux de contrôle d’un vrai panneau osdp_cp_setup / osdp_cp_refresh par le vrai chemin canal, phy, CRC, séquence et dispatch ; écrasement arbitraire de la RA sauvée, octets nuls compris ; un ret dirigé vers une adresse choisie ; et les builds canary et FORTIFY qui retombent sur un DoS déterministe. Ce qui est mesuré mais pas encore exécuté : les offsets AArch64 (184) et ARMv7 (180) sont lus sur les frames générées mais pas confirmés dynamiquement sous émulation ici, bloqués seulement par l’outillage de cet environnement, par aucune inconnue technique. Ce qui reste dû, et relève d’un vrai banc plutôt que de ce write-up : re-mesurer l’offset sur la toolchain exacte d’un produit livré, confirmer le modèle fetch-execute sur Xtensa ou Cortex-M avec une pile de tâche RTOS, et vérifier si tel build éditeur active un canary, ce qui le replafonnerait au DoS. Nous sommes précis, à dessein, sur la frontière entre démontré et faisable.

La lecture : une longueur que le décodeur de fichier ne vérifie jamais

osdp_file_cmd_tx_decode() parse une commande CMD_FILETRANSFER entrante, lit une longueur de payload sur 16 bits telle quelle sur le fil, et passe cette longueur au handler d’écriture enregistré sans la confronter au nombre d’octets réellement reçus. Le seul contrôle de cohérence présent est un assert(), compilé hors du binaire sous NDEBUG, le build de release normal.

Une trame annonçant une grande longueur mais ne portant que peu d’octets de données fait lire au handler d’écriture loin au-delà de la fin du buffer de réception du PD, jusqu’à environ 64 Kio, jusqu’à fauter en franchissant une page non mappée. C’est un déni de service fiable du lecteur.

Une limite honnête sur celle-ci : les octets sur-lus sont remis au handler d’écriture local de l’intégrateur et consommés en interne, typiquement persistés dans le fichier reçu. Ils ne repartent pas sur le fil OSDP, il n’y a donc pas de canal de relecture à distance. C’est un crash, pas une fuite d’information.

Qui peut le déclencher, et quand

Les deux sinks sont atteignables sous le même modèle :

  • Sur le bus. OSDP, c’est du RS-485. L’attaquant doit être sur le segment de bus : un PD malveillant, cloné ou compromis ; un fil de lecteur physiquement sur écoute ; ou un convertisseur RS-485 ou pont série-vers-IP en coupure injectant une trame au CRC valide. C’est exactement le modèle d’attaquant sur le bus contre lequel Secure Channel existe. Ce n’est pas routable sur Internet en soi, même si un pont série-vers-IP change ce calcul.
  • Avant authentification par défaut. cp_decode_response() tourne à chaque réponse du cycle de scrutation normal, et le décodeur de fichier dispatche un CMD_FILETRANSFER en clair directement, les deux sans exigence de Secure Channel quand OSDP_FLAG_ENFORCE_SECURE n’est pas défini, ce qui est le défaut. Aucun appairage, aucune clé, aucune poignée de main préalable. Dans notre reproduction de bout en bout, le débordement se déclenche dès la première réponse acceptée par le panneau, avant même qu’il atteigne son état ONLINE.
  • Verrouillé par la config quand c’est durci. Un déploiement qui force Secure Channel repousse les deux dans une session sécurisée établie, ramenant le vecteur à post-authentification.

Le correctif

Les deux problèmes sont corrigés dans LibOSDP 3.2.4. Les deux sinks d’écriture ont gagné le bornage supérieur que le handler frère avait déjà :

src/osdp_cp.c, cp_decode_response() (corrigé en 3.2.4)
// REPLY_KEYPAD
if (/* cohérence de longueur du payload */ ||
    event.keypress.length > OSDP_EVENT_KEYPRESS_MAX_DATALEN) {
    break;   // rejeter au lieu de copier au-delà du champ de 64 octets
}

// REPLY_RAW
if (/* cohérence de longueur du payload */ ||
    t > OSDP_EVENT_CARDREAD_MAX_DATALEN) {
    break;
}

La copie est désormais bornée à la destination de 64 octets. Côté lecture, le décodeur de fichier valide la longueur annoncée contre les octets réellement reçus avant de dispatcher.

Au-delà du patch, le conseil pratique pour tout déploiement LibOSDP est le même que celui du protocole lui-même : forcez Secure Channel (OSDP_FLAG_ENFORCE_SECURE). Ça ne corrige pas le parseur, mais ça enlève l’atteignabilité avant authentification qui rend ces trames triviales à injecter.

Versions affectées

AffectéCorrigé
CVE-2026-77273 (écriture)LibOSDP >= 1.3.03.2.4
CVE-2026-77275 (lecture)LibOSDP >= 2.0.03.2.4

Les bindings Rust (libosdp, libosdp-sys), Python et C++ suivent le même cœur, donc un binding épinglé sur une version de cœur affectée porte la même faille.

Chronologie

  • 2026-08-30 : les deux advisories publiées, GHSA-vcf2-q3rm-gfpq et GHSA-23h3-j4q9-8hf6, corrigées en 3.2.4.
  • Coordonné avec les mainteneurs ; crédit Eun0us.

Références