A stored XSS in iCagenda 4.0.8 through 4.0.12, found while I was rebuilding the extension's KEV-listed upload bug during an incident response. The event submission form stores the image and file fields as raw strings, and the event page prints them straight into an img src and an a href. This memo walks the code line by line, the lab repro, and the retest of the vendor candidate build.
1. How an Incident Response Put Me Inside iCagenda
I did not start this hunt in a lab. I started it during an incident response, on a shared hosting account where several Joomla sites had gone down together. The engagement details stay under NDA, and none of them are needed for what follows.
The layout was the usual one for that kind of host: several virtual hosts running as a single Linux user, which is how one broken site turns an entire account into a crime scene. Two extensions sat at the top of my suspect list: Balbooa Forms and iCagenda. Both carried unauthenticated file upload bugs in 2026. Both are in CISA's Known Exploited Vulnerabilities catalog (CVE-2026-56291 for Balbooa Forms, CVE-2026-48939 for iCagenda). Both were being mass-scanned by bots at the time.
So I read the access logs. The bots were asking for both components, the way they ask on every Joomla site on the internet. On the hosts I could assess, neither extension was installed, and the sites that did fall went down through a different door. That is as far as attribution went, and the figures stay out of this memo on purpose.
What stayed with me was the question the response left open. The logs tell you a component was not the door. They cannot tell you what the exploit does on a site where it is the door. I could not leave it alone: what does a KEV-listed upload chain look like when you run it end to end? To answer that, I rebuilt the environment in the lab. A Joomla install, the vendor packages for 4.0.7, 4.0.8 and 4.0.11, and the public PoCs for CVE-2026-48939.
Rebuilding an exploit means reading the code the fix touched. That is how I ended up staring at one assignment line in the event submission model, wondering why it changed shape between two releases.
The engagement handed me version numbers, vendor packages and a reason to trust nothing about the extension. A cold review installs 4.0.11, sees an upload gate, and moves on. The IR gave me the 4.0.7 tree to diff against, and the diff is where the bug was hiding.
2. The Fix That Closed the KEV Bug
CVE-2026-48939 is the iCagenda bug everyone knows. An unauthenticated attacker uploads a file through the frontend submit form, the file lands in the web root with no extension check, and the attacker runs PHP. It was fixed in 4.0.8 (and in 3.9.15 on the older branch), and it is in the KEV catalog for good reason.
In 4.0.7, the submit model assigns the image and file columns like this (SubmitModel.php:607 and :616):
$eventData->image = ! empty($image['name']) ? $this->frontendImageUpload($image) : '';
$eventData->file = ! empty($file['name']) ? $this->frontendFileUpload($file) : '';
That ternary does two jobs at once. It calls the upload helper when a file was attached, and it stores an empty string when one was not. A POSTed string never reaches the database, because the branch that stores it does not exist. The bug lives inside the helper instead: frontendImageUpload() and frontendFileUpload() wrote whatever the uploader handed them.
The 4.0.8 patch added the missing control. A MediaHelper import arrives at line 36, and the attachment block grows a gate (SubmitModel.php:597 to :609):
$fileLink = '';
if ($file && !empty($file['name'])) {
$helper = new MediaHelper();
$can = $helper->canUpload($file);
if (!$can) {
return false;
}
$fileLink = $this->frontendFileUpload($file);
}
Joomla's own canUpload() is the check that CVE-2026-48939 was missing. The patch is correct, and the assignment lines stay exactly as they were, ternary and all (4.0.8, lines 622 and 631):
$eventData->image = ! empty($image['name']) ? $this->frontendImageUpload($image) : '';
$eventData->file = ! empty($file['name']) ? $this->frontendFileUpload($file) : '';
I verified the other half of that fix in the lab as well. On 4.0.11 a shell.php upload through the submit form is rejected by canUpload() with an invalid file type message, and nothing reaches disk. The upload RCE is closed, and it stays closed on the later builds I tested.
3. The Assignment That Stopped Being Safe
By 4.0.11 the same block has been refactored, and the refactor is the bug. Here is the whole attachment section (SubmitModel.php:594 to :646):
$files = $jinput->files->get('jform', null, 'raw');
$image = $files['image'];
$file = $files['file'];
$imageLink = '';
$fileLink = '';
if ($image || $file) {
$errorUpload = false;
$helper = new MediaHelper();
if (!empty($image['name'])) {
$imageCheck = $image;
$imageCheck['name'] = File::makeSafe($imageCheck['name']);
$canUploadImage = $helper->canUpload($imageCheck);
if ($canUploadImage) {
$imageLink = $this->frontendImageUpload($image);
}
$data['image'] = $imageLink;
}
if (!empty($file['name'])) {
$fileCheck = $file;
$fileCheck['name'] = File::makeSafe($fileCheck['name']);
$canUploadFile = $helper->canUpload($fileCheck);
if ($canUploadFile) {
$fileLink = $this->frontendFileUpload($file);
}
$data['file'] = $fileLink;
}
...
}
Read the branches closely. The write into the data array sits inside the file-name checks. With no file attached, neither branch runs, and the data array keeps whatever the form field carried.
The old code is still in the file, commented out, right underneath (lines 648 to :684). Someone moved the logic into the upload block, and nobody covered the case where the block never executes.
Two lines later, in the storage section, the consequence is visible (SubmitModel.php:697 and :706):
$eventData->image = isset($data['image']) ? $data['image'] : '';
$eventData->file = isset($data['file']) ? $data['file'] : '';
The filtered POST value is stored verbatim. The form field declares filter="safehtml" on both the image field (site/forms/submit.xml:76) and the file field (:323). Joomla strips tags from the value, and that is the only thing standing between the request body and the database column.
The 4.0.7 and 4.0.8 ternaries discarded the POST string by construction. The 4.0.11 code stores it. Same file, same intent, opposite behaviour.
One version-precision note, because the CVE record and the code disagree slightly. The Joomla CNA record scopes CVE-2026-75948 to 4.0.8 through 4.0.12. In the 4.0.8 package I pulled from the vendor update feed, the assignment is still the safe ternary, and that file contains no writes into the data array at all. I could not reproduce the string path on 4.0.8. The vulnerable shape is present in 4.0.11. I did not have 4.0.9 or 4.0.10 packages to bisect the exact release that introduced it. Treat 4.0.8 as the vendor's conservative floor and 4.0.11 as the version I proved.
4. Following the String to the Render Sinks
A stored string is only a bug if something prints it. Two sinks do.
The image sink lives in the shared library, not the component. In lib_ic_library, Get.php::thumbnail() reaches a fallback branch when it cannot read the image to build a thumbnail (Get.php:214 to :223):
$imageIsReadable = @file_get_contents($linkToImage);
if ($imageIsReadable === false) {
if ($linkToImage == $file_local) {
// If image is local, we display original image.
if ($type == 'imgTag' || $type == 'imgTagLinkModal') {
$style = $app->isClient('administrator') ? ' style="outline: 5px solid var(--danger)"' : '';
$img = '<img src="' . $image . '" width="' . $width . '" alt=""/' . $style . '>';
$img.= $app->isClient('administrator') ? $img_not_readable : '';
return $img;
}
An injected string always lands in that branch, because file_get_contents() on something that is not a path fails. The value then goes into the tag by string concatenation, with no escaping anywhere in the chain.
The call chain is short and it starts on the event page. EventShortcuts.php imports the thumb class at line 32 and builds the event image at line 106:
$item->imageTag = $EVENT_IMAGE ? icagendaThumb::sizeLarge($EVENT_IMAGE, 'imgTag', true) : '';
sizeLarge() calls the thumbnail helper with the imgTag mode, which is the branch that concatenates. The same file has two more tag builders in the thumbnail path (Get.php:399 and :401) with the same concatenation pattern, so the sink is not a single line.
The file sink is in the component and it is just as plain. Render.php::fileTag() builds an attachment link (Render.php:194 to :211, the href at :205):
static public function fileTag($file)
{
$path_parts = pathinfo($file);
$attachment = isset($path_parts['filename'])
? '<div class="ic-attachment-filename">' . $path_parts['filename'] . '</div>'
. (isset($path_parts['extension'])
? '<div class="ic-attachment-extension">.' . $path_parts['extension'] . '</div>'
: '')
: Text::_('COM_ICAGENDA_EVENT_DOWNLOAD');
$fileTag = '<div class="ic-attachment-download">';
$fileTag.= '<a class="ic-attachment-link" href="' . $file . '" rel="noopener" target="_blank" title="' . Text::_('COM_ICAGENDA_EVENT_DOWNLOAD') . '">';
$fileTag.= $attachment;
$fileTag.= '</a>';
$fileTag.= '</div>';
return $fileTag;
}
websiteTag() in the same class has the identical shape for the event website field, with the href and the link text both raw at :188. That matters for the patch discussion later, because a fix at the image sink alone leaves the file sink alive, and the vendor had to touch all of them.
The payload has no HTML tags in it. It is a quote, a space and an event handler. The tag closes early, the browser reads the next token as a new attribute, and the handler runs. Input filters that hunt for tags never see anything to strip.
5. Why the Filter Does Not Catch It
Both fields use the same form type, an icagenda.MediaUpload element with filter="safehtml" on it. safehtml removes tags from the submitted value. It does not escape quotes, and it does not validate URIs as paths, so an attribute-breakout string passes validation as plain text.
The lab results were unambiguous. Tag payloads, an img with an onerror handler for example, died at validation and never stored. Attribute-breakout payloads stored every time. The filter is not failing at its job. The job was never wide enough to cover the output side.
6. Who the Payload Fires On
The event detail view gates pending events, and the gate is worth reading because it explains the impact (site/src/View/Event/HtmlView.php:92 to :114):
$userLevels = $user->getAuthorisedViewLevels();
$userGroups = $user->getAuthorisedGroups();
$groupid = ComponentHelper::getParams('com_icagenda')->get('approvalGroups', ['8']);
$groupid = \is_array($groupid) ? $groupid : [$groupid];
$uri = Uri::getInstance();
$return = base64_encode($uri); // Encode Return URL
$rlink = Route::_("index.php?option=com_users&view=login&return=$return", false);
if (!\in_array('8', $userGroups)
&& (!\in_array($item->access, $userLevels)
|| ($item->approval == 1 && ! array_intersect($userGroups, $groupid))
)
) {
A submitted event starts as pending, with the approval flag set to 1. Pending events are visible only to users in the approval groups, and that parameter defaults to group 8, the Super Users group. Super Users skip the gate entirely through the first condition.
So the first render happens in the approver's browser, during the ordinary moderation flow. A submission arrives, a notification email goes out with a link to the event, and the administrator opens it to decide. The payload runs there, with the administrator's session. That is the case I care about most, because the victim does the review on purpose.
After approval the event page is public, and every visitor triggers it. The submission side depends on configuration. submitAccess defaults to Registered, so on a default install any self-registered account reaches the form, and sites that open submissions to the Public access level expose it to anonymous visitors. That is the feature working as documented for community calendars, which is exactly why the bug earned a proper report.
7. The Lab Repro
The lab was Joomla 5.4.7 with iCagenda 4.0.11 on PHP 8.4, Apache and MariaDB 11.4, built from the vendor packages. The chain is four steps.
First, load the submit form and pull the CSRF token out of the hidden input. Second, POST the event with no file attached and the payload in the image field:
POST /index.php?option=com_icagenda&task=submit.submit&itemID=<submit-menu-id>
Content-Type: application/x-www-form-urlencoded
jform[username]=attacker
jform[created_by_email]=a@b.c
jform[title]=Free Pizza
jform[catid]=1
jform[itemid]=<submit-menu-id>
jform[consent][consent_tos]=tos
jform[dates][0][date]=2026-12-01 10:00:00
jform[displaytime]=1
jform[image]=x" onerror="alert(document.domain)
<token>=1
Two details from the debugging sessions cost me real time. The controller reads itemID with a capital D, not the Itemid the rest of Joomla uses, so a request that misses that spelling redirects instead of storing. The token parameter is the raw 32 hex name, sent as its own form field with a value of 1.
The request returns 303, and the row lands with the payload in the image column and the approval flag set. Third, open the event as an approval-group user. The page renders this:
<img src="/x" onerror="alert(document.domain)" width="900" alt=""/>
The image source 404s, the handler fires on load, and no click is required from anyone. Three submissions, three stored rows, three identical rendered fragments. Deterministic.
Then I went looking for variants and dead ends, because the first payload is rarely the interesting one.
- The file field takes the same treatment: a quote and an onmouseover handler in
jform[file]render into the attachment anchor and fire on hover - The file field also accepts a
javascript:URI, which lands in the href and fires on click - Tag payloads die at validation, as covered earlier, so the attribute breakout stays the reliable vector
- Joomla 6.1.2 is a dead end for a different reason: the submit task returns HTTP 500 before storage, even for a benign submission, because the form declares an
IC.PositiveIntegervalidation rule that is not registered on Joomla 6. I sent the stack trace to the vendor with a correction, since my first round of 500s was my own lab database crashing mid-test - CVE-2026-48939 stays closed on 4.0.11: a PHP upload is rejected by the upload gate and nothing reaches disk
That Joomla 6 result is worth keeping in mind when someone reports the extension as unexploitable on 6.x. The code path is identical. It simply cannot be reached, because validation stops the request before storage.
8. Retesting the Vendor Candidate Build
Jooml!IC sent back a candidate build, icagenda-4-0-13-package-dev1, and I ran the full battery against it on an in-place upgrade from 4.0.11.
The patch diff covers 19 files. The security-relevant part is exactly what I asked for. Storage goes back to upload-only (patched SubmitModel.php:697 and :706):
$eventData->image = $imageLink ?? '';
$eventData->file = $fileLink ?? '';
The two variables are initialized empty at lines 598 and 599, and only the upload helpers assign them, after canUpload() passes. A raw POST value has no path to the database anymore. The escaping layer arrived as well. fileTag() and websiteTag() now wrap their concatenations in htmlspecialchars() with quoted entities (Render.php:188, :205). The events feed view escapes the RSS item image, and the shortcut helpers escape venue, city, country and address.
The battery results, against the installed candidate:
- Image string injection, the original vector: accepted, stored empty, three for three. Fixed
- File string injection with a
javascript:URI: stored empty. Fixed - Image upload named with a quote and an onerror handler: the filename stem is slugified, quotes come back as
-22-, the extension is allowlisted, and the rendered tag carries no attributes. Fixed - File upload named with a quote and an onmouseover handler: same slugification, clean href. Fixed
- CVE-2026-48939 upload attempt: still rejected, nothing on disk
No bypass in the upload paths. The one thing the package does not carry is the fix for CVE-2026-67365, the unauthenticated SQL injection in the calendar module, because that module ships as a separate package. I flagged it to the vendor so the release notice covers both.
9. The Gap in a Forward-Only Fix
A code fix protects new writes. It does nothing about rows already in the table, and it does nothing about a render path the patch does not ship. This fix has both problems.
I proved the first one in the lab the blunt way. Create an event with the payload while running 4.0.11, upgrade the site to the patched build, then load the event page. The public page still serves this:
<img src="/x" onerror="alert(document.domain)" width="900" alt=""/>
Two reasons. First, the update SQL is a single statement that bumps the version row (admin/sql/updates/4.0.12.sql:1), so every poisoned event in the events table keeps its payload. Second, the image sink at Get.php:221 is unchanged in the patched tree, and lib_ic_library is a separate package that the component patch does not include. A site that updates only the component keeps the unescaped sink and the old data. The stored XSS fires for the approver during review and for every visitor afterwards, indefinitely, on a fully updated site.
The file sink is a different story, because the new htmlspecialchars() in fileTag() neutralises legacy file rows at render time. The gap is specific to image rows.
The remediation I recommended, and the vendor has it in writing, is a data cleanup in the update SQL. Any image value that is not a real path under the media folder gets reset. Plus an escape at the image sink, so the render side is safe regardless of what anyone stores later. A fix at the write side without a fix at the read side is half a fix, and attackers submit once and wait.
10. The Verdict, and What Is Public Now
CVE-2026-75948 was published on 2026-08-20 by the Joomla project's CNA, credited to me, described as authenticated stored XSS in iCagenda 4.0.8 through 4.0.12, scored CVSS 4.0 8.6 (AV:N/AC:L/AT:N/PR:L/UI:P/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N) under CWE-79. The affected range is the vendor's conservative floor, as covered earlier, and the patched behaviour is the candidate build I retested. If you run iCagenda, the practical order is: update, then clean the existing rows.
Where I land on severity: the published score is the number to cite, and it is fair. The low-privilege metric reflects a registered submitter, and the passive interaction metric reflects a payload that fires on page load. The confidentiality and integrity ratings reflect an administrative session in the attacker's hands. In CVSS 3.1 terms I had it at 8.1 for a registered attacker and about 9.1 where submissions are open to the public level. I still think that range describes how the impact distributes across real sites.
For anyone auditing iCagenda, the surrounding record is worth having in one place:
- CVE-2026-48939, unauthenticated file upload to code execution, fixed in 4.0.8 and 3.9.15, in the KEV catalog
- CVE-2026-67365, unauthenticated SQL injection in the calendar module reachable through
com_ajax, CVSS 4.0 9.2, fixed in 4.0.12, reported by Joep van Antwerpen of Onvio - CVE-2026-67366, CSRF on frontend registration actions, 5.3, fixed in 4.0.12
- CVE-2026-75948, this one, fixed after 4.0.12
One practical trap with that list: the calendar module's version number does not follow the package. It sat at 4.0.7 while the package climbed to 4.0.11, so an extension list showing 4.0.11 can sit next to a vulnerable module. Check the module, not the package.
The lessons I am keeping from this one are about fix quality more than about cross-site scripting:
- A fix that relies on a discarding ternary is one refactor away from being no fix at all
- Output escaping is the durable control. Input filtering closes the cases you thought of
- A patch that ships without a data migration leaves the vulnerability live on the sites that were hit first
- Diffing the fixed release against the vulnerable one finds bugs the vendor did not intend to write
The PoC is a short stdlib-only Python script that submits the event and then proves the render. It stays with the disclosure packet for now, the same way the Balbooa writeups waited, and it comes here with the rest of the chain once enough sites have moved.
@article{lulz2026_icagenda-s,
author = {Lulztigre},
title = {The String the Fix Stopped Watching: CVE-2026-75948 in iCagenda},
journal = {Lulztigre Dispatches},
year = {2026},
url = {https://lulztigre.pw/posts/icagenda-stored-xss-75948.html}
}