CVE-2026-77991: Joomla Event Manager, privileged RCE
JEM 5.0.0's CSS manager writes whatever filename the administrator hands it. resolveSourceFile blocks .. and cleans the path. It does not require .css. A PHP file under media/com_jem/css is the bug. Unauthenticated POST is not.
- Name
- CVE-2026-77991: Joomla Event Manager, privileged RCE
- Type
- N-day analysis
- CVE
- CVE-2026-77991
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- joomlaeventmanager.net
- Affected
- JEM - Joomla Event Manager (com_jem), versions through 5.0.0; patched in 5.0.1
- Published
- 18 Sept 2026
- Updated
- 18 Sept 2026
- Tags
- n-day, joomla, file-write, cwe-434, privileged, rce
The advisory named a model
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-77991 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-77991 (NVD, GHSA-gh3c-9wwf-mj2v) is privileged remote code execution in JEM - Joomla Event Manager 5.0.0, component com_jem. The Joomla CNA scored it critical (about 9.4). CWE-434: the administrator CSS source model will write a dangerous type, including PHP. Patched in 5.0.1 (changelog). Source: jemproject/JEM-Project.
The advisory names the administrator source model. That is JemModelSource. HTTP is option=com_jem&task=source.save on /administrator/index.php. PHP save() is not an action= query. Unauthenticated POST is not this CVE. You need an administrator session with core.edit on com_jem, and a valid CSRF token.
This is the map I used to get from a CSS manager 200 to a witness under media/com_jem/css/. Isolated lab, loopback only. I am not publishing a shell. Echo of a unique string is enough. The stack is in the CVE repo so you can run it at home. The client is the repo above.
What an attacker can do
Log in as an administrator who can core.edit com_jem. Edit a CSS source whose filename is poc_witness.php. Save echo-only PHP. GET /media/com_jem/css/poc_witness.php. That is code execution as the web user. The CSS manager is not only CSS.
The lab (run this at home)
Source of truth is lab/ on GitHub. The Dockerfile pins joomla:5-php8.3-apache. Compose adds mysql:8.0. Port on loopback only. Bind the JEM 5.0.0 package zip next to compose as pkg_jem.zip (release, vendor download).
# Loopback lab image pin for CVE-2026-77991. Full stack: docker-compose.yml
FROM joomla:5-php8.3-apache# CVE-2026-77991 local Joomla lab. Loopback only.
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: joomla
MYSQL_USER: joomla
MYSQL_PASSWORD: joomla
MYSQL_ROOT_PASSWORD: root
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
joomla:
image: joomla:5-php8.3-apache
ports:
- "127.0.0.1:8088:80"
environment:
JOOMLA_DB_HOST: db
JOOMLA_DB_USER: joomla
JOOMLA_DB_PASSWORD: joomla
JOOMLA_DB_NAME: joomla
volumes:
- joomla_data:/var/www/html
- ./pkg_jem.zip:/tmp/pkg_jem.zip:ro
depends_on:
db:
condition: service_healthy
volumes:
joomla_data:docker-compose.override.yml only routes logs. It is not required to hit the sink.
# App processes should log to stdout and/or /var/log/lab.
services:
db:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/db:/var/log/lab
- ./logs/db-mysql:/var/log/mysql
joomla:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/joomla:/var/log/lab
- ./logs/joomla-apache:/var/log/apache2Bring-up from the CVE repo lab/. Joomla's installer wants a 12-character admin password. The loopback client in the repo uses labadmin1234.
git clone https://github.com/abraxas/CVE-2026-77991
cd CVE-2026-77991/lab
# place JEM 5.0.0 as pkg_jem.zip next to compose
docker compose up -d --force-recreate
docker compose exec -T joomla php installation/joomla.php install \
--no-interaction \
--site-name='CVE-2026-77991 Lab' \
--admin-user=Admin \
--admin-username=admin \
--admin-password=labadmin1234 \
--admin-email=lab@localhost.invalid \
--db-type=mysqli \
--db-host=db \
--db-user=joomla \
--db-pass=joomla \
--db-name=joomla \
--db-prefix=jos_
docker compose exec -T joomla php cli/joomla.php extension:install --path=/tmp/pkg_jem.zip
python3 ../CVE-2026-77991-Abraxas-Labs.pymedia/com_jem/css has to be writable by the web user. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.
What the tree actually registers
JemControllerSource::save is a backend task. Token first. Then jform from the request.
public function save() {
Session::checkToken() or jexit(Text::_('JINVALID_TOKEN'));
$app = Factory::getApplication();
$data = $app->input->get('jform', array(), 'array');
$model = $this->getModel();
$file = $model->getState('filename');allowSave() is core.edit on com_jem. No session, no token, no privilege: you are not in this CVE. JINVALID_TOKEN is a miss, not a sink.
The filename the model will write is session state from the previous source.edit, not a free path in the POST by itself. The edit URL takes id= as base64 of the filename. GET that first so filename in the model matches what you intend to save.
The CSS manager that is not only CSS
JemModelSource::resolveSourceFile in 5.0.0 does real work, just not enough. It InputFilter-cleans the name as a path. It rejects ... It keeps the write under media/com_jem/css. It does not require .css. It does not refuse php.
protected function resolveSourceFile($fileName)
{
$custom = stripos($fileName, 'custom#:') === 0;
$file = $custom ? substr($fileName, strlen('custom#:')) : $fileName;
if ($file === '' || $file !== InputFilter::getInstance()->clean($file, 'path')) {
$this->setError(Text::_('COM_JEM_CSSMANAGER_ERROR_SOURCE_FILE_NOT_FOUND'));
return false;
}
$basePath = Path::clean(JPATH_ROOT . '/media/com_jem/css' . ($custom ? '/custom' : ''));
$filePath = Path::clean($basePath . '/' . $file);
// ... prefix check, reject .. ...Then save calls File::write on that path with jform[source] as the body.
public function save($data)
{
$fileName = $this->getState('filename');
$source = $this->resolveSourceFile($fileName);
if (!$source) {
return false;
}
$return = File::write($source->path, $data['source']);Apache will execute a .php sitting in media/. That directory is web-reachable on purpose: it is where the stylesheets live. The manager was built to edit CSS. The write API never asked whether the name was CSS.
5.0.1 adds JemCssFilePolicy::isValidFileName. Must end in .css. No ... No executable segments in the name (php, phtml, phar, ...). resolveSourceFile calls it. That is the patch.
public static function isValidFileName($fileName): bool
{
if (!preg_match('/^(?!.*\.\.)[\pL\pN_-][\pL\pN._-]*\.css$/iuD', $fileName)) {
return false;
}
$segments = explode('.', strtolower($fileName));
array_pop($segments);
return !array_intersect($segments, self::EXECUTABLE_SEGMENTS);
}The 200 that was the CSS manager
The first client I pointed at this was polite in the wrong direction. Unauthenticated POST at source.save is a login page. task=save without option=com_jem is some other component. Saving jem.css is the product working.
A few other ways to lose without learning anything:
- Generic 200 Joomla homepage HTML. You never reached administrator.
JINVALID_TOKEN. You posted without the 32-hex token from the edit form.COM_JEM_CSSMANAGER_ERROR_SOURCE_FILE_NOT_FOUND. Path filter or... Not the type check.- Writing a
.cssfile only. Intended CSS manager. This CVE is the other extension. - A reverse shell or an outbound connect. Theatre. The witness is a unique string in the written file.
- Skipping
source.edit. The model filename comes from session state. POSTjform[filename]alone is not enough if edit never ran.
The save response is still a large HTML admin page. Do not wait for a tiny JSON body. This is not WordPress admin-ajax. The tell is the follow-up GET of /media/com_jem/css/<name>.php. If that body is the string you wrote, File::write ran on a PHP name. That is the proof.
What I actually did
Treat the on-disk product as the spec. The advisory named the source model. I read save, then resolveSourceFile, then the 5.0.1 policy class. Proof of concept: CVE-2026-77991-Abraxas-Labs.py.
Login like an administrator. GET /administrator/, pull the CSRF token, POST com_login / task=login. Keep the cookie jar.
Edit first. GET option=com_jem&task=source.edit&id= plus the base64 of the target filename. That is how the model learns the name. Pull a fresh token from that page.
Save. POST option=com_jem&task=source.save with jform[filename], jform[source], and the token. Source is an echo of a witness string, not a shell.
Witness in the file. GET /media/com_jem/css/ plus that filename. If the unique string is in the body, the sink wrote attacker-controlled bytes to a path the web server will execute. Anything else is theatre.
Last lab run, trimmed:
login GET status=200
login POST status=200
edit GET status=200
save POST status=200
get status=200 len=17
POC_WITNESS_77991
SUCCESS CVE-2026-77991The save 200 is still tens of kilobytes of administrator HTML. The GET is seventeen bytes. That size jump is the tell that the write landed.
Wrong turns: unauthenticated POST; writing a .css file only (intended CSS manager); skipping source.edit and POSTing jform[filename] alone; waiting for tiny JSON (this is not WordPress admin-ajax).
What this is not
It is not unauthenticated RCE. If you do not have an administrator, you do not have this bug. It is also not "Joomla will execute PHP in media." Of course it will. The bug is a CSS editor that never required CSS.
Update to 5.0.1 or newer. Re-run the loopback client against the patched build: the witness must not appear.
I am not going to print a multipart recipe you can paste at someone else's administrator. The call chain and the missing .css policy are the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the extension was fine.
References
- Proof of concept: abraxas/CVE-2026-77991 · CVE-2026-77991-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-77991 · NVD · GHSA-gh3c-9wwf-mj2v · CWE-434
- Vendor: joomlaeventmanager.net · changelog
- 5.0.0:
JemControllerSource::save,JemModelSource::resolveSourceFile,JemModelSource::save - 5.0.1:
JemCssFilePolicy, patched resolveSourceFile