Skip to content
Libsmb2 Dcerpc Ndr Count Overflow Oob

Libsmb2 Dcerpc Ndr Count Overflow Oob

hackmd.io • October 23, 2026

# libsmb2: integer overflows in the DCE/RPC NDR decoder give a malicious server an out-of-bounds read and an out-of-bounds write when a client enumerates shares

![type]( ![cwe]( ![cwe2]( ![cwe3]( ![component]( ![severity]( ![status](

**Reported by:** HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology

**Project:** [sahlberg/libsmb2]( a C SMB2/3 client library, DCE/RPC NDR decoder used by the srvsvc enumeration client

**Affected:** latest release `libsmb2-6.2` (tag commit `d67e213a5c4e7e4969fd81f0b95e4ca5831fbba1`) and earlier 6.x releases. The code is on the default branch at the pinned commit. The later unreleased refactor into `libdcerpc/` reworks the first bug with a 64-bit guard, so no released version contains the fix.

**Class:** CWE-190 Integer Overflow or Wraparound, CWE-125 Out-of-bounds Read, CWE-787 Out-of-bounds Write

libsmb2 can act as a DCE/RPC client over SMB2 named pipes. The public call `smb2_share_enum_async` opens the `\srvsvc` pipe on the server the client connected to, sends a `NetrShareEnum` request, and decodes the with the NDR decoder in `lib/dcerpc.c`. Every byte of that is chosen by the server, which a client does not trust.

Two per-field length values in the NDR decoder are multiplied in 32-bit arithmetic before they are checked, and both products can wrap.

FINDING 1 is an out-of-bounds read. `dcerpc_decode_utf16` reads a 32-bit NDR `actual_count` from the , then guards the string read with `*offset + s->actual_count * 2 > iov->len` at `lib/dcerpc.c:1045`. The subexpression `s->actual_count * 2` is computed in `uint32_t`, so an `actual_count` at or above `0x80000000` makes the product wrap to a small value, the guard passes, and `smb2_utf16_to_utf8((uint16_t *)(&iov->buf[*offset]), (size_t)s->actual_count)` at `lib/dcerpc.c:1059` then scans `actual_count` UTF-16 code units, up to 4 GiB, off the end of the heap buffer that holds the . The read runs during normal decoding of a name or a remark.

FINDING 2 is an out-of-bounds write. `dcerpc_carray_coder` reads a 32-bit NDR conformance `max_count`, then allocates the array with `smb2_alloc_data(..., carray->max_count * elem_size)` at `lib/dcerpc.c:918`. The product `carray->max_count * elem_size` is also computed in 32-bit, so a `max_count` chosen so the product wraps to a value smaller than one element yields an undersized allocation. The element decode loop at `lib/dcerpc.c:926` then writes full `struct srvsvc_SHARE_INFO_1` records into that short buffer as long as the carries element bytes, which is a heap out-of-bounds write.

Both bugs are reachable under the default build and configuration from a single crafted `NetrShareEnum` . The attacker is the SMB server the client enumerates, or a machine-in-the-middle on an unsigned or unencrypted connection. The demonstrated primitives are a large out-of-bounds read that crashes the client and an out-of-bounds write of decoded NDR records. No instruction-pointer control and no code execution were established.

libsmb2 reaches these decoders whenever an application lists the shares exported by a server. The chain is short and has no authentication step between the and the decode.

`smb2_share_enum_async` is the public client API, declared in `include/smb2/libsmb2-dcerpc-srvsvc.h` and defined at `lib/smb2- -enum.c:134`. It creates a DCE/RPC context, binds to the `srvsvc` interface, and on the bind callback issues the call at `lib/smb2- -enum.c:119`:

srvsvc_NetrShareEnum_req_coder, &nse->se_req,

The request travels inside an SMB2 IOCTL to the `\srvsvc` pipe. The server answer comes back as the IOCTL output buffer. The IOCTL decoder stores that output and its length, then the DCE/RPC response callback builds an iovec straight over those server-supplied bytes at `lib/dcerpc.c:1544`:

if (dcerpc_pdu_coder(dce, pdu, &iov, &offset)) {

`rep->output` and `rep->output_count` are the content and the length of the SMB2 IOCTL , both chosen by the server. `dcerpc_pdu_coder` parses the DCE/RPC response header and then calls the response coder `srvsvc_NetrShareEnum_rep_coder` over the stub data.

Every NDR structure is decoded twice by `dcerpc_do_coder` at `lib/dcerpc.c:545`, a conformance pass that records counts and alignment, then a data pass that reads the bytes:

*offset = (*offset + (pdu->max_alignment - 1)) & ~(pdu->max_alignment - 1);

`srvsvc_NetrShareEnum_rep_coder` at `lib/dcerpc-srvsvc.c:334` walks a `SHARE_ENUM_STRUCT`, then a `SHARE_ENUM_UNION`, then for level 1 a `SHARE_INFO_1_CONTAINER` whose buffer is a conformant array of `SHARE_INFO_1`. Each `SHARE_INFO_1` carries a `netname` and a `remark`, both decoded as NUL-terminated UTF-16 strings at `lib/dcerpc-srvsvc.c:166` and `lib/dcerpc-srvsvc.c:173`:

if (dcerpc_ptr_coder(ctx, pdu, iov, offset, &nsi1->netname,

if (dcerpc_uint32_coder(ctx, pdu, iov, offset, &nsi1->type)) {

if (dcerpc_ptr_coder(ctx, pdu, iov, offset, &nsi1->remark,

`dcerpc_utf16z_coder` decodes to `dcerpc_decode_utf16`.

### FINDING 1, link 1, the length fields are plain 32-bit wire values

In the conformance pass `dcerpc_decode_utf16` reads the three NDR header words of a conformant varying string at `lib/dcerpc.c:1025`:

if (dcerpc_conformance_coder(ctx, pdu, iov, offset, &val)) {

if (dcerpc_conformance_coder(ctx, pdu, iov, offset, &val)) {

if (dcerpc_conformance_coder(ctx, pdu, iov, offset, &val)) {

`dcerpc_conformance_coder` reads each word with `dcerpc_get_uint32`, which bounds the 4-byte read against `iov->len` and otherwise copies the raw wire value. So `max_count`, `offset`, and `actual_count` are full 32-bit values with no range limit. `actual_count` is declared `uint32_t` in `struct dcerpc_utf16`.

### FINDING 1, link 2, the bounds check multiplies in 32-bit and wraps

The data pass runs the only guard that protects the string read, at `lib/dcerpc.c:1045`:

if (*offset + s->actual_count * 2 > iov->len) {

`*offset` is an `int`, `s->actual_count` is `uint32_t`, and `2` is an `int` that converts to `uint32_t` for the multiply. The product `s->actual_count * 2` is therefore computed in 32-bit unsigned arithmetic and wraps modulo 2^32. The sum then promotes to `size_t` for the comparison against `iov->len`, but the wrap has already happened. With `actual_count = 0x80000000` the product is `0`, so the test becomes `*offset > iov->len`, which is false for any in-range offset, and the guard passes. With `actual_count = 0x80000001` the product is `2`, and the guard passes whenever `*offset + 2 len`.

### FINDING 1, link 3, the sink reads actual_count code units off the heap

Right after the guard the decoder converts the string at `lib/dcerpc.c:1059`:

tmp = smb2_utf16_to_utf8((uint16_t *)(&iov->buf[*offset]), (size_t)s->actual_count);

`smb2_utf16_to_utf8` is passed the untruncated `actual_count` as the number of UTF-16 code units. It first sizes the output by scanning every input unit in `utf16_size` at `lib/unicode.c:205`:

utf16_size(const uint16_t *utf16, size_t utf16_len)

const uint16_t *utf16_end = utf16 + utf16_len;

With `utf16_len = 0x80000000` the loop walks `0x80000000` code units, which is `0x100000000` bytes, from `&iov->buf[*offset]` across and far beyond the small buffer. The walk reaches unmapped memory and the process faults. The server supplies the buffer length and the `actual_count` value independently, so the read length is not tied to the real buffer size.

Both the big-endian fixup loop at `lib/dcerpc.c:1051` and the little-endian path fall through to this read. The little-endian path is the default for a standard server data representation, and it reaches the read with no prior per-unit access.

### FINDING 2, link 1, the array allocation multiplies in 32-bit

`dcerpc_carray_coder` at `lib/dcerpc.c:897` decodes a conformant array. It reads the conformance `max_count`, then sizes the array at `lib/dcerpc.c:913`:

if (dcerpc_conformance_coder(ctx, pdu, iov, offset, &p)) {

if (dcerpc_pdu_direction(pdu) == DCERPC_DECODE) {

if (carray->max_count && carray->data == NULL) {

`carray->max_count` is `uint32_t` and `elem_size` is an `int`. For the enumeration the element is `struct srvsvc_SHARE_INFO_1`, which embeds two `struct dcerpc_utf16` and a `uint32_t`, 72 bytes on a 64-bit target. The product `carray->max_count * elem_size` is computed in 32-bit and wraps. A `max_count` of `0x20000000` with a 72-byte element gives `0x20000000 * 72 = 0x900000000`, which is `0` modulo 2^32, so `smb2_alloc_data` is asked for a buffer that holds only its own small header. `max_count` stays nonzero, so the allocation branch runs.

### FINDING 2, link 2, the element loop writes past the short buffer

The data loop writes `max_count` elements into that buffer at `lib/dcerpc.c:926`:

if (coder(ctx, pdu, iov, offset, &carray->data[i * elem_size])) {

The element coder `srvsvc_SHARE_INFO_1_coder` decodes into `&carray->data[i * elem_size]`, writing the `netname` pointer, the `type`, and the `remark` pointer of each record. The first element alone writes a full `struct srvsvc_SHARE_INFO_1` into the undersized allocation, which is an out-of-bounds write on the heap. The loop reads its per-element bytes from the through the bounded NDR getters, so it stops when the bytes run out, but one short with two elements worth of stub bytes is enough to write past the end. The loop bound `p` is the 64-bit conformance value, and the allocation uses the truncated 32-bit `max_count`, so the mismatch is built in.

### Why the field getters do not save these paths

The scalar NDR getters such as `dcerpc_get_uint32` at `lib/dcerpc.c:374` and the SMB2 getters such as `smb2_get_uint16` at `lib/pdu.c:402` check `offset + size > iov->len` on every access, so they cannot step off the buffer by themselves. The two defects here are a direct `smb2_utf16_to_utf8` read and a direct array write that bypass those getters, guarded only by the two length checks that overflow. This matches the pattern in the already-fixed QUERY_INFO decoders, where the safe fields go through bounded getters and the unsafe field goes straight into `smb2_utf16_to_utf8`.

Lib->>Srv: bind srvsvc, NetrShareEnum request over IPC pipe

Srv-->>Lib: IOCTL , crafted NDR NetrShareEnum response

Note over Lib: dcerpc_call_cb builds iov over server output bytes

Note over Lib: dcerpc_pdu_coder then srvsvc rep coder two-pass decode

Note over Lib: netname actual_count 0x80000000, guard product wraps to 0

Lib->>Lib: smb2_utf16_to_utf8 scans 0x80000000 units off the heap

Note over Lib: or array max_count makes allocation wrap to a short buffer

Lib->>Lib: element loop writes SHARE_INFO_1 records past the buffer

The trigger is a single crafted `NetrShareEnum` response returned by the server on the `\srvsvc` pipe. The builder below assembles the NDR stub for a level 1 that carries one `SHARE_INFO_1` whose `netname` is a conformant varying UTF-16 string with `actual_count` set to `0x80000000`. These are the bytes that become `iov` in `dcerpc_call_cb` and reach `dcerpc_decode_utf16` on the data pass. The transport framing, the pipe bind, and the SMB2 IOCTL carrier are produced by the malicious server and are left out here to keep the trigger field visible.

return struct.pack(LE + "I", v & 0xffffffff)

# NDR unique pointer: a nonzero referent id, the pointee is deferred

def conformant_varying_utf16(max_count, offset, actual_count, data=b""):

# [string] wchar_t *: max_count, offset, actual_count, then data units

return u32(max_count) + u32(offset) + u32(actual_count) + data

# unique pointer to SHARE_INFO_1_CONTAINER

# unique pointer to the SHARE_INFO_1 conformant array

# one SHARE_INFO_1 record in the main pass:

# unique pointer to netname, uint32 type, unique pointer to remark

out += unique_ptr(0x00020008) # netname present

out += u32(0) # type STYPE_DISKTREE

out += unique_ptr(0x0002000c) # remark present

# deferred netname string, the vulnerable field

# actual_count 0x80000000 so actual_count*2 wraps to 0 in the guard

out += conformant_varying_utf16(0x80000000, 0, 0x80000000, data=b"")

# alloc_hint, context_id, cancel_count, pad, then the stub

response = u32(len(stub)) + struct.pack(LE + "HBx", 0, 0) + stub

print("stub len", len(stub), "netname actual_count 0x80000000")

The state change inside `dcerpc_decode_utf16` on the `netname` field:

1. The conformance pass reads `max_count`, `offset`, and `actual_count` through `dcerpc_get_uint32`. Each 4-byte read is in range, and `s->actual_count` is set to `0x80000000`.

2. The data pass computes the guard `*offset + s->actual_count * 2`. The multiply `0x80000000 * 2` wraps to `0` in `uint32_t`, so the comparison is `*offset > iov->len`, which is false, and the guard lets the read run.

3. `smb2_utf16_to_utf8(&iov->buf[*offset], 0x80000000)` calls `utf16_size`, which walks `0x80000000` UTF-16 code units from the netname offset. The scan runs past the end of the few-hundred-byte buffer and into unmapped memory.

4. The process faults inside `utf16_size`, which crashes the client while it is listing shares.

For the write variant, set the conformant array `max_count` to `0x20000000` and keep two `SHARE_INFO_1` records worth of stub bytes. The allocation at `lib/dcerpc.c:918` wraps to the allocator header size, and the element loop writes the first `struct srvsvc_SHARE_INFO_1` past the end of that buffer.

The full chain was confirmed by reading the pinned `libsmb2-6.2` source at commit `d67e213a5c4e7e4969fd81f0b95e4ca5831fbba1`.

FINDING 1. The entry is `smb2_share_enum_async` at `lib/smb2- -enum.c:134` and the call at `lib/smb2- -enum.c:119`. The server bytes become the decode iovec at `lib/dcerpc.c:1544` inside `dcerpc_call_cb`, handed to `dcerpc_pdu_coder` at `lib/dcerpc.c:1550`. The response coder `srvsvc_NetrShareEnum_rep_coder` at `lib/dcerpc-srvsvc.c:334` reaches the `netname` and `remark` strings at `lib/dcerpc-srvsvc.c:166` and `:173` through `dcerpc_utf16z_coder`. The two-pass driver is `dcerpc_do_coder` at `lib/dcerpc.c:545`. The 32-bit multiply guard is at `lib/dcerpc.c:1045`, the field is the `uint32_t actual_count` read at `lib/dcerpc.c:1037`, and the sink is `smb2_utf16_to_utf8` at `lib/dcerpc.c:1059` reaching `utf16_size` at `lib/unicode.c:205`. The scalar getters at `lib/dcerpc.c:374` and `lib/pdu.c:402` are bounded, which shows the defect is specific to the direct string read.

FINDING 2. The array decoder is `dcerpc_carray_coder` at `lib/dcerpc.c:897`, the 32-bit allocation multiply is at `lib/dcerpc.c:918`, and the element write loop is at `lib/dcerpc.c:926`. The element `struct srvsvc_SHARE_INFO_1` embeds two `struct dcerpc_utf16` and a `uint32_t`.

Independent corroboration for FINDING 1 comes from the upstream default branch, where the DCE/RPC code was moved into `libdcerpc/dcerpc.c` and the same string decode now reads `if (s->actual_count > s->max_count) return -1;` followed by `if ((uint64_t)iov->offset + (uint64_t)s->actual_count * 2u > iov->max_offset) return -1;`. That change rejects the overflow with a 64-bit computation and a count sanity check. The fix is only on the unreleased refactor, so the latest release `libsmb2-6.2` is affected.

This write-up is source-confirmed. A runtime AddressSanitizer reproduction on an isolated build was not executed in this phase. A sanitizer build of a small driver that feeds a crafted `NetrShareEnum` through the DCE/RPC decoder is expected to report a heap-buffer-overflow READ inside `utf16_size` for FINDING 1 and a heap-buffer-overflow WRITE inside the `dcerpc_carray_coder` element loop for FINDING 2. A plain build may fault on the large read when the walk reaches an unmapped page, and the short-buffer write may corrupt adjacent heap metadata.

Attacker model. The attacker is the SMB server the client connects to and enumerates, or a machine-in-the-middle on a connection that is not signed and not encrypted. No credentials beyond what the client already uses are needed, and the trigger is one crafted to a request the client sends on its own.

Reachability. The path runs under the default build. `smb2_share_enum_async` is a public API used by applications that list the shares on a server, which is a normal client action against a server a user chose to browse. The decode has no option gate.

FINDING 1 effect. A heap out-of-bounds read whose length is a 32-bit server field after the guard overflow, up to 4 GiB. The read is a forward scan that reaches unmapped memory and crashes the client, which is a denial of service. The decoded string is returned to the application as a name or remark, so a short controlled over-read could in principle reach the caller, but the demonstrated behavior is a crash well before any value is returned.

FINDING 2 effect. A heap out-of-bounds write of decoded NDR records into an allocation that wrapped to a size smaller than one element. The written bytes are decoded pointer and integer fields, not fully attacker-chosen content, and the number of records is bounded by the bytes the server sends. This is memory corruption with limited control of the written data.

Not established. No instruction-pointer control, no confirmed information disclosure to the application, and no code execution. Severity is proposed Medium.

**Where exactly:** `lib/dcerpc.c:1045` for FINDING 1 and `lib/dcerpc.c:918` for FINDING 2.

FINDING 1, compute the bound in a width that cannot wrap and reject a count larger than the declared `max_count`:

- if (*offset + s->actual_count * 2 > iov->len) {

+ if (s->actual_count > s->max_count) {

+ if ((uint64_t)*offset + (uint64_t)s->actual_count * 2u > iov->len) {

FINDING 2, reject an element count that does not fit the and compute the allocation size in a width that cannot wrap:

- if (carray->max_count && carray->data == NULL) {

- carray->data = smb2_alloc_data(

- dcerpc_get_smb2_context(ctx),

- dcerpc_get_pdu_payload(pdu),

- carray->max_count * elem_size);

+ if (carray->max_count && carray->data == NULL) {

+ if ((uint64_t)carray->max_count * (uint64_t)elem_size > iov->len) {

+ carray->data = smb2_alloc_data(

+ dcerpc_get_smb2_context(ctx),

+ dcerpc_get_pdu_payload(pdu),

+ (size_t)carray->max_count * (size_t)elem_size);

Short term, do the two multiplications in 64-bit and keep the result below `iov->len` before any allocation or read. Medium term, cap NDR counts against the remaining size, which bounds both the string length and the array length to real bytes on the wire. Defense in depth, the unreleased `libdcerpc` refactor already carries the 64-bit guard and the `actual_count > max_count` check for the string decode, so shipping that path, or backporting the two checks, closes both bugs.

- Distinct from CVE-2025-57632, a heap out-of-bounds write in `smb2_add_iovector` for chained PDUs. That bug is in the iovec assembly of chained responses, not in the DCE/RPC NDR decoder.

- Distinct from CVE-2026-110712, CVE-2026-110713, and CVE-2026-110714, which are out-of-bounds reads and an ACL size underflow in the QUERY_INFO decoders (`smb2_decode_file_all_info`, the file system info decoders, and `decode_acl`). Those are different files and functions from `lib/dcerpc.c`, and the root cause here is a 32-bit multiply overflow in a bounds check rather than a missing check or a subtraction underflow.

- No public advisory names `dcerpc_decode_utf16` or `dcerpc_carray_coder` at the time of writing.

- Pinned source, latest release `libsmb2-6.2` at commit `d67e213a5c4e7e4969fd81f0b95e4ca5831fbba1`:

-

-

-

-

- Why mainstream fuzzing did not reach it. The trigger is a structurally valid DCE/RPC `NetrShareEnum` response carried inside an SMB2 IOCTL on a named pipe, which needs a bound srvsvc interface and a well-formed NDR frame before the one overflow field matters. libsmb2 fuzzing targets the raw SMB2 decoders, not the srvsvc client path, so the NDR string and array counters were not exercised.

Reported by HaiND (Nguyen Dinh Hai), Post and Telecommunication Institute of Technology, [github.com/Haind03](