Research/CVE-2026-77991
N-dayCVE-2026-77991CriticalPublic

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).

Dockerfile
# Loopback lab image pin for CVE-2026-77991. Full stack: docker-compose.yml
FROM joomla:5-php8.3-apache
YAML
# 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.

YAML
# 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/apache2

Bring-up from the CVE repo lab/. Joomla's installer wants a 12-character admin password. The loopback client in the repo uses labadmin1234.

Plain text
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.py

media/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.

PHP
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.

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.

PHP
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.

PHP
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 .css file 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. POST jform[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:

Plain text
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-77991

The 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