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”.

stagemeaning
S0request-target as received (GET <this> HTTP/1.1)
S1percent-decode (sometimes more than once)
S2collapse . / .. / extra /
S3map through DocumentRoot / Alias / ScriptAlias
S4Require / <Directory> / <Location>
S5open(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 denied as default; grant per <Directory> of canonical paths.
  • Prefer Alias targets 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 / ScriptAlias off 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 .%2e on patched 2.4.53 → 400 AH00126 Invalid URI
  • /outside/secret.txt via DocumentRoot → 404; Directory deny never reached via that URL (good)
  • Toy norm.c ASAN 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 LABONLY from outside/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