WHMCS released 8.13.7 and 9.0.8 on September 3 2026 to close CVE-2026-67399, a remote code execution (RCE) flaw an attacker can reach with no account. The advisory describes forged payloads accepted without adequate restrictions, and it offers no workaround: the fix is the update. We covered the advisory in our news post. This post is for the operator who read it and cannot upgrade tonight.
That operator exists in numbers. Plenty of WHMCS installs sit on an 8.x build that predates the current PHP requirement, behind custom modules nobody has touched in years, on the one system a hosting company cannot take offline on a weekday. We run one of those. So on September 4 we built, probed and deployed a stopgap hook on it, and this post publishes that hook with everything specific to our install stripped out.
Read this before you copy anything. This hook is not a vendor patch and not a confirmed fix. The mechanism behind CVE-2026-67399 is not public, so the hook blocks the two payload classes a bug described this way most likely needs, not the bug itself. It is for WHMCS operators only, it comes as is under the MIT license with no guarantee, and it does not change the advice: upgrade to 8.13.7 or 9.0.8 as soon as you can, then delete the hook. This is version 1. If a public proof of concept (PoC) appears and shows a gap, we will add a dated update here with a version 2.
Where the bug lives, and how we know
The advisory is deliberately thin. It names no file, no endpoint and no bug class. The vendor's own patch sets say more. WHMCS publishes a JSON index of its incremental patch sets on its download site, and each set is a zip whose file list you can read without opening a single file inside it. Compare the 8.13.7 set against any earlier set and the noise cancels: every patch set carries the same version-bump files. What remains is the fix.
curl -s -X POST https://download.whmcs.com/assets/scripts/get-downloads.php | python3 -m json.tool | grep -A2 '"8.13.7"' # fetch that entry's downloadUrl as whmcs-8.13.7-patch.zip, then list it without extracting unzip -l whmcs-8.13.7-patch.zip
That listing, checked again on September 4, contains seven files under the WHMCS root.
| File in the 8.13.7 patch set | What it is |
|---|---|
| vendor/whmcs/whmcs-foundation/lib/Invoice.php | The WHMCS Invoice class. The only substantive core change in the set, which points to this file as the likely home of the CVE-2026-67399 fix. The file is ionCube-encoded, so this is inference from the file list, not a confirmed diff. |
| modules/gateways/tco.php | The 2Checkout gateway module: the fix for the sibling data-leak flaw, CVE-2026-67398. |
| Application.php, Updater/Version/IncrementalVersion.php, resources/file_hashes/hash_list.php, resources/sql/install/tblconfiguration.data.sql | Version bump. These four appear in every patch set. |
| vendor/.htaccess | A deny-all rule for the vendor directory (the same Apache idiom we use for the callback lockdown below). |
Every file in that list is ionCube-encoded, so there is no diff to read and nothing to back-port. But the location is a strong hint, and the vendor's own phrase is forged payloads. On PHP, a payload that becomes code execution inside an invoice pipeline almost always travels one of two roads: a serialized PHP object the application unserializes, known as PHP object injection or insecure deserialization, or a phar:// path that PHP 7 deserializes during ordinary file operations. The hook closes both roads at the front door. If the real mechanism is a third road, the hook does not cover it, and the coverage table further down says so in writing.
What the hook does
Two layers, both in one file.
The first layer turns off PHP's phar:// stream wrapper. On PHP 7.x, any file function that touches a phar:// path deserializes the archive's metadata, which is the classic route from "attacker controls a path" to "attacker runs code". PHP 8 stopped doing that, so on PHP 8 this layer is belt and braces. Nothing in WHMCS, the ionCube loader or the Composer autoloader used phar at runtime on our install; the layer is a toggle in case yours differs.
The second layer inspects the request before WHMCS acts on it. It reads the URL path, the raw query string, GET, POST, cookies, every HTTP header, the raw body (the first 2 MB, except multipart uploads) and uploaded file names. Every string is checked as received, URL-decoded once and twice, and HTML-entity-decoded, then one layer deeper: JSON string escapes are undone and base64 tokens are decoded. It looks for exactly two things: a PHP serialized-object marker, which is the shape O:8:"ClassName":2:{ or its C: sibling, and the string phar://. A match ends the request with a 403, writes one line to the WHMCS activity log, and optionally sends a throttled email.
The reason a hook can do this at all: WHMCS includes every file in includes/hooks during initialization, on every request, before the page, cart, API or gateway-callback code runs. Top-level code in a hook file is therefore a request filter. The two layers differ in scope on purpose: the request scanner steps aside when PHP runs from the command line, so cron is never blocked, but the phar layer stays on for cron too, because the invoice PDF is rendered by cron and that is where a phar chain would fire. We confirmed the load order on WHMCS 8.1.3 with PHP 7.4 by probing it. It should hold on 8.13 and 9.x, since hooks load during init on every branch, but the smoke test below is how you prove it on yours rather than take our word.
Cost is small. A 2 MB body took between 18 and 60 milliseconds to scan on a laptop, and a normal WHMCS request carries a few kilobytes.
Install the hook
- Save the file below as
includes/hooks/Suriq_WHMCS_CVE_2026_67399_Guard.phpinside your WHMCS root. Any name works, WHMCS loads every PHP file in that folder; this one makes it easy to find and delete later. - Optionally set an alert address. With the address empty, nothing is emailed and the activity log is the record. You can define any of the constants in
configuration.phpinstead of editing the hook. - Open the client area, the cart, an invoice and the admin area. Then run the probes in the next section.
Rollback is deleting the file. If you already run an earlier version of this hook under another name, keep exactly one copy, or every request is scanned twice and every block is logged and mailed twice.
<?php /** * Suriq stopgap hook for WHMCS CVE-2026-67399 (unauthenticated RCE). * https://suriq.io/blog/whmcs-cve-2026-67399-stopgap-hook * * Defense in depth for installs that cannot move to 8.13.7 / 9.0.8 yet. * Not a vendor patch and not a confirmed fix: the bug's mechanism is not * public. It closes the two payload classes such a bug most likely needs: * 1. the phar:// stream wrapper (metadata deserialization on PHP 7.x) * 2. PHP serialized-object markers (O:n:"..":n:{ / C:n:"..":n:{) or * phar:// anywhere in the request: 403 + activity log + optional mail * * WHMCS includes every file in includes/hooks/ during init, on every * request, before page, cart, API and gateway-callback code runs, so the * top-level calls at the bottom of this file act as a request filter. * * Override any constant from configuration.php. Remove the file after * upgrading; rollback is deleting it. * * Version 1.0, 2026-09-04. SPDX-License-Identifier: MIT. Provided as is. */ if (!defined('WHMCS')) { die('This file cannot be accessed directly'); } defined('SURIQ_CVE_2026_67399_ALERT_EMAIL') || define('SURIQ_CVE_2026_67399_ALERT_EMAIL', ''); defined('SURIQ_CVE_2026_67399_ALERT_THROTTLE_SECONDS') || define('SURIQ_CVE_2026_67399_ALERT_THROTTLE_SECONDS', 600); defined('SURIQ_CVE_2026_67399_THROTTLE_DIR') || define('SURIQ_CVE_2026_67399_THROTTLE_DIR', sys_get_temp_dir() . '/suriq_cve_2026_67399'); defined('SURIQ_CVE_2026_67399_EXEMPT_ADMINS') || define('SURIQ_CVE_2026_67399_EXEMPT_ADMINS', false); defined('SURIQ_CVE_2026_67399_DISABLE_PHAR') || define('SURIQ_CVE_2026_67399_DISABLE_PHAR', true); /** * First attack marker found in $data (recursively), or null. */ function suriq_cve_2026_67399_find_marker($data, $depth = 0) { if (is_array($data)) { foreach ($data as $k => $v) { $hit = suriq_cve_2026_67399_find_marker($k, $depth); if ($hit === null) { $hit = suriq_cve_2026_67399_find_marker($v, $depth); } if ($hit !== null) { return $hit; } } return null; } if (!is_string($data) || $data === '') { return null; } // WHMCS has already run $_GET/$_POST through htmlspecialchars by the time // hook files load, so a literal " arrives as ". Check raw, URL-decoded // once and twice, and entity-decoded. Lengths accept a '+' (WAF bypass). $once = rawurldecode($data); $twice = rawurldecode($once); $plain = stripslashes(html_entity_decode($twice, ENT_QUOTES | ENT_HTML5, 'UTF-8')); foreach (array($data, $once, $twice, $plain) as $s) { if (preg_match('/(?<![A-Za-z0-9])[OC]:\+?\d+:"[^"]{1,255}":\+?\d+:\{/', $s, $m)) { return $m[0]; } if (stripos($s, 'phar://') !== false) { return 'phar://'; } } if ($depth < 3) { // JSON layer: undo the string escapes (\" \/ \uXXXX) in one pass over the // text instead of decoding and walking the document, so a large body // costs O(n) and cannot become a CPU sink. foreach (($twice === $data ? array($data) : array($data, $twice)) as $t) { if (strpos($t, '\\') === false) { continue; } $u = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', 'suriq_cve_2026_67399_unescape', $t); $u = str_replace(array('\\"', '\\/', '\\\\'), array('"', '/', '\\'), $u); if ($u !== $t) { $hit = suriq_cve_2026_67399_find_marker($u, $depth + 1); if ($hit !== null) { return $hit; } } } // 16 base64 chars = 12 bytes, enough for phar://x or O:1:"a":. if (preg_match_all('/[A-Za-z0-9+\/_-]{16,}={0,2}/', $plain, $mm)) { foreach (array_slice($mm[0], 0, 50) as $tok) { $dec = base64_decode(strtr($tok, '-_', '+/'), true); if ($dec !== false && $dec !== '' && preg_match('/[OC]:\+?\d+:"|phar:\/\//i', $dec)) { $hit = suriq_cve_2026_67399_find_marker($dec, $depth + 1); if ($hit !== null) { return 'base64(' . $hit . ')'; } } } } } return null; } /** * \uXXXX to its ASCII character; non-ASCII code points become '?', so * escaped foreign text can never assemble a marker out of low bytes. */ function suriq_cve_2026_67399_unescape($m) { $cp = hexdec($m[1]); return $cp < 128 ? chr($cp) : '?'; } /** * Log-safe fragment: no angle brackets or control bytes, bounded length. */ function suriq_cve_2026_67399_clean($s, $max) { return substr(preg_replace('/[<>\x00-\x1F\x7F]+/', ' ', (string) $s), 0, $max); } /** * Inspect the current request; block, log and alert if a marker is present. */ function suriq_cve_2026_67399_guard() { if (PHP_SAPI === 'cli') { return; } // Off by default: a logged-in admin's browser can be made to send the // payload by a malicious page, and the exemption would wave it through. if (SURIQ_CVE_2026_67399_EXEMPT_ADMINS && !empty($_SESSION['adminid'])) { return; } $headers = array(); foreach ($_SERVER as $k => $v) { if (strncmp($k, 'HTTP_', 5) === 0 && is_string($v)) { $headers[$k] = $v; } } // QUERY_STRING and php://input are the only sources WHMCS has not touched. $sources = array( 'URI' => (string) (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '') . ' ' . (string) (isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : ''), 'QUERY' => (string) (isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : ''), 'GET' => $_GET, 'POST' => $_POST, 'COOKIE' => $_COOKIE, 'HEADERS' => $headers, ); $ct = strtolower(isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : ''); if (strpos($ct, 'multipart/form-data') === false) { $sources['BODY'] = (string) @file_get_contents('php://input', false, null, 0, 2097152); } foreach ($_FILES as $f) { $sources['FILES'][] = isset($f['name']) ? $f['name'] : ''; } foreach ($sources as $where => $data) { $hit = suriq_cve_2026_67399_find_marker($data); if ($hit === null) { continue; } $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'unknown'; $xff = isset($_SERVER['HTTP_CF_CONNECTING_IP']) ? $_SERVER['HTTP_CF_CONNECTING_IP'] : (isset($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : '-'); $msg = sprintf( 'CVE-2026-67399 BLOCKED: %s marker "%s" in %s. IP=%s XFF=%s uid=%s admin=%s UA=%s', $where, suriq_cve_2026_67399_clean($hit, 60), suriq_cve_2026_67399_clean(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '', 300), suriq_cve_2026_67399_clean($ip, 45), suriq_cve_2026_67399_clean($xff, 60), isset($_SESSION['uid']) ? (int) $_SESSION['uid'] : '-', isset($_SESSION['adminid']) ? (int) $_SESSION['adminid'] : '-', suriq_cve_2026_67399_clean(isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 200) ); if (function_exists('logActivity')) { logActivity($msg, isset($_SESSION['uid']) ? (int) $_SESSION['uid'] : 0); } suriq_cve_2026_67399_alert($msg); if (!headers_sent()) { http_response_code(403); } exit('Forbidden'); } } /** * Throttled mail: one per source IP per ALERT_THROTTLE_SECONDS and at most * one per minute overall. If the throttle state cannot be written, no mail * is sent; the activity log stays the durable record. Never fatal. */ function suriq_cve_2026_67399_alert($msg) { if (SURIQ_CVE_2026_67399_ALERT_EMAIL === '') { return; } try { if (!is_dir(SURIQ_CVE_2026_67399_THROTTLE_DIR)) { @mkdir(SURIQ_CVE_2026_67399_THROTTLE_DIR, 0700, true); } $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'unknown'; $now = time(); foreach (array(sha1($ip) => SURIQ_CVE_2026_67399_ALERT_THROTTLE_SECONDS, '_global' => 60) as $key => $window) { $lock = SURIQ_CVE_2026_67399_THROTTLE_DIR . '/' . $key . '.lock'; if (is_file($lock) && ($now - @filemtime($lock)) < $window) { return; } if (!@touch($lock)) { return; } } $host = substr(preg_replace('/[^A-Za-z0-9.-]/', '', isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''), 0, 80); if ($host === '') { $host = 'whmcs'; } @mail( SURIQ_CVE_2026_67399_ALERT_EMAIL, '[' . $host . '] CVE-2026-67399 attack attempt blocked', $msg . "\n\nTime: " . date('Y-m-d H:i:s') . "\n" . 'Request: ' . suriq_cve_2026_67399_clean((isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '') . ' ' . (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''), 400) . "\n" . 'Referer: ' . suriq_cve_2026_67399_clean(isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '', 300) . "\n" ); } catch (\Throwable $e) { // alerting must never break the block } } // Layer 1 runs for web AND cron/CLI: the invoice PDF is rendered by cron. if (SURIQ_CVE_2026_67399_DISABLE_PHAR && in_array('phar', stream_get_wrappers(), true)) { @stream_wrapper_unregister('phar'); } // Layer 2 steps aside on CLI (see the top of the function). suriq_cve_2026_67399_guard();
The five constants at the top are the only knobs. ALERT_EMAIL turns mail on. ALERT_THROTTLE_SECONDS is the per-IP mail interval; a second, global one-per-minute cap protects you from an attacker rotating addresses. THROTTLE_DIR is where the two lock files live, under the system temp directory by default. EXEMPT_ADMINS skips the scan for a logged-in administrator, for the case where a third-party module posts serialized data from its own admin form. Leave it off unless that bites you: a malicious page can make an administrator's browser send the payload, and the exemption would then wave it through. DISABLE_PHAR is the first layer; set it to false only if something on your install genuinely needs phar at runtime. On a shared host, point THROTTLE_DIR at a directory only your account can write, because a predictable name under the shared temp directory lets a neighbour pre-create it and silence your alerts (the activity log is unaffected).
Prove it on your server
Two checks, and the second one matters more.
The self-check is a unit test for the detector. It feeds the marker function about a dozen clean inputs that must pass (a Stripe webhook body, a PayPal notification, a JSON web token, a serialized array, benign base64) and about two dozen attack shapes that must be caught (entity-encoded, URL-encoded once and twice, the + length trick, JSON-escaped quotes and slashes, nested arrays, base64-wrapped, phar in several spellings). Run it from a directory outside the web root and never copy it into includes/hooks, because WHMCS would include it on every request. Run it with the same PHP binary your web server uses: on our host the web PHP was 7.4 and the command-line PHP was 7.2, and they are not the same thing.
<?php // Self-check for Suriq_WHMCS_CVE_2026_67399_Guard.php. Run it from the CLI, from a // directory OUTSIDE the web root, with the same PHP binary your web server uses: // php suriq_cve_2026_67399_selfcheck.php /path/to/whmcs/includes/hooks/Suriq_WHMCS_CVE_2026_67399_Guard.php // Never copy this file into includes/hooks/: WHMCS would include it on every request. if (PHP_SAPI !== 'cli' || defined('WHMCS')) { return; } // Explicit checks, not assert(): servers that pin zend.assertions=-1 compile assert() out. function check($cond, $msg) { if (!$cond) { fwrite(STDERR, "FAIL: $msg\n"); exit(1); } } $hook = isset($argv[1]) ? $argv[1] : __DIR__ . '/Suriq_WHMCS_CVE_2026_67399_Guard.php'; check(is_file($hook), "hook not found: $hook"); define('WHMCS', true); require $hook; $f = 'suriq_cve_2026_67399_find_marker'; // Must pass: real-world clean inputs. $clean = array( array('firstname' => 'José', 'companyname' => 'O:Rly Ltd', 'address1' => '12:30 Main St O:1'), array('companyname' => 'Müller & Söhne GmbH', 'notes' => 'a:1:{i:0;s:3:"abc";}'), '{"id":"evt_1","object":"event","data":{"object":{"id":"pi_1"}}}', // Stripe webhook 'txn_id=1AB&[email protected]&custom=123&mc_gross=51.92', // PayPal IPN 'O:8 not serialized', 'C:1:"x"', 'O:8:"stdClass"', 'FOO:1:"x":1:{', 'phar', 'http://phar.example.com/', str_repeat('a:1:{i:0;s:3:"abc";}', 3), // serialized array, no object base64_encode('{"id":"evt_1","object":"event"}'), // benign base64 JSON 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG', // JWT-like '{"cart":[{"pid":"12","domain":"example.com","opts":{"O":"1"}}]}', // benign JSON '{"name":"\\u05d9\\u05d5\\u05e8\\u05dd \\u00d6zt\\u00fcrk","note":"12:30 \\"Room O:1\\""}', // JSON-escaped non-ASCII text array('name' => array('a.jpg', 'b.pdf')), // files[] upload names ); foreach ($clean as $c) { check($f($c) === null, 'false positive on ' . json_encode($c, JSON_UNESCAPED_UNICODE)); } // Must block: attack shapes and known WAF bypasses. $attacks = array( 'O:8:"stdClass":0:{}', 'C:11:"ArrayObject":21:{x:i:0;a:0:{};m:a:0:{}}', 'O:+8:"stdClass":0:{}', // '+' length bypass 'a=O:23:"GuzzleHttp\\Cookie\\FileCookieJar":4:{', 'O:12:"Monolog\\Test":0:{', '{"k":"O:8:\"stdClass\":0:{}"}', // JSON-escaped quotes 'O:8:"stdClass":0:{}', // WHMCS-sanitized $_GET 'O:8:"stdClass":0:{}', 'O:8:\\"stdClass\\":0:{}', // numeric entity / entity + addslashes 'O%3A8%3A%22stdClass%22%3A0%3A%7B%7D', // url-encoded 'O%253A8%253A%2522stdClass%2522%253A0%253A%257B%257D', // double url-encoded 'phar://../x.jpg/y', 'PHAR://x', 'phar%3A%2F%2F/tmp/x', 'php://filter/resource=phar://x', '{"src":"phar:\\/\\/..\\/x.jpg"}', // JSON-escaped slashes array('x' => array('y' => 'O:8:"stdClass":0:{}')), // nested value array('O:8:"stdClass":0:{}' => 'key attack'), // key array('name' => array('a.jpg', 'O:8:"stdClass":0:{}')), // marker in a files[] name base64_encode('O:8:"stdClass":0:{}'), // base64-wrapped object 'tok=' . rtrim(strtr(base64_encode('x=phar://../a.jpg'), '+/', '-_'), '='), // url-safe base64 phar '{"outer":{"inner":"O:8:\\"stdClass\\":0:{}"}}', // nested JSON string '{"u":"\\u004f:8:\\"stdClass\\":0:{}"}', // JSON \u escape '{"u":"O\\u003a8\\u003a\\"stdClass\\":0:{}"}', // \u-escaped colons '{"w":"{\\"k\\":\\"O:8:\\\\\\"stdClass\\\\\\":0:{}\\"}"}', // JSON string holding JSON ); foreach ($attacks as $a) { check($f($a) !== null, 'missed attack ' . json_encode($a)); } check(!SURIQ_CVE_2026_67399_DISABLE_PHAR || !in_array('phar', stream_get_wrappers(), true), 'phar wrapper still registered'); echo "SELF-CHECK OK\n";
php suriq_cve_2026_67399_selfcheck.php /path/to/whmcs/includes/hooks/Suriq_WHMCS_CVE_2026_67399_Guard.php SELF-CHECK OK
It uses explicit checks rather than assert() on purpose. Our production php.ini pinned zend.assertions=-1, which compiles assertions out, and the first version of this script printed OK on the server without testing anything. A test that cannot fail is worse than no test. Found a bypass or a false positive? Write to [email protected] with the request shape and we will fold it into the next version.
The probes are the real acceptance test, because they exercise the plumbing the unit test cannot: does the hook actually run before your pages, and does it see the query string, the body, the cookies and the headers on your web server? Both of the bugs we found in earlier versions were found this way, not by the self-check, so the list deliberately covers the layers that broke: double URL-encoding, JSON with escaped quotes, JSON with a unicode-escaped letter, nested JSON, base64 in the query, URL-safe base64 in a form body, a cookie, a header and the URL path. Point BASE at your install. The first group must return 403; the second must return whatever your site normally returns, usually 200 or 302.
BASE=https://billing.example.com # must all be 403 curl -g -s -o /dev/null -w '%{http_code}\n' "$BASE/cart.php?a=O:8:\"stdClass\":0:{}" curl -s -o /dev/null -w '%{http_code}\n' "$BASE/cart.php?a=O%253A8%253A%2522stdClass%2522%253A0%253A%257B%257D" curl -s -o /dev/null -w '%{http_code}\n' --data-urlencode 'message=O:8:"stdClass":0:{}' "$BASE/submitticket.php" curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: application/json' --data '{"k":"O:8:\"stdClass\":0:{}"}' "$BASE/index.php" curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: application/json' --data '{"k":"\u004f:8:\"stdClass\":0:{}"}' "$BASE/index.php" curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: application/json' --data '{"a":{"b":{"c":"phar:\/\/x"}}}' "$BASE/index.php" curl -s -o /dev/null -w '%{http_code}\n' "$BASE/index.php?t=Tzo4OiJzdGRDbGFzcyI6MDp7fQ==" curl -s -o /dev/null -w '%{http_code}\n' --data 'tok=eD1waGFyOi8vLi4vYS5qcGc' "$BASE/index.php" curl -s -o /dev/null -w '%{http_code}\n' -b 'x=phar://x' "$BASE/" curl -s -o /dev/null -w '%{http_code}\n' -A 'phar://x' "$BASE/" curl -g -s -o /dev/null -w '%{http_code}\n' "$BASE/index.php/O:8:\"stdClass\":0:{}" # must be your normal response (usually 200 or 302) curl -s -o /dev/null -w '%{http_code}\n' "$BASE/" curl -s -o /dev/null -w '%{http_code}\n' "$BASE/cart.php" curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: application/json' --data '{"id":"evt_1","object":"event","data":{"object":{"id":"pi_1"}}}' "$BASE/" curl -s -o /dev/null -w '%{http_code}\n' -H 'Content-Type: application/json' --data '{"name":"יורם Öztürk","note":"12:30 \"Room O:1\""}' "$BASE/" curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' "$BASE/"
One caveat if a CDN or web application firewall sits in front of WHMCS: it may return its own 403 for these probes before the request reaches PHP. That is fine, but it proves nothing about the hook. The proof is the activity-log line. Run the probes, then open Utilities, Logs, Activity Log in the admin area, or query the table directly, and confirm one BLOCKED entry per probe.
Watch it
Three queries cover the operational questions: is anyone trying, who, and did we block a real user.
-- blocks per day SELECT DATE(date) AS d, COUNT(*) FROM tblactivitylog WHERE description LIKE 'CVE-2026-67399 BLOCKED:%' GROUP BY d ORDER BY d DESC; -- source addresses, for your firewall SELECT SUBSTRING_INDEX(SUBSTRING_INDEX(description, 'IP=', -1), ' ', 1) AS ip, COUNT(*) AS c, MAX(date) FROM tblactivitylog WHERE description LIKE 'CVE-2026-67399 BLOCKED:%' GROUP BY ip ORDER BY c DESC; -- false-positive check: any block with a logged-in client or admin SELECT date, description FROM tblactivitylog WHERE description LIKE 'CVE-2026-67399 BLOCKED:%' AND description NOT LIKE '%uid=- admin=-%';
The known false positive is a staff member pasting a PHP-serialized string into a ticket reply or a client note; that request gets a 403 and shows up in the third query with the admin id. If that happens in your workflow, set EXEMPT_ADMINS to true. Behind a CDN, the IP field holds the edge address; the XFF field in the same line holds the connecting client address as the CDN reported it, so use that one for blocking.
What it covers and what it does not
This is the honest part. Each row is a hypothesis about how a forged payload could reach code execution through the invoice code, and whether the hook stands in the way.
| Possible mechanism | Covered? |
|---|---|
| A serialized PHP object in the request that WHMCS unserializes (object injection) | Yes. Every request source, every encoding, JSON escapes and base64 tokens. |
| A phar:// path reaching a file operation (PHP 7.x metadata deserialization) | Yes. The wrapper is off and the marker is blocked in every source. |
| A payload in the URL path (9.0.8 added route middleware named EncodedPathGuard) | Yes. The path and query are scanned, decoded twice. |
| A payload wrapped in JSON or base64 that WHMCS decodes before use | One layer, depth-limited. A payload wrapped three times passes. |
| A forged gateway callback | Only if you also lock the callback directory (next section). |
| A trigger from stored data, for example a field saved earlier and read by the cron that renders invoice PDFs | Not by the scanner, which never sees stored data. The phar layer still holds. Object injection via stored data is open. |
| A logic flaw with no serialization and no phar | No. Only the upgrade closes it. |
| Bodies beyond the first 2 MB, contents of uploaded files | Not scanned. A planted phar archive is harmless while the wrapper is off. |
Why there were four versions before this one
The changelog is worth reading because every entry is a mistake you would otherwise repeat.
- v1 matched the serialized-object shape on raw quotes only. The first live probe sailed through the query string, because WHMCS runs
$_GETand$_POSTthroughhtmlspecialcharsbefore hook files load: a literal"arrives as". The raw query string and the raw body are the only sources WHMCS has not touched by then. - v2 added the entity-decoded variant and the raw query string. Fifteen attack shapes returned 403; fifteen clean requests were unchanged.
- v3 added the URL path, every HTTP header, JSON and base64 layers.
- v4 dropped the base64 minimum from 24 to 16 characters (a short phar:// sample encodes to 23) and stopped running
stripslashesbefore JSON handling, which had been destroying the very escapes it needed to read. Both misses were found by live probes, not the unit checks, which is why the self-check grew and why this post makes you run the probes. - 1.0, this release, rewrites the JSON layer as a single pass that undoes string escapes instead of decoding and walking the document (walking a 2 MB document was the slow path), strips angle brackets and control bytes from everything it logs, sanitizes the host used in the mail subject, makes every constant overridable, adds the admin exemption and phar toggles, and gives the self-check a guard that refuses to run inside WHMCS.
Optional: lock the gateway callback directory
Gateway callback files, the scripts in modules/gateways/callback/, are the WHMCS endpoints designed to accept unauthenticated input from the outside world and hand it to payment and invoice code. That makes them the most direct route to the class patched here. Many installs do not need most of them. Bank transfer, cash, mail-in and server-to-server gateways never receive a callback. On our install, five gateways were active, none used a callback file, and the access logs showed no request to the directory in two weeks, so we denied the whole directory.
Check before you copy. The first query lists the gateways your install shows to clients; the second shows what unpaid invoices are actually waiting on.
SELECT gateway, value FROM tblpaymentgateways WHERE setting = 'visible'; SELECT DISTINCT paymentmethod FROM tblinvoices WHERE status = 'Unpaid';
If any gateway in that list confirms payments through a callback (PayPal, Stripe and most card processors do), you must allowlist its callback file or payments stop being marked paid. The file below denies everything and shows the allowlist shape; it uses the same two-syntax Apache idiom WHMCS itself ships in the patch set for its vendor directory, so it works on Apache 2.2, 2.4 and LiteSpeed. The 2Checkout files stay denied regardless, because of CVE-2026-67398.
# modules/gateways/callback/.htaccess # Deny every gateway callback, then allow only the files your active gateways use. # List them first (8.x schema): # SELECT gateway, value FROM tblpaymentgateways WHERE setting = 'visible'; # SELECT DISTINCT paymentmethod FROM tblinvoices WHERE status = 'Unpaid'; # tco.php and 2checkout.php stay denied (CVE-2026-67398). <IfModule mod_authz_core.c> Require all denied </IfModule> <IfModule !mod_authz_core.c> Order deny,allow Deny from all </IfModule> # One block per callback file you need, for example PayPal: #<Files "paypal.php"> # <IfModule mod_authz_core.c> # Require all granted # </IfModule> # <IfModule !mod_authz_core.c> # Order allow,deny # Allow from all # </IfModule> #</Files>
On nginx there is no .htaccess. The equivalent is one location rule placed above your PHP handler, with your allowed files in the negative lookahead.
location ~ ^/modules/gateways/callback/(?!paypal\.php$).*\.php$ { return 403; }
Then request one denied callback file and one allowed one in a browser and confirm 403 and the gateway's normal response.
What this does not replace
Three things, in order.
The upgrade. Every row marked "No" above is closed by 8.13.7 or 9.0.8 and by nothing else. Old 8.x installs usually need a newer PHP and a pass over custom modules first, which is exactly why the stopgap exists: it buys the days that work takes, not a reason to skip it.
Web-server hardening you may already owe yourself. If your web PHP still has an empty disable_functions, setting it for the web SAPI removes the tools most post-exploitation payloads reach for first. Check your cron scripts before you copy this line, because they may need some of these functions, and put it only where the web PHP reads it.
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_execThe hunt. A stopgap installed on September 4 says nothing about September 3. The news post ends with what to look for after the window: files in the web root you did not ship, administrator accounts you did not create, hook and template directories that differ from a known-good copy, and unusual unauthenticated POST requests in the pre-patch logs. Do that regardless of whether you install the hook. And if you run the 2Checkout module, deactivate it until you have patched; that one the vendor does offer as an interim step for CVE-2026-67398.
Install the hook, run the probes, watch the log, and schedule the upgrade. Then delete the file and forget it existed. That is what a stopgap is for.