This is a normalization lab, not a traversal exploit. Target is Apache httpd 2.4.x in a VM with a single Alias and a Require on that alias. Goal: write down raw request URI, URI after decode, URI after /./ and /../ collapse, and filesystem path after Alias, as four columns. I do not publish a request that reads /etc/passwd. When a mapping would escape the alias prefix, the lab is configured to 403, and that 403 is the artifact.
1Figure 1. Access control that keys off the pre-normalized string is the class of bug.
2S0 request-target -> S1 decode -> S2 collapse -> S3 Alias -> S4 Require -> S5 open(2)
Lab layout
1labs/httpd_path/
2 httpd.conf
3 htdocs/index.html
4 aliased/readme.txt # the only file Alias should serve
5 outside/secret.txt # exists on disk, must stay 403
1# httpd.conf — loopback, no .htaccess
2Listen 127.0.0.1:8080
3ServerName lab.local
4DocumentRoot "/labs/httpd_path/htdocs"
5<Directory "/labs/httpd_path/htdocs">
6 Require all granted
7</Directory>
8
9Alias "/icons/" "/labs/httpd_path/aliased/"
10<Directory "/labs/httpd_path/aliased">
11 Require all granted
12</Directory>
13
14# this prefix is intentionally NOT aliased; Require denies it
15<Directory "/labs/httpd_path/outside">
16 Require all denied
17</Directory>
1$ apachectl -t -D DUMP_VHOSTS
2*:8080 lab.local (/labs/httpd_path/httpd.conf:2)
3$ ls -l /labs/httpd_path/aliased /labs/httpd_path/outside
4-rw-r--r-- 1 lab lab 12 readme.txt
5-rw-r--r-- 1 lab lab 7 secret.txt # contents: LABONLY
secret.txt is a lab marker, not a password file. I still do not want Alias to serve it.
Normalization as a table of strings
I treat path handling as a pipeline. Each stage is a string. Bugs in the 2021 class were “stage 3 saw a prefix that stage 4 did not”.
| stage | meaning |
|---|---|
| S0 | request-target as received (GET <this> HTTP/1.1) |
| S1 | percent-decode (sometimes more than once) |
| S2 | collapse . / .. / extra / |
| S3 | map through DocumentRoot / Alias / ScriptAlias |
| S4 | Require / <Directory> / <Location> |
| S5 | open(2) |
Lab captures with LogFormat "%r uri=%U file=%f":
1# 1. boring
2GET /icons/readme.txt HTTP/1.1
3 uri=/icons/readme.txt
4 file=/labs/httpd_path/aliased/readme.txt
5 → 200 body=hello icons
6
7# 2. extra slash + dot — after S2 should equal #1
8GET /icons/./readme.txt HTTP/1.1
9 uri=/icons/readme.txt
10 file=/labs/httpd_path/aliased/readme.txt
11 → 200
12
13# 3. encoded slash in a segment (does NOT become a separator on this build)
14GET /icons/foo%2fbar HTTP/1.1
15 uri=/icons/foo%2fbar
16 file=/labs/httpd_path/aliased/foo/bar # or 404 if the file is absent
17 → 404
I write those three rows before I think about ... The 2021 CVEs were about .. and encoded dots not being collapsed before Alias/Require. The notebook shows the shape, not a working bypass.
Encoded-dot examples I keep as text, and I send them only to this VM:
1GET /icons/.%2e/readme.txt HTTP/1.1
2Host: 127.0.0.1:8080
On a patched 2.4.51+ in the lab:
1127.0.0.1 - - [11/Jun/2022:13:08:02 +0800] "GET /icons/.%2e/readme.txt HTTP/1.1" 400 226
2AH00126: Invalid URI in request GET /icons/.%2e/readme.txt HTTP/1.1
400 + Invalid URI is the artifact I want. On an unpatched image I do not keep running, public write-ups said S2 failed to treat %2e as . so S3 mapped under /icons/ while S5 walked into a parent. I am not reproducing that open(2). I upgrade the package and keep the 400.
Alias vs DocumentRoot, as paths
1$ curl -sD - http://127.0.0.1:8080/icons/readme.txt | head -8
2HTTP/1.1 200 OK
3Content-Length: 12
4
5hello icons
6
7$ curl -sD - http://127.0.0.1:8080/outside/secret.txt | head -8
8HTTP/1.1 404 Not Found
9# DocumentRoot has no /outside; the Directory block on the real path is never
10# reached via URL /outside/... — good, we did not Alias it.
11
12$ curl -sD - http://127.0.0.1:8080/../outside/secret.txt | head -8
13HTTP/1.1 400 Bad Request
The 400 on literal .. in the request-target is httpd rejecting an invalid URI before mapping. That is not “traversal failed”, that is “parser said no”. Different layer from Alias.
What I check after every httpd CVE in this class:
1apache2ctl -S
2httpd -v
3# Server version: Apache/2.4.53 (lab)
4dpkg -l apache2 | awk 'NR==2{print $3}'
5# 2.4.53-1 (>= 2.4.51 is the 41773/42013 line on Debian)
Sanitized reproduction (400 / 403 / crash only)
A long path that blows a stack buffer in a toy mapper I wrote, not in httpd. I do not fuzz httpd until it executes a CGI outside docroot.
1/* norm.c — lab, mimics "decode then collapse" poorly */
2#include <stdio.h>
3#include <string.h>
4static void collapse(char *s) {
5 char out[32];
6 size_t n = strlen(s);
7 if (n >= sizeof(out)) n = sizeof(out) - 1; /* still wrong if decode expands */
8 memcpy(out, s, n);
9 out[n] = 0;
10 puts(out);
11}
12int main(int argc, char **argv) { collapse(argv[1]); }
1$ clang -fsanitize=address -g -o norm norm.c
2$ ./norm /icons/readme.txt
3/icons/readme.txt
4$ ./norm $(python3 -c 'print("/icons/"+"A"*80)')
5=================================================================
6==5501==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x[REDACTED]
7WRITE of size 32 at 0x[REDACTED] thread T0
8 #0 memcpy
9 #1 collapse /labs/httpd_path/norm.c:8
10# this is MY mapper, not a httpd 0-day. It exists so the notebook has a crash.
httpd itself, on the encoded-dot request, stayed at 400. Access log + error log are the production-shaped artifacts:
1# error.log
2[core:error] [pid 4412:tid [REDACTED]] [client 127.0.0.1:51022]
3 AH00126: Invalid URI in request GET /icons/.%2e/readme.txt HTTP/1.1
WAF noise (CRS) on the same request, which I do not confuse with a confirmed bypass:
1id "930100" Path Traversal Attack (/../)
2id "930110" Path Traversal Attack (/..)
3action: 403 (if I put nginx+CRS in front; httpd never saw it)
CGI / ScriptAlias is a different mapping
If I had enabled ScriptAlias /cgi-bin/ /labs/httpd_path/cgi/, S3 would map into an interpreter, not a static file. The 2021 write-ups that reached RCE needed that extra handler. This lab leaves CGI off:
1# not present on purpose
2# ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/
1$ curl -sD - http://127.0.0.1:8080/cgi-bin/printenv | head -5
2HTTP/1.1 404 Not Found
404 here is the posture I want on hosts that are not CGI servers. A 200 with printenv output would be an inventory finding even without traversal.
Mitigation
- Patch httpd past CVE-2021-41773 / CVE-2021-42013 (2.4.51 / 2.4.52 depending on distro; I verify the running binary, not the package changelog rumor).
Require all deniedas default; grant per<Directory>of canonical paths.- Prefer
Aliastargets that are not a prefix of other sensitive trees (/icons/→ a directory that has no..-reachable siblings you care about). AllowEncodedSlashes Off(default) unless you have a documented reason; then test S1 twice.- Do not use
<Location /icons>as the only ACL if<Directory>is what maps the file. Location keys off URL, Directory keys off filesystem. The 2021 class was the gap between those two. - CGI /
ScriptAliasoff unless needed. Several follow-on write-ups needed a mapped CGI interpreter.
1# extra belt: refuse leftover encoded dots at the proxy
2# (nginx in front of the lab)
3if ($request_uri ~* "\.\.|%2e%2e|%2e\.|\.%2e") { return 400; }
What I file after this lab
- Map:
/icons/readme.txt→/labs/httpd_path/aliased/readme.txt→ 200 - Literal
..and.%2eon patched 2.4.53 → 400AH00126 Invalid URI /outside/secret.txtvia DocumentRoot → 404; Directory deny never reached via that URL (good)- Toy
norm.cASAN overflow on 80-byte path — my bug, not httpd - Fix: patched httpd, Alias+Directory on canonical paths, encoded-dot 400 at proxy
- Out of scope: a request that returns
LABONLYfromoutside/secret.txt
Commands appendix
1httpd -v
2apachectl -t -f /labs/httpd_path/httpd.conf
3curl -sD - http://127.0.0.1:8080/icons/readme.txt
4curl -sD - http://127.0.0.1:8080/icons/.%2e/readme.txt
5grep AH00126 /var/log/apache2/error.log | tail