ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL注入攻击实验全解析:从绕过登录到拖库的攻防实战

SQL注入攻击实验全解析:从绕过登录到拖库的攻防实战

1. 从一次课程实验说起:SQL注入到底在防什么

信息安全这门课,理论听了一学期,什么CIA三元组、访问控制模型、密码学基础,考试前背得滚瓜烂熟,考完就还给老师了。但Lab12这个SQL Injection Attack的实验,是我整个学期做下来印象最深的一次——因为它让我第一次真切地感受到,一个看起来"能跑就行"的登录框,背后可能藏着多大的窟窿。

这篇博文就是围绕这个实验展开的。我会把整个实验的设计思路、核心原理、实操步骤、踩过的坑,以及做完之后对防御方案的思考,全部拆开来讲。不管你是正在做这个实验的同学,还是想了解SQL注入到底怎么回事的开发者,应该都能从中拿到一些可以直接用的东西。

先说说这个实验大概是什么。简单讲,就是给你一个存在SQL注入漏洞的Web应用(通常是PHP+MySQL的经典组合),让你扮演攻击者的角色,通过构造恶意的SQL语句,绕过登录验证、窃取数据库中的敏感信息、甚至修改或删除数据。实验的核心不是教你"黑"什么,而是让你站在攻击者的视角,理解漏洞是怎么产生的,从而在写代码的时候知道该怎么防。

关键词里提到了"SQL Injection Attack",这确实是整个实验的核心。但我想说的是,光知道"SQL注入是把用户输入拼接到SQL语句里导致的问题"这句话,远远不够。你得亲手试过,看到那个' OR '1'='1真的能登进去,看到UNION SELECT真的能把密码哈希捞出来,你才会真正理解为什么这个漏洞能常年霸占OWASP Top 10的前几名。

这个实验适合谁来参考?我觉得有三类人:第一类是在做类似课程实验的同学,可以直接对照着看操作步骤和排查思路;第二类是有一定Web开发基础、但没系统学过安全的人,可以借此补上"安全编码"这一课;第三类是对CTF或者渗透测试感兴趣、想找个入门靶场练手的人,这个实验的环境搭建和攻击手法都是很经典的入门素材。

下面我会按照实验的实际推进顺序来展开,从环境搭建到攻击实施,再到防御方案,尽量把每一步的"为什么"都讲清楚。

2. 实验环境搭建与核心原理拆解

2.1 为什么选PHP+MySQL这套经典组合

这个实验用的技术栈是PHP+MySQL,说实话,这套组合现在看起来有点"老派",但作为教学环境,它几乎是无可替代的。原因有几个:一是PHP的MySQL扩展函数(比如mysqli_query)天然支持把字符串直接拼接到SQL语句里,这让漏洞的产生非常直观,不需要你去理解什么复杂的ORM框架;二是MySQL的语法相对简单,UNION SELECT、information_schema这些关键知识点查起来方便;三是这套环境搭建成本极低,一台普通的虚拟机就能跑起来。

我实际搭建的时候用的是某Linux发行版自带的Apache+PHP+MySQL,也可以用集成环境一键搞定。数据库这边,实验通常会提供一个初始化SQL脚本,里面建了一个用户表,大概长这样:

CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(100), role VARCHAR(20) DEFAULT 'user' );

然后插入几条测试数据,包括一个管理员账号和几个普通用户。这个表结构很关键,因为后面做UNION SELECT注入的时候,你需要知道列数、列的类型,才能构造出能正常返回结果的语句。

提示:初始化脚本里的密码字段,实验环境通常存的是明文或者简单的MD5,这是为了方便你注入之后直接看到"成果"。但真实环境里,密码应该是加盐哈希存储的,这一点在做防御方案的时候要特别注意。

2.2 SQL注入的本质:数据变成了代码

很多人对SQL注入的理解停留在"输入单引号会报错"这个层面,但这只是表象。真正的本质是:用户输入的数据,被当成了SQL代码来执行。

打个比方,你写了一张便签,上面写着"请把冰箱里的苹果拿出来",然后你把这张便签交给助手。正常情况下,助手会去执行这个指令。但如果有人在你写便签之前,偷偷在便签上加了几个字,变成了"请把冰箱里的苹果拿出来,然后把冰箱门拆了",助手还是会照做——因为它分不清哪些是你原本写的,哪些是别人加的。

SQL注入就是这个道理。后端代码本来想执行的是:

SELECT * FROM users WHERE username = '用户输入的用户名' AND password = '用户输入的密码';

你输入的用户名是admin,密码是123456,那拼接出来就是:

SELECT * FROM users WHERE username = 'admin' AND password = '123456';

但如果你输入的用户名是admin' --,拼接出来就变成了:

SELECT * FROM users WHERE username = 'admin' --' AND password = '任意密码';

--在SQL里是注释符,后面的内容全被注释掉了。于是这条语句实际上变成了:

SELECT * FROM users WHERE username = 'admin';

只要数据库里存在admin这个用户,不管密码是什么,都能登录成功。这就是最经典的注释绕过。

2.3 实验的三种攻击目标

这个实验通常不会只让你做一种注入,而是分层次设置目标。我总结下来大概是三类:

第一类是绕过登录验证。这是最基础的,用' OR '1'='1或者注释符就能搞定,目的是让你理解"认证绕过"这个概念。

第二类是窃取数据。在登录之后(或者在某些不需要登录的查询接口上),通过UNION SELECT把其他表的数据带出来,比如用户密码、邮箱、角色信息。这一步会用到information_schema来查表名和列名,是实验里最有技术含量的部分。

第三类是篡改或删除数据。通过注入UPDATE或DELETE语句(通常需要配合堆叠查询或者特定的注入点),修改某个用户的密码或者删除数据。这一步是为了让你理解注入不仅能"读",还能"写",危害更大。

注意:做这类实验一定要在隔离的虚拟机环境里进行,不要对着任何真实在用的系统操作。实验用的数据库也是专门准备的,不要拿真实数据来练手。

3. 攻击实操:从绕过登录到拖库的完整过程

3.1 第一步:找到注入点并判断类型

拿到实验环境之后,第一件事不是急着输入' OR '1'='1,而是先判断注入点的类型。SQL注入按数据类型分,主要有数字型和字符型两种。判断方法很简单:输入一个单引号,看页面返回什么。

如果输入1'之后页面报错,提示SQL语法错误,那基本可以确定是字符型注入,而且没有做过滤。如果输入1'页面正常,输入1 and 1=1正常,输入1 and 1=2页面异常,那可能是数字型注入。

我在实验里遇到的是字符型注入,登录框的用户名和密码字段都没有做任何过滤。输入admin'之后,页面直接返回了MySQL的报错信息,连数据库版本都暴露出来了。这个报错信息本身就是个信息泄露漏洞,真实环境里应该关闭详细错误显示。

判断出注入类型之后,下一步是判断列数。用ORDER BY来试:

admin' ORDER BY 1 -- admin' ORDER BY 2 -- admin' ORDER BY 3 -- admin' ORDER BY 4 --

如果ORDER BY 4报错,说明这个查询只返回3列。这个列数信息在后面的UNION SELECT里会用到。

3.2 第二步:绕过登录验证

判断完注入点,先做最简单的登录绕过。在用户名框输入:

admin' --

密码框随便填。提交之后,如果数据库里有admin这个用户,就能直接以管理员身份登录。原理前面讲过了,--把后面的密码验证部分注释掉了。

如果admin这个用户名不存在,或者你想绕过的是"用户名和密码必须匹配"的逻辑,可以用:

' OR '1'='1' --

这条语句会让WHERE条件永远为真,返回用户表里的第一条记录。通常第一条就是管理员。

我实际测试的时候,发现实验环境对--后面的空格有要求,必须是--(两个减号加一个空格),否则MySQL可能不认。这一点在手工注入的时候很容易忽略,导致明明语句是对的却执行不了。

实操心得:不同的数据库注释符不一样。MySQL用--(注意空格)或#,SQL Server用--,Oracle用--。做实验的时候先确认数据库类型,能省不少事。

3.3 第三步:用UNION SELECT窃取数据

登录绕过只是开胃菜,真正有意思的是用UNION SELECT把数据库里的其他数据捞出来。前提是你已经知道了查询的列数(前面用ORDER BY试出来的)。

假设查询返回3列,你可以这样构造:

' UNION SELECT 1, username, password FROM users --

这条语句会把users表里的用户名和密码拼接到结果里返回。如果页面只显示第一列或者某一列,你可能需要调整UNION SELECT里字段的位置,把想看的字段放在页面会显示的那一列上。

但问题是,你往往不知道数据库里有哪些表、哪些列。这时候就要用到information_schema了。MySQL的information_schema数据库里保存了所有表名和列名的元数据。

先查表名:

' UNION SELECT 1, table_name, 3 FROM information_schema.tables WHERE table_schema = database() --

这会返回当前数据库里所有的表名。找到感兴趣的表(比如users、admin、secrets之类的),再查列名:

' UNION SELECT 1, column_name, 3 FROM information_schema.columns WHERE table_name = 'users' --

拿到列名之后,就可以精准地把数据拖出来了:

' UNION SELECT 1, GROUP_CONCAT(username, ':', password), 3 FROM users --

GROUP_CONCAT是MySQL的函数,能把多行结果拼成一行,方便一次性把所有用户名和密码都带出来。如果数据量太大,可以用LIMIT分页。

我实际做的时候,第一次用UNION SELECT发现页面只返回了第一列,后来调整了字段顺序,把想看的字段放到第二列,才成功显示出来。这个细节在实验指导书里通常不会写,但实际操作中很常见。

3.4 第四步:尝试写入和删除操作

读数据做完了,实验通常还会让你尝试写操作。MySQL里,如果注入点支持堆叠查询(也就是用分号分隔多条语句),你可以直接执行UPDATE或DELETE。

比如修改管理员密码:

'; UPDATE users SET password = 'hacked' WHERE username = 'admin'; --

或者删除某个用户:

'; DELETE FROM users WHERE username = 'testuser'; --

但堆叠查询能不能成功,取决于后端用的数据库访问函数。比如PHP的mysqli_query默认不支持多条语句同时执行,而mysqli_multi_query支持。实验环境通常会特意开启这个功能,让你能体验到写操作的危害。

如果堆叠查询不行,还可以尝试用INSERT或者UPDATE配合UNION SELECT来做一些间接的写操作,但难度会大很多。这个实验里一般不会要求这么复杂。

注意:写操作在真实环境里危害极大,可能导致数据被篡改或删除。做实验的时候,如果环境是共享的,做完记得把数据恢复回去,免得影响其他同学。

4. 防御方案:从代码层面堵住漏洞

4.1 预编译语句:最有效的防御手段

做完攻击之后,实验的下半部分通常是让你修复漏洞。最核心的防御手段就是预编译语句(Prepared Statement)。

预编译的原理是:先把SQL语句的模板发给数据库,数据库解析并编译好,然后再把用户输入作为参数传进去。这样,用户输入永远只是"数据",不会被当成"代码"来执行。

用PHP的mysqli举例,有漏洞的代码可能是这样的:

$username = $_POST['username']; $password = $_POST['password']; $sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'"; $result = mysqli_query($conn, $sql);

修复之后:

$stmt = $conn->prepare("SELECT * FROM users WHERE username = ? AND password = ?"); $stmt->bind_param("ss", $username, $password); $stmt->execute(); $result = $stmt->get_result();

关键区别在于,?是占位符,用户输入通过bind_param绑定,数据库会把它当作纯数据来处理。即使你输入admin' --,数据库也只会把它当成一个普通的字符串去匹配用户名,不会执行注释。

我实际测试的时候,把修复后的代码部署上去,再用之前的注入语句去试,页面返回的是"用户名或密码错误",而不是登录成功。这说明预编译确实把注入点堵死了。

4.2 输入验证和过滤:辅助手段但不能依赖

除了预编译,输入验证也是常见的防御手段。比如限制用户名只能包含字母和数字,长度不超过20个字符。这能挡住一部分注入,但不能作为唯一的防御手段。

原因很简单:过滤规则很难做到完备。你可能过滤了单引号,但攻击者可以用十六进制编码、URL编码、或者利用数据库的其他特性来绕过。而且,过滤太严格可能会影响正常用户的输入(比如用户名里带连字符或者下划线)。

我在实验里试过用preg_replace过滤单引号,结果发现攻击者可以用CHAR(39)来代替单引号,照样能注入。所以,输入验证可以作为纵深防御的一层,但核心还是要靠预编译。

4.3 最小权限原则:限制数据库账号的能力

还有一个容易被忽略的防御点:数据库账号的权限。实验环境里,Web应用连接数据库用的账号通常是root,拥有所有权限。这意味着一旦注入成功,攻击者可以读写任何数据,甚至执行系统命令。

真实环境里,应该给Web应用分配一个专用的数据库账号,只授予必要的权限。比如,如果应用只需要读取用户表,那就只给SELECT权限,不给UPDATE、DELETE、DROP这些。这样即使被注入,攻击者能做的事情也有限。

我在实验里试着把数据库账号的权限降下来,再去做注入,发现UNION SELECT还能用(因为需要读权限),但UPDATE和DELETE就执行不了了。这说明最小权限原则确实能有效限制注入的危害范围。

4.4 错误处理:别把数据库报错暴露给用户

实验环境里,注入的时候页面会直接显示MySQL的报错信息,包括数据库版本、表名、语法错误位置等。这些信息对攻击者来说简直是"指路明灯"。

修复方法很简单:关闭详细错误显示,把错误信息记录到日志里,页面上只显示一个通用的错误提示。在PHP里,可以通过设置display_errors = Off和自定义错误处理函数来实现。

我实际测试的时候,把错误显示关掉之后,再用单引号去试,页面只返回"系统繁忙,请稍后重试",攻击者拿不到任何有用的信息。这虽然不能阻止注入,但能增加攻击的难度。

5. 常见问题与排查技巧实录

5.1 注入语句不生效的几种可能

做这个实验的时候,最容易遇到的问题就是"语句明明是对的,但就是没反应"。我总结了几种常见原因:

第一种是注释符的问题。前面提过,MySQL的--后面必须跟一个空格,否则不生效。另外,如果用#作为注释符,在URL里需要编码成%23,否则会被当成锚点。

第二种是引号闭合的问题。字符型注入需要先闭合前面的引号,再构造自己的语句。如果闭合不对,整个SQL语句的语法就是错的,自然不会执行。比如admin' --和admin'--的区别,前者在--后面有空格,后者没有,在某些环境下结果不一样。

第三种是列数不匹配。用UNION SELECT的时候,前后两个查询的列数必须一致。如果ORDER BY试出来的列数是3,但你UNION SELECT只写了2列,就会报错。

第四种是权限不足。如果数据库账号没有SELECT权限,UNION SELECT就查不出数据。这种情况在实验环境里少见,但真实环境里很常见。

5.2 常见问题速查表

问题现象可能原因排查方法
输入单引号页面正常,无报错可能做了过滤,或错误显示被关闭尝试用and 1=1和and 1=2对比页面差异
ORDER BY报错列数超出实际返回列数从1开始逐个试,直到报错为止
UNION SELECT无数据返回列数不匹配或字段类型不兼容确认列数,尝试用NULL代替具体值
注释符不生效注释符格式不对MySQL用--(带空格)或#,注意URL编码
堆叠查询不执行数据库访问函数不支持多语句检查后端用的是mysqli_query还是mysqli_multi_query
注入后页面空白可能触发了错误但被隐藏查看服务器日志或开启调试模式

5.3 几个容易被忽略的细节

第一个细节是URL编码。在浏览器地址栏里输入注入语句的时候,空格、单引号、注释符这些特殊字符可能需要URL编码。比如空格编码成%20,单引号编码成%27。用Burp Suite或者浏览器开发者工具抓包修改的时候,工具通常会自动处理编码,但手工在地址栏输入就要注意。

第二个细节是Cookie和Session。有些实验环境会把用户身份存在Cookie里,注入的时候可能需要带上特定的Cookie才能访问到注入点。这个在实验指导书里通常会说明,但容易漏看。

第三个细节是数据库版本差异。MySQL 5.0以上才有information_schema,5.7和8.0的语法也有细微差别。如果实验环境用的是老版本MySQL,information_schema可能不可用,需要用其他方式猜表名。

实操心得:做注入的时候,建议用Burp Suite或者类似的抓包工具,把请求拦截下来慢慢改。直接在浏览器里操作,遇到编码问题会很头疼。另外,养成看服务器日志的习惯,很多在页面上看不到的错误信息,日志里都有。

6. 做完这个实验之后的一些思考

这个实验做完之后,我对"安全"这两个字的理解变了很多。以前写代码,想的是"功能能跑就行",现在会多想一步:"如果用户输入了奇怪的东西,这段代码会怎么样?"

SQL注入只是众多Web安全漏洞中的一种,但它的原理很有代表性:信任了不该信任的输入。这个道理放到其他场景也一样——XSS是信任了用户输入的HTML,CSRF是信任了来自浏览器的请求,文件上传漏洞是信任了用户上传的文件类型。本质上都是没有做好"边界检查"。

实验里用的攻击手法,在真实环境里可能已经被各种WAF(Web应用防火墙)挡住了,但WAF只是最后一道防线,不能替代安全编码。我见过太多项目,代码里全是字符串拼接的SQL,然后指望WAF来兜底。这种思路本身就是危险的,因为WAF的规则是可以被绕过的,而预编译是从根上解决问题。

另外,这个实验也让我意识到,安全不是一个"功能",而是一个"属性"。你不能说"我们这个系统有安全功能",而应该说"我们这个系统在设计上就是安全的"。这需要从需求分析、架构设计、编码实现、测试部署每一个环节都把安全考虑进去,而不是等到上线之后再来打补丁。

最后分享一个我做实验时的小技巧:每次构造完注入语句,先在心里默念一遍"这条语句在数据库里会变成什么样",然后再提交。这个习惯能帮你快速定位问题——很多时候语句不生效,就是因为拼接出来的SQL和你想象的不一样。比如你输入admin' --,实际拼接出来可能是SELECT * FROM users WHERE username = 'admin' --' AND password = 'xxx',注意--后面那个单引号被注释掉了,所以语法是对的。但如果你输入admin'--(没有空格),拼接出来就是SELECT * FROM users WHERE username = 'admin'--' AND password = 'xxx',MySQL可能把--'当成一个整体,注释就不生效了。这种细节,只有亲手试过才会记住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进