先跟大家说个真事儿。去年我一个朋友的电商网站被黑了,黑客拿走了两万多条用户订单数据,包括姓名、电话、收货地址。最后怎么进来的?就是登录框那里一个很简单的SQL注入漏洞。那天晚上他给我打电话的时候声音都是抖的。
这事儿给我敲了警钟。其实SQL注入这个概念,2000年就有了,快25年的老漏洞了,但现在依然遍地都是。说白了,这个问题的本质很简单——我们把用户传来的字符串直接拼到SQL语句里了。
注入攻击到底是怎么发生的?
举个例子你就明白了。假设登录验证的SQL是这样写的:
php $sql = "SELECT * FROM users WHERE username = '".$_POST['username']."' AND password = '".md5($_POST['password'])."'";
看起来没问题对不对?但是如果用户在用户名框里输入的是:
text admin' OR '1'='1
那么拼出来的SQL就变成了:
sql SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = '...'
1=1永远为真,整个条件就失效了。黑客根本不需要知道密码,直接用管理员身份就能登录。更狠的,如果输入admin'; DROP TABLE users; --,你的用户表就直接没了。
所以别再相信“用户不会那么无聊”这种话了。互联网上每天都有扫描器在自动探测SQL注入点,没人跟你客气。
第一道防线:预编译语句
这是目前公认最有效的防御方案。它的原理很简单——把SQL代码和用户数据分开传输,数据库知道哪部分是命令、哪部分是参数,用户数据永远不会被当作代码执行。
PDO的写法:
php $pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', $user, $pass); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND status = :status"); $stmt->execute([ ':email' => $email, ':status' => 1 ]);Pdd.HoUniaohaO.coM $user = $stmt->fetch();
注意那个charset=utf8mb4,很多人会漏掉。之前有个叫“UTF-8宽字节注入”的漏洞,就是客户端编码设置不当导致的。把它写在DSN里比后面用SET NAMES更安全。
MySQLi的预编译写法稍微啰嗦一点:
php $mysqli = new mysqli('localhost', $user, $pass, 'test'); $stmt = $mysqli->prepare("SELECT * FROM products WHERE category = ? AND price < ?"); $stmt->bind_param('si', $category, $maxPrice); $stmt->execute();Pdd.HoUniaohaO.coM $result = $stmt->get_result();
bind_param的第一个参数si代表第一个参数是字符串、第二个是整数。类型绑错了虽然不影响安全,但可能导致查询结果不对。
第二道防线:输入过滤与验证
预编译能解决99%的注入问题,但有些场景没法用预编译——比如动态表名、动态列名、ORDER BY后面的字段。这时候就需要手动过滤。
拿动态排序举例:
php $allowedColumns = ['id', 'username', 'create_time']; $column = in_array($_GET['sort'], $allowedColumns) ? $_GET['sort'] : 'id'; $order = strtoupper($_GET['order']) === 'DESC' ? 'DESC' : 'ASC'; $sql = "SELECT * FROM users ORDER BY $column $order";
这个做法的核心叫“白名单验证”——只允许几个明确安全的选项,其他全部拒绝。千万不要自己写正则去清洗SQL关键字,洗不干净的。
对于数字类型的参数,强制转型就能直接解决问题:
php $id = (int)$_GET['id']; $sql = "SELECT * FROM posts WHERE id = $id"; 如果是UUID或其他格式,用正则验证格式: php if (!preg_match('/^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/i', $uuid)) { throw new Exception('无效的UUID格式'); }
第三道防线:最小权限原则
我见过太多项目的数据库只用了一个root账号。你说这图啥呢?万一被注入,黑客直接就有最高权限,DROP DATABASE都能执行。
正确的做法是按应用模块分开账号:
查询账号:只有SELECT权限
写入账号:INSERT + UPDATE权限
管理账号:额外给DDL权限(平时不用,只在迁移脚本时用)
sql
-- 只读账号
GRANT SELECT ON myapp.* TO 'app_read'@'localhost' IDENTIFIED BY '强密码';-- 读写账号
GRANT SELECT, INSERT, UPDATE ON myapp.* TO 'app_write'@'localhost';-- 删除操作单独用一个账号
GRANT DELETE ON myapp.* TO 'app_delete'@'localhost';
这样即使查询接口被注入了,黑客也只能查数据,删不了改不了。
那些年我踩过的坑
误区一:转义就能高枕无忧
很多人用过addslashes或者mysqli_real_escape_string,觉得转义了就安全了。但实际上,转义函数只对字符串有效。数字型注入根本不走引号,id=1 OR 1=1这种完全绕得过。而且不同数据库的转义规则不一样,今天跑在MySQL上没问题,明天换成PostgreSQL可能就出事了。
误区二:框架自动帮我处理了
ORM确实能防止大部分注入,但也不是绝对安全的。Laravel的DB::raw()、ThinkPHP的whereRaw(),这些原生查询接口如果直接拼接用户输入,照样能注入。我自己就见过有人这样写:
php DB::table('users')->whereRaw("username = '".$request->input('name')."'")->get(); 这个$request->input('name')要是没过滤,跟原生mysqli写法没区别。实际生产环境要怎么配置?
线上项目我建议直接按这个模板来:
php class Database { private static $instance = null; public static function getConnection() { if (self::$instance === null) { $options = [ Pdd.HoUniaohaO.coM PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, // 关闭模拟预编译,强制走真预编译 PDO::ATTR_STRINGIFY_FETCHES => false, PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci" ]; self::$instance = new PDO( 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, $options ); } return self::$instance; } }
注意那个ATTR_EMULATE_PREPARES => false,这行很关键。默认情况下PDO会模拟预编译,虽然也是安全的,但真预编译在某些边界情况下防御能力更强。
如果网站已经被注入了怎么办?
第一步别慌。立刻联系运维把数据库设为只读模式,然后检查access log里有没有异常请求,特别是包含union select、into outfile、information_schema这些关键词的。
第二步导出当前数据做备份。
第三步把所有拼接SQL的地方改成预编译,尤其是登录、搜索、详情页这几个常见入口。
第四步重置所有用户密码和API密钥,假设它们已经泄露了。
最后说几句
写过代码的人都知道,安全这件事投入产出比看着很低——你可能花了好几天加固了一堆地方,什么都没发生,老板觉得你在摸鱼。但一旦出事,那个损失是多少天工资都填不平的。
我现在的习惯是:默认不相信任何外部输入。GET参数、POST表单、Cookie、HTTP头、甚至从数据库里查出来的数据(因为那个数据也可能是别的漏洞写进去的),凡是来源不可控的,一律当作可能有恶意。
安全不是某一个函数或者配置项能做到的事,它是一种思维方式。就像开门锁,装一个防盗门不代表家里所有窗户都关好了。写每一行SQL之前,停下来想三秒钟:“这个变量是从哪里来的?有没有可能被改写成其他东西?”
希望这篇文章能帮你少踩几个坑。如果你们公司还有项目在裸写SQL不加防护,把这篇文章转发给同事看看吧。保护用户数据,也保护自己的职业生涯。