
PoC en Python y plantilla de Nuclei que explotan CVE-2026-18110, una vulnerabilidad de enumeración de usuarios no autenticados en Concrete CMS 9.0.0-9.5.2 a través del endpoint de autocompletado de usuarios.
Concrete CMS 9.0.0 hasta 9.5.2 no realiza una comprobación de autorización en el endpoint de autocompletado del selector de usuarios (/ccm/system/user/autocomplete), que respalda el panel "Preview as User" y otros componentes de selección de usuarios. Esto permite a un atacante remoto no autenticado enumerar el directorio completo de usuarios internos —incluyendo IDs de usuario, nombres de usuario y direcciones de correo electrónico— sin ninguna sesión válida ni credenciales.
La vulnerabilidad es un caso clásico de un token CSRF utilizado (incorrectamente) como control de autorización: el token demuestra la integridad de la solicitud, pero nunca responde a la pregunta de seguridad real de si el solicitante tiene permiso para consultar la lista de usuarios.
| Producto | Afectadas | Corregida |
|---|---|---|
| Concrete CMS | 9.0.0 – 9.5.2 | 9.5.3 |
El panel Preview As User está registrado en concrete/routes/panels.php bajo la ruta base /ccm/system/panels:
GET /ccm/system/panels/page/preview_as_user
Su controlador (concrete/controllers/panel/page/preview_as_user.php) llama a UserSelector::quickSelect() para renderizar un componente Vue <concrete-user-select>. A diferencia de su método hermano selectUser(), que correctamente restringe el acceso mediante canAccessUserSearch(), quickSelect() no realiza ninguna comprobación de permisos antes de generar e incrustar un 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;
}
Por lo tanto, la respuesta HTML renderizada por el servidor expone un access-token válido a cualquier visitante —incluidos los no autenticados— que pueda alcanzar esta ruta.
El valor de access-token tiene el formato:
{unix_timestamp}:{md5_hash}
Donde el hash se calcula en el servidor como:
md5( timestamp : userID : action : pepper )
timestamp — hora UNIX en el momento de la generación, enviada en texto plano como prefijo del token.userID — el ID de usuario del solicitante en el momento de la generación; 0 para visitantes no autenticados.action — la cadena user_select:format:{labelFormat}:avatar:{includeAvatar}, donde ambos valores son proporcionados por el atacante a través de parámetros de consulta.pepper — un secreto aleatorio de 64 caracteres generado una vez en el momento de la instalación y almacenado en application/config/generated_overrides/concrete.php.Los tokens son válidos durante 24 horas y son totalmente reutilizables dentro de esa ventana —no hay aplicación de uso único/nonce. Tampoco existe una comprobación de marca de tiempo con límite inferior, lo que hace que la comprobación de frescura sea unilateral.
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]"},
...
]
La llamada a checkAccess() dentro de view() recalcula el hash utilizando el uID de la solicitud actual (0 para invitados), que coincide perfectamente porque el token también se generó como uID=0. La comprobación pasa y se devuelve el directorio completo de usuarios.
La comprobación canAccess() / checkAccess() en el endpoint de autocompletado valida la integridad del token (no falsificado, no expirado), pero nunca valida la autorización (¿tiene este solicitante permiso para buscar usuarios?). La validez del token CSRF no sustituye a una comprobación de autorización. En comparación con getSelectedUsers() en el mismo controlador, que llama correctamente a Checker::canViewUser() por cada resultado, view() no tiene una comprobación equivalente.
En este repositorio se incluyen un script de Python y una plantilla de detección de Nuclei. El script de Python solo se puede utilizar contra una única URL. Si deseas probar varios objetivos simultáneamente, utiliza nuclei en su lugar. Ambos automatizan la cadena de dos pasos descrita anteriormente y coinciden con datos de usuario confirmados en la respuesta del Paso 2.
python3 CVE-2026-18110.py https://target.example.com
nuclei -t CVE-2026-18110.yaml -u https://target.example.com
Destinado únicamente a su uso contra sistemas que estés autorizado a probar.
Actualiza a Concrete CMS 9.5.3 o posterior. La corrección introduce una comprobación de autorización en la etapa de emisión del token (quickSelect()) coherente con la restricción canAccessUserSearch() ya presente en selectUser(), y añade una comprobación de permisos equivalente dentro de view() del controlador de autocompletado, replicando el patrón Checker::canViewUser() ya utilizado correctamente por getSelectedUsers().
Si no es posible una actualización inmediata, considera bloquear el acceso no autenticado a /ccm/system/panels/ a nivel de servidor web o WAF como mitigación temporal.
Este repositorio se publica con fines educativos y de seguridad defensiva. Todas las pruebas se realizaron contra sistemas bajo autorización explícita. Los autores no se responsabilizan de ningún uso indebido de la información o las herramientas contenidas en este documento.