The card reader bolted to the wall outside your building is the part nobody trusts. That is exactly why the protocol lets it talk to the panel inside. Two missing bounds checks turn that reply channel into a memory-corruption primitive against the controller that unlocks the door.
TL;DR
Two flaws in LibOSDP, the cross-platform C implementation of OSDP, the protocol behind most modern badge readers. Same root cause on two sides of the bus: a length taken off the wire and trusted against a fixed-size destination.
The write (CVE-2026-77273, GHSA-vcf2-q3rm-gfpq, CVSS 8.1 high, CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H, CWE-787). In cp_decode_response(), the control panel copies a reply into a fixed 64-byte field of a 160-byte struct osdp_event on the stack, with attacker-controlled length and fully attacker-controlled content. A single reply frame overruns the field by well over 100 bytes, past the end of the event object and into adjacent stack. On an OSDP controller built without stack mitigations, the common case for this hardware, we take that overwrite end to end into control of the panel’s program counter, proven against the real library. On a hardened host it degrades to a reliable crash. We keep confidentiality scored as none (C:N): the write itself reads nothing back to the attacker. That is deliberately conservative. A control-flow hijack on an unhardened build could be argued up to C:H and a base near 9.0, but we hold to the vector the CNA assigned.
The read (CVE-2026-77275, GHSA-23h3-j4q9-8hf6, CVSS 6.5 medium, CWE-125). In osdp_file_cmd_tx_decode(), a 16-bit length is read verbatim from the wire and passed to the write handler with no check against the bytes actually received, reading far past the receive buffer.
- Reachable pre-authentication on the default configuration: both paths run before Secure Channel when it is not enforced (
OSDP_FLAG_ENFORCE_SECUREunset, the default). - Transport: the OSDP RS-485 serial bus. The attacker is on the bus, a rogue or cloned PD, a tapped reader wire, or an in-line serial-to-IP bridge.
- Affected: LibOSDP
>= 1.3.0(write) />= 2.0.0(read). Fixed:3.2.4.
Neither needs credentials. Neither needs anyone to click anything.
Background: who talks to whom on an OSDP bus
OSDP (Open Supervised Device Protocol, IEC 60839-11-5, maintained by the Security Industry Association) is how an access-control control panel (CP) talks to one or more peripheral devices (PD), the card readers and keypads at the door, over a two-wire RS-485 multi-drop serial bus. An optional Secure Channel adds encryption and authentication on top.
The trust model is the whole point: the reader outside is the exposed, untrusted end; the panel inside is what actually decides to unlock. In the normal poll cycle, the CP sends a command and the PD answers with a reply. The CP parses every reply it receives in cp_decode_response().
LibOSDP is the open-source implementation of both roles, distributed through Cargo, PyPI, Vcpkg, PlatformIO and the Arduino registry, which puts it in a lot of embedded DIY and integrator controllers, ESP32 and other microcontrollers included. The large closed-source vendors run their own OSDP stacks and are not affected; what is affected is everything built on LibOSDP.
The write: a reply that overruns the panel’s stack
cp_decode_response() decodes each PD reply into a local, fixed-size struct osdp_event event (160 bytes) held on the CP’s stack. Two reply types carry variable-length payloads into fixed 64-byte fields of that struct:
REPLY_KEYPAD: a keypress-count byte is read straight off the wire (0 to 255). The only guard is a consistency check, that the declared count equals the number of payload bytes present in the frame. There is no check that the count fits the destination, a fixed 64-byte array (OSDP_EVENT_KEYPRESS_MAX_DATALEN).REPLY_RAW: a 16-bit card-data bit-length is read off the wire and converted to a byte count. Again the only guard is payload-length consistency, with no check against the fixed 64-byte destination (OSDP_EVENT_CARDREAD_MAX_DATALEN).
A frame that declares more than 64 payload bytes therefore passes the consistency check and drives a memcpy of well over 100 bytes into the 64-byte field, overrunning the event object on the stack. The upper bound is the physical-layer packet length, so a single frame reaches far past the field, past the 160-byte struct, into adjacent stack, and the copied content is entirely attacker-controlled, nulls included.
Here is the sink itself, verbatim from the affected release. Both copies trust a length taken off the wire, and neither checks it against the fixed 64-byte destination:
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++]; // length byte straight off the wire, 0..255
if ((len - REPLY_KEYPAD_DATA_LEN) != event.keypress.length) {
break; // only a payload-consistency check
}
memcpy(event.keypress.data, buf + pos, event.keypress.length);
// ^ no clamp to OSDP_EVENT_KEYPRESS_MAX_DATALEN (the 64-byte field)
...
case REPLY_RAW:
...
event.cardread.length = bread_u16_le(buf, &pos); // 16-bit length off the wire
t = (event.cardread.length + 7) / 8; // to a byte count
if (t != (len - REPLY_RAW_DATA_LEN)) {
break; // again, only consistency
}
memcpy(event.cardread.data, buf + pos, t);
// ^ no clamp to OSDP_EVENT_CARDREAD_MAX_DATALEN (the 64-byte field)
...
case REPLY_MFGREP: // the sibling handler, done right
...
event.mfgrep.length = len - REPLY_MFGREP_LEN;
if (event.mfgrep.length > OSDP_EVENT_MFGREP_MAX_DATALEN) {
break; // clamps against its destination first
}
memcpy(event.mfgrep.data, buf + pos, event.mfgrep.length);The tell is right there in the same function. The sibling variable-length handler, REPLY_MFGREP, clamps its declared length against its own MAX_DATALEN before copying. The KEYPAD and RAW handlers omit that clamp. A pickaxe over the history of these constants shows only macro renames, never a bounds fix at these two sinks: the clamp was never there.
From missing clamp to a hijacked control panel
The advisory floor is availability: aim the overflow at the stack and the panel crashes, a repeatable denial of service of the device that unlocks the door. That much is certain. The question is whether the primitive goes further, and here it does, with a caveat that depends entirely on how the panel was built.
We proved this against our own compiled instance of a LibOSDP control panel, never a third-party deployment. Version under test: v3.2.3, the last vulnerable release before the 3.2.4 fix.
The primitive. A forward memcpy into a fixed 64-byte field of a 160-byte stack local, with attacker-chosen length and fully arbitrary content, null bytes included. Fixed sink offset, attacker-controlled length. That is the clean shape you want: you write what you want, as far as you want, and the destination sits on the stack in front of the saved return address.
End to end, through the real receive path. The proof is not a direct call to cp_decode_response(). A real CP is stood up with osdp_cp_setup() and driven by osdp_cp_refresh() in a poll loop; a fake PD registered on the channel’s recv callback answers each poll with a crafted KEYPAD reply as a raw byte stream. The library then runs its own path with nothing hand-called:
osdp_cp_refresh
-> cp_phy_state_update
-> cp_process_reply (osdp_cp.c:729)
-> osdp_phy_check_packet header + CRC16 + sequence validation
-> osdp_phy_decode_packet
-> cp_decode_response (osdp_cp.c:412)
-> REPLY_KEYPAD sink (osdp_cp.c:580) memcpy -> saved RA overwrite
-> ret -> win()The run speaks for itself:
[+] WIN: CP program counter redirected to win() at 0x401790followed by a clean _exit(42). The library emitted no CRC, sequence, or format error: the frame passed phy_check_packet and was dispatched as a legitimate reply. A single legal-looking KEYPAD frame took control of the panel’s program counter.
The frame on the wire. It is a valid OSDP reply, not a malformed blob the stack rejects early:
53 e5 ca 00 04 53 00 c0 41 41 ... (184 x 0x41) ... 90 17 40 00 00 00 00 00 3c f853SOM,e5PD address (101 with the reply MSB set),ca 00length = 202,04control = sequence 0 with the CRC flag- payload:
53REPLY_KEYPAD,00reader number,c0keypress length = 192, then 184 filler bytes, then the 8-byte saved-RA slot90 17 40 00 00 00 00 00= the address ofwin()(0x401790) 3c f8= CRC160xf83c, computed with the library’s ownosdp_compute_crc16so it is valid by construction, and the sequence number mirrors the CP’s outgoing command sophy_check_packetaccepts it
Total 202 bytes, under the 256-byte OSDP_PACKET_BUF_SIZE, so it is transmittable on a real RS-485 segment. The length guard passes because the declared keypress length (192) equals the payload bytes present, and 192 fits a single length byte. The vulnerable path has no upper clamp, so that is all it checks.
Reaching the return address on every ABI. The saved return address sits a fixed distance past the sink, and on all three targets that distance is within the single-byte length ceiling of 255, so it is always reachable:
| ABI | Build | event size | sink | field to saved RA | status |
|---|---|---|---|---|---|
| x86-64 | -O2 | 160 | frame+32 | 184 | dynamically proven, controlled ret to win, direct and end-to-end |
| AArch64 | -O0 | 160 | sp+176 | 184 | statically measured from the generated frame |
| ARMv7 (AArch32) | -O0 | 152 | sp+104 | 180 | statically measured from the generated frame |
On ARM the function epilogue pop {..., pc} loads the corrupted slot straight into the program counter. The REPLY_RAW sink gives an equivalent primitive with a 16-bit length bounded only by the 256-byte packet buffer. Offsets are optimization-level dependent; these are the measured values at the stated levels.
What stops it, and what does not. The same real function, four build postures:
| Target build | Result |
|---|---|
Embedded-representative CP (-fno-stack-protector -no-pie, no ASLR, fixed addresses) | RCE-FEASIBLE. pd stays register-live across the overflowing copy, so the trailing dereference does not fault before ret; clean controlled transfer proven. |
Standard host, stack canary (-fstack-protector-strong) | DoS-only. The canary provably sits between the sink and the saved RA, stack smashing detected before ret. |
Standard host, FORTIFY (-D_FORTIFY_SOURCE=2) | DoS-only, earlier. __memcpy_chk knows the destination is 64 bytes, buffer overflow detected at the copy, before any corruption. |
| ASLR, no leak | NEEDS-LEAK for a non-fixed target; not required on no-ASLR embedded flash. |
The split is the whole story. On a hardened glibc host this is a denial of service and nothing more. On the realistic OSDP controller, a no-MMU microcontroller with no canary, no FORTIFY, and fixed flash addresses, it is a control-flow hijack of the device that unlocks the door. That is not a rhetorical worst case: it is the common build for this class of hardware.
Scoped honestly. What is proven, now, on x86-64 against the real unmodified library: full end-to-end control-flow hijack of a real osdp_cp_setup / osdp_cp_refresh panel through the genuine channel, phy, CRC, sequence and dispatch path; arbitrary saved-RA overwrite including null bytes; a directed ret to a chosen address; and the canary and FORTIFY builds degrading to a deterministic DoS. What is measured but not yet run: the AArch64 (184) and ARMv7 (180) offsets are read from the generated frames but not dynamically confirmed under emulation here, blocked only by tooling in this environment, not by any technical unknown. What is still owed, and belongs on a real bench rather than in this write-up: re-measuring the offset on a shipped product’s exact toolchain, confirming the fetch-execute model on Xtensa or Cortex-M with an RTOS task stack, and checking whether a given vendor build happens to enable a canary, which would cap it back at DoS. We are precise about the boundary between demonstrated and feasible on purpose.
The read: a length the file decoder never checks
osdp_file_cmd_tx_decode() parses an incoming CMD_FILETRANSFER command, reads a 16-bit payload length verbatim from the wire, and hands that length to the registered write handler without checking it against the number of bytes actually received. The only consistency check present is an assert(), which is compiled out under NDEBUG, the normal release build.
A frame advertising a large length but carrying few data bytes makes the write handler read far past the end of the PD receive buffer, up to roughly 64 KiB, faulting once it crosses an unmapped page. This is a reliable reader denial of service.
One honest limit on this one: the over-read bytes are handed to the integrator’s local write handler and consumed internally, typically persisted to the received file. They are not reflected back onto the OSDP wire, so there is no remote read-back channel. It is a crash, not an information leak.
Who can trigger it, and when
Both sinks are reachable under the same model:
- On the bus. OSDP is RS-485. The attacker must be on the bus segment: a rogue, cloned or compromised PD; a physically tapped reader wire; or an in-line RS-485 converter or serial-to-IP bridge injecting a CRC-valid frame. This is exactly the bus-attacker model Secure Channel exists to defend against. It is not internet-routable on its own, though a serial-to-IP bridge changes that calculus.
- Pre-auth by default.
cp_decode_response()runs on every reply during the normal poll cycle, and the file decoder dispatches a plaintextCMD_FILETRANSFERdirectly, both with no Secure-Channel requirement whenOSDP_FLAG_ENFORCE_SECUREis unset, which is the default. No pairing, key or prior handshake is needed. In our end-to-end reproduction the overflow fires on the first reply the panel accepts, before it even reaches its ONLINE state. - Config-gated when hardened. A deployment that enforces Secure Channel pushes both into an established secure session, degrading the vector to post-auth.
The fix
Both issues are fixed in LibOSDP 3.2.4. The two write sinks gained the missing upper clamp that the sibling handler already had:
// REPLY_KEYPAD
if (/* payload-length consistency */ ||
event.keypress.length > OSDP_EVENT_KEYPRESS_MAX_DATALEN) {
break; // reject instead of copying past the 64-byte field
}
// REPLY_RAW
if (/* payload-length consistency */ ||
t > OSDP_EVENT_CARDREAD_MAX_DATALEN) {
break;
}The copy is now bounded to the 64-byte destination. On the read side, the file decoder validates the advertised length against the bytes actually received before dispatching.
Beyond the patch, the practical guidance for any LibOSDP deployment is the same as the protocol’s own: enforce Secure Channel (OSDP_FLAG_ENFORCE_SECURE). It does not fix the parser, but it removes the pre-auth reachability that makes these frames trivial to inject.
Affected versions
| Affected | Fixed | |
|---|---|---|
| CVE-2026-77273 (write) | LibOSDP >= 1.3.0 | 3.2.4 |
| CVE-2026-77275 (read) | LibOSDP >= 2.0.0 | 3.2.4 |
The Rust (libosdp, libosdp-sys), Python and C++ bindings track the same core, so a binding pinned to an affected core version carries the same flaw.
Timeline
- 2026-08-30: both advisories published,
GHSA-vcf2-q3rm-gfpqandGHSA-23h3-j4q9-8hf6, fixed in 3.2.4. - Coordinated with the maintainers; credit
Eun0us.
References
- CVE-2026-77273, write: GHSA-vcf2-q3rm-gfpq
- CVE-2026-77275, read: GHSA-23h3-j4q9-8hf6
- LibOSDP and doc.osdp.dev
- OSDP at the Security Industry Association
