Research/joomla-articles-catid
0-dayNo CVEMediumPublic

Joomla, articles API ignores per-category ACL

Administrator ArticleController checks core.create on com_content.category.{catid}. The webservice never sees catid. A drafts-only Manager token can POST into Press. Labbed 6.1.3.

Name
Joomla, articles API ignores per-category ACL
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Joomla
Affected
Joomla CMS 6.1.3 (0f2d4de). POST /api/index.php/v1/content/articles. Manager token with core.login.api, com_content manage, and create on one category. Default core.login.api is Super User only.
Published
12 Oct 2026
Updated
12 Oct 2026
Tags
0-day, joomla, privilege-leftover, articles, cwe-863, authenticated

any category is not this category

I am @abraxas_null. The proof of concept is on GitHub: abraxas/joomla-articles-catid (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. 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. API token. Not Super User. Not a read of hidden articles.

Administrator ArticleController::allowAdd checks com_content.category.{catid}. The articles webservice never sees catid. ArticlesController has no allowAdd. ApiController::add() calls allowAdd() with no data. Base allowAdd is true if the user can create in any category. A drafts-only editor can plant published copy in Press. They do not become Super User from this leftover.

Same pin: levels API, menu items API, media override.

Why Joomla

Same 6.1.3 hunt. Content IDOR and webservices ACL both land on catid. The form already knows the category asset. The API add() method does not pass the JSON body into the check that would use it. That is a leftover you can see without a payload.

Pinning 6.1.3

Tag 6.1.3, 0f2d4de4729eb951e0878fec98c7990da07915b2. Image joomla:6.1.3-php8.3-apache plus mariadb:11.4. Default core.login.api is Super User (group 8). The lab grants it to Manager (group 6) without component-level core.create, same setup as CVE-2026-71574 / 72531 / 48904.

The skip list is half the hunt

  • CVE-2026-71574 mutating webservice vs UI. Component core.manage, not category assets.
  • Groups core.admin. Closed.
  • CVE-2026-48947 webservice media override. Closed. Site media is a different leftover.
  • CVE-2026-73337 MFA. Closed.
  • CVE-2026-73373 SHTML. Closed.
  • Categories / fields / privacy XSS wave. Patched.

I did not re-file those.

Surfaces that returned nothing

Administrator save into allowed category A: succeeds. That is intended. Administrator save into denied category B: "not permitted." That is UI allowAdd on com_content.category.B. Component core.create on com_content is Author group 3 in the lab, not Manager. Manager still has core.manage plus one category allow. That is enough for the base getAuthorisedCategories count.

Stock Manager without core.login.api cannot hit the articles webservice. PATCH/DELETE on this Manager are inferred closed: base allowEdit / allowDelete want component core.edit / core.delete, which the lab rules give to Author, not Manager. I did not PATCH.

No unpublished unauthenticated RCE or SQLi. Contacts / banners / newsfeeds look like the same missing allowAdd. This lab did not exercise them.

The leftover

ArticlesController.php has no allowAdd. ApiController::add():

PHP
public function add()
{
    if (!$this->allowAdd()) {
        throw new NotAllowed('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED', 403);
    }
    $recordId = $this->save();
    $this->displayItem($recordId);
}

Base check:

PHP
protected function allowAdd($data = [])
{
    $user = $this->app->getIdentity();

    if (!$user->authorise('core.manage', $this->option)) {
        return false;
    }

    return $user->authorise('core.create', $this->option)
        || \count($user->getAuthorisedCategories($this->option, 'core.create'));
}

Administrator UI already keys off catid:

PHP
protected function allowAdd($data = [])
{
    $categoryId = ArrayHelper::getValue(
        $data,
        'catid',
        $this->input->getInt('filter_category_id'),
        'int'
    );

    if ($categoryId) {
        return $this->app->getIdentity()->authorise(
            'core.create',
            'com_content.category.' . $categoryId
        );
    }

    return parent::allowAdd();
}

The JSON body has catid. add() never hands it over.

What an attacker can do

Use a non-Super-User API token that may create in one category to publish into categories they are banned from. Unauthorized publishing, not a site takeover, not a read of hidden articles.

Class: Privilege leftover. Reach: Remote.

Privilege Method / path Kind Lab-proven or inferred
Create an article in a category they are denied POST /api/index.php/v1/content/articles JSON catid of B integrity Lab-proven. HTTP 200, #__content id=1, catid=9 (Press), title poc-art-JOOMLA-ARTICLES-CATID-WITNESS
Plant published copy in Official notices / Press while only allowed Drafts Same POST, state=1 integrity Lab-proven create into denied Press. Inferred for any other category asset with core.create denied
Edit an existing article via the API PATCH /api/index.php/v1/content/articles/{id} n/a Inferred closed for this Manager. Lab did not PATCH
Delete an article via the API DELETE /api/index.php/v1/content/articles/{id} n/a Inferred closed. Lab did not DELETE
Save an article into allowed category A from administrator POST ... task=article.save jform[catid]=A intended Lab-proven open as intended. HTTP 200, not denied
Save an article into denied category B from administrator Same task, jform[catid]=B n/a Lab-proven closed. HTTP 200 with "not permitted"
Hit the API with a stock Manager (no core.login.api) Bearer / X-Joomla-Token n/a Inferred closed. Default is Super User only
Same gap on contacts / banners / newsfeeds POST /api/index.php/v1/contacts (and siblings) with foreign catid integrity Inferred. Those API controllers also lack allowAdd. Lab did not exercise them
Super User / installer RCE n/a n/a Closed. Manager stays Manager

The lab

Loopback 127.0.0.1:19550. Super User token creates categories Drafts (A, id 8) and Press (B, id 9). CLI user:add a Manager. Grant group 6 core.login.api on root.1. com_content has core.manage for 6 and no component core.create. Category A asset: core.create allow for group 6. Category B asset: core.create deny for group 6. Mint a Bearer. POST /api/index.php/v1/content/articles with catid of B, title poc-art-JOOMLA-ARTICLES-CATID-WITNESS. Expect 200 and #__content in B. Administrator login as the same Manager. Save into A succeeds. Save into B is denied.

Plain text
SUCCESS joomla-articles-catid api-http=200 article-id=1 catid=9 cat-a=8 cat-b=9 ui-a=200 ui-b=200 JOOMLA-ARTICLES-CATID-WITNESS

article-id is the next #__content autoincrement (1 on a clean 6.1.3 install). ui-a / ui-b are HTTP 200. Denial of B is the UI "not permitted" body, not a 403 status.

Plain text
python3 joomla-articles-catid-Abraxas-Labs.py

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

Wrong turns, in order

I read UI HTTP 200 as allow. Denial of B is the "not permitted" body. Same lesson as the menu leftover. Joomla administrator likes 200.

I treated component core.create as the hole. The lab Manager has none. One category allow is enough for the base getAuthorisedCategories count. That is the whole trick.

I hit the API as a stock Manager. Default core.login.api is Super User only. Grant it first.

Theatre: a reverse shell. The oracle is API 200 into Press plus UI allow on Drafts plus UI deny on Press.

The fix

Override allowAdd (and allowEdit) on api/components/com_content/src/Controller/ArticlesController.php the same way as administrator ArticleController: check core.create on com_content.category.{catid}. Pass the request JSON into ApiController::add() so catid actually reaches that check. Apply the same pattern to contacts, banners, and newsfeeds if those controllers stay on the default allowAdd.

Pack

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