PHP2Shell: Your Code Was Fine, The Engine Was Not
Hello, I'm Maneshwar, and I'm building LiveReview — a blast-radius aware AI code review built for your business-critical systems. Star us to help devs discover the project, give it a try, and share your feedback to help
Hello, I'm Maneshwar, and I'm building LiveReview — a blast-radius aware AI code review built for your business-critical systems. Star us to help devs discover the project, give it a try, and share your feedback to help improve the product.
Imagine you do everything right.
You validate your inputs. You use prepared statements. You escape your output. You have a linter, a code review process, and a slightly smug Slack channel called #security-wins.
Then someone sends your server one weird HTTP request, and it runs their code instead of yours.
Not because of anything you wrote.
Because of something PHP did while cleaning up after itself.
On October 21, 2026, the PHP project shipped a security release for two critical bugs, CVE-2026-109618 and CVE-2026-109619.
The internet has already nicknamed the pair PHP2Shell, which is a great name and a terrible Wednesday.
Let's walk through what actually broke, why it is a bigger deal than "yet another CVE", and also where the scary headlines are a little ahead of the facts.
Why this one is different
PHP is not a niche language. W3Techs says 69.3% of websites whose server-side language it can identify run PHP.
WordPress, Laravel, Drupal, Magento, that one invoice portal from 2014 that nobody dares touch.
Most PHP vulnerabilities live in an application or a plugin. You patch the plugin, you move on.
These two live in the engine.
The engine is the thing underneath your code. It is the C program that parses your PHP, allocates your memory, and tears down after every request.
You can write the most boring, correct PHP of your life, and the floor below it can still have a hole in it.
[DIAGRAM PLACEHOLDER] the stack, bottom to top: the web server (Apache or nginx), the SAPI (mod_php or PHP-FPM), the PHP engine, then your application code on top. Highlight the engine layer in red with the caption "both bugs live here, below everything you wrote". Show that an attacker's request has to pass through the engine before your code even sees it.
Both bugs are about the same unglamorous thing: memory management around a request that goes wrong.
To see why, you need to know how PHP spends its life.
PHP's life is one request long
PHP's model is famously "shared nothing".
A request comes in, PHP sets up a fresh little world for it, runs your script, then throws the whole world away.
The worker process survives, but the request's state is supposed to die with the request.
That is why PHP is so forgiving. Leak some memory in a script? Nobody cares, the request ends and the memory gets reclaimed.
Here is the happy path:
flowchart LR
A([Request arrives]):::start --> B[Request startup<br/>build per-request state]:::engine
B --> C[Run your script]:::app
C --> D[Request shutdown<br/>cleanup and free]:::engine
D --> E([Worker waits for the next request]):::start
E --> A
classDef start fill:#e9ecef,stroke:#6c757d,color:#1a1a1a
classDef engine fill:#9d8cff,stroke:#5b4bcc,color:#1a1a1a
classDef app fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
Notice that the purple boxes, startup and shutdown, are the engine's job.
Your code only owns the teal one in the middle.
Both vulnerabilities are about what happens when something goes wrong between those purple boxes, and the engine's cleanup code assumes a state that no longer holds.
PHP has a hard ceiling on how much memory one request may use, memory_limit. Hit it, and PHP gives up on the request on the spot.
"Gives up on the spot" is where the trouble starts.
Bug 1: cleaning up a house that was never built
CVE-2026-109618, the out-of-memory one.
Here is the scenario, paraphrasing the PHP Foundation's write-up and Rapid7's analysis.
PHP is in the middle of request startup. It is building that per-request state, piece by piece.
A memory allocation fails. PHP abandons the request abruptly.
But the state is only half built. Some fields are set, some are whatever bytes happened to be sitting in that memory.
Then the cleanup code runs, and it does what cleanup code does: it trusts the state it is handed.
It follows pointers that were never initialized. It frees things that were never allocated. It treats garbage as a structure.
The mental model I like: a demolition crew is sent to tear down a building.
The building was never finished, and the blueprint is a napkin with random scribbles on it.
The crew follows the napkin anyway, and demolishes whatever the scribbles point at.
Here is the shape of the bug as a deliberately simplified sketch. This is not PHP's real source, just the pattern:
// simplified illustration of the bug class, not actual PHP code
int request_startup(request *r) {
r->headers = alloc_table(); // ok
r->uploads = alloc_table(); // out of memory! bail out...
if (!r->uploads) return FAILURE;
r->hooks = alloc_table(); // never reached, r->hooks is junk
return SUCCESS;
}
void request_shutdown(request *r) {
free_table(r->headers);
free_table(r->uploads);
free_table(r->hooks); // frees a pointer nobody ever set
}
The mistake is not in either function alone. It is in the assumption that shutdown only ever runs on a fully started request.
Now here is the part that makes this one nasty.
How do you make a server run out of memory on purpose? You do not need to hack anything, you just ask nicely and badly.
Rapid7 notes that crafted multipart requests can exhaust memory even when their overall size looks like it fits inside the configured limits.
And on Apache with mod_php, the demonstrated attack happens before any of your application code runs.
That is the whole reason the "zero authentication" line is true. There is no login to bypass. Your code never got the chance to say no.
Memory corruption you can trigger with a request, in a place your app cannot guard, is how you end up with "9.2 critical" in a headline.
Bug 2: the ghost of requests past
CVE-2026-109619, the stale state one.
Same family, different flavour.
Remember that a worker process handles request after request. Some bookkeeping state is global to the worker, and is supposed to be reset between requests.
Now picture this sequence:
sequenceDiagram
participant A as Request A
participant W as PHP worker
participant B as Request B
A->>W: runs, global state points at memory M
W->>W: fatal error interrupts cleanup
W-->>W: M is freed, but the pointer to M is not cleared
B->>W: arrives on the same worker
W->>W: reads the old pointer before init resets it
Note over W: use-after-free: B is reading memory it does not own
A fatal error, like running out of memory or a timeout, can interrupt the cleanup in the middle.
The memory gets freed, but a pointer to it survives. The next request lands on the same worker, and something reads that pointer before normal initialization has a chance to wipe it.
That is a classic use-after-free. The name is the best part, because the thing you are using after it is free is not free at all, it is very expensive.
Think of a hotel room that was demolished but the key card still opens the door.
Whoever is standing there next gets to decide what is inside.
Rapid7 describes two concrete paths to reach it.
One is the PHP 8.5 fatal error backtrace feature, which is on by default and was confirmed on mod_php. The other is a session upload progress path that works on PHP-FPM, and needs an application operation to leave state behind first.
Note the pattern. Neither bug is about a missing bounds check in some parser. They are both about error paths.
The happy path of PHP has been tested for decades. The path where a request dies at the worst possible moment is the one that gets less love.
So can anyone hack 70% of the internet?
Short answer: no, and it is worth being precise, because the headline version of this story sells more than it delivers.
What is solid:
Both bugs are real and rated 9.2 (CVSS 4.0). Both can lead to code execution as the web worker's user, with no authentication.
RCE was demonstrated by researchers, against real setups, not just crashes.
mod_php is the highest risk. Rapid7 notes that for the OOM bug, mod_php runs cleanup even when startup failed. PHP-FPM kills the worker when startup fails, which blocks that specific sequence.
What is more nuanced:
"Any PHP server" is too strong. Rapid7 reviewed multiple public proof of concepts, and each one works only for a specific combination of OS, PHP version and web server. None is broadly reliable yet.
The attack complexity is rated High. Memory corruption exploits are fiddly. That is a speed bump, not a wall.
Reports of exploitation are mixed. watchTowr says it has seen exploitation in the wild through its honeypots, while Rapid7 has seen attempts but not confirmed in-the-wild compromise.
Whichever way that lands, Rapid7 also warns that a more portable technique could show up quickly.
And the exposed surface is not small. Palo Alto's Unit 42 brief cites telemetry that counts over 22 million potentially vulnerable internet-facing instances. That is a vendor number, so season to taste, but it is the right order of magnitude for "a lot".
If I had to summarize it: not the end of the web, but also not a drill.
The part that is actually uncomfortable
Here is the story behind the patch, and it is the most human part of the whole thing.
flowchart LR
A[2024<br/>Quarkslab audit,<br/>first signs of the OOM issue]:::early --> B[Fix stalls<br/>competing patches,<br/>each with trade-offs]:::slow
B --> C[Aug 2026<br/>Calif, OpenAI and Wiz<br/>report it, RCE shown]:::report
C --> D[Oct 20<br/>CVE IDs leak<br/>2 days early]:::bad
D --> E[Oct 21<br/>Chainguard images<br/>break the embargo]:::bad
E --> F[Oct 21, 14:00 UTC<br/>patched releases ship]:::good
classDef early fill:#6ea8ff,stroke:#3a6fcc,color:#1a1a1a
classDef slow fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
classDef report fill:#9d8cff,stroke:#5b4bcc,color:#1a1a1a
classDef bad fill:#ff9a5c,stroke:#cc6a2a,color:#1a1a1a
classDef good fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
Per the PHP Foundation, an external audit by Quarkslab back in 2024 led a core developer to spot the first signs of the OOM problem while fixing something else.
No exploit path was known at the time.
Then the fix stalled. Competing patches each had a cost: breaking compatibility, opening new denial of service angles, or touching lots of third-party extensions.
The Foundation's own words are refreshingly blunt: the delay was "a structural failure, caused by both the security team's decision process and its lack of resources."
Then in August 2026 the bug was reported again, with a working unconditional RCE. The CVE IDs leaked online before the release. The embargo then cracked a second time when a vendor published images that already contained the fix.
I find this oddly reassuring and deeply annoying at the same time.
Reassuring, because an open source project wrote that sentence about itself in public, and is changing its process: majority decisions, an ecosystem security team, more staff.
Annoying, because "everyone who maintains critical infrastructure is under-resourced" is not a new lesson, and PHP is carrying a very large share of the web on it.
The AI angle, and where I would push back
A lot of the commentary around this release points at one thing: AI changes who can exploit this.
The argument goes like this. Turning a subtle memory corruption bug into a working attack used to need rare skills. Now somebody with a laptop and an open-weights model can point an agent at the internet, hand it the advisory and say "go find the ones that are still vulnerable".
I think the direction is right and the details are overstated.
What is right: the cost of going from advisory to attempt is falling fast. The patch itself is a diff. A diff tells a model exactly where to look. And scanning is cheap when the scanner does not get tired.
What is overstated: the hard part of this bug is not finding targets, it is the exploit reliability. Rapid7 says the public PoCs are fragile. Memory corruption is a craft, and models are not magic yet at crafting heap layouts for your particular distro.
And there is a side of the story that gets less airtime: the defenders have the same tools.
The Foundation says it used AI-assisted scanning and validation as part of its security work, with the line that "a person makes every call and works on every patch."
So the realistic summary is not "attackers got superpowers". It is this:
The window between "patch published" and "someone is trying it on your server" is shrinking.
Your patching speed is now a security control, not an IT chore.
What to do about it
Boring answer first: update.
The fixed releases are PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35.
If you are on 8.1 or older, there are no routine upstream releases. Rapid7 notes limited source backports exist for 7.4, 8.0 and 8.1, so lean on your distribution or an LTS vendor, or finally take the upgrade you have been postponing since 2022.
Two gotchas people will trip on:
A patched package on disk is not a patched process in memory. Running workers keep the old code until they restart. Qualys specifically calls this out, so restart or gracefully reload your PHP services after upgrading.
Containers do not save you from stale base images. If your image was built last week, it is still carrying last week's PHP.
Quick check:
php -v # compare against 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
# then make sure the running workers are the new ones
sudo systemctl reload php8.3-fpm # or: apachectl graceful
If you truly cannot patch yet, the advisories list temporary mitigations. They reduce exposure, they do not fix it:
; php.ini or FPM pool config, NOT ini_set() or .user.ini (too late for these)
session.upload_progress.enabled = 0 ; closes the known session path
fatal_error_backtraces = 0 ; PHP 8.5 only
post_max_size = 8M ; and cap multipart size at the frontend too
You will see memory_limit=-1 suggested too, and here is where I have to confess the Roll Safe meme is doing real work:
It genuinely appears in the advisory guidance, set administratively so apps cannot override it. But Rapid7 is clear that an allocation failure can still reach the vulnerable path, so it narrows the window and does not close it.
Also, a server with no memory limit has its own personality. It is called "an outage".
And two things not to reach for. Rapid7 warns that RLIMIT_AS and RLIMIT_DATA can themselves trigger the vulnerable allocation failure path, and container or cgroup memory limits are an incomplete answer and can kill the whole worker pool.
Do not hide the evidence either. Silencing errors with display_errors or @ does not prevent stale state from being read, and a quiet log is not proof you are fine.
Here is the whole decision as a flowchart, for the person who has a patch window and a manager:
flowchart TD
A([Do you run PHP on a web server?]):::start --> B{Is it on 8.2.35, 8.3.36,<br/>8.4.27 or 8.5.12 or newer?}:::decision
B -- yes --> C{Did you restart or reload<br/>workers after upgrading?}:::decision
C -- yes --> D([You are patched. Go touch grass.]):::good
C -- no --> E[Restart workers now]:::action
B -- no --> F{Is it 8.1 or older?}:::decision
F -- yes --> G[Distro or LTS backport,<br/>or plan the upgrade]:::action
F -- no --> H[Upgrade to a fixed release]:::action
G --> I[Apply interim mitigations<br/>until it lands]:::warn
H --> E
classDef decision fill:#f4d35e,stroke:#b8991f,color:#1a1a1a
classDef start fill:#e9ecef,stroke:#6c757d,color:#1a1a1a
classDef good fill:#5ee6c8,stroke:#1f9c86,color:#1a1a1a
classDef action fill:#6ea8ff,stroke:#3a6fcc,color:#1a1a1a
classDef warn fill:#ff9a5c,stroke:#cc6a2a,color:#1a1a1a
The thing worth taking away
The patch is the urgent lesson. The durable one is quieter.
Your security posture includes layers you did not write and never read.
Your code can be perfect and the runtime under it can still ship a bug that was first noticed two years ago.
That is not a reason to panic about PHP specifically. Every runtime has error paths nobody loves, and the next bug of this kind will be in a different one.
It is a reason to treat runtime and base image updates as first class work, to know which version is actually running in production, and to keep the gap between "advisory" and "deployed" as small as you can.
Cleanup code, it turns out, is also code.
Maybe even the code most worth reviewing.
Your team's attention is limited, and the deluge of AI-generated code is making it harder to keep production secure and reliable without slowing you down.
I'm building LiveReview, a blast-radius aware AI code review built for your business-critical systems.
Instead of presenting every diff with equal emphasis, LiveReview scores each change by blast radius — how far its impact reaches through your call graph — so you can focus attention where it actually matters.
Spend code review effort where business risk is highest — not spread evenly across every diff.
⭐ Star it on GitHub:
HexmosTech
/
LiveReview
Risk-Aware AI Code Review for Business-Critical Systems
LiveReview: Risk-Aware AI Code Review for Business-Critical Systems
LiveReview is built for engineering teams that ship often, but also care about keeping production stable for customers. It approves some 40-50% of low-risk changes automatically, and it gives your team the tools to handle the rest:
- Understand risky changes faster.
- Fix problems in those changes, with human attention directed to the work that needs it.
- Enforce your engineering standards.
- Learn from production outcomes, such as incidents, rollbacks, and queue wait times.
- Improve the review process over time and make it more efficient.
- Connect to the major engineering tools that your team already uses.
Auto-Approve: Ship the Safe 50% Without a Human
Auto-Approve Upto 50% of Your Pull Requests, Safely. Risk scoring decides which MRs/PRs are safe enough to ship without a human, so your team only spends review time on risky and critical changes.
demo.mp4LiveReview auto-approving a low-risk change while…
Click below to try LiveReview with your codebase:
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.


