Two of the four Joomla extension flaws from the CISA-KEV summer of 2026 are simple — an anonymous endpoint, a file lands in the webroot. What neither the advisories nor the public PoCs mention: Joomla core puts two independent upload gates in the way, and the bypass trick differs for each. Gate 1 (InputFilter::isSafeFile, an archive content scan via strstr) is case-sensitive → shell.PHP sails through (CVE-2026-48908). Gate 2 (File::upload, an extension check via strtolower) is case-insensitive → .PHP does not help; what gets through here is .htaccess (CVE-2026-48939). Both verified live on Joomla 5.4/PHP 8.3 and 4.4/PHP 8.0.
Part 1 — CVE-2026-48908 (SP Page Builder) and Gate 1: the archive content scan
The bug in brief
SP Page Builder is one of the most widely deployed page builders for Joomla (more than a million active installations). The component ships a site controller (components/com_sppagebuilder/controllers/asset.php) that, among other things, exposes uploadCustomIcon: an anonymous endpoint for uploading an “IcoMoon” ZIP of custom icons.
The diff between 6.6.1 and 6.6.2 pinpoints the problem:
- 6.6.1: the controller has no authentication whatsoever. Anyone who knows the URL can upload the ZIP.
- 6.6.2:
uploadCustomIcon()itself is unchanged — instead the controller gains a constructor guard: withoutcore.admin/core.managepermission → 403, without a session (!user->id) → 401.
When the uploaded ZIP contains a plausible selection.json (holding, among other things, fontFamily and prefix), SP Page Builder copies the entire unpacked fonts/ directory into the webroot, at:
Every file placed in fonts/ is then reachable over HTTP. That is an unauthenticated arbitrary file write into the webroot — on shared hosting with thousands of sites, the worst case.
The gate nobody mentions: InputFilter::isSafeFile()
Here it gets interesting. Before SP Page Builder ever sees the file, Joomla core stands in the way. Every upload passes through Joomla\CMS\Filter\InputFilter::isSafeFile(). That function knows a list of archive extensions (php_ext_content_extensions: zip, rar, tar, gz, tgz, bz2, tbz, jpa) and treats uploaded archives differently from single files: it reads the raw byte stream of the upload and searches it for PHP artifacts.
Decisive for this case: the search for the string .php is done with strstr() — which, as the PHP documentation states unambiguously, is case-sensitive (for case-insensitive there would be stristr()).
Why does this hit every upload of a shell, no matter how cleverly the ZIP is built?
ZIP member names are stored in cleartext. In the ZIP format only the payload of a member is compressed — the file names in the local file header and the central directory are stored uncompressed. A member named fonts/shell.php therefore makes the string .php appear verbatim in the raw bytes of the upload → strstr() finds it → Joomla rejects the upload long before SP Page Builder does anything with it. shell.pHp, SHELL.PHP and so on would change nothing — the name only has to contain .php in lowercase somewhere. The converse holds too: a member named shell.PHP does not contain the needle string, and because our file's content is a harmless echo marker (no <?php tag), the second scan (for <?) finds nothing either.
The bypass therefore costs exactly one capital letter. In our Docker harness the counter-proof looks like this — the same ZIP, once with fonts/dast_marker.php, once with fonts/dast_marker.PHP:
Why public PoCs fail on stock Joomla
The PoCs circulating publicly for CVE-2026-48908 typically pack their shell as shell.php (or fonts/shell.php) into the icon ZIP. Against a stock Joomla you get, everywhere — Joomla 3, 4 and 5 — the response {"status":false,"output":"Upload Failed"} and wrongly conclude the server is not vulnerable, or already patched. In reality the exploit code never arrived: the core filter, not the vendor fix, stopped the upload.
That has two practical consequences:
- A PoC that only checks the JSON status is worthless. “Upload Failed” can mean a core-filter rejection or a genuine malfunction. You have to prove the file write by its result.
- The working path is trivial but unfamiliar: a
.PHPmember in the ZIP. That the marker is then served statically is enough to prove the write; execution as PHP is not yet implied (see the honest framing below).
For context: isSafeFile() is old, much-seen core code — the uppercase bypass is not a new weakness in Joomla but a property of a known check that, in our research, is nowhere publicly tied to this flaw. The actual vulnerability is, as described above, in SP Page Builder.
The PoC that works
The whole icon ZIP is about 480 bytes and consists of three members:
selection.json—{"preferences":{"fontPref":{"metadata":{"fontFamily":"dasticons"},"prefix":"icon-"}}}style.css—.icon-dast:before { content: "\e001"; }fonts/dast_marker.PHP—<?php echo "DAST_OK_48908";
The builder (Python standard library, no dependencies):
And the request against a target server:
The marker is deliberately inert — an echo, no system() calls, no further processing. It proves the write without doing harm itself or acting as a shell.
The PoC was checked in our Docker harness against fresh default installs — Joomla 5.4 with PHP 8.3 and Joomla 4.4 with PHP 8.0, each with SP Page Builder 6.6.1 and 6.6.2: on 6.6.1 the marker is placed unauthenticated and served (vulnerable); on 6.6.2 the upload is rejected with HTTP 403 and the marker GET returns 404 (patched). There is no PHP-version dependency of the vector — on both PHP versions the endpoint behaves identically; the only difference is the SP Page Builder version.
Honest framing: file write ≠ executed code
Three points belong to a fair picture:
- Serving a
.PHPfile statically is not yet code execution. Apachemod_php/PHP-FPM usually maps execution to the lowercase.phpextension; our.PHPis served as text. That is entirely sufficient as an oracle for the write. A true remote code execution via this path would need additional steps (classically an.htaccesswithAddHandler/<FilesMatch>that also treats.PHPas PHP — which in turn would first need a writable.htaccesslocation). We deliberately did not escalate further; the value of the proof is the safe demonstration of the write. - The CVSS 10.0 rating is nonetheless reasonable: “write arbitrary files unauthenticated into the webroot” is enough, depending on the target environment, for site defacement, malware drops, SEO-spam injections or — with the
.htaccessstep — full RCE. CISA lists the flaw in the KEV catalog as actively exploited. - For defenders the detour changes nothing: updating to 6.6.2 closes the hole completely (the guards sit in the controller constructor, not just in the upload task), and the free Lite edition is identically affected in all versions ≤ 6.6.1.
Part 2 — CVE-2026-48939 (iCagenda) and Gate 2: File::upload and the extension check
Same week, same class, different gate
CVE-2026-48939 (iCagenda, in the CISA KEV catalog since 2026-07-10) is the second upload-based KEV entry in the Joomla extension space: the frontend “submit event” form (index.php?option=com_icagenda&view=submit, task=submit.submit) accepts an anonymous file attachment (jform[file]), and iCagenda ≤ 4.0.7 stores it unchecked in images/icagenda/frontend/attachments/ — file name slugified, extension unchanged, no MIME check, no extension check, no blacklist check. 4.0.8 adds MediaHelper::canUpload().
Anyone who now uploads a PHP marker meets a surprise the advisory keeps quiet about — and that has nothing to do with iCagenda:
Gate 2: File::upload calls isSafeFile — and the extension check knows no case trick
iCagenda's upload ends in Joomla's core API: File::upload($src, $dest, false). The third parameter is $useStreams — the fourth, $allowUnsafe, defaults to false. And so, before a single byte is written, this runs:
The same isSafeFile() that scanned the ZIP content in Part 1 — but now in its second guise: the extension check. And it works differently from the content scan:
Every dot-segment of the file name is lowercased and checked against FORBIDDEN_FILE_EXTENSIONS: php, phar, pht, phtml, php3…php9, phps, asp, aspx, cgi, fcgi, inc, jar, java, pl, py, sht, shtm, shtml, stm. The trick from Part 1 — an uppercase .PHP — does not work here, because strtolower runs before the comparison. Verified live on 4.0.7: both marker.php and marker.PHP are silently swallowed — the event is created, the file column stays empty, and the browser is dutifully redirected to the thank-you page. A scan that looks at the HTTP status or the redirect target reads “success” and misses that the file never arrived.
The reliable counter-check is, as always, the stored content:
The marker that gets through — and why it is the most dangerous
.htaccess is not in FORBIDDEN_FILE_EXTENSIONS — so core File::upload stores it itself on the vulnerable iCagenda 4.0.7 branch. At the same time, iCagenda 4.0.8 rejects exactly this type via MediaHelper::canUpload() (.htaccess is not an allowed media extension). That makes .htaccess the perfect discriminating marker: it passes 4.0.7 and is stopped by the 4.0.8 fix — and it is, technically, the most consequential type too, because an Apache config droppable into a served directory is the classic RCE escalation step (SetHandler/AddType for a harmless companion file). Our test harness runs AllowOverride None — execution was deliberately not provable there, the marker itself is a comment with no directives, and the oracle remains the write proof via the served content bytes.
The request (the public form's token fetched beforehand via GET, the session cookie sent along; the 4.0.8 fix additionally requires submitAccess=["1"] in the component params and a real submit menu item — both part of the test setup, not of the exploit):
The same discipline as in the scanner applies here: first run a harmless .txt through the complete chain and verify the stored content (this proves token, session, menu item, category and upload pipeline on both arms) — only then does the .htaccess response count as a fix difference rather than a configuration error.
Verification: as in Part 1, in fresh Docker stacks (Joomla 5.4/PHP 8.3) against iCagenda 4.0.7 and 4.0.8: 4.0.7 places and serves the marker, 4.0.8 rejects it. The machine-readable case is held in our in-house DAST harness. Two field notes from practice: Joomla's calendar field filters on DATE_FORMAT_FILTER_DATETIME = "Y-m-d H:i:s" (seconds are mandatory, otherwise “Invalid field”), and with SEF enabled the POST must go to the form action (/index.php/component/icagenda) — the raw option=… URL 404s in the router.
Detection over exploitation — what black-box scanners can learn
For vulnerability scanners this case is a prime example of why arming proofs are indispensable:
- A scan that only checks whether the upload “fails” produces the same false negative on a core-filter rejection, a target without the extension, and a patched target. The only valid oracle is: was a file placed in the webroot and served?
- The two versions' responses differ cleanly: 6.6.1 answers the upload with component JSON and status 200 (marker placed), 6.6.2 with 403 — before the extension processes the request at all. A target without the extension, by contrast, answers with Joomla's component 404 — that is a scan error (target wrongly armed), never a “patched”.
- The
.PHPmarker is ideal for production scans: it leaves no executable code behind and still proves the full exploit path up to webroot serving.
We have captured the case as a machine-readable test case in our in-house DAST harness: a case definition with the steps upload → marker GET → three-verdict logic VULNERABLE/PATCHED/INCONCLUSIVE, plus a Docker harness that reproducibly reconstructs the claims of this article.
Conclusion
Both KEV flaws are, at their core, alarmingly simple — an anonymous endpoint, a file in the webroot. But the path there runs, each time, past a different, poorly documented core gate that silently neutralizes public PoCs and produces false “not vulnerable” signals:
- Gate 1 (archive content scan,
strstr) is case-sensitive →shell.PHPgets through (CVE-2026-48908). It affects uploads whose archive type is inphp_ext_content_extensions(zip, rar, tar, gz, …). - Gate 2 (extension check in
File::upload,strtolower) is case-insensitive → uppercase does not help; what gets through is whatever is not inFORBIDDEN_FILE_EXTENSIONS— in the iCagenda case,.htaccess(CVE-2026-48939). It affects every upload written via the core API with$allowUnsafe=false— i.e. nearly all of them.
Anyone who wants to prove upload flaws in Joomla extensions dynamically has to know both gates, prove the write by the placed marker (never by the upload response — Gate 2 redirects to “thank you” while silently discarding), and run a harmless control type through the same chain to separate a fix difference from a configuration error.
Affected: Joomla with SP Page Builder ≤ 6.6.1 (→ 6.6.2+) or iCagenda ≤ 4.0.7 (→ 4.0.8+; the 3.x branch fixes in 3.9.15). Indicators of compromise (iCagenda): unknown files under images/icagenda/frontend/attachments/ — especially .htaccess — and event rows with an empty file column that nonetheless redirected to the thank-you page.
All technical claims in this article were verified live against fresh Joomla test installs (5.4/PHP 8.3, plus 4.4/PHP 8.0 for Part 1) with the named extension versions; the case definitions and the reproducible Docker harness run in-house. The markers are inert and serve defensive verification only.