实战经验

一次sql注入真实救火事件复盘

多年以前,接手了一个二手交易平台的“疑难杂症”。客户急匆匆找到我,说网站最近总是莫名其妙地卡顿,后台日志里还经常出现一堆看不懂的报错。他们怀疑是被黑了,但当初做外包的团队早就联系不上了。

以下是完整复盘经历。

问题出现:一次"正常"搜索引发的雪崩

翻看日志,一条特殊的sql引起了我的注意,攻击者输入的不是正常关键词。他输入的是:

' UNION SELECT 1,2,3,username,password,5,6 FROM users --

这一行payload,像一把钥匙,打开了不该开的门。

UNION让数据库把两次查询的结果合并在一起。第一次查询是原本的商品搜索,返回若干列;第二次查询是攻击者构造的SELECT username, password FROM users。只要列数对齐,MySQL就会把用户表的数据当成商品数据返回给前端。

前端页面突然多出了几百行"商品信息",每一行都是真实用户的用户名和密码MD5值。

攻击者不需要慢慢拖库。他一行payload,一次请求,全站用户的凭据全部出现在响应体里。

排查过程:我第一反应是数据库被拖了

查看数据库的慢查询日志和访问日志。

慢查询日志里有一条记录格外刺眼:

# Query_time: 0.003  Rows_sent: 2847
SELECT * FROM products WHERE title LIKE '%%' OR description LIKE '%%'

Rows_sent: 2847。一次搜索,返回了近三千条用户数据。

访问日志里,同一个IP在一分钟内发起了47次搜索请求,每次的keyword参数都不一样,但模式高度一致——都是' UNION SELECT ... --的变体,只是替换了表名和字段名。

查看源码,这套系统用的是传统的PHP 7.4 + MySQL 5.7。搜索功能位于/search.php,逻辑很简单:用户输入关键词,后端拼SQL去products表里模糊匹配。我翻出那段外包留下的代码:

<?php
// search.php - 线上版本(已脱敏)
$conn = new mysqli($host, $user, $pass, $db);

$keyword = $_GET['keyword'];  // 第7行:直接拿用户输入

$sql = "SELECT * FROM products 
        WHERE title LIKE '%$keyword%' 
           OR description LIKE '%$keyword%'";
        // 第12行:直接拼接,没有任何过滤

$result = $conn->query($sql);
while ($row = $result->fetch_assoc()) {
    echo "<div>{$row['title']}</div>";
}
?>

第7行拿输入,第12行拼SQL。中间没有任何过滤、转义、参数绑定。这段代码很明显存在sql注入漏洞。

解决方案:不是加过滤,是换一种写SQL的方式

这是典型的sql注入攻击,单纯加个`mysqlrealescape_string`转义输入,不能根源解决问题,需要用到“PDO预处理,或者MySQLi的参数绑定”。

我把代码改成了这样:

<?php
// search_fixed.php
$pdo = new PDO(
    "mysql:host=$host;dbname=$db;charset=utf8mb4",
    $user,
    $pass,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES => false,  // 关键:禁用模拟预处理
    ]
);

$keyword = $_GET['keyword'];

// 预处理语句:? 是占位符,与用户输入完全隔离
$stmt = $pdo->prepare(
    "SELECT * FROM products 
     WHERE title LIKE :keyword 
        OR description LIKE :keyword"
);

// 绑定参数:用户输入永远只是数据,不是SQL的一部分
$stmt->execute([':keyword' => "%$keyword%"]);

$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($products as $row) {
    echo "<div>" . htmlspecialchars($row['title']) . "</div>";
}
?>

关键有两点。

第一,PDO::ATTR<em>EMULATE</em>PREPARES => false。默认情况下,PHP会模拟预处理——把参数拼成字符串再发给MySQL,本质上还是拼接。设为false之后,PHP会把SQL模板和参数分开发送给MySQL,由数据库引擎完成绑定。这是真正意义上杜绝注入的关键。

第二,htmlspecialchars()。这是第二道防线,防的是XSS,不是SQL注入。但既然改了,顺手把跨站脚本也堵上。

效果与复盘

修复上线后,老张让我复现一下攻击。

我构造了同样的payload:

' UNION SELECT 1,2,3,username,password,5,6 FROM users --

发过去,返回结果是:

PDOException: SQLSTATE[HY093]: Invalid parameter number

数据库根本没收到那个UNION SELECT。参数被当作纯字符串处理,MySQL执行的是:

sqlSELECT * FROM products 
WHERE title LIKE '%\' UNION SELECT 1,2,3,username,password,5,6 FROM users --%\'
   OR description LIKE '%\' UNION SELECT 1,2,3,username,password,5,6 FROM users --%'

搜索结果为空。攻击者拿到的是一个空页面,而不是2847条用户数据。


【关键代码附录】

攻击payload(仅用于教学演示):

' UNION SELECT 1,2,3,username,password,5,6 FROM users --

防御代码核心片段:

// 1. 使用PDO并禁用模拟预处理
$pdo = new PDO(
    "mysql:host=$host;dbname=$db;charset=utf8mb4",
    $user, $pass,
    [PDO::ATTR_EMULATE_PREPARES => false]
);

// 2. 预处理 + 参数绑定
$stmt = $pdo->prepare(
    "SELECT * FROM products 
     WHERE title LIKE :kw OR description LIKE :kw"
);
$stmt->execute([':kw' => "%$keyword%"]);

密码存储(应替换MD5):

// 存密码
$hash = password_hash($plainPassword, PASSWORD_BCRYPT);

// 验密码
if (password_verify($inputPassword, $hash)) {
    // 登录成功
}

写作后记

这篇文章的素材源于我多年前亲历的一次真实救火事件(细节已脱敏)。事后复盘我常想:如果上线前多花半小时做Code Review,这场危机完全可以避免。

但我也深知外包行业的真实生态:很多团队的核心KPI是“快速交付”,往往只追求功能跑通,交付后便撒手不管。当然,真正靠谱的外包会兼顾代码严谨与售后保障,但把系统命脉完全寄托在别人的“自觉”上,是对业务极大的不负责任。

这也是为什么我接手任何项目,都会习惯性地先做一次全面的安全体检。因为有些坑一旦踩了,代价将是整个公司都无法承受之重。

登录收藏

评论 (0)

暂无评论,快来发表第一条评论吧