h3llh0und

CVE-2026-111913 and CVE-2026-111914: two ways to take a list out of Admidio

Admidio lets an operator decide who may see the inventory and who may export a member list. I found that both decisions were enforced only where the list is shown, and that each list had a second way out: the inventory as JSON, through the feed behind its own page, and the member list as a landscape PDF, under a mode name the export check did not know.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to Admidio’s released Docker images, v5.0.15 and v5.0.11, installed on my own machine through the product’s own installer, with test accounts and test items created through its own interface. Both issues were reported privately through the project’s security advisory process. They are written up here after the advisories, the fixed release and the CVE records were public.

Admidio is an open-source user and membership manager for organisations and groups. Its preferences panel holds two settings that answer the same kind of question, who may have this list?

Both settings were enforced, and enforced correctly, at the place where the list is displayed. The trouble was that each list had another way out of the installation, and the setting was not standing at it.

The inventory page

modules/inventory.php opens with the module gate. In v5.0.15, lines 65 to 72:

// check if module is active
if ($gSettingsManager->getInt('inventory_module_enabled') === 0) {
    throw new Exception('SYS_MODULE_DISABLED');
} elseif ($gSettingsManager->getInt('inventory_module_enabled') === 2 && !$gValidLogin
    || ($gSettingsManager->getInt('inventory_module_enabled') === 3 && !$gCurrentUser->isAdministratorInventory())
    || ($gSettingsManager->getInt('inventory_module_enabled') === 4 && !InventoryPresenter::isCurrentUserKeeper() && !$gCurrentUser->isAdministratorInventory())
    || ($gSettingsManager->getInt('inventory_module_enabled') === 5 && !$gCurrentUser->isAllowedToViewInventory() && !$gCurrentUser->isAdministratorInventory())) {
    throw new Exception('SYS_NO_RIGHTS');
}

Every mode of that page runs through it. Set the module to For module administrators only, log in as an ordinary member, and the list, the field editor, the item editor, the CSV print and the import wizard all answer “You do not have the required permission to perform this action”.

The same rows, as JSON

The page does not hold the items itself. Since v5.0.11 the list has been a DataTables table, and the table fills itself from a second script, modules/inventory/inventory_data.php. Here is that file’s entire access control:

require_once(__DIR__ . '/../../system/common.php');
require(__DIR__ . '/../../system/login_valid.php');

login_valid.php checks that a session exists. Nothing in the file reads inventory_module_enabled. The file does call isAdministratorInventory() four times, but each call decides which edit and delete icons to draw in a row, never whether the row is sent. The query is scoped to the organisation and to nothing narrower, and the rows go out as json_encode($jsonArray).

So the inventory existed in two formats: an HTML page that asked six questions, and a JSON feed that asked one. I ran every value of the setting against the released v5.0.15 image with an account called lowuser, which holds the Member role and no inventory right of any kind:

setting inventory.php?mode=list inventory/inventory_data.php
0, Disabled refused 200, every item
1, Enabled served served
2, For logged in users only (the default) served served
3, For module administrators only refused 200, every item
4, Administrators and item keepers refused 200, every item
5, Administrators and role members refused 200, every item
any, without a session — 303 to the login page

Two rows are correct, and they are the two where is logged in happens to be the right answer. Since the default is one of them, the feed was right out of the box and wrong in every configuration an operator chose in order to restrict it.

The first row is the strangest. With the module disabled, the administrator who disabled it opens the page and reads “This module is disabled.” An ordinary member asks the feed and receives the inventory.

What the feed sends is not a count or a yes/no answer. It is every item with every field, and the keeper and last receiver of each item resolved to a real surname and given name. The feed also honours its own filter parameters for any caller, so items_filter_keeper=<user id> answers the question which of our assets is this member holding? one member at a time. I reproduced the same result on a separate stack running v5.0.11, the first release that contains the file. A request with no session was redirected to the login page, so this needs an account and nothing more.

Where the two lines came from

Line 6 of inventory_data.php says where its shape came from: “This script is modeled after modules/messages/messages_data.php”. That file does start with the same two require lines, and there they are enough. Every query in messages_data.php is scoped to the caller’s own user id, so is logged in is a sufficient check for reading your own messages.

The inventory feed copied the preamble, but not the scoping. Its WHERE clause covers the whole organisation, so the same two lines that are complete for a private mailbox became the entire authorization for a shared dataset.

Five files and two views

The member lists live in modules/groups-roles/lists_show.php. The script accepts seven values of mode, at line 51 of v5.0.15:

'validValues' => array('xlsx', 'ods', 'csv', 'html', 'print', 'pdf', 'pdfl')

Two of those are views: html and print render in the browser. The other five produce a downloadable file, and pdfl is the landscape spelling of pdf. The export setting is checked at line 215:

// check if user has the right to export lists
if (in_array($getMode, array('csv', 'xlsx', 'ods', 'pdf'), true)
    && ($gSettingsManager->getInt('groups_roles_export') === 0 // no one should export lists
        || ($gSettingsManager->getInt('groups_roles_export') === 2 && !$gCurrentUser->checkRolesRight('rol_edit_user')))) {
    throw new Exception('SYS_NO_RIGHTS');
}

And at line 318, a hundred lines further down:

case 'pdfl':
    $classTable = 'table';
    $orientation = 'L';
    $getMode = 'pdf';
    break;

The check asks about the mode the request names. The export acts on the mode after pdfl has been renamed to pdf. Between the two, a request for a landscape PDF passes a check that was never asked about it and then runs the same export code as a portrait one.

With the setting at 2, users with the right to edit all profiles, I asked for one list in all seven modes as two users, in the same run:

as labadmin (has rol_edit_user)      csv xlsx ods pdf pdfl  -> all five served
as lowuser  (no rol_edit_user)       csv                    -> refused
                                     xlsx                   -> refused
                                     ods                    -> refused
                                     pdf                    -> refused
                                     pdfl                   -> 200, application/pdf, 8615 bytes
                                        Content-Disposition: attachment; filename="LAB-Member-Address_list.pdf"

The four refusals are the control. The check was running, it was keyed on the right permission, and it reached the right decision about lowuser four times in the same session. At setting 0, no one should export lists, the administrator was refused pdf and served pdfl.

The user interface agreed with the setting, which is why this stayed hidden. Lines 544 to 546 decide whether to draw the export menu at all, and for a restricted user they draw nothing. The only way to reach pdfl was to type it.

Not the same weight

The two findings have the same shape and very different consequences, and I scored them that way.

The inventory feed reads data the user could not otherwise see. I filed it at CVSS 3.1 C:H, 6.5 Medium, following Admidio’s own scoring of its two closest earlier cases.

The PDF does not. Its rows are the rows lowuser already reads on screen through mode=html. What fails is the operator’s decision that member data may not leave the installation as a file. That matters in a membership product, where the setting usually exists for data-protection reasons, and the hidden menu tells the operator the restriction is in place. But it is not a disclosure. I filed the vector at C:L (4.3), and in the same report asked the maintainers to treat it as low, saying that C:N and a plain hardening fix would also be a defensible call. They published the vector as filed, 4.3 Medium. The CVE records carry CVSS 4.0 scores as well: 7.1 for the inventory feed and 5.3 for the PDF.

Eight and a half hours

I sent both reports on 13 September. Markus Faßbender (Fasse on GitHub) accepted both the same day. Fasse committed the export fix at 14:10 UTC, and at 14:15 the inventory fix.

The inventory fix, 726d161, is the shape the report proposed. The six-way gate moves out of inventory.php into a static InventoryPresenter::checkModuleAccess(), and the page and the feed both call it:

// Apply the same module access restriction as the inventory page before reading any items.
InventoryPresenter::checkModuleAccess();

With one helper and two callers, the two formats can no longer drift apart.

The export fix, 4b5110c, is the smaller of the two the report offered: pdfl is added to the list, with a comment explaining why it has to be there.

// Include landscape PDF here because pdfl is normalized to pdf only after this check.
if (in_array($getMode, array('csv', 'xlsx', 'ods', 'pdf', 'pdfl'), true)

The other option was to turn pdfl into pdf before the check, so that only one name for the export ever reaches it. That would stop the next mode added to validValues from slipping past the same way. Both versions are correct today. Both fixes are on master too, which mattered because v5.0.15 is not an ancestor of master.

I read both diffs and checked them against the files on master and in v5.0.17. I did not rerun the probes against a fixed image.

Both fixes shipped in Admidio 5.0.16 on 26 September, whose security notes name the inventory module and the PDF landscape export. Four minutes before that release, Fasse requested a CVE on both advisories. One thing from the inventory report was not taken up, and it was offered as a side note rather than as part of the finding: the feed still resolves keeper names without checking whether the caller may view that member’s profile. v5.0.17 still does.

A second report on the same feed

The advisories went public on 2 October, the export one at 18:00:44 UTC and the inventory one at 18:01:03. Seventeen seconds later Admidio published GHSA-qfjf-c48f-x87g, credited to manus-use. It describes the same feed ignoring the same setting, with a range ending at 5.0.14. I can’t see when it was submitted, so I can’t say which report came first. I only know both were fixed by the same commit.

GitHub had not issued either CVE by mid-October. On 17 October VulnCheck, which is also a CVE numbering authority, assigned and published both records without a request from me: CVE-2026-111913 for the export and CVE-2026-111914 for the inventory. Both credit me as reporter. The inventory record references both inventory advisories. The GitHub pages still read “No known CVE”, so the advisories and the records have to be cited side by side.

If you run Admidio

Upgrade to 5.0.16 or later; 5.0.17 is current. Until then, neither setting should be read as enforced:


Advisory GHSA-5f29-r7rj-43mf · CVE CVE-2026-111914 · CVSS 6.5 Medium (v3.1), 7.1 High (v4.0) · Fix 726d161 · Affected >= 5.0.11, <= 5.0.15 · Fixed 5.0.16

Advisory GHSA-86f5-rg96-frrj · CVE CVE-2026-111913 · CVSS 4.3 Medium (v3.1), 5.3 Medium (v4.0) · Fix 4b5110c · Affected >= 5.0.0, <= 5.0.15 · Fixed 5.0.16

Both reported 13 September · accepted and fixed on 13 September · CVEs requested and 5.0.16 released 26 September · advisories published 2 October, crediting me as reporter · CVE records assigned and published by VulnCheck 17 October.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und