One upload, full control: the PHP web shell in vibe-coded apps
The most common way an AI-built app gets owned is the humblest feature it ships: the file upload. Here is the whole chain — a profile-picture field that accepts a .php file and runs it — with the attack, how to detect it, and the four fixes that close it, demonstrated end to end on Kali Linux.
Asghar Ali · Chief Technology Officer, Kakushin.io LTD
This is the first in a short series where I take a realistic, AI-built app, break it the way an attacker would, and then fix it — all against my own lab, nothing pointed at anyone else's systems. I picked the file upload to start, because across the apps I review it is the single feature most likely to turn into a full server takeover, and the reason is almost always the same omission.
A model gives you the feature you asked for. It rarely gives you the question a security engineer asks on reflex: what if this input is not what it claims to be? Nowhere is that gap cheaper to exploit than "let users upload a profile picture."
The feature that ships in every app
Ask an AI to add an avatar upload and you tend to get this: take the file, save
it to an uploads/ folder, show it back. It works on the first try, so it ships.
// upload.php — the happy path, and nothing else
$name = $_FILES['avatar']['name'];
move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/' . $name);
Read it twice. It takes the filename the user supplied and writes the file, unchanged, into a directory the web server will happily execute. There is no check that the thing is an image. That one missing check is the entire vulnerability. It is the same family of omission I wrote about in the security risks nobody checks in vibe-coded apps: generated code implements the happy path and skips the adversarial one.
The attack: one line of PHP
An attacker does not upload a picture. They upload a tiny program — a web shell:
<?php system($_GET['cmd']); ?>
That single line runs whatever you put in the URL as a command on the server.
Save it as shell.php, upload it through the avatar field — the app accepts it —
then open it in a browser:
http://the-app/uploads/shell.php?cmd=id
The server does not display the file. It runs it:
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)
From there, every command is a URL away: whoami, uname -a,
ls -la, cat /etc/passwd. Source code, database credentials, API keys,
environment files, customer data — anything the app's user can read, the attacker
can now read. One upload, full control. And to your access logs, nearly all of it
looks like ordinary traffic.
Why is the upload so dangerous? Two "no"s have to fail at once: the server lets a
.php file in where it expected an image, and it is willing to execute files
from that folder. A secure app blocks both. The vulnerable one blocks neither.
How to detect it
You do not need expensive tooling — you need to know where to look.
Audit the uploads folder. It is for images. If it contains a .php,
.phtml, .phar, or a double extension like avatar.jpg.php, that is not a user
mistake — it is an intrusion.
find ./uploads -type f \( -name '*.php*' -o -name '*.phtml' -o -name '*.phar' \)
Watch the access logs. Legitimate users request images from that folder;
they do not pass commands to them. A request to a file inside uploads/ carrying
a query string — especially cmd= — is a screaming red flag.
grep -E 'uploads/.*\.(php|phtml|phar)' access.log
If you log and alert on one thing, alert on that.
How to prevent it — four fixes
Here is the same feature built defensively. Each line is doing a job.
// upload.php — defended
$f = $_FILES['avatar'];
// 1. Verify the real type by CONTENT, not by name.
$info = getimagesize($f['tmp_name']); // false if it is not a real image
if ($info === false) { http_response_code(422); exit('Only PNG, JPG or WEBP images are allowed.'); }
// 2. Allow-list the types you support; reject everything else by default.
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
if (!isset($allowed[$info['mime']])) { http_response_code(422); exit('Only PNG, JPG or WEBP images are allowed.'); }
// 3. The SERVER chooses a random filename. Never trust the user's.
$ext = $allowed[$info['mime']];
$name = bin2hex(random_bytes(16)) . '.' . $ext;
// 4. Store OUTSIDE the web root, so nothing here is directly reachable or executable.
move_uploaded_file($f['tmp_name'], __DIR__ . '/../storage/avatars/' . $name);
And serve it back through a script that forces a safe content type, so even a file that somehow slipped through can never run as code:
// avatar.php
header('Content-Type: image/png'); // never text/html, never PHP
header('X-Content-Type-Options: nosniff'); // stop the browser guessing it is HTML
readfile(__DIR__ . '/../storage/avatars/' . $safeName);
In plain English:
- Check what the file is, not what it's called.
getimagesize()reads the actual bytes. Renameshell.phptoshell.pngand it is still caught, because the contents are not an image. - Allow-list, don't block-list. You will never out-guess every dangerous extension; name the handful you accept and reject the rest.
- Let the server name the file. A random, server-chosen name means the attacker never controls what their upload is called or where it lands.
- Store outside the web root and serve through a script. Even if something gets through, the server has no path to execute it, and the content type is pinned to an image.
Block the bad upload, and neutralise anything that gets through. Two layers, not one. This is exactly the kind of thing a second pair of eyes catches quickly — the whole point of application security best practices and a proper code audit.
The takeaway
The PHP web shell is not exotic. It is one of the most common ways AI-built apps get owned, and it traces back to a single question the model was never prompted to ask: what if this file isn't what it claims to be? Ask that of every input your app accepts, and you have closed the door most attackers try first.
FAQ
What is a PHP web shell?
A web shell is a small script — often a single line like <?php system($_GET['cmd']); ?> —
that an attacker uploads to a server and then calls over HTTP to run operating-system
commands. On a vulnerable file upload it turns a profile-picture field into full
remote code execution.
Why are vibe-coded apps especially exposed to this?
AI-generated code implements the "happy path" it saw most in training — take the file, save it, show it — and skips the validation a security engineer adds by habit. The result is an upload that trusts the filename and stores files in a web-reachable, executable directory.
How do I check whether my app is vulnerable?
Look for uploads that trust the user's filename or extension, store files inside
the web root, and never verify the file's real type by its content. Audit your
uploads/ folder for non-image files and grep your access logs for requests to
uploaded files that carry a query string.
What is the single most important fix?
Verify the file by its content (for images, getimagesize() or an equivalent
magic-byte check), not by its name or extension — and store uploads outside the
web root. Together those remove both halves of the attack.
This is Day 1 of a weekday series — build the vulnerable app, break it, then fix it, in the open. If you have shipped something AI-built and want it tested properly before someone else does, that is what I do.