TL;DR: Error pages are an unintentional API documentation endpoint. Across a mixed estate I found stack traces, framework banners and absolute filesystem paths, and I show the three-line fixes at the edge.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A mixed estate of several generations of web platforms audited in a single pass, including a legacy content management system, a modern framework application and a static marketing site. The focus was on what the platform tells an unauthenticated visitor when something goes wrong.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
Information disclosure is usually reported as a list of strings found in responses: a version number, a file path, a framework name. Filed that way it looks like housekeeping. The strings are the symptom. The mechanism is a general one: the platform distinguishes between the person who caused the error and the person reading the response, and for anonymous visitors it has not decided which one you are.
Three shapes recur. Development mode left on, where the framework renders a full trace with source context and local variable names. Generic server pages that still advertise the server product and version in a footer. And catch-all routes that answer every unknown path with a two hundred status and a rendered application shell, which turns a scanner from a fast filter into a slow one because nothing ever returns not found.
None of these grant access. Together they compress a multi-hour reconnaissance into a single afternoon, because the attacker no longer needs to guess framework versions, locate plugin directories or infer the deployment layout. They are handed it.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HIGH | Full stack trace returned to anonymous visitors | Source context and local values included |
| MEDIUM | Framework banner and version in generic error pages | Exact platform and release identifiable |
| MEDIUM | Absolute filesystem paths disclosed | Deployment layout and user account names |
| MEDIUM | Catch-all routing answers every path with success | No not-found status anywhere in the application |
| LOW | Debug tooling endpoints reachable | Diagnostic pages exposed without a gate |
| LOW | Internal hostnames in internal response headers | Backend naming convention exposed |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Provoke errors on your own lab and read what comes back
# target: your own lab on localhost
# 1. malformed requests that commonly trigger the debug path
$ for p in '/%' '/?q[]=' '/index.php?file=../../../../etc/passwd' '/nonexistent/deep/path'; do
printf '%s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080$p")"
done
# 2. read the body of the one that failed
$ curl -s 'http://localhost:8080/?q[]=' | grep -iE 'traceback|stack trace|\.py"|\.php"|at [A-Za-z_]+\(|File "|/var/www|/home/' | head
# 3. headers that describe the platform
$ curl -sI http://localhost:8080/ | grep -iE 'x-powered-by|x-generator|server|x-aspnet|via:'
Detection in your own estate
Run the disclosure sweep across a host list you own
# read-only sweep, one line per finding
$ for h in example.org example.com; do
printf '%s: ' "$h"
curl -sI "https://$h/" | grep -icE 'x-powered-by|x-generator|x-aspnet-version' | tr -d '\n'
printf ' banner-hits, '
curl -s "https://$h/does-not-exist-$(date +%s)" | grep -icE 'traceback|stack trace|/var/www|/home/[a-z]+/' | tr -d '\n'
printf ' trace-hits\n'
done
Detect catch-all routing that hides missing pages
# every path must answer 404, not 200
$ for p in /nope /nope/deeper /admin /wp-content/nope; do
printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.org$p)"
done
Remediation
Turn off debug output and customise the error body
# Django: never ship the debug page to production
DEBUG = False
ALLOWED_HOSTS = ["example.org"]
# Flask: use a real error handler instead of the interactive debugger
@app.errorhandler(500)
def server_error(err):
app.logger.exception("unhandled error")
return "Internal error", 500 # no trace, no paths
Serve a static error page at the edge, with no upstream detail
server {
error_page 500 502 503 504 /errors.html;
location = /errors.html { internal; root /var/www/html; }
}
# and stop advertising the platform
server_tokens off;
proxy_hide_header X-Powered-By;
proxy_hide_header X-AspNet-Version;
Once an attacker gains access, they can perform the following actions:
Takeaways
- Version strings are reconnaissance you are handing over for free.
- Development mode in production is a configuration error with a large blast radius.
- Catch-all two hundred responses break your own monitoring as well as an attacker tooling.
- Fix disclosure at the edge, where you control it, not in every application.