Skills
Nov 08, 2026

Anatomy of a File Upload Vulnerability: From WebShell Intrusion to Defense in Depth

In early November, during a routine check, we found .php files sitting in our avatar upload directory — files that should never have existed there. The access logs showed they had been requested multiple times two days earlier, all returning 200. An attacker had turned our file upload feature into a WebShell and run commands on the server. This post is a full retrospective: how it happened, how we found it, how we fixed it, and what we learned.

⚠️ The short version: the root cause was a single sentence — the upload validation trusted information supplied by the client. The browser said "I'm an image", and the code believed it. The file name said ".jpg", and the code believed that too. All an attacker had to do was set the MIME type to image/png and name the file shell.php.

1. Timeline

  • Nov 6–7: The attacker uploaded several PHP files disguised as images through the avatar upload endpoint. They landed in uploads/avatars/.
  • Nov 7: Access logs show repeated requests to those PHP files, all 200 — the WebShell was live and executing.
  • After that: Running as the web server user (www), the attacker read files outside the web root and a local database copy, and attempted to establish persistence.
  • Discovery: We spotted the rogue .php files while inspecting the upload directory and started incident response.

One honest caveat: logs can only prove what happened at minimum, not what happened at most. We cannot fully reconstruct everything that was taken. That is why rotating every potentially exposed secret was treated as mandatory, not optional.

2. Root cause: the code trusted the client

The vulnerable upload handler looked roughly like this (simplified):

// ❌ Vulnerable pattern (illustrative)
$ext  = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
$mime = $_FILES['avatar']['type']; // MIME reported by the browser

if (in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    move_uploaded_file($_FILES['avatar']['tmp_name'],
        "uploads/avatars/{$name}.{$ext}");
}

Two fatal assumptions:

  • $_FILES['avatar']['type'] is not detected by the server — it is declared by the browser. With curl, an attacker can set it to anything; image/png is just one -F flag away.
  • The extension came from the user-supplied file name. The content was PHP code, the name was shell.php, so the extension was php — the check never looked at what the file actually was.

Worse, the upload directory sat inside the web root with PHP execution enabled, closing the loop: upload → visit the URL → code execution. Ironically, another upload endpoint on the same site was written correctly — detecting the real type with finfo and deriving the extension from it. Same codebase, two patterns; the security level depended on who wrote that particular function.

3. The attack chain

  1. Bypass validation: Send a request whose body is PHP code, whose declared MIME is image/png, and whose file name ends in .php. Upload succeeds.
  2. Trigger execution: Visit https://site/uploads/avatars/xxx.php directly; Nginx hands it to PHP-FPM and it runs.
  3. Read laterally: As www, read files outside the web root and a local database copy.
  4. Attempt persistence: Afterwards we found suspicious accounts created in the same window, plus an unfamiliar key in the server's SSH authorized_keys file — all treated as compromised and reviewed one by one.

The attacker also exploited a second low-hanging fruit: timestamped backup files (.bak.<timestamp>) were publicly downloadable, including a backup of the configuration file. The old Nginx rule only blocked names ending in .bak; the timestamped variants slipped through. Backups are attack surface too — easy to overlook.

4. Incident response, in six steps

  1. Quarantine: Move all 9 suspicious PHP files out of the web root for forensics (with a SHA256 manifest). The original URLs immediately returned 404. One turned out to be a PNG misnamed .php; it was quarantined too.
  2. Fix the upload flaw: Rewrite validation (see §5) and deny PHP execution in upload directories at the Nginx layer.
  3. Remove public backups: Archive all 38 .bak / .yfbk files, delete them from the web root, and tighten Nginx so timestamped backups and chat_data.json return 404.
  4. Rotate secrets: Database password and application encryption key rotated; settings encrypted with the old key were re-encrypted and round-trip verified; the old DB password confirmed dead. Keys stored in plaintext in the database (third-party API keys, SMTP password) must be re-issued at their providers — that cannot be done server-side.
  5. Sweep for persistence: Grep the whole codebase for suspicious patterns (eval, base64_decode($_GET…), etc.) — zero hits. Cron jobs checked (only the panel's certificate renewal), admin accounts checked (only one). Suspicious accounts and SSH keys logged for owner confirmation before removal.
  6. Verify: A PHP file disguised as an image is now rejected; a real PNG named .php is stored as .png; a test PHP file in the upload directory returns 404 while real images still return 200; homepage and core features normal.

5. The fixed code

Two principles: trust only what the server itself detects; let the detected type decide the extension.

// ✅ Fixed pattern (illustrative)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime  = finfo_file($finfo, $tmpPath); // server-side detection

$allowed = [
    'image/jpeg' => 'jpg',
    'image/png'  => 'png',
    'image/webp' => 'webp',
    'image/gif'  => 'gif',
];
if (!isset($allowed[$mime])) {
    throw new Exception('Unsupported file type');
}
if (getimagesize($tmpPath) === false) {
    throw new Exception('Not a valid image');
}
$ext = $allowed[$mime]; // extension decided by the real type
move_uploaded_file($tmpPath, "uploads/avatars/{$name}.{$ext}");

Fixing the application layer is not enough. The upload directory itself must be non-executable by default, enforced at the web server layer:

# Deny PHP execution under uploads
location ^~ /uploads/ {
    location ~ \.php$ { deny all; }
}

The two layers complement each other: validation stops the wrong file from being stored; the Nginx rule makes sure that even if one slips through, it cannot execute. Either layer alone is not enough — that is what defense in depth means.

6. Defense-in-depth checklist

If you run a site with file uploads, check yourself against this list:

  • Validate content, not client claims: Use finfo on the file header plus getimagesize as a second opinion. Never use $_FILES['type'] or the original file name extension for security decisions.
  • Let the server decide the extension: Derive it from the detected MIME type and randomize stored file names; never keep the user-supplied name.
  • Upload directories are non-executable by default: Deny script execution at the web server layer; better still, store uploads outside the web root and serve them through the application.
  • Separate uploads from the main site: Use a separate domain or object storage where possible, isolated from the main site's cookies and privileges.
  • Limit size and frequency: Cap file size, total storage, and upload rate to prevent storage exhaustion and mass probing.
  • Keep backups out of the web root: Store them off-site or in an inaccessible directory; make sure server rules cover .bak.<timestamp> variants, not just names ending in .bak.
  • Make secret rotation a process: On any suspected exposure, rotate database passwords, encryption keys, third-party API keys, and SMTP credentials; move plaintext-stored keys to encrypted storage or environment variables.
  • Run with least privilege: The PHP-FPM / web user should read only what it must; the database account should have only the permissions it needs.
  • Monitor and alert: Watch for executable files appearing in upload directories, accounts created at odd hours, and changes to authorized_keys. The shorter the detection window, the smaller the damage.

7. Lessons learned

  1. Never trust client-supplied information for security decisions. MIME types, file names, and extensions are request parameters the attacker fills in — not facts.
  2. Treat the upload directory as untrusted by design: non-executable, no directory listing, isolated from the main site.
  3. One upload path for the whole site. We had one secure implementation and one vulnerable one. A single, audited, shared upload function beats fixing endpoints one by one.
  4. Backups are attack surface. One timestamped .bak can leak an entire site's configuration; backup policy deserves the same security review as upload policy.
  5. Order matters in incident response: isolate first, then fix the hole, then clean up exposure, then rotate secrets, then hunt for persistence — and verify every step. "I think it's fixed" is the most dangerous state.

This incident caused no confirmed financial loss, but it was a reminder: most breaches are not the result of a brilliant attacker, but of an ordinary-looking feature that was never written securely in the first place. If your site has a file upload, go look at its validation logic today — is it checking the real type detected by the server, or the type the browser claims?

Comments

0 条评论 · 评论需审核通过后显示

加载评论中...

发表评论

0/2000

← Back
🔍
客服图标
微信客服图标
AI 助手
×

微信客服

微信二维码