三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

SQL注入进阶:无列名注入原理与实战绕过information_schema过滤

SQL注入进阶:无列名注入原理与实战绕过information_schema过滤

1. 从一道“广告发布系统”CTF题看无列名注入的实战演变

最近在复盘一些经典的Web安全CTF题目,发现一道来自SWPU2019的Web1题,虽然题目本身叫“广告发布系统”,听起来平平无奇,但它的解题路径却非常典型地串联起了SQL注入从基础到高阶的几种关键手法。很多刚接触安全的朋友,可能对union selectorder by这些基础操作很熟悉,但一旦遇到过滤了information_schema、甚至不知道表名和列名的情况,就容易卡住。这道题恰好就是一个绝佳的教学案例,它引导你一步步思考:当常规的“地图”(information_schema)被没收后,你该如何在数据库的迷宫里找到你要的“宝藏”(flag)?

这道题的核心考点,就是无列名注入。这不仅仅是CTF里的炫技,在真实的渗透测试中,当管理员意识到information_schema是敏感信息并加以过滤时,这种技术就成了突破的关键。今天,我就结合这道题,把无列名注入的原理、几种实战手法以及背后的思考逻辑,彻底讲透。你会发现,它本质上是一种基于SQL语言本身特性的“逻辑推导”,而不是什么黑魔法。

2. 环境初探与常规注入点的发现

题目通常是一个简单的Web应用界面。第一步永远是信息收集。我们打开页面,可能看到一个广告列表或者一个搜索框。用最基础的方式测试,比如在搜索框输入一个单引号,观察页面是否返回数据库错误信息,或者页面内容/结构是否发生异常变化(如部分内容消失)。

假设我们输入1‘后,页面显示了SQL语法错误,这基本确认了存在SQL注入漏洞。接下来,我们需要判断注入的类型(字符型还是数字型)以及闭合方式。通过尝试1‘ and ‘1’=‘11‘ and ‘1’=‘2,观察页面返回结果是否不同,可以确认是字符型注入,并且字符串是用单引号闭合的。

注意:在实际CTF或测试中,错误信息可能被屏蔽。这时就需要依赖布尔盲注时间盲注的技巧,通过页面返回内容的真假(如图片是否加载、文字是否存在)或响应时间的差异来判断注入语句是否成功执行。这是基本功,必须熟练掌握。

确认注入点后,我们会尝试获取数据库的基本信息。经典的步骤是:

  1. 判断字段数:使用order by语句,例如1‘ order by 1--,逐渐增加数字,直到页面返回错误,从而确定select查询的字段数量。假设我们测试出字段数是2。
  2. 判断回显点:使用union select联合查询,将我们可控的数据显示在页面上。例如-1‘ union select 1,2--。这里-1‘是为了让原查询不返回结果,从而让我们的union select结果得以显示。页面可能会在某个位置显示数字“1”或“2”,这就是我们可以利用的回显点。

走到这一步,很多新手会下意识地去查information_schema。通常会构造这样的payload:-1‘ union select 1, group_concat(table_name) from information_schema.tables where table_schema=database()--

目的是列出当前数据库中的所有表名。然而,在这道SWPU2019的Web1题目中,你会发现这条语句执行失败了。页面可能没有任何回显,或者直接提示错误。这就是题目设置的第一个障碍:过滤了information_schema库的访问。在真实环境中,这可能是WAF(Web应用防火墙)的规则,也可能是开发人员手动过滤了相关关键字。

information_schema这条路被堵死,我们就进入了“盲区”。你不知道表名,更不知道列名,union select似乎失去了方向。但这正是考验你对SQL理解深度的时候。我们需要转换思路。

3. 核心突破:理解无列名注入的基本原理

无列名注入,顾名思义,就是在不知道表名和列名的情况下,依然能够从数据库中提取数据。它的基石是SQL的**别名(Alias)子查询(Subquery)**特性。

让我们忘掉information_schema。假设我们已经通过某种方式(比如错误提示、时间盲注的延时判断)猜解或者推断出了一个疑似存放flag的表名,例如flagsecretusers等。在CTF中,表名常常是flag。我们假设目标表名就是flag

我们知道表名flag,但不知道里面有什么列。传统的select * from flag需要知道列名才能指定输出。这里,无列名注入的第一种手法登场了:

手法一:利用子查询与别名进行逐位猜解

我们可以把整个select * from flag查询作为一个子查询,并为这个子查询结果集起一个别名(比如a)。同时,我们还需要为这个子查询结果集的每一列起别名。因为我们不知道列数,所以需要先猜列数。

select * from (select * from flag) a;

这条语句本身是合法的,但它只是原样输出。关键在于,我们可以通过union select来“拼接”这个子查询,并利用数字作为列别名来探测数据。

假设我们通过order by猜出flag表有1列(实际情况可能更多,这里简化)。我们可以构造:

-1‘ union select 1, (select `1` from (select * from flag) a) --

等等,这里的`1`是什么?这就是关键!在MySQL中,反引号`可以用来包裹列名。当我们写`1`时,它并不是数字1,而是被当作一个名为“1”的列别名

我们来拆解这个payload:

  1. (select * from flag) a: 这是一个子查询,它选取flag表的所有内容,并给这个结果集起别名叫a
  2. select1from (select * from flag) a: 这是外层查询,它试图从子查询结果集a中,选取列名为1的列。
  3. 由于a结果集本身有列(我们假设有一列,实际列名未知),但它的列名并不是“1”。所以,我们需要在子查询内部就为列定义别名。

正确的构造应该是这样的:

-1‘ union select 1, (select `1` from (select 1, 2, 3 union select * from flag) a) --

这看起来复杂了,我们再拆解:

  • 最内层:select 1, 2, 3 union select * from flag。这个union创建了一个临时结果集,前半部分是我们自定义的三列数据(1,2,3),后半部分是flag表的所有数据。union要求前后列数一致,所以我们自定义的列数必须等于flag表的列数(这里假设为3列)。
  • 中间层:(select 1, 2, 3 union select * from flag) a。给这个临时结果集起别名叫a。此时,a这个结果集就有了明确的列名:第一列叫“1”,第二列叫“2”,第三列叫“3”。因为select 1, 2, 3语句定义了这些列名。
  • 最外层:select1from ...。现在,我们就可以从a中,名正言顺地选取列名为“1”的列了。如果flag表的第一列就是我们想要的flag数据,那么它就会随着union查询,被“附加”到a结果集的“1”列下面,从而被我们选取出来。

如果flag表不止一列,而flag数据在第三列,我们就需要选取列3

-1‘ union select 1, (select `3` from (select 1, 2, 3 union select * from flag) a) --

这个过程就像是你不知道仓库里箱子(列)的名字,但你通过先搬进去几个自己贴好标签(1,2,3)的箱子,和仓库里原有的箱子混在一起,然后你就可以按照自己贴的标签,把整个仓库里对应位置的箱子内容取出来看了。

4. 实战升级:利用JOIN与USING语法进行更优雅的探测

上面这种方法需要猜列数,并且构造的payload比较长。还有一种在MySQL中更巧妙的方法,利用JOIN ... USING语法。这种方法可以直接爆出列名,在某些情况下更加高效。

它的原理是利用了JOIN语句的USING子句。USING(column_name)用于指定两个表连接时依据的相同列名。如果指定的列名不存在,就会报错。我们可以利用这个报错信息来泄露列名。

假设我们猜测存在一个flag表。我们可以构造如下语句:

1‘ and (select * from (select * from flag a join flag b)c)

这条语句尝试将flag表自连接(ab是它的两个别名),并将连接后的结果集再赋予别名c。如果flag表存在,但连接没有指定条件,会产生笛卡尔积,可能返回大量数据导致报错(如“The SELECT would examine more than MAX_JOIN_SIZE rows”),但这至少能证明表存在。

更精妙的是下面这个:

1‘ and (select * from (select * from flag a join flag b using(column_name))c)

这里的column_name就是我们想要探测的列名。如果flag表中存在名为column_name的列,那么USING(column_name)语句成立,查询会执行(可能因为数据量大而报其他错)。如果column_name列不存在,MySQL会直接返回一个错误:“Unknown column ‘column_name’ in ‘from clause’”

看,列名通过错误信息泄露出来了!这本质上是一种基于错误信息的注入。我们可以写一个脚本,暴力猜解列名。常见的列名可能是id,username,password,flag,value,secret等。在CTF中,很可能列名就是flag

一旦通过报错确认了列名,比如列名就是flag,那么获取数据就非常简单了,直接select flag from flag即可,完全不需要复杂的无列名技巧了。所以,这种方法是在“无列名”场景下,一种“曲线救国”获取列名的方法。

5. 盲注场景下的无列名数据提取

在真实环境或更复杂的CTF题中,可能没有显式的回显点(即我们之前看到的数字1,2位置)。所有注入结果都不会直接显示在页面上,这就是盲注。在盲注下实施无列名注入,思路是一致的,但手段需要换成布尔逻辑或时间判断。

假设我们通过时间盲注,确认了flag表存在,并且有我们关心的数据。我们想逐字符取出flag列的数据。在知道列名的情况下,盲注payload是这样的(以时间盲注为例):

1‘ and if(ascii(substr((select flag from flag),1,1))=102, sleep(2), 1)--

这条语句的意思是:如果flagflag列的第一个字符的ASCII码等于102(即字母‘f’),则让数据库睡眠2秒,否则立即返回。通过观察页面响应时间,就能判断字符是否正确。

在无列名且不知道列名的情况下,我们就需要把之前提到的子查询技巧融入到这个盲注框架中。例如,我们猜测flag在flag表的第1列:

1‘ and if(ascii(substr((select `1` from (select 1 union select * from flag) a),1,1))=102, sleep(2), 1)--

这个payload看起来复杂,但核心逻辑很清晰:

  1. (select 1 union select * from flag) a:创建临时结果集a,其第一列的别名是“1”。
  2. select1from ...:从a中选取别名为“1”的列,这里就包含了flag表第一列的数据。
  3. 将这个数据作为substr()ascii()函数的输入,进行字符判断。
  4. 将整个判断嵌入if(..., sleep(2), 1),实现时间盲注。

通过循环遍历每一个字符的位置和可能的ASCII值,我们就能像挤牙膏一样,把完整的flag数据提取出来。这个过程非常耗时,必须借助自动化脚本(如Python的requests库,或直接使用sqlmap--technique=T时间盲注技术并搭配--tamper脚本绕过过滤)。

6. 工具辅助与自动化利用

手动构造这些payload,尤其是在盲注情况下,是不现实的。我们必须借助工具。最强大的工具莫过于sqlmap。但对于这种过滤了information_schema和特定关键词的题目,直接使用sqlmap可能无法成功。

这就需要我们为sqlmap编写tamper脚本。Tamper脚本的作用是在payload发送前,对其进行混淆、编码或改写,以绕过WAF或过滤机制。

例如,题目可能过滤了information_schema这个词。我们可以写一个tamper脚本,将information_schema拆分成information/*注释*/schema,利用注释符绕过字符串匹配。或者,对于无列名注入的payload,我们可以编写一个专门的tamper,当sqlmap试图查询列名时,将其payload替换成我们前面提到的join using或子查询别名的方式。

更高级的用法是,我们可以使用sqlmap--sql-shell参数,在成功注入后获得一个交互式的SQL shell。然后,在这个shell里手动输入我们精心构造的无列名注入语句,直接读取数据。这要求我们已经通过其他方式(如--dbs爆数据库名)确认了注入点可用,并且对当前数据库结构有基本猜测。

除了sqlmap,自己编写Python脚本是更灵活的方式。脚本的逻辑通常如下:

  1. 首先爆破当前数据库名(有时database()函数未被过滤)。
  2. 然后通过join using报错法,尝试爆破表名(常见表名字典爆破)。
  3. 找到疑似表(如flag)后,再用join using报错法爆破列名。
  4. 最后,使用布尔盲注或时间盲注,结合substr()ascii()函数,逐位读取指定表、指定列的数据。

这个过程就像侦探破案,每一步都基于上一步的发现进行推理和验证。

7. 防御视角:开发者如何避免此类漏洞

作为开发者,了解攻击手法是为了更好地防御。针对这道题展现的SQL注入,尤其是无列名注入这种“进阶”手段,防御措施需要层层加固:

  1. 根本解决:使用参数化查询(预编译语句)。这是唯一能从根本上杜绝SQL注入的方法。无论是使用PHP的PDO、Python的sqlalchemy、Java的PreparedStatement,都应该将用户输入全部作为参数传递,而不是拼接进SQL字符串。这样,即使用户输入包含了unionselectjoin等关键字,数据库也会将其视为纯粹的数据,而非可执行的SQL代码。

  2. 最小权限原则:给Web应用使用的数据库账户分配最小的必要权限。比如,只授予它对特定业务表的SELECTINSERTUPDATE权限,并且坚决拒绝它对information_schemamysql等系统库的访问权限。这样,即使发生了SQL注入,攻击者也无法通过information_schema来窥探数据库结构,极大地增加了攻击难度。这道题模拟的就是这种环境。

  3. 输入验证与过滤:虽然这不是银弹,但可以作为辅助手段。对用户输入进行严格的类型检查(比如ID必须是数字)、长度限制,并使用安全的过滤函数(如PHP的mysqli_real_escape_string,但请注意它并非绝对安全,尤其是在不同字符集下)。对于无法使用参数化查询的复杂场景(如动态表名、列名),必须使用白名单机制,只允许特定的、预定义的值。

  4. 错误信息处理:绝对不要将详细的数据库错误信息直接返回给前端用户。像“Unknown column ‘xxx’ in ‘from clause’”这样的错误,正是攻击者梦寐以求的信息泄露。应该配置自定义的错误页面,只返回通用的错误提示,并将详细错误记录到后端日志中供管理员排查。

  5. 使用Web应用防火墙(WAF):WAF可以识别和拦截常见的SQL注入攻击模式。它可以配置规则来过滤information_schemaunion selectsleep()benchmark()等敏感关键词和函数。但要注意,WAF可能被绕过(如通过注释、编码、等价函数替换),它应该作为纵深防御的一环,而不是唯一依赖。

通过这道“广告发布系统”CTF题,我们深入走完了一次完整的、面对过滤的SQL注入攻坚流程。从最初的发现注入点,到遇到information_schema被过滤的障碍,转而理解并运用无列名注入的核心原理(子查询别名),再到探索更巧妙的JOIN USING报错手法,最后讨论了在盲注环境下如何应用以及如何自动化。整个思考链路,其价值远超过获取一个flag本身。它训练的是在限制条件下,如何灵活运用已有知识进行突破的安全思维。这种思维,无论是在CTF赛场上,还是在真实世界的渗透测试中,都是至关重要的。下次当你再遇到一个看似“无路可走”的注入点时,不妨想想:我是不是还能从SQL语言本身的基本特性里,找到另一条路?

← 返回列表