Episode 01 · Kill chain
CVE-2023-20198 was a critical flaw in the web UI of Cisco IOS XE, allowing unauthenticated attackers on exposed devices to create privileged accounts and persist on the box—often followed by deep lateral movement across carrier cores.
This lab is not a Cisco emulator; it’s a focused Nginx exercise in the same spirit: how a “blocked” path can still be reached when normalization, aliases, and encoding don’t line up.
ep01-lab and contain docker-compose.yml, nginx/, and html/.ep01-lab folder.docker-compose up
Leave this running. The lab listens on localhost:8080.
If you use Docker Compose V2, run docker compose up instead.
Confirm the admin route is locked down for direct requests.
curl -i http://localhost:8080/admin/index.html
location /admin and applied deny all. That’s the intended
lock — but it isn’t the whole story.
URL encoding represents characters as a
% plus two hex digits. For example, dot is %2e and slash is
%2f, so a path segment ../ can appear on the wire as
%2e%2e%2f—same traversal intent, different bytes.
This stack mounts a misaligned location /files + alias pair (classic Nginx footgun).
A URI beginning with /files can still carry .. segments that collapse into
/admin/ on disk after alias substitution — without ever matching the
/admin location first.
curl -i http://localhost:8080/files../admin/index.html
http://localhost:8080/files%2f%2e%2e%2f%2e%2e%2fadmin%2findex.html
In your ep01-lab folder, edit nginx/nginx.conf. Add a defense that rejects any URI
containing .. (the commented template below was the missing control). Align
location /files/ with a trailing slash and matching alias …/ if you keep this pattern.
Apply a change equivalent to:
--- nginx.conf (vulnerable)
+++ nginx.conf (hardened)
@@
server {
listen 80;
...
+ # Block traversal sequences anywhere in the URI
+ location ~ \.\. {
+ deny all;
+ }
+
location /admin {
deny all;
}
- location /files {
+ location /files/ {
alias /usr/share/nginx/html/;
}
-
- # Missing defense ...
+ # (optional) keep explicit deny on /admin as defense-in-depth
ep01-lab:
docker-compose restart nginx
(or docker compose restart nginx with Compose V2).
Re-run the exploit:
curl -i http://localhost:8080/files../admin/index.html
Expect 403 again — or 404 if the tightened
/files/ prefix no longer matches the poisoned URI (both outcomes mean you’ve closed the hole).
You blocked traversal, restored least-privilege path handling, and verified with curl. Next step on the chain: trust boundaries around privileged portals — think CALEA and operator consoles under pressure.