AV
6 min read

How a single file upload gave me my college's entire server

A grievance form that took any file, a debug flag left on, and one over-broad route — chained into remote code execution, the full source, the database, and the administrator's login. Found, reported, and patched.

My college runs its entire public website — admissions, notices, the grievance portal, all of it — on a single Django application. One evening I started poking at it the way I poke at most things: not to break it, but to understand how it was built. A few hours later I had command execution on the server, a copy of the source code, the full database, the administrator's cracked password, and a working bypass for their two-factor login. I could have defaced the homepage or quietly walked away with every record in it.

I did neither. I wrote everything up in a formal report and sent it to the people who run the site so they could fix it — which they did. This is the story of how one unrestricted file upload snowballed into a complete takeover, and the very ordinary misconfigurations that made each step possible.

Everything below is either already patched or redacted. No live secret, credential, or student record appears in any screenshot. This is a lessons-learned writeup, not a how-to.

The whole chain, start to finish, looked like this:

flowchart TD
    A["anonymous file upload<br/>a grievance form accepts any file"] --> B["remote code execution<br/>.aspx web shell runs on IIS"]
    B --> C["DEBUG=True leak<br/>SECRET_KEY, paths, full route map"]
    C --> D["arbitrary file read<br/>one route serves the whole project dir"]
    D --> E["exfiltrate db.sqlite3 + settings.py<br/>no authentication required"]
    E --> F["crack admin hash + forge TOTP<br/>from the leaked 2FA seed"]
    F --> G["full admin · full source · full database<br/>site defacement possible"]

1. It started with a file upload

The site has a grievance form that lets anyone attach a file. The problem: it accepted any file — no extension check, no content check, nothing. On a Windows server running IIS, that is close to the worst case, because IIS will happily execute certain server-side file types. I uploaded a tiny .aspx web shell, and the moment the server rendered it, I had a text box on a web page that ran commands on the machine behind it.

An ASP.NET web shell with its command-execution payload redacted
The uploaded web shell — a form field, and server-side code that runs whatever you type. The command-execution core is redacted here.

That is remote code execution: arbitrary commands on the server, from an anonymous upload. Everything after this point is just me being thorough.

2. DEBUG = True handed me the blueprints

Django ships a debug mode that renders a rich error page when something breaks — stack traces, settings, request data. It is a lifesaver while developing and a disaster in production, because those pages leak the application's internals to anyone who can trigger an exception. This site was running DEBUG = True in production.

A single malformed request was enough to spill a debug page listing the SECRET_KEY, the database path, the installed apps, the middleware stack, the entire URL routing table, and environment variables. One screen, and I had a map of the whole application.

The Django settings file with DEBUG=True and a redacted hardcoded SECRET_KEY
The settings file (pulled in full a step later): DEBUG=True, ALLOWED_HOSTS = ['*'], and a hardcoded SECRET_KEY (redacted). The commented-out line right beneath it — reading the key from an environment variable — is literally the fix.

3. Reading any file on the server, no login required

That route map pointed straight at the real prize. One URL pattern was wired to Django's static file server, but pointed at the project root and set up to capture any path handed to it. Translated into plain English: give it a filename, and it gives you the file — no authentication, no path restriction. So I asked it for the two files that matter most:

  • db.sqlite3 — the entire database: every user and admin account.
  • settings.py — the SECRET_KEY, email credentials, and crypto keys, in plaintext.
The downloaded SQLite database open in a database browser, 124 tables
The whole database, downloaded and opened locally — 124 tables, the complete application. (Schema view only; no records shown.)

4. From the database to the admin's account

Django stores passwords as salted PBKDF2 hashes, which is the correct thing to do. But the administrator's password was weak enough that a few minutes of Hashcat turned the hash back into plaintext.

Hashcat recovering the admin password from the leaked hash, both redacted
Recovering the admin password from the leaked hash. Both the hash and the cracked plaintext are redacted.

The account was protected by two-factor authentication — a time-based one-time code (TOTP), the kind an authenticator app produces. Normally that stops you cold even with the password. But the seed those codes are derived from was sitting in the database I had already downloaded. With that seed, generating valid codes on my own laptop was a twenty-line script — and it would keep working indefinitely.

A short Python script generating a valid admin OTP from the leaked TOTP seed, redacted
Generating a valid admin OTP locally from the leaked TOTP seed (redacted). The second factor reduced to a formality.

Password plus a self-serve code generator meant I could sign in as the administrator whenever I liked.

5. What "full admin" actually meant

Inside the dashboard I had the run of the place: edit or delete any content, tamper with records, and — since I already had code execution and the source — rewrite the running application itself. The dashboard also surfaced more SQL-injection and stored-XSS issues, each of which could have been its own writeup. At that point the tally read:

  • Remote code execution on the server
  • The complete source code
  • The entire database
  • The administrator account
  • The ability to rewrite the site itself

Confidentiality, integrity, availability — all three, gone. In a report you label that "Critical, 10/10." In plain English: whoever found this owned the site.

Why I reported it — and what actually fixes it

The entire point of learning this is to be the person who finds the bug before someone who would abuse it does. So I stopped, wrote a full report with reproduction steps and impact, and handed it to the people who run the site. It has since been fixed.

None of the fixes are exotic. Every link in this chain breaks with a boring, well-known control:

  • Validate uploads — whitelist extensions and content types, and never let a user drop an executable file into a web-served directory.
  • DEBUG = False in production — and load SECRET_KEY from an environment variable, not a hardcoded string.
  • Scope your file-serving routes — never point a public static server at the project root.
  • Keep secrets out of the database's blast radius — a leaked DB should not also surrender the keys that protect it, TOTP seeds included.
  • Patch the framework — this was an end-of-life Django carrying known CVEs.
None of this needed a clever zero-day. It was a stack of small, individually-forgivable defaults — an unchecked upload, a debug flag, one over-broad route — that lined up into a total compromise. That is almost always how it goes.

I also posted a shorter version of this on LinkedIn.

securityethical-hackingresponsible-disclosureweb