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():
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:
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:
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.
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-WITNESSarticle-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.
python3 joomla-articles-catid-Abraxas-Labs.pyor 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
- GitHub: abraxas/joomla-articles-catid
- Loopback client: joomla-articles-catid-Abraxas-Labs.py
- Lab: lab/
Abraxas Labs · abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me