Skip to content

fix(security): bounds-check untrusted shmem offsets in port and libunit#21

Open
andypost wants to merge 2 commits into
masterfrom
claude/audit-pr-b-shmem-bounds
Open

fix(security): bounds-check untrusted shmem offsets in port and libunit#21
andypost wants to merge 2 commits into
masterfrom
claude/audit-pr-b-shmem-bounds

Conversation

@andypost

@andypost andypost commented May 8, 2026

Copy link
Copy Markdown
Owner

Summary

Audit-driven shared-memory bounds-check pass (PR-B from security-audit.md / PR #10). CVE-track — bundles five findings clustered around the "trust untrusted-input-from-shmem-peer" theme.

Findings addressed

  • V5 [High] chunk_id no bounds check (port_memory.c)
  • V5 [High] chunk_id + nchunks overflow (port_memory.c)
  • V5 [Medium] TOCTOU on shmem mmap_id lookup (port_memory.c)
  • V10 [Medium] response field count*size overflow (nxt_unit.c)
  • V10 [Low] sptr offset dereferenced without bounds (nxt_unit.c)

Cross-cutting helper

File-static helper nxt_port_mmap_chunk_range_valid in src/nxt_port_memory.c and two siblings in src/nxt_unit.c (nxt_unit_response_buf_size, nxt_unit_sptr_in_buf). None exported through public headers; libunit ABI unchanged (nxt_unit.h, nxt_unit_sptr.h, nxt_unit_request.h, nxt_unit_field.h struct layouts and inline-helper bodies untouched).

Notes on the TOCTOU fix

nxt_port_get_port_incoming_mmap() had a real lookup-vs-deref window: the function released process->incoming.mutex before returning, then the caller dereferenced mmap_handler->hdr and only afterwards bumped the existing atomic refcount. The fix bumps the (existing) use_count refcount under the mutex inside the lookup — no new refcount field invented. The sole caller adopts that reference; the previously-explicit handler_use(+1) at the buf-parent assignment site is now removed, and every error path between lookup and buf creation drops the lookup ref.

Test plan

  • ./configure --openssl && make builds unitd clean under -Werror.
  • nxt_unit.c compile-checked standalone with stock CFLAGS (-Werror).
  • pytest-3 --collect-only test/ collects 898 tests (imports parse).
  • Full root-required pytest run — not exercised in the worktree; needs sudo pytest-3 test/ in a normal checkout.
  • Targeted libunit smoke (Python WSGI / PHP) from a runtime-equipped environment to exercise the new sptr validation on real router→app traffic.

Upstream

Same fixes apply to freeunitorg/freeunit; will forward after merge. Maintainer should consider disclosure timing — V5 chunk_id bounds is the closest to RCE-shaped finding in this bundle.

Generated by Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several security hardening measures, including bounds-checking for shared-memory offsets, addressing a TOCTOU vulnerability in mmap handler refcounting, preventing integer overflows during response buffer allocation, and validating string pointers (sptrs) in incoming requests. A critical issue was identified in the nxt_unit_sptr_in_buf function, where the bounds check for the sptr struct itself is insufficient, potentially allowing an out-of-bounds read when accessing its members.

Comment thread src/nxt_unit.c Outdated
Comment on lines +264 to +266
if (sptr_off > buf_size) {
return 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The current check is insufficient to prevent an out-of-bounds read. It does not ensure that the entire nxt_unit_sptr_t struct is within the buffer before its members are accessed. For example, if sptr_off is equal to buf_size, this check passes, but sptr would point just beyond the buffer, and accessing sptr->offset would be an out-of-bounds read. A similar issue occurs if sptr is only partially inside the buffer.

To fix this, you must validate that the sptr struct itself is fully contained within the buffer.

    if (sptr_off > buf_size - sizeof(nxt_unit_sptr_t)) {
        return 0;
    }

Audit-driven hardening pass clustered around the trust boundary where
one process writes a struct into shared memory and another process
reads it.  Five findings (security-audit.md V5/V10), one helper per
file, no public-API or libunit-ABI changes.

V5 [High]   chunk_id no bounds check (src/nxt_port_memory.c:698 on
            master @ 7b12696).  Peer-supplied chunk_id was passed to
            nxt_port_mmap_chunk_start() without checking against
            PORT_MMAP_CHUNK_COUNT, yielding an OOB pointer.  Reject in
            nxt_port_mmap_get_incoming_buf() before pointer arithmetic.

V5 [High]   chunk_id + nchunks past mapped region (line 701).
            nchunks is computed from untrusted mmap_msg->size; a peer
            could land b->mem.end past the mapped segment.  Combined
            with #1 above into a single overflow-safe range check
            (chunk_id + nchunks <= PORT_MMAP_CHUNK_COUNT, written as
            nchunks <= PORT_MMAP_CHUNK_COUNT - chunk_id after the
            chunk_id bound holds).

V5 [Medium] TOCTOU on shmem mmap_id lookup (lines 676-678).
            process->incoming.mutex was released between locating
            mmap_handler and the caller's first dereference of
            mmap_handler->hdr; a concurrent peer-side close could free
            the handler in that window.  nxt_port_get_port_incoming_mmap
            now bumps the existing atomic refcount under the mutex; the
            sole caller adopts the reference (the redundant explicit
            handler_use(+1) at the buf-parent assignment site is
            removed) and releases it on every error path.

V10 [Medium] max_fields_count * sizeof(field) + 2 overflow on response
            (src/nxt_unit.c:2049-2051, 2128-2130).  Application-supplied
            counts/sizes were multiplied without overflow checking;
            wrap-around produced an undersized buffer that subsequent
            field memcpy()s overran.  New file-static helper
            nxt_unit_response_buf_size() computes the size with
            UINT32_MAX-bounded checks at both multiplication and
            addition; nxt_unit_response_init/realloc reject overflow
            with a libunit error.

V10 [Low]   sptr offset dereferenced without bounds (line 1329 etc.).
            Every nxt_unit_request_t sptr is now validated at
            request-arrival time inside nxt_unit_process_req_headers()
            before any consumer dereferences it.  New file-static
            helper nxt_unit_sptr_in_buf(sptr, length, buf, size)
            performs the underflow-safe check; one-shot validation
            keeps the per-deref hot paths and the libunit ABI
            unchanged.

Cross-cutting: file-static helpers nxt_port_mmap_chunk_range_valid()
in src/nxt_port_memory.c and nxt_unit_sptr_in_buf() /
nxt_unit_response_buf_size() in src/nxt_unit.c.  None exported
through public headers; nxt_unit_sptr.h, nxt_unit_request.h,
nxt_unit_field.h, and nxt_unit.h struct layouts are unchanged.

Builds clean with ./configure --openssl && make under -Werror.
nxt_unit.c also compile-checked standalone with the stock CFLAGS.
pytest-3 --collect-only test/ collects 898 tests; root-required
runs were not exercised in the worktree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@andypost andypost force-pushed the claude/audit-pr-b-shmem-bounds branch from e855da4 to d010e20 Compare May 11, 2026 21:28
@andypost

Copy link
Copy Markdown
Owner Author

Gemini's [security-critical] finding addressed in d010e207: nxt_unit_sptr_in_buf now checks sptr_off > buf_size - sizeof(nxt_unit_sptr_t) before dereferencing sptr->offset, so the struct itself is fully bounded inside the buffer before any member access. Force-pushed; build clean.

@andypost

Copy link
Copy Markdown
Owner Author

Coming back to this with three coverage gaps surfaced by a parallel-POV review of the integration branch (audit-1.35.6, HEAD f44897a). All three sit squarely in PR-B's named scope — "bounds-check untrusted shmem offsets in port and libunit" — and extending this PR is the natural place rather than spinning new ones.

1. Send-side: nxt_port_mmap_at index cap (CR-class)

src/nxt_port_memory.c:46-86. The function trusts the caller's i parameter without a range cap. At i = 0xFFFFFFFF:

  • cap = port_mmaps->cap (e.g. 16)
  • if (cap == 0) cap = i + 1 skipped
  • while (i + 1 > cap): i + 1 wraps to 0, 0 > 16 is false — growth loop skipped
  • cap != port_mmaps->cap: 16 != 16 is false — realloc skipped
  • return port_mmaps->elts + i → returns a pointer 4 GB past elts

The caller at line 261 (nxt_port_get_port_incoming_mmap) writes port_mmap->mmap_handler = mmap_handler to that OOB pointer. hdr->id is read from a peer-mapped region; a worker that maps its own region with hdr->id = 0xFFFFFFFF and sends the fd to main triggers a wild write inside the privileged process.

The nxt_port_mmap_chunk_range_valid helper this PR already introduces handles (chunk_id, nchunks) pairs but doesn't cover the mmap-region index used by nxt_port_mmap_at. Same shape, different consumer.

Suggested shape:

static nxt_port_mmap_t *
nxt_port_mmap_at(nxt_port_mmaps_t *port_mmaps, uint32_t i)
{
    uint32_t  cap;

    /*
     * Peer-supplied indices flow into this function via hdr->id on the
     * receive path.  Cap the index against a generous ceiling so a
     * forged hdr->id of 0xFFFFFFFF cannot wrap the growth loop and
     * return an out-of-range pointer.
     */
    if (nxt_slow_path(i >= NXT_PORT_MAX_MMAPS)) {
        return NULL;
    }

    cap = port_mmaps->cap;
    ...
}

Pick NXT_PORT_MAX_MMAPS generously (e.g. 1u << 20); the real bound is a Unit deployment characteristic, not a hard cap.

2. libunit write-side chunk validation

src/nxt_unit.c. The receive side now validates incoming sptr offsets via nxt_unit_sptr_in_buf (good). The write side — where libunit composes responses and stamps chunk_id into shared memory before notifying the router — uses the same (chunk_id, nchunks) shape but lacks the equivalent guard. The DedSec POV flagged a memset(0xA5) poison pattern reachable through a forged chunk index on the write path.

I haven't read this one line-by-line yet, so this needs your verification before fixing. Specifically: trace every site in nxt_unit.c that writes a chunk_id / nchunks into a nxt_port_mmap_msg_t (or similar). If the index comes from libunit-internal state, fine; if it can be influenced by app code, it needs validation.

If confirmed, the fix is the same shape: call nxt_port_mmap_chunk_range_valid (or the libunit-side equivalent) before the index is committed to shmem.

3. Response-side sptr validation

src/nxt_unit.c. This PR added nxt_unit_sptr_in_buf on request parsing (10 fields in nxt_unit_process_req_headers). The DedSec POV flagged a heap-disclosure path through response sptrs: an app builds a response whose sptr offsets encode arbitrary values, the router copies the encoded region out of router-process heap onto the wire, the client sees router secrets (TLS keys, other tenants' state).

Need to trace every site that builds a response sptr and verify the offset is bounded against the response buffer's [start, end) window. Likely candidates:

  • nxt_unit_response_init field append paths
  • nxt_unit_response_add_field / add_content
  • nxt_unit_response_realloc sptr re-anchoring

Suggested helper: nxt_unit_response_sptr_in_buf(sptr, resp_start, resp_end) mirroring the existing request-side helper at line 254.

Why extend this PR rather than spin new ones

All three are "peer-supplied index into IPC structures, no bounds check before pointer arithmetic" — the named scope of PR-B. Splitting them across separate PRs would fragment the review (and the eventual CVE if these become reportable). Squashing them in here keeps the bundle coherent.

Happy to push the nxt_port_mmap_at patch as a separate commit on this branch if you want to take the libunit work yourself, or to do the full thing — say the word.


Generated by Claude Code

Two follow-ups that sit squarely inside this PR's named scope —
"bounds-check untrusted shmem offsets in port and libunit" — surfaced
during integration audit on the post-merge codebase.

1. nxt_port_mmap_at() index cap (src/nxt_port_memory.c:47)

   When called from the receive path, the i parameter is hdr->id of a
   peer-mapped shared-memory region.  A forged value of 0xFFFFFFFF
   wraps (i + 1) to 0 in both the initial cap assignment and the
   growth loop condition, skips realloc, and returns
   port_mmaps->elts + 0xFFFFFFFF — an out-of-array pointer the caller
   then writes through (nxt_port_get_port_incoming_mmap stores
   mmap_handler at that offset).  Worker-to-main wild write at root.

   Cap i against a generous ceiling (PORT_MMAP_MAX_REGIONS = 1 << 20)
   at function entry.  Symmetric with the (chunk_id, nchunks) helper
   this PR already introduces; the chunk_id helper covers the
   receive-path chunk arena, this cap covers the mmap-region index
   that flows through the same receive path one level up.

2. Response-side sptr validation in the router (src/nxt_router.c:4221)

   The router resolves response field sptrs (name / value) and the
   piggyback content sptr via nxt_unit_sptr_get() without bounds
   checking the embedded offset against the response buffer.  A buggy
   or malicious worker can encode arbitrary offsets and have the
   router serialise bytes from outside the response buffer (within the
   same mmap region) onto the wire — a cross-request bleed inside the
   worker's own shmem area.

   This PR already added nxt_unit_sptr_in_buf() for the request-side
   sptrs on the libunit side.  Move that helper from nxt_unit.c into
   nxt_unit_sptr.h so the router can use it too, then call it for
   each f->name / f->value / resp->piggyback_content sptr at the
   router's response-resolve site.

   No new helper: same shape, same underflow-safe pattern, same
   constant-side subtraction.

The libunit write-side memset(0xA5) poison-fill site flagged
alongside these by parallel review is intentionally not addressed
here: it executes inside the worker process, with both source and
length owned by the worker itself, so it does not cross a trust
boundary.  Filing as a hardening backlog note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants