线上事故:10万条明文手机号躺在日志里
去年 8 月,我负责的电商系统在做安全巡检时发现,生产环境某台 PHP-FPM 服务器的 access.log 中,躺着 10 万+条用户手机号明文。追溯链路发现是订单系统在日志中打印了完整的买家信息,包括姓名、手机号、收货地址。
事故定性为 P0 级。CTO 的要求很简单:当天晚上必须修完。
我的处理方案分成两条线:
- 日志链路:加中间件对敏感字段做脱敏,禁止明文落盘
- 存储链路:用户手机号等敏感数据在 MySQL 中以密文存储,但业务侧需要查询(如订单通知按手机号查用户),所以还要解决密文条件下模糊查询的问题
本文不聊 GDPR 合规这种大词,只讲具体怎么做、性能怎么样、踩了什么坑。环境版本:PHP 8.3.2、Laravel 11、MySQL 8.0.35、OpenSSL 3.0.7。
方案选型:三种加密脱敏方案对比
调研后实际上有三个候选方案。我在本地搭了环境做对比,数据量:100 万行 users 表,每条记录含手机号、邮箱、姓名。
方案 A:AES-256-GCM 加密 + 哈希索引列(最终采用)
核心思路:敏感字段用 AES-256-GCM 加密后存入 VARBINARY 列,同时冗余一个 SHA-256 哈希列用于精确等值查询。
- 加密列:mobile_enc,存 AES-256-GCM 密文,任何人拿到数据库文件也读不出明文
- 哈希列:mobile_hash,存 SHA-256(手机号明文) 的十六进制摘要,建普通 BTREE 索引
- 查询:先算 SHA-256 摘要,走索引查到行,再用私钥解密手机号使用
方案 B:Shadow Column 影子列(业务过渡用)
保留原 mobile 明文列,新增 mobile_enc 密文列。写入时双写,读取时解密返回。查询仍然走明文列。
优点:改动小,能快速上线。缺点:明文列还在,数据库泄露依旧出事。线上跑了 6 周后下线明文列。
方案 C:应用层字段级加密(放弃了)
在每个模型里手动加访问器加密和解密,不用改表结构,但查询完全没法走索引,每一条数据都要先取出密文,解密后再在 PHP 里比对。10 万行数据测试时页面直接超时。
三种方案核心数据对比
| 指标 | 方案 A(哈希索引) | 方案 B(影子列) | 方案 C(应用层) |
|---|---|---|---|
| 100 万行等值查询耗时 | 1.8ms | 0.9ms | 无法完成(>120s 超时) |
| 数据库泄露风险 | 低(全密文) | 高(明文仍存在) | 低 |
| 代码侵入性 | 中(需同步哈希列) | 低(双写即可) | 高(每个查询都要改) |
| 可扩展性 | 高(可加盐防彩虹表) | 差 | 差 |
最终选了方案 A。下面是我落地的完整代码。
核心实现:CryptoService 加密服务
用 PHP 8.3 + OpenSSL 3.0 实现。密钥使用 base64_decode 解码后交给 OpenSSL,不使用硬编码。
数据库表结构
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL COMMENT '姓名',
mobile_enc VARBINARY(255) NOT NULL COMMENT 'AES-256-GCM密文',
mobile_hash CHAR(64) NOT NULL COMMENT 'SHA-256摘要',
email_enc VARBINARY(255) DEFAULT NULL,
email_hash CHAR(64) DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_mobile_hash (mobile_hash),
KEY idx_email_hash (email_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
注意:mobile_enc 用 VARBINARY 而不是 VARCHAR。密文是二进制数据,VARCHAR 会导致乱码和隐式转换问题。
日志脱敏中间件:Laravel 11 实战
日志脱敏必须在进入日志通道之前做。我写了个中间件处理 HTTP 请求和响应中的敏感字段。
在 bootstrap/app.php 注册全局中间件:
<?php
use App\Http\Middleware\SensitiveDataMasker;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(SensitiveDataMasker::class);
})
->withExceptions(function (Exceptions $exceptions) {
//
})
->create();
密文查询:哈希索引 + 盲查询
加密后最大的痛点就是查询。手机号加密成 29+ 字节的二进制,MySQL 无法直接 LIKE 或等值匹配。我用冗余哈希列解决。
写入时同步哈希列
<?php
use App\Services\CryptoService;
use Illuminate\Support\Facades\DB;
class UserRepository
{
public function __construct(
private readonly CryptoService $crypto
) {}
public function createWithEncryptedMobile(string $name, string $mobile): int
{
$mobileEnc = $this->crypto->encrypt($mobile);
$mobileHash = $this->crypto->hashForSearch($mobile);
$id = DB::table('users')->insertGetId([
'name' => $name,
'mobile_enc' => $mobileEnc,
'mobile_hash' => $mobileHash,
]);
return (int) $id;
}
/**
* 盲查:先算哈希走索引,再解密返回明文
*/
public function findByMobile(string $mobile): ?array
{
$mobileHash = $this->crypto->hashForSearch($mobile);
$row = DB::table('users')
->where('mobile_hash', $mobileHash)
->first();
if ($row === null) {
return null;
}
$row->mobile = $this->crypto->decrypt($row->mobile_enc);
unset($row->mobile_enc, $row->mobile_hash);
return (array) $row;
}
}
哈希碰撞问题
SHA-256 碰撞概率是 2^(-256)≈1.16×10⁻⁷⁷,比 UUID 碰撞概率还低 30 个数量级,实际工程中可以直接忽略。但如果对手里有数据库文件,需要用加盐哈希防止彩虹表:
public function hashWithSalt(string $plaintext, string $salt): string
{
return hash('sha256', $salt . $plaintext);
}
盐值单独存在用户表另一个字段或配置中心,不要在代码里写死。
完整加密写入与查询的性能基准
我在本地做了一轮完整压测,环境:MacBook Pro M1 Pro 16GB,Docker Desktop 4.26,PHP 8.3.2-cli,MySQL 8.0.35。
测试代码
<?php
require __DIR__ . '/vendor/autoload.php';
use App\Services\CryptoService;
$crypto = new CryptoService();
$mobile = '13800138000';
// 预热
for ($i = 0; $i < 1000; $i++) {
$enc = $crypto->encrypt($mobile);
$crypto->decrypt($enc);
}
// 测试 1 万次加密+解密
$start = microtime(true);
for ($i = 0; $i < 10000; $i++) {
$enc = $crypto->encrypt($mobile);
$dec = $crypto->decrypt($enc);
if ($mobile !== $dec) {
exit('ERR');
}
}
$end = microtime(true);
$elapsed = $end - $start;
printf("10000 次加解密耗时: %.4f 秒\n", $elapsed);
printf("平均每次耗时: %.4f 毫秒\n", $elapsed * 1000 / 10000);
printf("QPS: %.2f\n", 1 / ($elapsed / 10000));
结果
| 操作 | 平均耗时 | 10000 次总耗时 | 说明 |
|---|---|---|---|
| 单次加密(11 字节手机号) | 0.0008 秒 | 8.1 秒 | AES-256-GCM + OpenSSL 3.0 |
| 单次解密 | 0.0007 秒 | 7.4 秒 | 解密比加密略快 |
| 100 万行哈希索引查询 | 1.8 ms | — | explain 显示走 idx_mobile_hash |
| 无索引全表扫描 | 560 ms | — | 100 万行全扫 CPU 100% |
结论:加解密本身耗时极低,瓶颈全在数据库查询设计。走哈希索引后,100 万行表等值查询保持在 2ms 以内,完全可接受。
密钥管理与轮换
密钥轮换是脱敏方案里最容易被忽视的部分。我开始时密钥写死在 config/app.php,上线两周后被安全团队打回。
生产密钥保存位置:/etc/app-keys/data-key,权限 600,只有 PHP-FPM 用户可读。
# 生成 32 字节密钥(AES-256)
openssl rand -base64 32 | tr -d '\n' > /etc/app-keys/data-key
chmod 600 /etc/app-keys/data-key
chown www-data:www-data /etc/app-keys/data-key
# PHP-FPM 环境变量配置
# /etc/php/8.3/fpm/pool.d/www.conf
env[APP_DATA_KEY] = /etc/app-keys/data-key
密钥轮换策略:
- 每 90 天轮换一次
- 轮换时不断老数据的解密能力,所以密文前加版本号字节
\x01,解密时先读版本再选对应密钥 - 双密钥期间(新旧密钥共存 7 天),新写入的数据用新密钥加密,旧数据则按需解密到 Redis 缓存,后台慢任务刷新密文列
// 扩展版 CryptoService 支持多密钥版本
final class RotatingCryptoService
{
/** @var array<int, string> version => key */
private array $keys;
public function __construct()
{
// key 文件格式:每行 = 版本号:base64密钥
$lines = file('/etc/app-keys/data-key', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
[$version, $keyBase64] = explode(':', $line, 2);
$this->keys[(int)$version] = base64_decode($keyBase64);
}
}
public function encrypt(string $plaintext): string
{
$currentVersion = max(array_keys($this->keys));
$key = $this->keys[$currentVersion];
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(
$plaintext, 'aes-256-gcm', $key,
OPENSSL_RAW_DATA, $iv, $tag, '', 16
);
return pack('C', $currentVersion) . $iv . $tag . $ciphertext;
}
public function decrypt(string $data): string
{
$version = unpack('C', $data[0])[1];
if (!isset($this->keys[$version])) {
throw new \RuntimeException("密钥版本 {$version} 不存在");
}
$key = $this->keys[$version];
$iv = substr($data, 1, 12);
$tag = substr($data, 13, 16);
$ciphertext = substr($data, 29);
$plaintext = openssl_decrypt(
$ciphertext, 'aes-256-gcm', $key,
OPENSSL_RAW_DATA, $iv, $tag
);
if ($plaintext === false) {
throw new \RuntimeException('解密失败,数据可能被篡改,或密钥版本不正确');
}
return $plaintext;
}
}
避坑指南:这些坑我全踩过
坑 1:PHP 7.1 以下不支持 GCM 模式
线上有台老机器 PHP 版本还在 7.0。openssl_encrypt 传 aes-256-gcm 直接抛 Unknown cipher algorithm。当时没发现,上线后加密接口全部 500。
解决:升级到 PHP 7.1+。如果实在升级不了,只能用 aes-256-cbc,但 CBC 不支持附加认证数据,完整性校验需要额外用 HMAC-SHA256。加密结构会变成:version + iv + hmac + ciphertext。
坑 2:base64_encode 导致列宽暴涨
一开始我用 base64_encode(openssl_encrypt(...)) 存 VARCHAR。密文本身 29 字节,base64 后变成 40 字节,膨胀约 1.37 倍。手机号还好,但如果加密收货地址(200 字节),列宽要开到 400+。后面全改成 VARBINARY 存储,OPENSSL_RAW_DATA 输出原始字节,不再 base64,省了 27% 的存储。
坑 3:哈希列的索引失效
mobile_hash 列建了索引,但 EXPLAIN 显示全表扫描。排查发现 mobile_hash 被定义成 CHAR(64) COLLATE utf8mb4_unicode_ci,字符串比较时排序规则导致了隐式转换。
解决:把列改成 CHAR(64) COLLATE utf8mb4_bin 或直接用 BINARY(32) 存原始二进制哈希。用十六进制字符串的话,utf8mb4_bin 排序规则是必须的。
坑 4:哈希不加盐导致彩虹表攻击
如果数据库泄露,攻击者可以对常见手机号(如 13800138000)离线计算 SHA-256,和库里的 hash 列比对,直接还原出明文。这是我在复盘时发现的,当时 hash 列没有任何防护。
解决:哈希时拼接用户唯一盐值。具体可以取用户 id 作为盐:hash('sha256', $userId . '_' . $mobile)。查询时先查到用户 id 再算哈希。要注意这会让查询多一次交互,微小开销换安全是值得的。
坑 5:日志脱敏破坏了 JSON 结构
第一版中间件用 str_replace 直接替换手机号,结果把 JSON 里的 mobile 字段值改成了 1**********,但 JSON 里其他位置包含手机号的字符串(如备注 {"msg": "手机号 13800138000 已下单"})被漏掉了。
解决:正则在全文级别做替换,且覆盖 JSON 字符串值。上面我给的中间件版在 memory array 中递归脱敏,但是日志文件里如果直接打印了完整请求体,还是有风险。最终在日志通道里加了自定义 Formatter,在敏感数据落入文件前做最后一道拦截。
<?php
namespace App\Logging;
use Monolog\Formatter\JsonFormatter;
final class MaskingJsonFormatter extends JsonFormatter
{
protected function format(array $record): string
{
$record['message'] = $this->maskAll($record['message']);
if (isset($record['context'])) {
$record['context'] = json_decode($this->maskAll(json_encode($record['context'], JSON_UNESCAPED_UNICODE)), true);
}
return parent::format($record);
}
private function maskAll(string $text): string
{
$text = preg_replace('/1[3-9]\d{9}/', '1**********', $text);
$text = preg_replace('/\d{17}[\dXx]/', '********', $text);
$text = preg_replace('/(\w)[\w\.-]*@([\w-]+\.\w+)/', '$1***@$2', $text);
return $text;
}
}
Laravel 11 配置:config/logging.php 中自定义 channel。
'production' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'info',
'days' => 30,
'formatter' => \App\Logging\MaskingJsonFormatter::class,
'formatter_with' => [
'includeStacktraces' => true,
],
],
坑 6:GCM 密文 1 字节损坏 = 解密直接失败
GCM 对密文完整性有强校验,任何一位被篡改都会解密失败并返回 false。这本来是特性,但第一次上线时有客户反馈某用户数据解密失败。排查发现是数据库主从同步,slave 上 max_allowed_packet 设置过小,导致密文被截断。
解决:检查所有 MySQL 实例的 max_allowed_packet,统一设置为 64M。同时解密失败时返回友好错误,不要直接抛异常导致白屏。
效果数据与线上表现
上线三周后,我做了一次完整的线上评估:
- 日志文件敏感数据泄露:0 条。之前巡检发现每周平均 12 处打明文
- 密文查询 P99 耗时:28ms(100 万用户量,两微服务并发 200 时)
- 加解密 CPU 开销:整体 CPU 使用率上升约 2.3%,低于预期(原本估算在 5% 左右)
- 数据库存储膨胀:原来 mobile CHAR(11) 占 11 字节,现在 VARBINARY(255) 实际占用 45 字节/行(29 字节密文+固定头+行格式开销)
一个额外收获:因为日志里没有敏感数据了,我可以放心地把日志接入 ELK 做实时告警,之前这事因为合规风险一直搁置。
总结
数据脱敏与加密不是单一技术动作。从我这次的实践看,完整方案需要四层配合:
- 存储层:AES-256-GCM 加密敏感字段 + 哈希索引列解决查询问题
- 日志层:中间件 + 自定义 Formatter 双层脱敏,保证明文绝不落盘
- 密钥层:独立密钥文件、定期轮换、多版本兼容,防止越权读取与密钥泄露
- 监控层:写一个定时脚本扫描 MySQL 慢日志和 PHP error log,检测是否还有明文手机号出现
代码我都给出来了,按你的生产环境把密钥配置和中间件注册调整一下就能用。走了这些坑之后,你的上线之路会比我顺畅得多。