Research/joomla-media-override
0-dayNo CVEMediumPublic

Joomla, Author overwrites media without core.edit

CVE-2026-48947 taught the webservice media API to require core.edit on override. The site JSON controller still passes override into createFile. Default Author can replace images/joomla_black.png. Labbed 6.1.3.

Name
Joomla, Author overwrites media without core.edit
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Joomla
Affected
Joomla CMS 6.1.3 (0f2d4de) site com_media JSON API. Default Author (group 3). No API token. Webservice /api/v1/media/files already checks core.edit.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, joomla, privilege-leftover, media, cwe-863, cwe-639, authenticated

PUT learned. POST did not.

I am @abraxas_null. The proof of concept is on GitHub: abraxas/joomla-media-override (loopback client). The lab stack is lab/: docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is Joomla CMS 6.1.3 (0f2d4de4729eb951e0878fec98c7990da07915b2). No CVE yet. CWE-863 / CWE-639. 6.5 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N). Remote privilege leftover. Default Author. No API token. Not a .php upload. Not Super User.

CVE-2026-48947 taught /api/index.php/v1/media/files to require core.edit when override is set. The site JSON API still does not. Default Author has core.create on com_media and no core.edit. The site dispatcher treats core.create as enough and loads the administrator ApiController. postFiles() then passes override straight into createFile(). PUT still 403s. POST without override still 409s. The leftover is POST with override: true.

Same pin: levels API, menu items API, articles catid.

Why Joomla

Same 6.1.3 hunt. Uploads and webservices ACL both pointed at com_media. When a CVE names "webservice endpoints," I open the site dispatcher. Joomla has a habit of loading the administrator API controller from the frontend once core.create is true.

Pinning 6.1.3

Tag 6.1.3, 0f2d4de4729eb951e0878fec98c7990da07915b2. Image joomla:6.1.3-php8.3-apache plus mariadb:11.4. This leftover does not need core.login.api. Default Author is enough. That is the difference from the three API-token leftovers on this pin.

The skip list is half the hunt

  • CVE-2026-48947 webservice media overwrite. api/.../MediaController.php allowAdd checks core.edit when override is set. Closed. This post is the internal controller.
  • CVE-2026-73373 SHTML in the forbidden list. Closed. Extension denylist is not this leftover.
  • CVE-2026-71574 mutating webservice core.manage. Different surface.
  • Groups core.admin. Closed.
  • MFA 73337. Closed.

I did not re-file the webservice path.

Surfaces that returned nothing

PUT on the same file as Author: 403. putFiles() already requires core.edit. POST without override when the name exists: 409, original SHA unchanged. That is create-if-absent working. jos_assets for com_media has no core.edit for group 3. The Author stays Author. .php is not this lab. The denylist is inferred closed.

Webservice /api/index.php/v1/media/files with override is the patched path. I did not treat a 403 there as this finding.

The leftover

Default Author on com_media:

Plain text
{"core.admin":{"7":1},"core.manage":{"6":1},"core.create":{"3":1},"core.delete":{"5":1}}

Site Dispatcher.php 56-80 lets core.create through and loads the administrator ApiController. postFiles() 187-204:

PHP
if (!$this->app->getIdentity()->authorise('core.create', 'com_media')) {
    throw new \Exception(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 403);
}
$override     = $content->get('override', false);
if ($mediaContent) {
    $name = $this->getModel()->createFile($adapter, $name, $path, $mediaContent, $override);

Fixed webservice:

PHP
if ($this->input->json->get('override', false)) {
    return $user->authorise('core.edit', 'com_media');
}

One check. They put it in one controller.

What an attacker can do

Log in as a stock Author and replace a file that is already on the site. Logo, header, an image linked from an article. Visitors keep hitting the same public URL and get the new bytes. This is defacement / bait-and-switch of stored media, not a site takeover.

Class: Privilege leftover. Reach: Remote.

Privilege Method / path Kind Lab-proven or inferred
Overwrite an existing media file Site POST /index.php?option=com_media&task=api.files&format=json&path=local-images:/ with JSON override: true integrity Lab-proven. HTTP 200, disk SHA changed, public GET /images/joomla_black.png served the new PNG that carries JOOMLA-MEDIA-OVERRIDE-WITNESS
Deface whatever still points at that URL Same public path, no cache-bust integrity Lab-proven the public bytes flipped. Inferred for other adapters under images/
Create a new allowed file Same POST without override intended Author has core.create on com_media
Edit via PUT PUT same task, path includes the filename n/a Lab-proven closed. HTTP 403
Create when the name exists, no override POST override: false n/a Lab-proven closed. HTTP 409
Upload .php or other denied types createFile / isMediaFile denylist n/a Inferred closed. This lab only sends a PNG
Call the webservice media API with override /api/index.php/v1/media/files n/a Inferred closed. CVE-2026-48947
Super User / RCE n/a n/a Closed. Author stays Author

The lab

Loopback 127.0.0.1:19540. Auto-install 6.1.3. CLI user:add a default Author. Confirm com_media has no core.edit for group 3. Frontend login. CSRF from csrf.token. POST without override: 409. PUT: 403. POST override: true with a 1x1 PNG that embeds the witness in a tEXt chunk: 200, disk SHA changes, public GET returns the witness.

Plain text
SUCCESS joomla-media-override post-http=200 override=yes bytes-changed=yes put-http=403 no-edit=yes conflict-http=409 JOOMLA-MEDIA-OVERRIDE-WITNESS
Plain text
python3 joomla-media-override-Abraxas-Labs.py

or cd lab && ./run.sh. Loopback only.

Wrong turns, in order

I treated PUT as the leftover. PUT is already 403. The SA named overwrite. Overwrite on this controller is POST plus override.

I treated the webservice path as still open. CVE-2026-48947 closed it. The leftover is the site/admin JSON controller the frontend dispatcher loads.

I read 409 without override as a miss. That is the intended create-if-absent path. The SHA is unchanged until the override POST.

Theatre: a reverse shell. The oracle is POST override 200 plus PUT 403 plus public GET of the witness PNG.

The fix

Same override -> core.edit check as MediaController::allowAdd inside ApiController::postFiles() before createFile(). Keep PUT on core.edit.

Pack

Abraxas Labs · abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me