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_strict400s..//\%in Host. - 1.4.85
%5Cparseopt. 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.
- GET
/index.html-> 200public-index, no witness. - GET
/../windows/win.ini-> 404, simplified/windows/win.ini. - GET
/..%20/windows/win.ini-> decoded/.. /windows/win.ini, simplify kept the space, Unix ENOENT, Win32 resolved/srv/windows/win.ini, 200 witness.
Replay:
git clone https://github.com/abraxas/lighttpd-win32-dotdot-space
cd lighttpd-win32-dotdot-space/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it.
SUCCESS lighttpd-win32-dotdot-space how=gadget win32-get=200 unix-open=enoent simplify-kept-dotdot-space=yes LIGHTTPD-WIN32-DOTDOT-SPACE-WITNESSWrong 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
- Proof of concept: abraxas/lighttpd-win32-dotdot-space · lighttpd-win32-dotdot-space-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · gadget.c - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-22
- Tree: lighttpd/lighttpd1.4 lighttpd-1.4.85 (
4852959e67ef51ea68084984400d3264b854c97d) ·src/buffer.c·src/response.c·src/fs_win32.c - Nearby patched (not this leftover): CVE-2025-12642 · 1.4.85
%5Cparseopt · CVE-2018-19052 · CVE-2014-2324