Saved in this browser and reused when you open other posts.
On 21 October 2026, the people who make PHP released an emergency fix for two serious security problems. In plain terms: an attacker could run their own code on your server without needing a password, a login, or any access at all. That's about as bad as it gets.
The problems are officially called CVE-2026-109618 and CVE-2026-109619. you don't need to remember those. Here's what matters:
If your website or app runs on PHP, update it to one of these versions as soon as you can: 8.5.12, 8.4.27, 8.3.36, or 8.2.35.
If you only read one line of this post, that was it. The rest explains why this one is different from the usual security warnings, and why the normal "we're protected" assumptions don't apply.
First, what does "run their own code on your server" even mean?
When an attacker can run their own code on your server, they essentially own it. They can read your database, steal your users' data, plant hidden backdoors, use your server to attack others, or just delete everything. This is the worst category of security problem, and it's why this fix is marked critical.
Why this one is different and scarier than usual
Most PHP security problems are mistakes in your own code. For example: a contact form that lets someone upload a file they shouldn't, or a search box that isn't cleaned up properly. Those are your responsibility to find and fix, and good developers guard against them.
This is not that. This problem is inside PHP itself the engine that runs your code. You didn't write the bug, and you can't fix it by improving your own code. It's like discovering the lock on your front door has a manufacturing defect. It doesn't matter how carefully you lock it; the lock itself is faulty, and only the manufacturer's replacement fixes it.
The exact recipe for pulling this off is something security researchers deliberately don't publish right after a fix comes out, because most servers in the world haven't updated yet. The point here isn't how to do it it's understanding that it's real, it's serious, and updating is the fix.
The uncomfortable part: your usual protections don't help here
This is the bit most people get wrong, so it's worth being clear.
A lot of developers assume they're safe because they have protections in place such as a firewall, a login system, careful input checking in their framework (like Laravel). Those are all good and you should keep them. But none of them stop this particular problem, because the vulnerable part of PHP runs before your app and your protections even wake up.
- Your firewall is watching for suspicious-looking requests. This attack doesn't have to look suspicious.
- Your login and permission checks happen inside your app. This fires before your app gets a turn.
- "We don't do anything risky on our site" you're still running PHP, open to the internet. That's the door being knocked on.
So for this specific issue, there's really only one honest answer: update PHP. The official advisory says so directly there is no clever workaround that makes you fully safe. Only the update does.
What to do, step by step
1. Find out which version of PHP you're running. If you're not sure, your hosting provider's dashboard usually shows it, or a developer can check in a minute.
2. Update to the safe version for your branch:
| If you're on PHP... | Update to... |
| 8.5 | 8.5.12 |
| 8.4 | 8.4.27 |
| 8.3 | 8.3.36 |
| 8.2 | 8.2.35 |
3. If someone else manages your hosting (a managed host, a web agency, your IT person), send them this post or just ask: "Have we applied the PHP security update from 21 October 2026?" That one question is enough.
4. If you're on an old version of PHP (8.1 or older), be aware those versions no longer get official fixes at all. This is a strong nudge to upgrade not just for this bug, but because you'll keep hitting this problem otherwise.
For the technical folks
If you manage your own servers, a few specifics worth knowing:
- Every setup is affected PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP, LiteSpeed. The advisory flags mod_php as the highest risk, because the attack can trigger before any application code runs.
- Docker users: rebuild from the patched base images (
php:8.3.36-fpm, etc.). Confirm the tag is actually published and pull fresh — a cached old layer will fool you into thinking you've patched. - If you use
rpms.remirepo.net: it won't have same-day updates. Switch to your distribution's packages or build from source so you can patch immediately. - There are partial stopgaps in the advisory (limiting
post_max_size, disablingsession.upload_progress, and on 8.5 turning offfatal_error_backtraces) but they only block known attack paths and are explicitly not a substitute for updating. Use them only to buy time.
If your site runs on WordPress
Millions of sites use WordPress — and every one of them runs on PHP under the hood. That includes yours if you have /wp-admin, plugins, and themes. Updating WordPress itself, or installing a security plugin, does not replace updating PHP. Plugins load after PHP has already started handling the request. This bug is below WordPress, in the same layer that powers index.php before WordPress bootstraps.
What WordPress people often get wrong
- “I have Wordfence / iThemes / Sucuri — I’m fine.” Those tools are valuable for WordPress-layer problems (bad logins, malicious uploads inside WP, known plugin flaws). They do not patch the PHP engine. See WordPress hardening guidance for what they do cover — and remember it starts with a supported PHP version on the hosting requirements page.
- “I only clicked Update plugins.” Plugin and core updates are still worth doing, but they are unrelated to PHP2Shell. The fix is the server’s PHP package (8.2.35, 8.3.36, 8.4.27, or 8.5.12), usually via your host, CloudPanel (see below), or VPS
apt upgradenot the WordPress dashboard alone. - “My host hides PHP.” Use Site Health in wp-admin (Tools → Site Health → Info → Server). The PHP version listed there is what matters. If it is below the patched levels in the table above — or stuck on 7.4 / 8.0 / 8.1 open a ticket with your host and point them at the October 2026 PHP security release.
What to do on a typical WordPress site
- Confirm PHP version Site Health (no SSH needed) or the
server-audit.php/ Mac checks elsewhere in this post if you manage the server yourself. - Get PHP updated on the server On shared or managed WordPress hosting, that almost always means support chat or control panel, not a setting inside wp-admin. Ask explicitly: “Is our site’s PHP updated for CVE-2026-109618 and CVE-2026-109619?”
- After the host patches Load the front page and wp-admin, run Site Health again, and test anything that uploads files (Media Library, forms plugins, WooCommerce product images). WordPress relies on
multipart/form-datafor uploads; patching PHP is what secures that path — you are checking that the site still works, not that a plugin “blocked” the CVE. - Do not leave quick-debug files lying around Some tutorials suggest dropping a
phpinfo()file to see the version. If you do, delete it immediately. Prefer Site Health or the token-protected audit script in this post. See Debugging in WordPress for proper log-based troubleshooting instead of exposing server info. - If you suspect compromise Follow the log and filesystem section later in this post; for WordPress specifically, look for unknown admin users (Users screen), unexpected plugins under Plugins → Installed Plugins, and files under
wp-content/uploads/that should not be.php. The WordPress security team publishes advisories for WordPress core and plugins; this incident is separate, but their blog is the right place to watch for official WP project news.
Check your site from your Mac (safe tests — not an attack)
You do not need to run a real exploit to decide whether you're in danger. If your server is still on a vulnerable PHP build, assume it can be taken over from the internet until you've patched. What you can do from a Mac is confirm the version, see whether interim hardening is in place, and check whether a firewall or CDN is blocking the known request shapes attackers use without trying to break into your own box.
Use this only on sites you own or have written permission to test.
This repo includes two small tools (no exploit payloads):
| File | What it does |
server-audit.php | Runs on the server and returns JSON: PHP version, how PHP is wired (FPM, mod_php, etc.), and whether key mitigation settings look right. |
php2shell-pentest.php | Runs on your Mac in Terminal: audits local PHP, or sends benign HTTP probes to your live URL. |
They align with public guidance (including AWS's PHP2Shell WAF notes): harmless multipart upload-style requests, oversized POST bodies, and version checks — not memory-corruption or code-execution triggers.
server-audit.php
<?php
/**
* PHP2Shell readiness audit — run on the server under test.
* Protect with a secret token; remove from production when done.
*/
declare(strict_types=1);
const DEFAULT_AUDIT_TOKEN = 'CHANGE_ME_BEFORE_UPLOAD';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$expected = getenv('PHP2SHELL_AUDIT_TOKEN') ?: DEFAULT_AUDIT_TOKEN;
$provided = $_GET['token'] ?? $_SERVER['HTTP_X_AUDIT_TOKEN'] ?? '';
if (!is_string($provided) || $provided === '') {
http_response_code(403);
echo json_encode(['error' => 'Forbidden', 'hint' => 'Missing token query parameter.'], JSON_PRETTY_PRINT);
exit;
}
if (strlen($expected) !== strlen($provided) || !hash_equals($expected, $provided)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden', 'hint' => 'Token mismatch — check DEFAULT_AUDIT_TOKEN or PHP2SHELL_AUDIT_TOKEN on the server.'], JSON_PRETTY_PRINT);
exit;
}
function first_fixed_for_branch(int $major, int $minor): ?string
{
if ($major === 8) {
if ($minor === 5) {
return '8.5.12';
}
if ($minor === 4) {
return '8.4.27';
}
if ($minor === 3) {
return '8.3.36';
}
if ($minor === 2) {
return '8.2.35';
}
}
return null;
}
function parse_php_version(string $version): array
{
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)/', $version, $m)) {
return ['raw' => $version, 'major' => 0, 'minor' => 0, 'patch' => 0];
}
return [
'raw' => $version,
'major' => (int) $m[1],
'minor' => (int) $m[2],
'patch' => (int) $m[3],
];
}
function version_is_patched(string $version): array
{
$p = parse_php_version($version);
$fixed = first_fixed_for_branch($p['major'], $p['minor']);
if ($fixed === null) {
if ($p['major'] < 8 || ($p['major'] === 8 && $p['minor'] <= 1)) {
return [
'status' => 'end_of_life',
'patched' => false,
'first_fixed' => null,
'message' => 'PHP 8.1 and older have no upstream fix; migrate or use vendor backports.',
];
}
return [
'status' => 'unknown_branch',
'patched' => false,
'first_fixed' => null,
'message' => 'Unsupported branch for this checker; verify against php.net releases.',
];
}
$fp = parse_php_version($fixed);
$patched = $p['patch'] >= $fp['patch'];
return [
'status' => $patched ? 'patched' : 'vulnerable',
'patched' => $patched,
'first_fixed' => $fixed,
'message' => $patched
? 'Version meets or exceeds the fixed release for this branch.'
: 'Update to ' . $fixed . ' or newer on this branch immediately.',
];
}
function ini_bool(string $name): ?bool
{
$v = ini_get($name);
if ($v === false || $v === '') {
return null;
}
return filter_var($v, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}
function memory_limit_bytes(?string $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if ($value === '-1') {
return -1;
}
$value = trim($value);
$unit = strtolower(substr($value, -1));
$num = (float) $value;
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
$version = PHP_VERSION;
$verCheck = version_is_patched($version);
$sapi = PHP_SAPI;
$mem = ini_get('memory_limit');
$memBytes = memory_limit_bytes($mem !== false ? $mem : null);
$mitigations = [
'memory_limit_unlimited' => [
'value' => $mem,
'ok_for_cve_109618' => $memBytes === -1,
'note' => 'PHP recommends memory_limit=-1 via php_admin_value / pool config (not app-overridable).',
],
'session_upload_progress_enabled' => [
'value' => ini_get('session.upload_progress.enabled'),
'ok_for_cve_109619' => ini_bool('session.upload_progress.enabled') === false,
'note' => 'Should be 0; must apply before request parsing and restart workers.',
],
'post_max_size' => [
'value' => ini_get('post_max_size'),
'note' => 'Lower values reduce OOM attack surface; balance with legitimate uploads.',
],
'upload_max_filesize' => [
'value' => ini_get('upload_max_filesize'),
],
];
if (version_compare($version, '8.5.0', '>=')) {
$mitigations['fatal_error_backtraces'] = [
'value' => ini_get('fatal_error_backtraces'),
'ok_for_cve_109619' => ini_bool('fatal_error_backtraces') === false,
'note' => 'PHP 8.5: set fatal_error_backtraces=0 and restart workers.',
];
}
$risk = 'medium';
if (!$verCheck['patched']) {
$risk = ($sapi === 'apache2handler' || strpos($sapi, 'apache') !== false) ? 'critical' : 'high';
} elseif ($risk !== 'critical') {
$risk = 'low';
}
$report = [
'tool' => 'php2shell-server-audit',
'generated_at' => gmdate('c'),
'php' => [
'version' => $version,
'version_id' => PHP_VERSION_ID,
'sapi' => $sapi,
'zend_version' => zend_version(),
'cve_check' => $verCheck,
],
'risk_summary' => $risk,
'cves' => [
'CVE-2026-109618' => 'OOM corruption during request handling (multipart / large bodies).',
'CVE-2026-109619' => 'Stale global state across requests on same worker.',
],
'mitigations_observed' => $mitigations,
'recommendations' => array_values(array_filter([
!$verCheck['patched'] ? 'Upgrade PHP to a fixed release and restart all workers.' : null,
($memBytes !== -1) ? 'Set memory_limit=-1 using admin-level config if you cannot patch yet.' : null,
ini_bool('session.upload_progress.enabled') !== false
? 'Set session.upload_progress.enabled=0 and restart workers.'
: null,
isset($mitigations['fatal_error_backtraces']) && ($mitigations['fatal_error_backtraces']['ok_for_cve_109619'] ?? true) === false
? 'Set fatal_error_backtraces=0 on PHP 8.5.'
: null,
'Remove this audit script from the web root when finished.',
])),
];
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
php2shell-pentest.php
#!/usr/bin/env php
Safe HTTP probes against a site you own
Options (scan):
--audit-url= Fetch server-audit.php JSON (recommended)
--timeout= HTTP timeout (default: 15)
--insecure Skip TLS certificate verification
Examples:
php {$self} audit
php {$self} scan https://www.example.com --audit-url='https://www.example.com/server-audit.php?token=SECRET'
TXT);
}
function color(string $text, string $tone): string
{
if (!function_exists('posix_isatty') || !@posix_isatty(STDOUT)) {
return $text;
}
$codes = [
'red' => "\033[31m",
'green' => "\033[32m",
'yellow' => "\033[33m",
'cyan' => "\033[36m",
'reset' => "\033[0m",
];
return ($codes[$tone] ?? '') . $text . $codes['reset'];
}
function first_fixed_for_branch(int $major, int $minor): ?string
{
return match ([$major, $minor]) {
[8, 5] => '8.5.12',
[8, 4] => '8.4.27',
[8, 3] => '8.3.36',
[8, 2] => '8.2.35',
default => null,
};
}
function parse_php_version(string $version): array
{
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)/', $version, $m)) {
return ['major' => 0, 'minor' => 0, 'patch' => 0];
}
return ['major' => (int) $m[1], 'minor' => (int) $m[2], 'patch' => (int) $m[3]];
}
function version_audit(string $version): array
{
$p = parse_php_version($version);
$fixed = first_fixed_for_branch($p['major'], $p['minor']);
if ($fixed === null) {
if ($p['major'] < 8 || ($p['major'] === 8 && $p['minor'] <= 1)) {
return ['patched' => false, 'level' => 'critical', 'detail' => 'EOL branch — no upstream fix'];
}
return ['patched' => false, 'level' => 'unknown', 'detail' => 'Unknown branch'];
}
$fp = parse_php_version($fixed);
$patched = $p['patch'] >= $fp['patch'];
return [
'patched' => $patched,
'level' => $patched ? 'ok' : 'critical',
'detail' => $patched ? 'At or above ' . $fixed : 'Below fixed ' . $fixed,
'first_fixed' => $fixed,
];
}
function run_local_audit(): int
{
fwrite(STDOUT, color("=== Local PHP audit ===\n", 'cyan'));
$version = PHP_VERSION;
$check = version_audit($version);
fwrite(STDOUT, "PHP version: {$version}\n");
fwrite(STDOUT, 'SAPI: ' . PHP_SAPI . "\n");
fwrite(STDOUT, 'CVE status: ' . ($check['patched'] ? color('PATCHED (version check)', 'green') : color('VULNERABLE (version check)', 'red')) . "\n");
fwrite(STDOUT, 'Detail: ' . $check['detail'] . "\n\n");
fwrite(STDOUT, "Mitigation-related settings:\n");
$rows = [
['memory_limit', ini_get('memory_limit'), 'CVE-109618: prefer -1 via admin config'],
['session.upload_progress.enabled', ini_get('session.upload_progress.enabled'), 'CVE-109619: should be 0'],
['post_max_size', ini_get('post_max_size'), 'Keep low where possible'],
['upload_max_filesize', ini_get('upload_max_filesize'), 'Must be < post_max_size'],
];
if (version_compare($version, '8.5.0', '>=')) {
$rows[] = ['fatal_error_backtraces', ini_get('fatal_error_backtraces'), 'CVE-109619 (8.5): should be 0'];
}
foreach ($rows as [$name, $value, $hint]) {
fwrite(STDOUT, sprintf(" %-32s %-12s (%s)\n", $name . ':', (string) $value, $hint));
}
fwrite(STDOUT, "\n" . color('Note: Only upgrading to a fixed PHP build fully addresses PHP2Shell.', 'yellow') . "\n");
return $check['patched'] ? EXIT_OK : EXIT_FINDINGS;
}
/**
* @return array{status:int,headers:array,body:string,error:?string}
*/
function http_request(
string $method,
string $url,
?string $body = null,
array $headers = [],
int $timeout = 15,
bool $insecure = false,
): array {
if (!extension_loaded('curl')) {
return ['status' => 0, 'headers' => [], 'body' => '', 'error' => 'ext-curl required for scan'];
}
$ch = curl_init($url);
if ($ch === false) {
return ['status' => 0, 'headers' => [], 'body' => '', 'error' => 'curl_init failed'];
}
$responseHeaders = [];
$headerFn = static function ($curl, string $line) use (&$responseHeaders): int {
$len = strlen($line);
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($k))] = trim($v);
}
return $len;
};
$opts = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HEADERFUNCTION => $headerFn,
CURLOPT_USERAGENT => 'php2shell-selftest/1.0 (+authorized security audit)',
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = $body;
}
if ($headers !== []) {
$opts[CURLOPT_HTTPHEADER] = $headers;
}
if ($insecure) {
$opts[CURLOPT_SSL_VERIFYPEER] = false;
$opts[CURLOPT_SSL_VERIFYHOST] = 0;
}
curl_setopt_array($ch, $opts);
$respBody = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_error($ch);
curl_close($ch);
return [
'status' => $status,
'headers' => $responseHeaders,
'body' => is_string($respBody) ? $respBody : '',
'error' => $err !== '' ? $err : null,
];
}
function build_multipart_canary(): array
{
$boundary = '----php2shell-canary-' . bin2hex(random_bytes(8));
$progressField = 'PHP_SESSION_UPLOAD_PROGRESS';
$body = "--{$boundary}\r\n"
. "Content-Disposition: form-data; name=\"{$progressField}\"\r\n\r\n"
. "canary\r\n"
. "--{$boundary}\r\n"
. "Content-Disposition: form-data; name=\"file\"; filename=\"canary.txt\"\r\n"
. "Content-Type: text/plain\r\n\r\n"
. "benign-canary-payload\r\n"
. "--{$boundary}--\r\n";
$headers = [
'Content-Type: multipart/form-data; boundary=' . $boundary,
'Cookie: PHPSESSID=canary-selftest-session',
];
return [$body, $headers];
}
function interpret_probe(string $name, array $resp): array
{
$status = $resp['status'];
$blocked = $status === 403 || $status === 413 || $status === 429;
return [
'probe' => $name,
'http_status' => $status,
'blocked_at_edge' => $blocked,
'curl_error' => $resp['error'],
'interpretation' => match (true) {
$resp['error'] !== null => 'Request failed — check URL/TLS/firewall.',
$blocked => 'Blocked or limited at edge/WAF — good as defense-in-depth, not a substitute for patching.',
$status >= 500 => 'Server error on benign probe — investigate logs (not proof of exploitability).',
default => 'Request reached origin — ensure PHP is patched; edge rules may be absent.',
},
];
}
function run_remote_scan(string $baseUrl, ?string $auditUrl, int $timeout, bool $insecure): int
{
$baseUrl = rtrim($baseUrl, '/');
fwrite(STDOUT, color("=== Remote safe probes: {$baseUrl} ===\n", 'cyan'));
fwrite(STDOUT, color("No exploit payloads are sent. Use only on sites you are authorized to test.\n\n", 'yellow'));
$findings = 0;
if ($auditUrl !== null) {
fwrite(STDOUT, "Fetching server audit JSON...\n");
$audit = http_request('GET', $auditUrl, null, [], $timeout, $insecure);
if ($audit['error']) {
fwrite(STDOUT, color(" Audit fetch error: {$audit['error']}\n", 'red'));
$findings++;
} elseif ($audit['status'] !== 200) {
fwrite(STDOUT, color(" Audit HTTP {$audit['status']} — check token and path.\n", 'red'));
$findings++;
} else {
$json = json_decode($audit['body'], true);
if (!is_array($json)) {
fwrite(STDOUT, color(" Audit response is not valid JSON.\n", 'red'));
$findings++;
} else {
$php = $json['php'] ?? [];
$ver = $php['version'] ?? 'unknown';
$cve = $php['cve_check'] ?? [];
$patched = (bool) ($cve['patched'] ?? false);
fwrite(STDOUT, " PHP: {$ver} (" . ($php['sapi'] ?? '?') . ")\n");
fwrite(STDOUT, ' Version check: ' . ($patched ? color('PATCHED', 'green') : color('VULNERABLE', 'red')) . "\n");
if (!$patched) {
$findings++;
}
if (isset($json['risk_summary'])) {
fwrite(STDOUT, ' Risk summary: ' . $json['risk_summary'] . "\n");
}
if (!empty($json['recommendations']) && is_array($json['recommendations'])) {
fwrite(STDOUT, " Recommendations:\n");
foreach ($json['recommendations'] as $rec) {
fwrite(STDOUT, " - {$rec}\n");
}
}
}
}
fwrite(STDOUT, "\n");
} else {
fwrite(STDOUT, color("Tip: deploy server-audit.php and pass --audit-url for accurate version/ini checks.\n\n", 'yellow'));
}
fwrite(STDOUT, "Probe 1: benign multipart + session upload progress field (WAF canary)...\n");
[$mpBody, $mpHeaders] = build_multipart_canary();
$p1 = http_request('POST', $baseUrl . '/', $mpBody, $mpHeaders, $timeout, $insecure);
$r1 = interpret_probe('multipart_session_progress_canary', $p1);
fwrite(STDOUT, " HTTP {$r1['http_status']} — {$r1['interpretation']}\n");
fwrite(STDOUT, "Probe 2: oversized POST body (~16 KiB)...\n");
$big = str_repeat('A', 16 * 1024);
$p2 = http_request('POST', $baseUrl . '/', $big, ['Content-Type: application/octet-stream'], $timeout, $insecure);
$r2 = interpret_probe('oversized_body_canary', $p2);
fwrite(STDOUT, " HTTP {$r2['http_status']} — {$r2['interpretation']}\n");
fwrite(STDOUT, "Probe 3: baseline GET (banner headers)...\n");
$p3 = http_request('GET', $baseUrl . '/', null, [], $timeout, $insecure);
if ($p3['error']) {
fwrite(STDOUT, color(" Error: {$p3['error']}\n", 'red'));
$findings++;
} else {
$xp = $p3['headers']['x-powered-by'] ?? null;
$server = $p3['headers']['server'] ?? null;
fwrite(STDOUT, ' HTTP ' . $p3['status'] . "\n");
if ($server) {
fwrite(STDOUT, " Server: {$server}\n");
}
if ($xp) {
fwrite(STDOUT, " X-Powered-By: {$xp}\n");
if (preg_match('/PHP\/([\d.]+)/i', $xp, $m)) {
$vc = version_audit($m[1]);
fwrite(STDOUT, ' Header version check: ' . ($vc['patched'] ? color('PATCHED', 'green') : color('VULNERABLE', 'red')) . " ({$vc['detail']})\n");
if (!$vc['patched']) {
$findings++;
}
}
} else {
fwrite(STDOUT, " X-Powered-By: (not disclosed — use server-audit.php for version)\n");
}
}
fwrite(STDOUT, "\n" . color('Summary: ' . ($findings ? "{$findings} issue(s) need attention." : 'No version red flags from checks performed.'), $findings ? 'red' : 'green') . "\n");
fwrite(STDOUT, color('Upgrade to PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 (or distro equivalent) for a complete fix.', 'yellow') . "\n");
return $findings ? EXIT_FINDINGS : EXIT_OK;
}
// --- CLI entry ---
$argv = $_SERVER['argv'] ?? [];
array_shift($argv);
if ($argv === [] || in_array($argv[0], ['-h', '--help', 'help'], true)) {
usage();
exit(EXIT_USAGE);
}
$command = array_shift($argv);
if ($command === 'audit') {
exit(run_local_audit());
}
if ($command === 'scan') {
if ($argv === []) {
fwrite(STDERR, "Missing base URL.\n");
usage();
exit(EXIT_USAGE);
}
$baseUrl = array_shift($argv);
$auditUrl = null;
$timeout = 15;
$insecure = false;
foreach ($argv as $arg) {
if (str_starts_with($arg, '--audit-url=')) {
$auditUrl = substr($arg, strlen('--audit-url='));
} elseif (str_starts_with($arg, '--timeout=')) {
$timeout = max(1, (int) substr($arg, strlen('--timeout=')));
} elseif ($arg === '--insecure') {
$insecure = true;
} else {
fwrite(STDERR, "Unknown option: {$arg}\n");
exit(EXIT_USAGE);
}
}
if (!filter_var($baseUrl, FILTER_VALIDATE_URL)) {
fwrite(STDERR, "Invalid base URL.\n");
exit(EXIT_USAGE);
}
exit(run_remote_scan($baseUrl, $auditUrl, $timeout, $insecure));
}
fwrite(STDERR, "Unknown command: {$command}\n");
usage();
exit(EXIT_USAGE);
Step A — Audit PHP on your Mac
If you develop locally, check whether your laptop is on a fixed build too:
cd /path/to/php-test php php2shell-pentest.php audit
Example output when your Mac is still on an unfixed build (terminal may add color on the CVE line):
=== Local PHP audit === PHP version: 8.5.3 SAPI: cli CVE status: VULNERABLE (version check) Detail: Below fixed 8.5.12 Mitigation-related settings: memory_limit: 128M (CVE-109618: prefer -1 via admin config) session.upload_progress.enabled: 1 (CVE-109619: should be 0) post_max_size: 8M (Keep low where possible) upload_max_filesize: 2M (Must be < post_max_size) fatal_error_backtraces: 1 (CVE-109619 (8.5): should be 0) Note: Only upgrading to a fixed PHP build fully addresses PHP2Shell.
You'll see whether your version is at or above 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35, plus settings like session.upload_progress.enabled and memory_limit. VULNERABLE on the CVE line means upgrade local PHP (Homebrew, phpbrew, or your stack’s installer) — not just the server.
Step B — Put the server audit script on your site (recommended)
- Open
server-audit.phpand changeCHANGE_ME_BEFORE_UPLOADto a long random secret (or set environment variablePHP2SHELL_AUDIT_TOKENon the server). - Upload the file to your site (any path your web server serves).
- From your Mac:
curl -sS 'https://YOUR-DOMAIN/path/server-audit.php?token=YOUR_SECRET'
Example response on a site still running PHP 7.4 (pretty-printed JSON; your fields will match your server):
{
"tool": "php2shell-server-audit",
"generated_at": "2026-10-22T09:34:40+00:00",
"php": {
"version": "7.4.33",
"version_id": 70433,
"sapi": "fpm-fcgi",
"zend_version": "3.4.0",
"cve_check": {
"status": "end_of_life",
"patched": false,
"first_fixed": null,
"message": "PHP 8.1 and older have no upstream fix; migrate or use vendor backports."
}
},
"risk_summary": "high",
"cves": {
"CVE-2026-109618": "OOM corruption during request handling (multipart / large bodies).",
"CVE-2026-109619": "Stale global state across requests on same worker."
},
"mitigations_observed": {
"memory_limit_unlimited": {
"value": "2G",
"ok_for_cve_109618": false,
"note": "PHP recommends memory_limit=-1 via php_admin_value / pool config (not app-overridable)."
},
"session_upload_progress_enabled": {
"value": "1",
"ok_for_cve_109619": false,
"note": "Should be 0; must apply before request parsing and restart workers."
},
"post_max_size": {
"value": "64M",
"note": "Lower values reduce OOM attack surface; balance with legitimate uploads."
},
"upload_max_filesize": {
"value": "64M"
}
},
"recommendations": [
"Upgrade PHP to a fixed release and restart all workers.",
"Set memory_limit=-1 using admin-level config if you cannot patch yet.",
"Set session.upload_progress.enabled=0 and restart workers.",
"Remove this audit script from the web root when finished."
]
}
Read the JSON:
cve_check.patched:true— version meets the fixed release for that branch (still restart workers after upgrading).cve_check.patched:false— treat as critical until you upgrade.mitigations_observed— shows whether stopgaps from the advisory are visible (helpful while waiting on a host like CloudPanel; not a substitute for patching).risk_summary:critical— often means mod_php plus an unpatched version; highest urgency.
Remove server-audit.php from the web root when you're done. It's only for this check.
Step C — Remote probes from your Mac
With the audit URL from Step B:
php php2shell-pentest.php scan https://YOUR-DOMAIN \ --audit-url='https://YOUR-DOMAIN/path/server-audit.php?token=YOUR_SECRET'
The scan prints a short summary from that JSON before it runs the harmless HTTP probes. Example (same 7.4.33 server as above):
Fetching server audit JSON...
PHP: 7.4.33 (fpm-fcgi)
Version check: VULNERABLE
Risk summary: high
Recommendations:
- Upgrade PHP to a fixed release and restart all workers.
- Set memory_limit=-1 using admin-level config if you cannot patch yet.
- Set session.upload_progress.enabled=0 and restart workers.
- Remove this audit script from the web root when finished.
Probe 1: benign multipart + session upload progress field (WAF canary)...
...
Without --audit-url, the scan still runs but can't see your real PHP version unless headers leak it — less reliable.
How to read probe results (plain English):
| What you see | What it usually means |
| Audit says VULNERABLE | Patch now. You don't need a successful exploit test to justify that. |
| Audit says PATCHED after upgrade | Good — confirm with clpctl php:list or php -v on the server and restart FPM/Apache. |
| Probe returns 403, 413, or 429 | Something at the edge (WAF, CDN, nginx limit) may be blocking risky request patterns. Helpful extra layer still patch PHP. |
| Probe returns 200 and version is still vulnerable | The request reached your stack; don't assume the WAF saved you. |
| 500 errors on harmless probes | Worth checking error logs (could be unrelated, or a sign of instability). |
Optional flags: --timeout=30 for slow hosts; --insecure only for local/dev TLS (avoid on production if you can).
What these tests deliberately do not do
They will not prove "an attacker couldn't get in" by running attack code. No responsible public checklist should hand you that. They will tell you if you're on a broken PHP version, whether advisory stopgaps are configured, and whether edge rules react to benign canaries which is what most people need before and after patching.
After a clean audit: keep the update, restart all PHP workers, delete server-audit.php, and optionally re-run scan once to confirm headers and probes look sane.
Full source server-audit.php and php2shell-pentest.php
Save these as two files in the same folder on your Mac. Upload server-audit.php to your server for Step B; run php2shell-pentest.php from Terminal for Steps A and C. Change CHANGE_ME_BEFORE_UPLOAD before deploying the audit script.
If you use CloudPanel (and the patched PHP hasn't landed yet)
CloudPanel is a bit of a special case, and it's worth its own section because the obvious "just install a newer PHP" move can actually break your server here. CloudPanel ships PHP as part of its technology stack (7.1 through 8.5 on supported Ubuntu/Debian releases) not from random third-party repos.
Why it's different: CloudPanel installs PHP from its own package channel and locks the system so outside PHP packages can't replace it. That keeps the control panel stable — but it also means you can't fix PHP2Shell by dropping in packages from ppa:ondrej/php. You wait for patched builds in CloudPanel's repository and install them the way CloudPanel expects: **apt on the server**, not by guessing at manual installs.
The mistake to avoid: Do not force PHP in from a third-party source to get ahead of CloudPanel. On a plain VPS that's sometimes fine; on CloudPanel it clashes with how sites, PHP-FPM pools, and nginx are wired together and can break both your sites and the panel.
Also easy to get wrong: [clp-update](https://www.cloudpanel.io/docs/v2/update/) updates CloudPanel itself (and bundled panel dependencies). It is not the same thing as "PHP got the security fix." For this CVE, the fixes are delivered through your OS package manager — the same native apt flow CloudPanel uses for PHP packages — not via clp-update alone. Run both when updates exist: upgrade PHP with apt, keep the panel current with clp-update.
What to do, safely:
1) Take a backup or server snapshot first. CloudPanel's update docs recommend an image/snapshot before any upgrade. If your provider (Hetzner, DigitalOcean, etc.) offers a one-click snapshot, do that now. This is your undo button.
2) See which PHP versions your sites use. In the CloudPanel dashboard (per-site PHP version) or via SSH — e.g. clpctl php:list if your CLI build provides it, or check each binary below.
3) Apply the temporary stopgaps right now (from the section above) to reduce exposure while you wait limiting post_max_size and disabling session.upload_progress. These don't make you safe, but they close known doors until the real fix lands. More background on managing PHP versions on CloudPanel.
4) Check if updates are available (patched PHP may show up here before you notice in the UI):
sudo apt update && sudo apt list --upgradable
Look for packages like php8.3, php8.3-fpm, php8.3-cli, or the 8.2 / 8.4 / 8.5 equivalents for the versions you actually run.
5) Update your server — this is where the PHP fix installs:
sudo apt update && sudo apt upgrade
Result: It depends on your OS (Ubuntu 22.04/24.04, Debian 12, etc.) and whether CloudPanel's repository already published the patched PHP builds. If nothing PHP-related appears in step 4, the packages aren't ready yet — keep the stopgaps, watch CloudPanel's blog and changelog, and check step 4 again daily. Do not add unofficial PHP repos.
6) Keep CloudPanel itself updated (separate step — still worth doing, but don't treat it as your only PHP patch):
sudo clp-update
See the official guide: Update CloudPanel.
7) Confirm your PHP actually updated. SSH in and run the command for each version you use. You want at least these patch levels:
| SSH command | Patched version |
php8.5 -v | 8.5.12 |
php8.4 -v | 8.4.27 |
php8.3 -v | 8.3.36 |
php8.2 -v | 8.2.35 |
Then restart and sanity-check FPM:
Reload a site in the panel and hit it in the browser. Root CLI commands cover automation if you manage many vhosts.
From your Mac, run Step B and Step C again — server-audit.php should report patched: true, then remove the audit file.
The CloudPanel admin UI still runs on PHP 8.1. patch or lock it down
Updating site PHP with apt (steps 4–7) does not automatically fix the control panel. Today, CloudPanel itself is still served by a separate service, clp-php-fpm, which often runs PHP 8.1. PHP 8.1 no longer gets fixes from the PHP project for this bug — there is no official 8.1.x release that fully closes PHP2Shell. That means your panel URL (port 8443 or your custom admin host) can remain a high-value target even after your customer sites are on 8.3.36 or similar.
You have two practical choices until CloudPanel ships a build that drops 8.1 for the UI:
- Restrict access — Put the panel behind a VPN, IP allowlist, or SSH tunnel so it is not reachable from the open internet. That does not patch PHP, but it shrinks who can hit it.
- Point
clp-php-fpmat a patched branch — Community reports and early adopters show the CLP UI can run on newer PHP (commonly 8.2.35+) by overriding the systemd unit. This is an advanced, unsupported-by-vendor tweak: take a snapshot first, install patchedphp8.2-fpmvia step 5, then follow the steps below.
Check what the panel uses now:
systemctl status clp-php-fpm
If you still see 8.1 in the description and Main PID / process name, the admin UI is on the vulnerable branch. Example (before any change):
● clp-php-fpm.service - The CLP PHP 8.1 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/clp-php-fpm.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-10-22 01:39:01 UTC; 7h ago
Docs: man:php-fpm8.1(8)
Main PID: 2892645 (php-fpm8.1)
Status: "Processes active: 0, idle: 0, Requests: 29, slow: 0, Traffic: 0req/sec"
Tasks: 1 (limit: 38463)
Memory: 38.2M (peak: 48.4M)
CPU: 5.404s
CGroup: /system.slice/clp-php-fpm.service
└─2892645 "php-fpm: master process (/home/clp/services/php-fpm/fpm/php-fpm.conf)"
Oct 22 01:39:01 hosting systemd[1]: Started clp-php-fpm.service - The CLP PHP 8.1 FastCGI Process Manager.
Oct 21 22:41:47 hosting sudo[2893910]: clp : PWD=/tmp ; USER=root ; COMMAND=/bin/cat /proc/meminfo
Oct 21 22:41:47 hosting sudo[2893910]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=1001)
Oct 21 22:41:47 hosting sudo[2893910]: pam_unix(sudo:session): session closed for user root
Oct 21 22:41:47 hosting sudo[2893918]: clp : PWD=/tmp ; USER=root ; COMMAND=/bin/cat /proc/cpuinfo
Oct 21 22:41:47 hosting sudo[2893918]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=1001)
Oct 21 22:41:47 hosting sudo[2893918]: pam_unix(sudo:session): session closed for user root
Oct 21 22:41:47 hosting sudo[2893923]: clp : PWD=/tmp ; USER=root ; COMMAND=/bin/cat /proc/meminfo
Oct 21 22:41:47 hosting sudo[2893923]: pam_unix(sudo:session): session opened for user root(uid=0) by (uid=1001)
Oct 21 22:41:47 hosting sudo[2893923]: pam_unix(sudo:session): session closed for user root
Override the service to PHP 8.2 (use 8.3 only if you have confirmed it works on your box — 8.2.35 is the usual minimum patched target):
sudo systemctl edit clp-php-fpm
Your editor opens a drop-in template. Paste the override between the “will become the contents of the drop-in file” comment and the “Edits below this comment will be discarded” line — the stock file below that is only reference. It looks roughly like this in GNU nano:
### Editing /etc/systemd/system/clp-php-fpm.service.d/override.conf ### Anything between here and the comment below will become the contents of the drop-in file [Unit] Description=The CLP PHP 8.2 FastCGI Process Manager [Service] ExecStart= ExecStart=/usr/sbin/php-fpm8.2 -R -F -y /home/clp/services/php-fpm/fpm/php-fpm.conf -c /home/clp/services/php-fpm/fpm/php.ini ### Edits below this comment will be discarded ### /usr/lib/systemd/system/clp-php-fpm.service # [Unit] # Description=The CLP PHP 8.1 FastCGI Process Manager # Documentation=man:php-fpm8.1(8) # After=network.target # # [Service] # Type=notify # PIDFile=/run/php/clp-php-fpm.pid # ExecStart=/usr/sbin/php-fpm8.1 -R -F -y /home/clp/services/php-fpm/fpm/php-fpm.conf -c /home/clp/services/php-fpm/fpm/php.ini # ExecReload=/bin/kill -USR2 $MAINPID # Restart=always # RestartSec=3 # # [Install] # WantedBy=multi-user.target
Save and exit (Ctrl+O, Enter, Ctrl+X in nano). The blank ExecStart= line is required — it clears the old 8.1 command before systemd applies your new one.
Apply and verify:
sudo systemctl daemon-reload sudo systemctl restart clp-php-fpm systemctl status clp-php-fpm
You want 8.2 (or your chosen version) in the unit description and php-fpm8.2 in the process list, and the panel should still load in the browser. Example (after a successful switch):
● clp-php-fpm.service - The CLP PHP 8.2 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/clp-php-fpm.service; enabled; preset: enabled)
Drop-In: /etc/systemd/system/clp-php-fpm.service.d
└─override.conf
Active: active (running) since Thu 2026-10-22 09:15:22 UTC; 4s ago
Docs: man:php-fpm8.2(8)
Main PID: 2910001 (php-fpm8.2)
Status: "Ready to handle connections"
Tasks: 1 (limit: 38463)
CGroup: /system.slice/clp-php-fpm.service
└─2910001 "php-fpm: master process (/home/clp/services/php-fpm/fpm/php-fpm.conf)"
Confirm the binary is patched: php8.2 -v should show 8.2.35 or newer.
Later, when CloudPanel stops using 8.1 for the UI, you can remove the override and let the package manage the unit again not needed on day one, only after an official CloudPanel release makes 8.1 obsolete:
sudo rm /etc/systemd/system/clp-php-fpm.service.d/override.conf sudo systemctl daemon-reload sudo systemctl restart clp-php-fpm systemctl status clp-php-fpm
If CloudPanel's repository is slow to publish site PHP packages, escalate with a stricter limit at your firewall or reverse proxy and lean harder on the stopgaps not by hand-swapping site PHP packages underneath the panel. The clp-php-fpm override above is a separate, panel-only exception many admins are using until CloudPanel catches up.
How to tell if someone already tried
Patching closes the vulnerability going forward. It does not answer two separate questions: whether anyone probed you while you were exposed, and whether anyone succeeded. You need a quick look at logs and the filesystem — especially if your site was on a vulnerable PHP version and reachable from the internet for any stretch of time around 21 October 2026 or after.
Set expectations first. There is no single “you’re clean” light. Clean logs are reassuring; they are not proof. Conversely, weird log lines are not automatic proof of a full takeover — PHP2Shell-style attacks are finicky. Many attempts fail noisily before they could ever work. That failure mode is actually useful: it can leave footprints even when the attacker never got in.
Signs in logs and process behaviour
Because these bugs live deep in PHP’s request handling, failed exploitation often shows up as instability, not as a neat line that says “attack blocked.” Things worth treating as a yellow or red flag particularly if they cluster in time or appear right after public news about the PHP emergency release:
- PHP-FPM workers dying and coming back. In
systemctl status php*-fpm(or your host’s equivalent), watch for frequent restarts, flapping “active” state, or pool processes that disappear and respawn more often than usual. - Segfaults, aborts, or “child exited with signal”. Messages mentioning
SIGSEGV,SIGABRT, or unexpected child exit codes around the same timestamps as heavy traffic. - Out-of-memory or memory-limit messages on requests that are not normal for your app — especially large or malformed POST / multipart uploads when you do not usually accept file uploads.
- A burst of 4xx/5xx from many IPs in a short window, often POST-heavy, sometimes with unusual
Content-Type: multipart/form-datatraffic. - A sudden jump in error volume in PHP-FPM slow logs, nginx error logs, or Apache error logs where there was previously silence.
On CloudPanel-style stacks, split the job mentally: site PHP (your vhost / php8.x-fpm) vs panel PHP (clp-php-fpm on 8.1). Either pool can crash independently. Check both if you run the panel on the same machine as customer sites.
Where to look (SSH or your host’s log viewer): nginx error log for the site, the matching PHP-FPM log/journal unit (journalctl -u php8.3-fpm --since "2026-10-20" adjust version and date), and system messages if the kernel logged a crash. Correlation matters: note the date range when you know you were still on an unpatched build, then search inside that window.
Signs on the server itself (if someone might have succeeded)
If you have reason to think an attempt could have worked long exposure, crash spikes, or odd new files skim for post-compromise basics. You do not need to be a forensic expert to notice the obvious:
- New or recently modified
.phpfiles in web roots,uploads/folders, or unexpected subdirectories; oddly named files (.ico.php, long random strings). - New admin users in WordPress, Laravel, databases, or CloudPanel; password resets you did not trigger.
- New cron jobs, systemd timers, or
@rebootentries. - Outbound connections you do not recognise (some hosts show this in a firewall or provider graph).
If you are not comfortable assessing that, treat it like any other serious incident: snapshot the server, patch first if you still have not, then get someone who does incident response or restore from a known-good backup taken before the exposure window and patch before putting it back online.