Research/lighttpd-win32-dotdot-space
0-dayNo CVEHighPublic

lighttpd 1.4.85, native Win32 GET /..%20/ file read

Native Win32 staticfile joins document-root after simplify. simplify only folds ../ when the third byte is /. GET /..%20/ survives, then Win32 strips the space per component and walks out. Lab is a gadget. No CVE yet.

Name
lighttpd 1.4.85, native Win32 GET /..%20/ file read
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
lighttpd
Affected
lighttpd 1.4.85 (4852959) native _WIN32 mod_staticfile. Unix/Linux is not this leftover. Cygwin compiles the same strip but POSIX open does not apply Win32 per-component rules.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, lighttpd, path-traversal, win32, cwe-22, file-read

The space kept ../ alive

I am @abraxas_null. The proof of concept is on GitHub: abraxas/lighttpd-win32-dotdot-space (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, gadget.c. Authorized lab only. It talks to loopback.

This is lighttpd 1.4.85 (4852959e67ef51ea68084984400d3264b854c97d). No CVE yet. CWE-22. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N). Remote file read. Unauthenticated GET/HEAD. Native _WIN32 only.

I do not have a native Win32 worker here. The lab is a gadget of buffer_urldecode_path, buffer_path_simplify (Win32 '\\' map), the response.c tail strip, and _wstati64 without \\?\. Last line uses how=gadget. Unix POSIX open is the negative control. Real /../windows/win.ini simplified to /windows/win.ini and 404'd. /..%20/windows/win.ini kept the space, Unix open was ENOENT, Win32 resolve was /srv/windows/win.ini, GET 200, body LIGHTTPD-WIN32-DOTDOT-SPACE-WITNESS.

Unix/Linux is not this leftover. File read, not RCE.

Why lighttpd

I had just finished unpublished Crit/High hunts of larger trees that came back empty on default. lighttpd is a small C server with a long history of path and trailer leftovers, a 1.4.81-1.4.85 parser wave, and a native Windows port that still uses _wopen without \\?\. That is a good place to look for a simplify that only folds ../ when the third byte is /.

I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. FastCGI/CGI/proxy/magnet/auth/TLS/WebDAV/SSI are off until included. Misconfiguration is out of bar.

Pinning 1.4.85

Tag lighttpd-1.4.85, commit 4852959, annotated tag object 0d01414, released 8 Jul 2026. Latest GA. This pin already ships 1.4.81 trailer reject (CVE-2025-12642), 1.4.82 empty trailer whitelist, 1.4.83 CGI-header-conflict / invalid backend Content-Length / li_hex2bin, and 1.4.85 FastCGI large-upload / magnet 4xx / alias order / WIN32 %5C parseopt.

Intake is security@lighttpd.net. GitHub is a collaboration mirror.

The skip list is half the hunt

On this pin the interesting ones were already closed:

  • CVE-2025-12642 trailer merge smuggling. 1.4.81.
  • CVE-2022-41556 gw_backend connection-slot leak. 1.4.67.
  • CVE-2022-37797 mod_wstunnel uninitialized handler. 1.4.66.
  • CVE-2022-22707 mod_extforward Forwarded overflow. 1.4.64.
  • CVE-2018-19052 alias. Not this leftover.
  • CVE-2014-2324 Host. Default http_host_strict 400s ../ / \ % in Host.
  • 1.4.85 %5C parseopt. Not this leftover.

Parent checks: trailer merge 400s framing/auth names. Default whitelist is NULL. HTTPoxy Proxy is not emitted as HTTP_PROXY. FastCGI/SCGI 400 on header-name conflicts. Backend Content-Length 502. Alias .. after a slash-terminated prefix is 403. HTTP/2 h2c upgrade off unless server.h2c.

I did not re-file any of that.

Twenty surfaces, one leftover

I split the tree. Request parser, CGI/FastCGI/gw_backend, proxy, magnet, TLS glue, auth, WebDAV, SSI, alias/rewrite/redirect, HTTP/2, trailers leftover of CVE-2025-12642, CGI header conflicts leftover of 1.4.83, wstunnel, extforward, vhost/userdir, range/chunked/deflate, sockproxy, staticfile/dirlisting, access/setenv/status, 1.4.81-1.4.85 patch siblings.

Default Unix/Linux 1.4.85 is NONE unpublished Crit/High. Stock static file serving already has url-normalize, %00 mapped to _, buffer_path_simplify, pathinfo-on-static rejected (1.4.80), and the 2025-2026 parser guards.

WebDAV COPY/MOVE Destination is document-root relative and is not re-run through activate/alias/auth. Module off by default. Documented. mod_cgi ignores http_cgi_headers rc; FastCGI/SCGI/SSI 400. Empty backend Content-Length is documented leniency. Default server.follow-symlink = enable is documented. Dirlisting reflected XSS is Medium and listing is off by default.

Then I opened the Win32 strip.

simplify only folds ../ when the third byte is /

buffer_path_simplify handles ./ and ../. The ../ branch wants walk[2] == '/'. %20 after .. is a space. That is not a slash. The component stays.

WIN32 strip in http_response_prepare_physical is the whole-path tail. Trailing " /" or "./" once, then all trailing spaces and dots. A non-final ..%20/ component is not the tail. /..%20/windows/win.ini stays /.. /windows/win.ini.

_wstati64 / _wopen pass UTF-16 without \\?\. Win32 strips trailing spaces per component. .. becomes ... Physical join is doc_root + rel_path with no post-stat basedir check.

The comment in response.c even says: on native windows we could prepend \\?\ to circumvent this behaviour. The code does not.

Unix/Linux: .. is a distinct name under the docroot. POSIX open of /srv/htdocs/.. /windows/win.ini is ENOENT. Cygwin compiles the same tail strip but POSIX open does not apply Win32 per-component rules. Treat Cygwin as unproven.

Default mod_staticfile is auto-loaded. Default parseopts include url-normalize and %2F decode. url-path-backslash-trans is opt-in and is not this leftover.

What I almost filed instead

Unix ENOENT as "not a bug." It is the negative control. The leftover is Win32 canonicalization after simplify kept the space.

Cygwin as native Win32. Same strip, different open.

1.4.85 %5C parseopt as this leftover. Different path.

CVE-2018-19052 alias. Different module.

Inflating file read to RCE. The worker account can open a regular file. That is a read. Class is file read.

A live native Win32 worker. I do not have one here. The gadget copies the pin functions. SUCCESS says how=gadget so nobody can pretend I booted Windows.

The lab

Faithful gadget of those pin functions. Unix POSIX open is the negative control. Win32 resolve is implemented as the same per-component trailing-space strip, then . / .., then a POSIX open of the resolved path. Loopback HTTP 127.0.0.1:19400.

  1. GET /index.html -> 200 public-index, no witness.
  2. GET /../windows/win.ini -> 404, simplified /windows/win.ini.
  3. GET /..%20/windows/win.ini -> decoded /.. /windows/win.ini, simplify kept the space, Unix ENOENT, Win32 resolved /srv/windows/win.ini, 200 witness.

Replay:

Plain text
git clone https://github.com/abraxas/lighttpd-win32-dotdot-space
cd lighttpd-win32-dotdot-space/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it.

Plain text
SUCCESS lighttpd-win32-dotdot-space how=gadget win32-get=200 unix-open=enoent simplify-kept-dotdot-space=yes LIGHTTPD-WIN32-DOTDOT-SPACE-WITNESS

Wrong turns, in the order I made them

Scoring Windows as "not High" because production is Linux. Production lighttpd is mostly Unix. lighttpd still ships a native _WIN32 port. A simplify that only folds ../ plus a tail-only strip plus _wopen without \\?\ is an incomplete path check.

Treating real /../ as the leftover. Simplify folds it. 404. The leftover is /..%20/.

Wine. The security-relevant lines are in C. Recreating the Win32 resolve on Linux is the gadget. SUCCESS says so.

A reverse shell. Theatre. The oracle is a 200 body from the parent of document-root.

What an attacker can do

Needs native Windows lighttpd serving static files. Worker account must be able to open the target regular file.

Privilege Method / sink Lab-proven or inferred
Read a file outside document-root Unauth GET /..%20/<name> Lab-proven (gadget). HTTP 200 body is the parent secret
Same class for other trailing ASCII space/dot on a non-final .. component url-decode then simplify keeps .. Lab-proven for %20. Inferred for other trailing space/dot
HEAD the same path staticfile HEAD Inferred. Lab used GET
Negative: real /../ buffer_path_simplify folds ../ Lab-proven. 404, simplified /windows/win.ini, no witness
Negative: Unix POSIX open of the unsimplified physical string open("/srv/htdocs/.. /windows/win.ini") Lab-proven. ENOENT

Closed on this leftover: Unix/Linux staticfile; Cygwin; 1.4.85 %5C parseopt; CVE-2018-19052 alias; CVE-2014-2324 Host. Not RCE. Not a write.

The fix

Strip trailing spaces and dots per path component before simplify, or prefix Win32 opens with \\?\ and reject a physical path that does not stay under document-root.

Until then, do not serve static files with native Windows lighttpd to anyone you do not already trust with the worker account's readable files.

I am not going to print a GET you can paste at someone else's :80. The missing per-component strip is the useful part. If you own the worker, run the proof of concept against loopback.

References