security
Securing PHP File Uploads: Stop Webshells and Scam Pages
Published 2026-09-09 · 8 min read
If you run an upload feature on a PHP site (profile pictures, document attachments, a media library), you've built the single most common way PHP sites get compromised. Attackers don't need a zero-day for this. They use the form you provided, exactly as designed, to place a malicious PHP file on your server and then run it.
This guide is for developers. It covers how the attack actually works and,
in detail, how to shut it down. None of this requires exotic tooling; it's
just discipline in your upload handler and a few lines of server config. The examples
assume an Apache/LiteSpeed environment (so .htaccessA per-directory Apache/LiteSpeed config file. It can change how files in that folder are served — including whether they run as code. works), which is what most
cPanelA popular web-hosting control panel for managing sites, email, databases, and files through the browser. hosting uses, with nginx equivalents noted.
How an upload turns into a hosted scam
The attack is a short, reliable pipeline:
- Find the form. Any endpoint that accepts a file is a candidate, even one that's "only for images."
- Slip a script past validation. The attacker uploads a small PHP file
(a webshell) disguised to defeat weak checks. Common tricks:
- Double extensions:
invoice.php.jpg, hoping the server runs anything containing.php. - Alternate PHP extensions:
.phtml,.php5,.pht,.phar, which many servers still execute. - PolyglotA file that is valid as two formats at once — for example a real image header followed by hidden PHP code — used to slip past naive checks. files: a real image header (like
GIF89a) followed by PHP code, so a naive "is it an image?" check passes. - A malicious
.htaccess: a file that tells Apache to execute.jpg(or any extension) as PHP, so an "image" upload becomes code.
- Double extensions:
- Execute it. The attacker visits the uploaded file's URL. If the upload directory can run PHP, the script runs with your site's permissions.
- Establish control and monetize. From there they add spam or scam pages (fake stores, phishingFraudulent pages or messages that impersonate a trusted brand to trick people into giving up credentials or payment details., pharma/SEO spam often cloaked to show only to Googlebot), send spam email, redirectA server instruction that automatically sends a browser from one URL to another. your visitors, or drop persistence like a cron job that recreates the shell after you delete it, which is why sites often get "re-hacked" days after a cleanup.
The takeaway: an upload form is only dangerous when two things are both true. It accepts a file it shouldn't, and that file can be executed. Break either link and the pipeline fails. Good defense breaks both.
Defense 1: Validate by content, not by extension
Never trust $_FILES['file']['type']; the browser sends it, and an attacker can
forge it. Determine the real type from the file's contents with finfoA PHP class that reads a file's real MIME type from its contents, rather than trusting the extension or browser-supplied type., and
whitelist a small set of allowed types:
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']);
if (!isset($allowed[$mime])) {
http_response_code(422);
exit('Unsupported file type.');
}
$extension = $allowed[$mime]; // trust THIS, not the uploaded name
Whitelist what you allow; never blacklist what you forbid, because blacklists always miss a variant. For images, go one step further and confirm the file really decodes as an image (see Defense 5).
Defense 2: Make the upload directory unable to run PHP
This is the most important control. If uploaded files can't execute, a webshell that slips through validation is just an inert file.
Best option: store uploads outside the web root. Put them somewhere like
/home/user/uploads/ that isn't served by the web server at all, and stream them
back through a PHP script (Defense 7). Files that aren't web-reachable can't be
requested, let alone executed.
If they must live under the web root, disable execution in that directory. On
Apache/LiteSpeed, place this .htaccess in the uploads folder:
# Disable PHP execution in this directory
php_flag engine off
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phar .pht
RemoveType .php .phtml .php3 .php4 .php5 .php7 .phar .pht
# As a backstop, deny direct access to any script-like file
<FilesMatch "\.(php|phtml|php[0-9]|phar|pht|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
On nginx, scope PHP to your app and refuse it under uploads:
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
Defense 3: Never let attackers upload their own config or scripts
The .htaccess trick only works if the attacker can write a .htaccess into a
directory Apache reads. Two safeguards:
- Reject dangerous filenames outright in your handler: anything named
.htaccess,.user.ini, or ending in a script extension. - Lock the rules in the parent directory, not the upload directory itself, so
an uploaded
.htaccesscan't override them. Because you control the filename anyway (Defense 4), a malicious.htaccessshould never be written in the first place; this is defense in depth.
Defense 4: Rename every file to something you control
Don't reuse the user's filename. Generate a random name and attach only the extension your content check approved. This kills double-extension and null-byte tricks in one move:
$safeName = bin2hex(random_bytes(16)) . '.' . $extension;
$target = '/home/user/uploads/' . $safeName;
if (!move_uploaded_file($_FILES['upload']['tmp_name'], $target)) {
http_response_code(500);
exit('Upload failed.');
}
Defense 5: Enforce size limits and re-encode images
Set limits both in PHP (upload_max_filesize, post_max_size) and in your
handler, so a single upload can't exhaust disk or memory.
For images, re-encode rather than trusting the upload. Loading and re-saving an image discards anything that isn't image data, including PHP hidden after a valid header, which defeats polyglot files:
$image = imagecreatefromstring(file_get_contents($_FILES['upload']['tmp_name']));
if ($image === false) {
exit('Not a valid image.');
}
imagejpeg($image, $target, 90); // clean, re-encoded copy
imagedestroy($image);
Defense 6: Set conservative file permissions
Uploaded files never need to be executable. Never use 0777. Files should be
0644, directories 0755, and owned by the account, not group- or
world-writable:
find /home/user/uploads -type f -exec chmod 0644 {} \;
find /home/user/uploads -type d -exec chmod 0755 {} \;
On shared hostingHosting where many websites run on one server and share its resources — the most affordable common option. this matters twice over: overly open permissions can expose your files to other accounts on the same server, and a compromise of one account should never be allowed to spread.
Defense 7: Serve user files safely
When you hand a stored file back to a browser, control how it's served: set the correct content-type, tell browsers not to sniff it, and force a download for anything that isn't meant to render inline.
header('Content-Type: ' . $storedMime);
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="' . $displayName . '"');
readfile($target);
For higher-risk sites, serve user uploads from a separate domain (for
example, a dedicated usercontent domain). That isolates them from your
application's cookiesA small piece of data a site stores in your browser to remember things like your login between requests. and origin, so even a file that does manage to run script
can't touch your sessionA way for a site to remember a specific visitor across multiple requests, usually backed by a cookie..
Beyond uploads: shrink the rest of the attack surface
- Disable dangerous PHP functions you don't use, via
disable_functionsA php.ini setting that switches off dangerous PHP functions you don't need, limiting what a webshell could do if one ran. inphp.ini(exec,shell_exec,system,passthru, and so on). This limits what a webshell can do if one ever lands. - Keep PHP patched. Run a supported PHP version; end-of-life versions stop getting security fixes.
- Use the web application firewall. ModSecurityA web application firewall available on most cPanel hosting. It blocks many known attack patterns before they reach your code. (available on most cPanel hosting) blocks many known upload and injection patterns before they reach your code. It's a safety net around good code, not a replacement for it.
- Log and monitor. Watch for new files appearing in upload directories and
for requests to unexpected
.phpURLs. Both are early signs of a shell.
A minimal secure upload handler
Putting the layers together:
<?php
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$uploadDir = '/home/user/uploads/'; // outside the web root
if ($_FILES['upload']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Upload error.');
}
if ($_FILES['upload']['size'] > 5 * 1024 * 1024) { // 5 MB cap
http_response_code(413);
exit('File too large.');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['upload']['tmp_name']);
if (!isset($allowed[$mime])) {
http_response_code(422);
exit('Unsupported file type.');
}
// Re-encode images to strip any embedded code
$image = imagecreatefromstring(file_get_contents($_FILES['upload']['tmp_name']));
if ($image === false) {
http_response_code(422);
exit('Invalid image.');
}
$safeName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = $uploadDir . $safeName;
imagejpeg($image, $target, 90);
imagedestroy($image);
chmod($target, 0644);
echo 'Uploaded as ' . $safeName;
This validates by content, caps size, re-encodes to defeat polyglots, renames to a value the user can't influence, stores outside the web root, and sets safe permissions. Combined with an upload directory that can't execute PHP, the webshell-to-scam pipeline has nowhere to start.
The bottom line
These compromises aren't the result of a weak hosting platform; they happen when an application accepts a file it shouldn't and stores it somewhere it can run. Both are fully within your control as the developer. Validate strictly, block execution in upload paths, rename and re-encode, lock down permissions, and serve files defensively. Do that and your upload form stays a feature instead of a door.
For the wider picture of what else targets web apps, see the most common website and server attacks and the security tools built into cPanel. Building on WordPress? Read the developer companion to this guide, hardening WordPress.
FAQs
Why is a file upload form such a common way for PHP sites to get hacked?+
Because an upload form is a direct channel for putting a file onto your server. If the form doesn't strictly validate what it accepts and the upload directory can execute PHP, an attacker can upload a small PHP script (a webshell) and then run it by visiting its URL, giving them a foothold to add scam pages, send spam, or pivot deeper. The form itself is the entry point; weak validation and an executable upload folder are what make it dangerous.
Isn't checking the file extension enough to block dangerous uploads?+
No. Extension checks are easily bypassed with tricks like double extensions (shell.php.jpg), alternate PHP extensions (.phtml, .php5, .phar), or files that carry a valid image header but contain PHP code. Extension whitelisting is one layer, but you must combine it with content-type verification, renaming files, and, most importantly, making sure the upload directory can't execute PHP at all.
What single change reduces upload risk the most?+
Making the upload directory unable to run PHP. If uploaded files can never be executed as code, because they live outside the web root, or because the server is configured not to run PHP there, then even a webshell that slips past validation is inert. That one control neutralizes the entire webshell-to-scam pipeline.
Does cPanel or my host protect me from this automatically?+
Hosting tools like ModSecurity, malware scanners, and account isolation are valuable safety nets and will catch a lot, but they sit around your application, not inside it. A poorly written upload handler can still accept a malicious file. Server security and application security are two different layers; you own the application layer, and that's where these defenses live.
Should uploaded files be stored inside the website folder?+
Ideally no. The safest pattern is to store uploads outside the web root and serve them through a PHP script that sets safe headers, so the files are never directly reachable or executable. If they must live under the web root, put them in a dedicated directory with PHP execution disabled and serve them with the correct content-type and a nosniff header.
Related reading
Register.rw vs. Bluehost, HostGator, GoDaddy, A2 Hosting, and Namecheap: Real Hosting Prices Compared
How Register.rw's cPanel shared hosting prices compare to Bluehost, HostGator, GoDaddy, A2 Hosting, and Namecheap, using renewal prices, not the discounted first-term rate.
2026-09-16 · 4 min read
Reseller Hosting Explained: Should You Resell Hosting Under Your Own Brand?
What reseller hosting actually is, who it makes sense for, and what running a small hosting business on top of it actually involves in Rwanda.
2026-09-13 · 3 min read
What Does "99.9% Uptime" Actually Mean?
How to read a hosting provider's uptime promise, what it translates to in actual downtime minutes, and why the number alone doesn't tell the whole story.
2026-09-11 · 3 min read