~/blog/information-leak-in-error-pagesPOSTED
by Shady Nathan Tawfik · June 20, 2026 · 4 min

Error Pages Are An Unintentional API Documentation Endpoint

Across a mixed estate the same three leaks appeared again and again: stack traces, framework banners and absolute filesystem paths. Each one maps the internals for free.

Interactive[blog-2.0]

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.

TL;DR: Across a mixed estate the same three leaks appeared again and again: stack traces, framework banners and absolute filesystem paths. Each one maps the internals for free.

Findings

SeverityFindingEvidence
HIGHFull stack trace returned to anonymous visitorsSource context and local values included
MEDIUMFramework banner and version in generic error pagesExact platform and release identifiable
MEDIUMAbsolute filesystem paths disclosedDeployment layout and user account names
MEDIUMCatch-all routing answers every path with successNo not-found status anywhere in the application
LOWDebug tooling endpoints reachableDiagnostic pages exposed without a gate
LOWInternal hostnames in internal response headersBackend naming convention exposed

The attack path

Malformed requestFramework error handlerDevelopment mode still onTrace with source contextFramework and version identifiedKnown CVE selectedTargeted exploit rather than blind scan

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

~/code/bash bash
# 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

~/code/bash bash
# 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

~/code/bash bash
# 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

~/code/python python
# 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

~/code/nginx nginx
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:

Full stack trace returned to anonymous visitors
Framework banner and version in generic error pages
Absolute filesystem paths disclosed
Catch-all routing answers every path with success

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.

For educational purposes only. Every reproducible command targets my own lab environment (localhost), never a live system. © 2026