Summary
The membership management feature allows access even to users who are not logged in. Additionally, the system fails to adequately validate user input for the relation_type_list parameter used when filtering the user list and lacks validation for dummy UUIDs in the role_list parameter, resulting in an SQL injection vulnerability.
Details
Vulnerability Location: modules/groups-roles/lists_show.php, src/Roles/Entity/ListConfiguration.php:989-996
Analysis confirms the absence of any prepared statement binding or security validation.
The vulnerability arises due to two instances of insufficient validation, as described below.
- The membership management feature in
modules/groups-roles/lists_show.php accepts a GET parameter named relation_type_list and uses it as a filter for user relationships.
- While the associated
role_list parameter undergoes validation using UUID::isValid() for each value, relation_type_list is not subjected to any security validation; instead, the result of explode(',', ...) is directly concatenated into an SQL IN (...) clause, with the values enclosed in single quotes (src/Roles/Entity/ListConfiguration.php:991).
- Missing input validation —
modules/groups-roles/lists_show.php
role_list validates each value using UUID::isValid() and removes invalid values:
// lists_show.php
$getRoleList = admFuncVariableIsValid($_GET, 'role_list', 'string');
$getRelationTypeList = admFuncVariableIsValid($_GET, 'relation_type_list', 'string');
...
$roleUuidList = explode(',', $getRoleList);
foreach ($roleUuidList as $key => $roleUuid) {
if (!UUID::isValid($roleUuid)) { // role_list UUID validation
unset($roleUuidList[$key]);
}
}
...
// However, `relation_type_list` is converted into an array as-is, without any validation.
if ($getRelationTypeList !== '') {
$relationTypeUuidList = explode(',', $getRelationTypeList); // <-- Missing Input Validation
}
...
// Unverified value sending to SQL builder
'showRelationTypes' => $relationTypeUuidList,
admFuncVariableIsValid(..., 'string') applies only strip_tags() and htmlspecialchars(ENT_QUOTES). In other words, while < > " ' are encoded as HTML entities (which is irrelevant for SQL escaping), characters such as backslashes (\), commas (,), parentheses, #, =, alphanumeric characters, and spaces pass through unchanged.
- String concatenation —
src/Roles/Entity/ListConfiguration.php:989-996
if (count($optionsAll['showRelationTypes']) > 0) {
$sqlUserJoin = 'INNER JOIN ' . TBL_USER_RELATION_TYPES . '
ON urt_uuid IN (\'' . implode('\', \'', $optionsAll['showRelationTypes']) . '\')
INNER JOIN ' . TBL_USER_RELATIONS . ' ...';
}
- This string is subsequently processed by
getSQL() and executed via $gDb->queryPrepared($sql, array()) (with an empty parameter array). Since user input is inserted directly into the urt_uuid IN ('...') literal, placing a backslash at the end of the final value escapes the closing single quote added by the imploder; this breaks the string literal and causes the subsequent text to be executed as SQL.
- Additionally, characters like
# can be used to comment out the remainder of the statement. Constraints regarding the encoding of ', <, and > can be bypassed using numeric conditions, operators like = or BETWEEN, and syntax such as SUBSTRING(x FROM p FOR 1) (ANSI syntax without commas).
- Since the result is not directly displayed on the screen, the injected subquery is crafted to return two rows, triggering a cardinality error (
ER_SUBQUERY_NO_1_ROW).
- Admidio does not suppress this error; instead, it calls
->fetchAll() at a higher level, exposing the error message Call to a member function fetchAll() on false in the response.
- This allows for the construction of a boolean oracle, as the outcomes—true (no error) versus false (error occurs)—are clearly distinguishable.
- Pre-auth(Unauthentication) Vulnerability
lists_show.php loads only system/common.php at the top and does not enforce login (via require_once('.../system/login_valid.php')), thereby allowing unauthenticated access to the vulnerability vector.
- The single access control currently in place is a permission check performed via a
foreach loop on the roles loaded into role_list.
foreach (... $roles as $role) { // Roles loaded from the database using the role_list UUID.
if (!$gCurrentUser->hasRightViewRole($roleId) || ...) {
throw new Exception('SYS_NO_RIGHTS'); // <-- Permission Blocking
}
}
- This loop executes only when actual roles are successfully loaded using the
role_list UUIDs. Consequently, if a non-existent dummy UUID (e.g., 00000000-0000-4000-8000-000000000000, which is valid in format) is provided, zero roles are loaded, and the permission-blocking logic is not triggered.
- In other words, the permission check itself is bypassed; the subsequent code proceeds to create a
ListConfiguration and execute a list query that includes showRelationTypes (the injection point). As a result, even an unauthenticated user can exploit this to launch an SQL injection attack.
PoC
- The attack payload is as follows:
/modules/groups-roles/lists_show.php?mode=html&role_list=00000000-0000-4000-8000-000000000000&mem_show_filter=0&relation_type_list={payload}
- Based on this, the boolean oracle can be distinguished as follows: the presence of the string "fetchAll() on false" indicates a False condition, while the presence of "No url within the navigation stack." indicates a True condition.
// True
\,) OR (1=(SELECT CASE WHEN (ASCII(SUBSTRING((SELECT VERSION()) FROM 23 FOR 1)) = 0) THEN 1 ELSE (SELECT 1 UNION SELECT 2) END))#
// False
\,) OR (1=(SELECT CASE WHEN (ASCII(SUBSTRING((SELECT VERSION()) FROM 22 FOR 1)) BETWEEN 50 AND 51) THEN 1 ELSE (SELECT 1 UNION SELECT 2) END))#
- Consequently, this enables an attacker to enumerate Admidio's entire database schema and contents.
Impact
- An unauthenticated (not logged in) remote attacker can read the entire database with just a single GET request. No user interaction or specific privileges are required.
- Arbitrary queries can be executed against all tables—including
adm_users (containing password hashes, emails, and usernames)—as well as the information_schema.
- As demonstrated in the PoC above, extracting and offline-cracking an administrator's bcrypt hash could lead to the hijacking of the administrator account or the mass exfiltration of sensitive data, such as stored Personally Identifiable Information (PII).
- Compromising an administrator account can escalate (or be chained) into a full takeover of the application, enabling actions such as creating new administrators or tampering with all data.
Recommended FIX
- Input validation — Validate each element as a UUID, just like
role_list
// lists_show.php — relation_type_list
if ($getRelationTypeList !== '') {
$relationTypeUuidList = explode(',', $getRelationTypeList);
foreach ($relationTypeUuidList as $key => $relationTypeUuid) {
if (!UUID::isValid($relationTypeUuid)) {
unset($relationTypeUuidList[$key]);
}
}
}
- Parameterize the sink - Bind
urt_uuid IN (...) in ListConfiguration.php using placeholders instead of string concatenation (applying the Database::getQmForValues() and binding pattern already correctly used in other queries within the same file).
// Example
$placeholders = Database::getQmForValues($optionsAll['showRelationTypes']); // '?, ?, ?'
$sqlUserJoin = 'INNER JOIN ' . TBL_USER_RELATION_TYPES . '
ON urt_uuid IN (' . $placeholders . ') ...';
// And pass the `showRelationTypes` values as binding parameters to the final `queryPrepared()` call.
- For reference, the query to retrieve relationship type names within the same
lists_show.php file already uses correct binding:
WHERE urt_uuid IN (' . Database::getQmForValues($relationTypeUuidList) . ') → queryPrepared($sql, $relationTypeUuidList). The same principle should be applied to the JOIN sink as well.
- Defense in Depth — If
lists_show.php is not intended to allow unauthorized access, explicitly enforce login/authorization at the top (e.g., via login_valid.php or by checking module access rights) to block paths where the authorization check could be bypassed using a non-existent UUID.
Summary
The membership management feature allows access even to users who are not logged in. Additionally, the system fails to adequately validate user input for the
relation_type_listparameter used when filtering the user list and lacks validation for dummy UUIDs in therole_listparameter, resulting in an SQL injection vulnerability.Details
Vulnerability Location:
modules/groups-roles/lists_show.php,src/Roles/Entity/ListConfiguration.php:989-996Analysis confirms the absence of any prepared statement binding or security validation.
The vulnerability arises due to two instances of insufficient validation, as described below.
modules/groups-roles/lists_show.phpaccepts a GET parameter namedrelation_type_listand uses it as a filter for user relationships.role_listparameter undergoes validation usingUUID::isValid()for each value,relation_type_listis not subjected to any security validation; instead, the result ofexplode(',', ...)is directly concatenated into an SQLIN (...)clause, with the values enclosed in single quotes (src/Roles/Entity/ListConfiguration.php:991).modules/groups-roles/lists_show.phprole_listvalidates each value usingUUID::isValid()and removes invalid values:admFuncVariableIsValid(..., 'string')applies onlystrip_tags()andhtmlspecialchars(ENT_QUOTES). In other words, while< > " 'are encoded as HTML entities (which is irrelevant for SQL escaping), characters such as backslashes (\), commas (,), parentheses,#,=, alphanumeric characters, and spaces pass through unchanged.src/Roles/Entity/ListConfiguration.php:989-996getSQL()and executed via$gDb->queryPrepared($sql, array())(with an empty parameter array). Since user input is inserted directly into theurt_uuid IN ('...')literal, placing a backslash at the end of the final value escapes the closing single quote added by the imploder; this breaks the string literal and causes the subsequent text to be executed as SQL.#can be used to comment out the remainder of the statement. Constraints regarding the encoding of',<, and>can be bypassed using numeric conditions, operators like=orBETWEEN, and syntax such asSUBSTRING(x FROM p FOR 1)(ANSI syntax without commas).ER_SUBQUERY_NO_1_ROW).->fetchAll()at a higher level, exposing the error messageCall to a member function fetchAll() on falsein the response.lists_show.phploads onlysystem/common.phpat the top and does not enforce login (viarequire_once('.../system/login_valid.php')), thereby allowing unauthenticated access to the vulnerability vector.foreachloop on the roles loaded intorole_list.role_listUUIDs. Consequently, if a non-existent dummy UUID (e.g.,00000000-0000-4000-8000-000000000000, which is valid in format) is provided, zero roles are loaded, and the permission-blocking logic is not triggered.ListConfigurationand execute a list query that includesshowRelationTypes(the injection point). As a result, even an unauthenticated user can exploit this to launch an SQL injection attack.PoC
Impact
adm_users(containing password hashes, emails, and usernames)—as well as theinformation_schema.Recommended FIX
role_listurt_uuid IN (...)inListConfiguration.phpusing placeholders instead of string concatenation (applying theDatabase::getQmForValues()and binding pattern already correctly used in other queries within the same file).lists_show.phpfile already uses correct binding:WHERE urt_uuid IN (' . Database::getQmForValues($relationTypeUuidList) . ')→queryPrepared($sql, $relationTypeUuidList). The same principle should be applied to the JOIN sink as well.lists_show.phpis not intended to allow unauthorized access, explicitly enforce login/authorization at the top (e.g., vialogin_valid.phpor by checking module access rights) to block paths where the authorization check could be bypassed using a non-existent UUID.