← Back to all writeups

From a Leaky Debug Error to Full Local File Read

What started as a weird error message in a file download API turned into reading /etc/passwd off a production server. Here's the full story.

lfifile-readapidebugburpsuite

Some findings land in your lap after hours of fuzzing. Some come from automated scanners running overnight. And then there are the ones that fall out of a single, curious moment where something just feels off.

This was one of those.


Setting the Scene

I was testing a web application that offered cloud-based document management — the kind of platform businesses use to store, share, and download files internally. Nothing exotic on the surface. Login, dashboard, upload files, download files.

I started with the usual recon. Mapped the endpoints, checked for IDORs, poked at the auth flow. Standard stuff.

Then I hit the file download endpoint.


Something Felt Off

The file download endpoint accepted a JSON body. Simple enough — you pass the filename and path, it returns the file as an attachment:

{
    "file_name": "report.pdf",
    "file_path": "uploads/documents/report.pdf",
    "is_flatten": "0",
    "action": "download"
}

I sent a legitimate request first — it returned the file as expected with a Content-Disposition: attachment header. Clean.

But then I tried something small. I changed file_path to a path that didn’t exist to see how the download endpoint handled errors:

{
    "file_name": "test.txt",
    "file_path": "uploads/documents/doesnotexist.txt",
    "is_flatten": "0",
    "action": "download"
}

The server returned a 500 error. Expected. But the response body was interesting — it leaked a full Python stack trace. Deep in the trace was this line:

FileNotFoundError: [Errno 2] No such file or directory: 
'/var/www/app/uploads/documents/doesnotexist.txt'

Two things immediately stood out:

  1. The server was running the app from /var/www/app/
  2. The file_path value was being passed directly into a file open call with zero sanitization

Based on the stack trace and the API behavior, I reconstructed what the vulnerable backend likely looked like. This is not the actual source code — it’s a logical reconstruction based on observed behavior:

@app.route('/api/download', methods=['POST'])
def download_file():
    data = request.get_json()

    # ⚠️ VULNERABLE: file_path comes directly from user input
    # No validation, no sanitization, no allowlist check
    file_path = data.get('file_path')
    file_name = data.get('file_name')
    action    = data.get('action')

    # ⚠️ VULNERABLE: os.path.join blindly combines BASE_DIR with user input
    # If file_path starts with "/" or "file://", it OVERRIDES BASE_DIR entirely
    # Example: os.path.join('/var/www/app', 'file:///etc/passwd')
    #          → 'file:///etc/passwd'  (BASE_DIR is completely ignored)
    full_path = os.path.join(BASE_DIR, file_path)

    # 💀 CRITICAL: Opens whatever path the attacker provides
    # No check that the path stays within the intended directory
    # At this point, full_path could be /etc/passwd, /etc/shadow, ~/.ssh/id_rsa
    with open(full_path, 'rb') as f:
        content = f.read()

    # The file content is returned directly to the attacker
    # wrapped in a download response — no questions asked
    return Response(
        content,
        headers={
            'Content-Disposition': f'attachment; filename={file_name}'
        }
    )

No `os.path.realpath()`. No allowlist check. No stripping of `../`. Just raw user input handed straight to `open()`.

---

## Going for It

If the path is being joined with `BASE_DIR` and passed directly to `open()`, path traversal is the natural next step.

I modified the request:

```json
{
    "file_name": "passwd.txt",
    "file_path": "file:///etc/passwd",
    "is_flatten": "0",
    "action": "download"
}

Sent it through Burp Repeater. Hit send.

/etc/passwd read via Burp Repeater — full server file read achieved

The response came back 200 OK.

Content-Type: application/x-download
Content-Disposition: attachment; filename=passwd.txt

And in the response body:

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

Full /etc/passwd off a production server. No authentication bypass needed. No complex chaining. Just a debug error that told me exactly where to look, and an API that trusted whatever path it was given.


Why This Worked

The root cause was a combination of two issues:

1. Debug mode left on in production

The stack trace that leaked the full file path should never reach a client in a production environment. Frameworks like Flask, Django, and Express all have debug modes that must be explicitly disabled before deployment. Here it wasn’t.

2. No path validation on user-supplied file paths

The file_path parameter was passed directly to the filesystem without:

  • Stripping ../ sequences
  • Checking against an allowlist of valid directories
  • Resolving the real path and confirming it stays within the upload directory

A simple fix would have been:

import os

BASE_DIR = '/var/www/app/uploads'

def safe_path(user_input):
    # Resolve the absolute path and ensure it stays within BASE_DIR
    requested = os.path.realpath(os.path.join(BASE_DIR, user_input))
    if not requested.startswith(BASE_DIR):
        raise PermissionError("Path traversal detected")
    return requested

Two lines. That’s all it takes.


Impact

With an arbitrary file read on the server, an attacker could:

  • Read /etc/passwd and /etc/shadow for user enumeration
  • Access application config files with hardcoded database credentials
  • Read private SSH keys from /home/user/.ssh/id_rsa
  • Exfiltrate source code and internal API keys from the app directory

In this case, the finding was reported immediately and triaged as High severity.


Takeaways

  • Debug mode in production is a vulnerability, not just bad practice. Error messages are roadmaps for attackers.
  • Never trust user-supplied file paths. Always resolve and validate against an allowlist.
  • Sometimes the best findings come from a single weird response. Stay curious.

Got questions or want to discuss the technique? Hit me up on X.