PHP2Shell Week: I Ran My Agents' Security Audit on My Own Site and It Found a Leak
PHP2Shell lets an anonymous request run code on a PHP server. The same week, I pointed my agents' external audit method at my own site: no PHP, but four findings, including an API that served unpublished drafts to anyone.
On October 21, 2026, PHP shipped an emergency security release for two critical flaws now known together as PHP2Shell. Both let an anonymous request end with an attacker running code on the server. Palo Alto Networks counted more than 22.6 million potentially vulnerable instances exposed to the internet, and Tenable reports that exploitation attempts started before the public disclosure.
Weeks like this one are when every site owner should look at their own stack from the outside. So I did exactly that with mine. My agents run external security audits of live sites with a dedicated skill, and I pointed that method at my own agency site the same day I was writing about PHP2Shell.
Our stack does not run PHP. The pass still found four things, and one of them was serious: the public API was handing out unpublished articles to anyone who asked, including the first draft of this very article. Below is what PHP2Shell is, how the audit method works, and what it found at home.
PHP2Shell in one table
PHP2Shell is two separate bugs in PHP itself, not in a framework or a plugin. CVE-2026-109618 is memory corruption in out-of-memory handling. CVE-2026-109619 is a use-after-free involving state left over from earlier requests. Both carry a CVSS v4 score of 9.2, and both can allow unauthenticated remote code execution with the privileges of the web worker, according to Rapid7 and CERT-EU.
| Branch | Fixed in |
|---|---|
| PHP 8.5 | 8.5.12 |
| PHP 8.4 | 8.4.27 |
| PHP 8.3 | 8.3.36 |
| PHP 8.2 | 8.2.35 |
| PHP 8.1 and older | no fix, end of life |
The last row is the one that hurts. The PHP project is not shipping fixes for 8.1 or earlier. A server on one of those branches has no patch to apply, only an upgrade.
Two days later PHP published one more advisory, CVE-2026-115560. It affects only unusually configured applications and PHP rates it an unlikely risk. The temporary mitigation is setting session.upload_progress.enabled to 0 and restarting the workers.
One caveat I want on the record: the public sources disagree on the exact disclosure time. The versions, scores and end-of-life status agree across advisories. The timeline does not, so check the official PHP advisories before you quote it.
History says the exploitation will not stop when the patch ships. CVE-2024-4577, a PHP-CGI command injection on Windows, was weaponized within 48 hours of its 2024 disclosure and a honeypot record still shows attempts as late as July 18, 2026. CVE-2017-9841, a PHPUnit test file left reachable on production servers, was the most targeted PHP flaw in a GreyNoise and Expel comparison. Old bugs keep paying because nobody updates the forgotten servers.
How the audit works
The audit skill is a written method the agent loads before it starts: six surfaces in a fixed order, a list of things it must never do, and rules for what counts as evidence. The agent's tools are ordinary ones, curl for HTTP, DNS lookups, and nothing that writes to the target. It sees only what an outside attacker sees.
The six surfaces:
p=none policy is a finding), CAA, DNSSEC, zone transfer refusal, wildcard records, subdomain takeover signs.alg:none, weak JWT secrets. The list is counted and published in the report.It refuses mass brute force, DoS and load testing, scanning hosting neighbours, social engineering, dumping a production database, and crafted parser payloads such as zip bombs. For PHP2Shell that means the audit reads the version a server announces in banners and headers and compares it with the table above. It never fires the exploit. Reading the version is the finding. Getting a shell on a production server is an incident, not a measurement.
Two evidence rules keep the report honest. Every finding quotes the real request and response. And the report lists what was not tested and why, because an untested limit must never read as a held one.
What it found on my own site
Here is the pass on our own domain, with the real exchanges.
Finding 1: the API served unpublished drafts to anyone
Surface 4 asks which endpoints answer without authentication. The blog's article list does, by design, because the public blog reads it. The question is what it returns.
GET https://api.tytarenkoagency.com/api/v1/articles?limit=5
(no Authorization header)200 OK (summarized: id, status, title)
art-902ddb37 draft PHP2Shell: 22.6 Million Exposed PHP Servers and How My Agents Audit a ...
art-243d6532 published A Verdict With an Expiry Date: ...
art-cb844212 draft Tracking and Surfacing Evaluation Metrics in an Autonomous AI Agency
An anonymous request got back every unpublished article, full text and internal quality score included. The newest entry was the first draft of this article, created minutes earlier and not yet approved. Fetching a draft directly by its id worked the same way.
The cause was a gap between two layers that each looked correct. The API has a global authentication gate that fails closed: every route needs a valid session unless it sits on an explicit public allowlist. The three article read routes are on that allowlist because the blog needs them. Once a route is public, the gate never looks at the caller, and nothing downstream did either. The public blog page itself refused to render drafts, so the site looked fine. The API underneath it did not.
The fix puts the rule where the data leaves. Anyone without a session that may work the blog now gets published articles only. A draft by id or slug now answers exactly like an article that does not exist, so the response does not even confirm the id is real. Admins, and staff who have been given the blog section, still see drafts in the admin panel, which calls the same routes.
A test drives the real application over HTTP as an anonymous caller, a staff account without blog access, an expired session, an unknown token, a blog editor and an admin. Against the old code it reports 30 failures. Then a second agent ran an adversarial pass with one job: break the fix. It tried uppercase and padded status filters, repeated query parameters, lowercase and tab-separated authorization headers, several authorization headers at once, and path variants. None of them reached a draft. It did find two gaps I had missed: the public image list for an article still answered for drafts, and staff with blog access would have been able to edit drafts they could no longer read. Both are fixed and both are in the test now.
The same requests after the fix went live, again with no Authorization header:
GET /api/v1/articles?limit=5 200 OK 33 articles, all "published"
GET /api/v1/articles?status_filter=draft 200 OK {"articles":[],"total":0,...}
GET /api/v1/articles/art-902ddb37 404 Not Found
Finding 2: the site announced its framework
Surface 3 reads the response headers.
HEAD https://www.tytarenkoagency.com/200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Powered-By: Next.js
X-Powered-By: Next.js is the same class of problem PHP2Shell turns into an emergency. A banner does not create a vulnerability, but it is the first thing a scanner matches on when a new CVE drops. Every PHP server that answers X-Powered-By: PHP/8.1.x this week is telling the internet it has no patch coming. Ours now sends no such header: the same HEAD request after the fix returns no X-Powered-By line.
Finding 3: no Content Security Policy
The same response had no Content-Security-Policy header at all. The site loads analytics, a marketing pixel, video embeds and a voice widget, and a strict script policy would need an allowlist entry for each plus nonces for the framework's own inline scripts. That is a real project, and rushing it is how you break your own site.
So the change is a baseline policy with only the directives that cannot break a page: object-src 'none', base-uri 'self', frame-ancestors 'none', form-action 'self' and upgrade-insecure-requests. Before shipping it I checked the source for plugin tags, off-site form targets and plain http:// resources. There were none. The script policy is written down as the next step, not quietly skipped.
Finding 4: DMARC set to report-only
Surface 1 reads the mail records.
_dmarc.tytarenkoagency.com TXT "v=DMARC1;p=none;..."
tytarenkoagency.com TXT "v=spf1 include:_spf.ukraine.com.ua ~all"
p=none means receiving servers are told to deliver mail that fails authentication anyway. Anyone can send convincing mail as our domain. This one I have not changed yet, on purpose. Moving to quarantine before checking which of our own senders pass alignment could send our own outreach to spam, so the next step is reading the DMARC reports first. In the report it stays a finding with its reason, which is the whole point of the not-tested section.
What the pass taught me
The serious bug was not exotic. No memory corruption, no clever chain. A route was public on purpose, and "public" quietly became "public for everything it can return". The authentication gate was right. The blog page was right. The leak lived between them.
An outside view is valuable. An external pass does not know what you meant to deploy, only what actually answers. And it is why the evidence rules matter. "The API is fine" is an opinion. A quoted request and response, before and after, is a fact someone else can check.
What to do this week
eval-stdin.php.X-Powered-By and server banners.FAQ
Is PHP2Shell a WordPress or framework bug?
No. Both flaws are in PHP itself, so any application on an affected PHP version is exposed whatever framework runs on top.
Which PHP versions fix PHP2Shell?
PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. PHP 8.1 and older get no fix because those branches are end of life.
Does the audit exploit PHP2Shell to confirm it?
No. It reads the version a server announces and compares it with the fixed releases. Running an exploit against a production server is outside the method.
Why did an authenticated API leak drafts?
The read routes were on the public allowlist so the blog could use them. Being public skipped the authentication gate, and nothing else filtered what those routes returned.
Why is DMARC still on report-only?
Tightening it before checking which of our own senders pass alignment could junk our own mail. The reports get read first, then the policy changes.
Related reading
Maksym Tytarenko
AI & SaaS Development Expert at Tytarenko AI Agency
Related Articles
Thirty-Four Minutes to Two: What pytest-xdist Exposed in a Nearly 9,000-Test Suite
pytest-xdist cut my agent fleet's CI test step from 34m40s to about two minutes. The speedup was the easy part: parallel runs exposed three classes of shared state the serial suite had been hiding.
12 min readSaaS DevelopmentTwo Green Pull Requests, One Red Main: The Stale Merge Ref
Two of my agent fleet's pull requests passed CI, then their merge turned main red and blocked every open pull request. Git saw no conflict, because there was none: one branch made a function argument required, the other added a call site that the first branch's tree never contained. A walk through the stale merge ref, the diagnosis I got wrong, the convenient fix I refused, and the merge rule I now follow.
12 min readAI & MLA Unity Editor inside the agent container: how our fleet shipped its first game
How an autonomous agent fleet got a real Unity Editor, a GPU and a licence inside its task containers, what broke on the way, and the first browser game it shipped end to end.
6 min readReady to Build Your AI-Powered Solution?
Let's discuss how we can help you leverage AI to transform your business.
Get in Touch