Guide
Forms and uploads returning 403 after PHP2Shell? Your WAF rule is too broad
By Aman Singh · Published · 7 min read
Since around 21 October 2026, forms, file uploads or checkout on your site may have started returning 403 Forbidden for no obvious reason. The code didn't change, and most of the site works. The usual cause is not a bug in your app: it is a blunt PHP2Shell mitigation rule that your host or web application firewall switched on, and it is blocking far more than the attack.
This guide explains why it happens, how to confirm it in a minute, and how to fix it properly: patch PHP so the rule isn't needed, or narrow the rule to the actual attack so your forms work again.
Want the rule fixed and PHP patched without the downtime? PHP security patching →
What's happening
PHP2Shell (CVE-2026-109618 and CVE-2026-109619) is a critical, actively exploited PHP flaw. To buy time before patching, hosts and WAF vendors published rules that block the request shape the attack uses: a POST with Content-Type: multipart/form-data. The trouble is that ordinary file uploads, and many login and contact forms, use exactly that content type. A rule that blocks all of it blocks your users too.
This is a known side effect, not a mistake unique to your host. AWS's own mitigation recipe warns that the rule "will break applications that require file uploads" and tells you to start it in count mode rather than block (AWS WAF guidance). Cloudflare's version carries the same caveat.
Confirm it's the rule, not a bug
It is almost certainly the mitigation rule if all of these are true:
- The 403s started around 21 October 2026, when PHP2Shell was disclosed, with no deploy of your own.
- Only uploads and multipart forms fail. Pages load, and forms that post as
application/x-www-form-urlencoded(many logins) still work. - The 403 comes back fast, before your app runs (no entry in your application log), and may name a firewall or rule id.
Check your access log for the pattern: a burst of 403s on POSTfrom real users' addresses, not just one scanner. The triage script below does this for you.
The fix, in order
- 1Patch PHP, then remove the rule. The rule is a stop-gap for unpatched servers. Update to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35 (or your distribution's fixed package), restart workers, and the blanket block is no longer needed. See the check-and-patch guide.
- 2Can't patch today? Narrow the rule. Replace "block all multipart" with "block multipart that contains
PHP_SESSION_UPLOAD_PROGRESS". That is the field the attack needs and that normal uploads never send (it is the same signal CISA's detection rule keys on). Your forms work again; the probe is still blocked. - 3On managed hosting? Ask support to scope their PHP2Shell rule to the upload-progress signature, or to exempt your upload and form paths, and give them the request shape from the triage script so they can see it is not the attack.
A scoped rule that doesn't break forms
The principle is the same on every WAF: match the body for PHP_SESSION_UPLOAD_PROGRESS instead of matching every multipart request. In AWS WAF, Azure and Cloudflare that is a custom rule on the request body; in ModSecurity it looks like this (illustrative; run it in DetectionOnly first and test your own uploads):
# Blanket rule (what a host often deploys): blocks EVERY multipart POST,
# so file uploads and some forms start returning 403.
SecRule REQUEST_HEADERS:Content-Type "@contains multipart/form-data" \
"id:1002600,phase:1,deny,status:403,log,msg:'block all multipart'"
# Scoped rule: block only the PHP2Shell probe, which carries the
# upload-progress field. Normal uploads do not, so they get through.
SecRule REQUEST_HEADERS:Content-Type "@contains multipart/form-data" \
"id:1002601,phase:2,chain,deny,status:403,log,msg:'PHP2Shell upload_progress probe'"
SecRule REQUEST_BODY|MULTIPART_PART_HEADERS "@rx PHP_SESSION_UPLOAD_PROGRESS" "t:none"Also make sure session.upload_progress.enabled=0 is set at server level, which removes the feature the attack relies on. The patch guide covers that and the related CVE-2026-115560, which needs the same setting.
Tell the attack from your real traffic
This small script reads an access log and summarises the blocked POSTs, and, given a captured request, says whether it was the PHP2Shell probe or an ordinary form. Use it to show a host that their rule is blocking real users, or to confirm a scoped rule is safe. Node.js, no dependencies.
#!/usr/bin/env node
// triage-403.mjs: after a blanket "block all multipart" rule starts returning
// 403s, work out which blocked requests were actually the PHP2Shell probe and
// which were ordinary forms and uploads. Point it at an access log (combined
// format) or pipe raw requests in; it reports the POSTs a *scoped* rule would
// still have caught vs the legitimate traffic a blanket rule broke.
//
// node triage-403.mjs /var/log/nginx/access.log # summarise 403s on POST
// node triage-403.mjs --body request.txt # classify one raw request
//
// The PHP2Shell signature is the session.upload_progress field in a multipart
// body (PHP_SESSION_UPLOAD_PROGRESS). Normal forms and uploads never carry it,
// so a rule keyed on it blocks the attack without breaking your site.
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const SIGNATURE = /name="PHP_SESSION_UPLOAD_PROGRESS|\bPHP_SESSION_UPLOAD_PROGRESS\b/i;
/** Classifies a raw HTTP request (headers + body) as attack-shaped or ordinary. */
export function classifyRequest(raw) {
const headEnd = raw.search(/\r?\n\r?\n/);
const head = headEnd >= 0 ? raw.slice(0, headEnd) : raw;
const body = headEnd >= 0 ? raw.slice(headEnd) : "";
const isPost = /^POST\s/i.test(head);
const ct = (/^content-type:\s*(.+)$/im.exec(head)?.[1] ?? "").toLowerCase();
const multipart = ct.includes("multipart/form-data");
const hasSignature = SIGNATURE.test(head) || SIGNATURE.test(body);
let verdict = "other";
if (isPost && multipart) verdict = hasSignature ? "php2shell-probe" : "legitimate-multipart";
else if (isPost) verdict = "non-multipart-post";
return { isPost, multipart, hasSignature, verdict };
}
/** Summarises POSTs in a combined-format access log by what a scoped rule would do. */
function summariseLog(file) {
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean);
let posts = 0;
let blocked403 = 0;
const byIp = new Map();
for (const l of lines) {
// "IP - - [date] "METHOD path HTTP/x" status size ..."
const m = /^(\S+).*?"(\S+)\s+(\S+)\s+HTTP\/[\d.]+"\s+(\d{3})/.exec(l);
if (!m) continue;
const [, ip, method, , status] = m;
if (method.toUpperCase() !== "POST") continue;
posts++;
if (status === "403") {
blocked403++;
byIp.set(ip, (byIp.get(ip) ?? 0) + 1);
}
}
console.log(`POST requests: ${posts}`);
console.log(`403 on POST: ${blocked403}` + (blocked403 ? " (a blanket multipart rule blocks real forms too)" : ""));
if (byIp.size) {
const top = [...byIp].sort((a, b) => b[1] - a[1]).slice(0, 5);
console.log("Top sources of blocked POSTs:");
for (const [ip, n] of top) console.log(` ${n.toString().padStart(5)} ${ip}`);
console.log(
"\nAccess logs do not record request bodies, so they cannot tell the attack\n" +
"apart from a broken checkout. Capture a few blocked requests (WAF sample,\n" +
"mod_security audit log) and run: node triage-403.mjs --body <file>",
);
}
}
function main(argv) {
if (argv[0] === "--body") {
const raw = argv[1] ? fs.readFileSync(argv[1], "utf8") : fs.readFileSync(0, "utf8");
const r = classifyRequest(raw);
const say = {
"php2shell-probe": "PHP2Shell probe — a scoped rule SHOULD block this.",
"legitimate-multipart": "Ordinary multipart form/upload — a blanket rule WRONGLY blocks this.",
"non-multipart-post": "Plain POST (not multipart) — a multipart rule does not touch it.",
other: "Not a POST request.",
};
console.log(`verdict: ${r.verdict}\n${say[r.verdict]}`);
return r.verdict === "php2shell-probe" ? 1 : 0;
}
if (!argv[0]) {
console.error("usage: node triage-403.mjs <access.log> | node triage-403.mjs --body <request.txt>");
return 2;
}
summariseLog(argv[0]);
return 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = main(process.argv.slice(2));
}# Summarise the POSTs a blanket rule blocked (bodies are not in access logs)
node triage-403.mjs /var/log/nginx/access.log
# Classify one captured request as the probe or a real form
node triage-403.mjs --body blocked-request.txt # exit 1 = probe, 0 = legitimateverdict: legitimate-multipart
Ordinary multipart form/upload — a blanket rule WRONGLY blocks this.Common questions
Why did my file uploads start failing with 403 in late October 2026?+
Is it safe to remove the PHP2Shell WAF rule?+
Will a narrower rule still stop the attack?+
My logins broke too. Are they affected?+
Want it fixed without the downtime?
I patch PHP to a fixed version, replace the blanket rule with a scoped one, and verify your uploads and forms work, usually within 24–48 hours. See PHP security update and upgrade, or security patching.
Sources
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call