
Python PoC and Nuclei template exploiting CVE-2026-18110, an unauthenticated user enumeration flaw in Concrete CMS 9.0.0-9.5.2 via the user autocomplete endpoint.
Concrete CMS 9.0.0 through 9.5.2 does not perform an authorization check on the user selector autocomplete endpoint (/ccm/system/user/autocomplete), which backs the "Preview as User" panel and other user-selector components. This allows an unauthenticated remote attacker to enumerate the full internal user directory — including user IDs, usernames, and email addresses — without any valid session or credentials.
The vulnerability is a classic case of a CSRF token being (mis)used as an authorization control: the token proves request integrity but never answers the actual security question of whether the caller is permitted to query the user list.
| Product | Affected | Fixed |
|---|---|---|
| Concrete CMS | 9.0.0 – 9.5.2 | 9.5.3 |
The Preview As User panel is registered in concrete/routes/panels.php under the base path /ccm/system/panels:
GET /ccm/system/panels/page/preview_as_user
Its controller (concrete/controllers/panel/page/preview_as_user.php) calls UserSelector::quickSelect() to render a <concrete-user-select> Vue component. Unlike its sibling method selectUser(), which correctly gates on canAccessUserSearch(), quickSelect() performs no permission check before minting and embedding a token:
// concrete/src/Form/Service/Widget/UserSelector.php (vulnerable — 9.5.2)
public function quickSelect(string $inputName, $userID = null, array $args = []): string
{
$userSelectInstance = $userSelectInstanceFactory->createInstance($labelFormat, $includeAvatar);
// No permission check here — token is rendered unconditionally
$html = <<<EOL
<concrete-user-select
access-token="{$userSelectInstance->getAccessToken()}"
label-format="{$labelFormat}"
:include-avatar="{$includeAvatar}"
...
EOL;
return $html;
}
The server-rendered HTML response therefore exposes a valid access-token to any visitor — including unauthenticated ones — who can reach this route.
The access-token value has the format:
{unix_timestamp}:{md5_hash}
Where the hash is computed server-side as:
md5( timestamp : userID : action : pepper )
timestamp — UNIX time at generation, sent in plaintext as the token prefix.userID — the requester's user ID at generation time; 0 for unauthenticated visitors.action — the string user_select:format:{labelFormat}:avatar:{includeAvatar}, where both values are attacker-supplied via query parameters.pepper — a 64-character random secret generated once at install time and stored in application/config/generated_overrides/concrete.php.Tokens are valid for 24 hours and are fully replayable within that window — there is no single-use/nonce enforcement. There is also no lower-bound timestamp check, making the freshness check one-sided.
1. GET /ccm/system/panels/page/preview_as_user
↓
Server responds with HTML containing:
<concrete-user-select
access-token="1738012345:a1b2c3d4e5f6..."
label-format="auto"
:include-avatar="true"
...>
2. POST /ccm/system/user/autocomplete
Body: accessToken=1738012345:a1b2c3d4e5f6...
&labelFormat=auto
&includeAvatar=true
&query=a
↓
Server responds with full user list:
[
{"id": 1, "primary_label": "admin", "secondary_label": "[email protected]"},
{"id": 6, "primary_label": "m.rossi", "secondary_label": "[email protected]"},
...
]
The checkAccess() call inside view() recomputes the hash using the current request's uID (0 for guests) — which matches perfectly because the token was also generated as uID=0. The check passes, and the full user directory is returned.
The canAccess() / checkAccess() gate on the autocomplete endpoint validates token integrity (not forged, not expired) but never validates authorization (is this caller permitted to search users?). CSRF token validity is not a substitute for an authorization check. Compared with getSelectedUsers() in the same controller, which correctly calls Checker::canViewUser() per result, view() has no equivalent check.
A Python script + Nuclei detection template are included in this repository. The Python script can be used only against a single URL. If you want to test for multiple targets simultaneously, use nuclei instead. Both automate the two-step chain described above and matches on confirmed user data in the Step 2 response.
python3 CVE-2026-18110.py https://target.example.com
nuclei -t CVE-2026-18110.yaml -u https://target.example.com
Intended for use against systems you are authorized to test only.
Upgrade to Concrete CMS 9.5.3 or later. The fix introduces an authorization check at the token-issuance stage (quickSelect()) consistent with the existing canAccessUserSearch() gate already present in selectUser(), and adds an equivalent permission check inside view() of the autocomplete controller, mirroring the Checker::canViewUser() pattern already correctly used by getSelectedUsers().
If immediate upgrade is not possible, consider blocking unauthenticated access to /ccm/system/panels/ at the web-server or WAF level as a temporary mitigation.
This repository is published for educational and defensive security purposes. All testing was performed against systems under explicit authorization. The authors are not responsible for any misuse of the information or tools contained herein.