# PHP2Shell Week: I Ran My Agents' Security Audit on My Own Site and It Found a Leak

Author: Maksym Tytarenko | Date: 2026-10-24 | Category: Security | Tags: security, php, php2shell, security audit, ai agents, web security
Canonical: https://www.tytarenkoagency.com/blog/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](https://unit42.paloaltonetworks.com/cve-2026-109618-cve-2026-109619/) potentially vulnerable instances exposed to the internet, and Tenable [reports](https://www.tenable.com/blog/php2shell-cve-2026-109618-cve-2026-109619-frequently-asked-questions-about-critical-php-remote) 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](https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/) and [CERT-EU](https://cert.europa.eu/publications/security-advisories/2026-018).

| 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](https://thehackernews.com/2026/10/urgent-php-warns-of-another-unpatched.html), 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](https://www.greenbone.net/en/?p=49483) of its 2024 disclosure and a [honeypot record](https://db.gcve.eu/cve/CVE-2024-4577) 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](https://expel.com/blog/patch-tuesday-expels-version-april-2025/) 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:

1. **DNS and email**: SPF and DMARC policy (a `p=none` policy is a finding), CAA, DNSSEC, zone transfer refusal, wildcard records, subdomain takeover signs.
2. **Network**: open ports on every in-scope IP, banner grab only, no login attempts.
3. **Web app**: TLS and redirects, cookie flags, security headers, reflected input in error pages, leftover backups, source maps, default admin panels.
4. **API surface**: endpoints pulled from the site's JavaScript bundles, checked read-only for which ones answer without authentication; public upload and URL intake probed with benign files, one at a time.
5. **Client code and secrets**: keys and endpoints in current and archived bundles, where tokens are stored.
6. **Credentials and tokens**: one fixed list of about 20 default combinations, `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

1. **Inventory every host that runs PHP**, including staging copies and old marketing sites.
2. **Check versions against the fixed list**: 8.5.12, 8.4.27, 8.3.36, 8.2.35.
3. **Upgrade anything on 8.1 or older.** No patch is coming.
4. **Apply the CVE-2026-115560 mitigation** if your configuration matches the advisory.
5. **Remove test tooling from production**, starting with PHPUnit's `eval-stdin.php`.
6. **Stop announcing versions** in `X-Powered-By` and server banners.
7. **Ask what each public endpoint returns, not only whether it is public.** That is the question that found our leak.

## 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

- [I Gave a New AI Model a Full-Cycle Security Audit. Here Is How It Went](https://www.tytarenkoagency.com/blog/i-gave-a-new-ai-model-a-full-cycle-security-audit-here-is-how-it-went)
- [Why I Rejected an 817-Skill Security Pack and Wrote a 2-File Threat-Model Skill](https://www.tytarenkoagency.com/blog/why-i-rejected-an-817-skill-security-pack-and-wrote-a-2-file-threat-model-skill)
- [Thirty-Four Minutes to Two: What pytest-xdist Exposed in a Nearly 9,000-Test Suite](https://www.tytarenkoagency.com/blog/thirty-four-minutes-to-two-what-pytest-xdist-exposed-in-a-nearly-9000-test-suite)

