网鼎杯2018的Web方向里,unfinish是让我刷了好几遍还觉得值得的一道题。名字叫“unfinish”,当时很多人做着做着就真觉得没写完——不是题目没做完,而是常规SQL注入的路在一层WAF面前几乎全被堵死,尤其information_schema被过滤后,连“查表名、查列名”这个最基本的动作都变得寸步难行。它的核心考点非常纯粹:SQL注入与WAF绕过,准确说是MySQL无列名注入。
如果你已经会基于information_schema写联合注入,却在面对“information_schema不可用”时大脑空白,这道题就是为你准备的。读懂它之后,你会理解MySQL临时表、派生表列名机制对注入的意义,也会知道在没有元数据表时如何一步步把数据抠出来。本文从前置原理开始,逐步推演完整解法,并把我自己复现时踩过的坑一并整理出来;不管你是刚学SQL注入的新手,还是已经打过几场比赛的老手,这篇内容应该都能给你一点可落地的启发。
1. 赛题概览:一道“没完成”的注入题为什么能讲一节课
1.1 它到底想考什么
unfinish是一个典型的PHP加MySQL登录注册场景。页面能注册、能登录,登录后把用户名回显出来,注入点就藏在用户名这个字段里。表面上看是一个普通字符型注入,但真正卡人的是过滤规则:WAF把information_schema当成第一目标,同时针对空格、等号、注释符等做了不同程度的拦截。这一下就把大多数选手的“肌肉记忆”打断了。
为什么这么说?常规SQL注入里,拿到注入点之后要做的就是三件事:查库名、查表名、查列名。查表名和查列名基本都要借助information_schema这个MySQL自带的元数据库。它一被过滤,选手们习惯了的payload样板全部失效,如果只会套模板,到这里就只能硬猜。所以这道题虽然叫“unfinish”,其实是逼你把“为什么这样注入”重新想一遍。
深层来看,这道题考察的不是某个冷门函数,而是一个核心思维:当信息收集的默认路径被切断后,你是否还能利用MySQL自身行为机制去完成等价的探测。这也是为什么网鼎杯后续很多Web题都喜欢出“过滤information_schema”变体——因为它确实能有效区分“背题的选手”和“懂原理的选手”。
1.2 适合谁看、需要哪些前置知识
建议你至少已经独立完成过两到三道基于information_schema的SQL注入题,知道database()、table_name、column_name这些基本字段,也理解union select为什么需要列数一致。只需要懂MySQL的基础语法,不需要会PHP代码审计。如果你连什么是联合注入都还不清楚,建议先用一道基础题练手再回来,否则直接看无列名注入容易一头雾水。
这道题涉及MySQL派生表、报错函数、WAF等价绕过等多个知识点,我会尽量把每条payload的“为什么这么写”拆开讲清楚,而不是只丢一个能跑的答案。毕竟以后遇到同一知识点的变体题,理解了原理才能真正复制。
2. 从常规注入链路说起:为什么information_schema被禁就“不会玩”了
2.1 一次标准SQL注入的完整数据流
先回忆一下常规注入的链路。假设注入点在用户名参数,目标是拿到当前库里某张表的数据,标准流程是:
第一步查库名:select database();
第二步查表名:select group_concat(table_name) from information_schema.tables where table_schema=database();
第三步查列名:select group_concat(column_name) from information_schema.columns where table_name='users';
第四步才是最终查数据:select username,password from users。
放到字符型注入里,写成联合注入大概是这样(假设目标表3列):
username=-1' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()-- -注意这条payload里,我们真正依赖的东西其实有两个:一个是information_schema.tables这张元数据表,另一个是“我猜users表里一定有username和password列”这个经验判断。真实世界里很多注入点根本不需要提前知道列名,只要列数对齐,用union select 1,2,3按位置读数据就行。但出题人不会让你一直好过,union和information_schema总有一个会被针对。unfinish就是精准地掐断了information_schema这条路。
当你在这条链路上走到第二步就断掉时,常规联合注入基本宣告失败。因为没有表名就没有列名,没有列名就无法准确指定要读取的字段。除非你走另一条路:不再依赖MySQL的元数据表,而是利用MySQL自身的临时表机制来完成数据提取。这就是无列名注入存在的意义。
2.2 临时表机制:无列名注入的基石
MySQL里执行一条最简单的select 1,2,3,结果集的列名并不是col1、col2、col3,而是数字1、2、3。如果把它包成一个子查询:
select `1`,`2`,`3` from (select 1,2,3) as x在反引号可用的前提下,这条语句是成立的。因为(select 1,2,3) as x生成了一张临时派生表,它的三列分别叫1、2、3,外层查询用反引号把数字列名引用出来。这就是“无列名注入”的第一个入口。
用一个生活化类比:临时表就像一张没有表头的Excel表,第一列不叫“姓名”而叫“1”,第二列叫“2”。正常情况下我们不知道表头,但如果你知道它一定有张表,就能直接用列序号去取内容。反引号就是那把打开“列序号”大门的钥匙。
这个机制看起来简单,却是整个无列名注入体系的根基。MySQL允许把select结果集当作一个派生表来用,派生表必须有列名,没有显式别名时就用字段表达式本身作为列名,于是数字、表达式都成了“列名”。理解了这一点,你就能明白为什么select \1``这种奇怪写法是合法的,也就能明白为什么要靠join制造列名冲突来反向探测。
2.3 两种主流无列名手法对比
无列名注入常见的实现方式有两种,我在做题时会先做一个快速判断,看当前环境适合哪条路:
| 方式 | 核心思路 | 适用条件 | 典型场景 |
|---|---|---|---|
| 数字列名引用 | 直接用反引号引用select 1,2,3生成的数字列名 | 反引号未被过滤 | 快速取数、联合注入占位 |
| join重复列名报错 | 构造一个带候选列名的派生表,与目标表join,让MySQL报“Duplicate column name” | 页面能回显MySQL报错 | 反引号被过滤、需要逆向得到真实列名 |
先说第二种,它是unfinish这类题的核心。MySQL在执行join操作时,要求两侧结果集不能有重复列名,一旦出现重复会直接报Duplicate column name 'xxx',而xxx就是冲突列的名字。如果我们能控制其中一侧的列名,另一侧是目标表,那就能通过“故意制造冲突”把目标表的真实列名从报错里逼出来。原理部分先点到为止,实操章节我会给完整payload。
说回第一种,它的局限也很明显:一旦WAF把反引号加入黑名单,这条捷径就直接关闭。我当时刷unfinish的时候,第一反应就是用数字列名,结果反引号被过滤,只能转向join路线。这也是题目设计比较精巧的地方——它把一个技术点做成了连环扣,你以为绕过了一个限制,却发现还有第二个限制在等你。
3. 复现环境的过滤规则推演与常规路线复盘
3.1 页面逻辑与注入点确认
我复现时,本地搭的是PHP 5.x加MySQL 5.7的环境。页面流程是:注册 -> 登录 -> 首页显示当前用户名。注册提交的username字段进入后端SQL,常见拼接形态是:
$sql = "select * from users where username='" . $username . "'";因为注册时写入的是字符串,登录查询时同样被拼到单引号里,所以直接在注册用户名里闭合单引号,就能控制查询逻辑。这里有一个小经验:先注册一个带英文单引号的用户名,比如test',如果登录后页面报错或者显示异常,说明单引号没有被完整转义,注入点基本成立。
从实际做题的角度,这个场景的难点不在于找注入点,而在于注入点周围的过滤规则。很多选手习惯性认为,只要注入点存在,后面就是套payload的事;结果一测试才发现,平时用的空格、等号、注释符号全部被拦,心态直接崩掉。所以我建议遇到这类题,先用最小代价做一轮“过滤面探测”,再决定用哪套绕过方案。
3.2 过滤规则的“关键词画像”
由于不同复现环境下的WAF版本差异,过滤点会略有不同。我这里以覆盖绝大多数版本的最小过滤模型来讲,核心是四类:
information_schema:整条串直接拦截,大小写变体也被拦,常见的绕过思路必须在“不依赖该库”的前提下展开。- 空格:SQL里的空格无法直接使用,需要
/**/、%09、%0a等替代。 - 等号:
where条件里不能直接写等于号,可用like、in、regexp或<>。 - 注释符:部分版本会过滤
#和--的组合,但--+或--%09有时能活下来。
注意,我说的是“关键词画像”,因为同一题在不同复现平台上的WAF并不完全一致。做这类题第一件事不是背payload,而是探测当前环境的过滤面。最简单的探测方法:注册test'/**/and/**/1=1-- -,如果回显正常,说明空格可以用注释符替代、注入成立;再换test'/**/and/**/1=2-- -,如果无结果或报错,就确认了布尔条件生效。
这里还有一个容易被忽略的点:注册时写入的数据会先经过一次PHP层面的转义或过滤,登录查询时又经过一次WAF检测,两次过滤可能不一样。所以你在注册阶段测试某个payload,不代表登录阶段就一定成功。我通常的做法是两边一起测,注册时能写入的,登录时也要再验证一遍。
3.3 为什么联合注入和常规报错注入都会翻车
站在选手角度,最容易想到的两条常规路线,在unfinish里都会翻车,而且翻车的理由不同。
路线一是联合注入,union select 1,2,3。只要列数对齐,就能按位置读取。这条路的问题在于,查表名和列名还是要靠information_schema,如果不知道目标表结构,union就只能拿来做“猜列数”这种基础动作。原题如果连union的检测也做了,那么这一路基本废掉;就算union没被拦,没有表名和列名,你也拼不出完整的查询语句。
路线二是报错注入,extractvalue或updatexml。报错注入能查当前库名,比如extractvalue(1,concat(0x7e,database()))就能把库名带出来。但继续往下查表名时,select ... from information_schema.tables又被过滤,相当于信息收集链条在第一步之后直接中断。表名都拿不到,列名更是无从谈起。
所以你会看到,这个题的难点不是某个注入技巧本身有多高级,而是它把“元数据访问”这个前提抽掉之后,绝大多数常规payload全部失能。要想继续往下走,只能绕过列名依赖,直接和MySQL的行为机制打交道。
4. 手把手实操:从探测到注出Flag的完整链路
4.1 第一步:确认注入点与回显类型
注册一个用户名为admin' and extractvalue(1,concat(0x7e,database()))-- -的账号。注意如果空格被过滤,and后要写成/**/;等号被过滤时,database()本身不需要等号,所以这条payload暂时能跑通。登录后如果页面出现类似XPATH syntax error: '~xxx'的报错,其中xxx是当前库名,说明两点:字符型注入成立、extractvalue报错函数可用。
这一步要重点确认的是“回显类型”:页面到底是在出错时直接打印MySQL错误,还是把查询结果里的某个字段值显示出来。unfinish属于前者,所以走报错注入路线最直接。如果页面只显示正常查询结果、不显示报错,那就要改用时间盲注或布尔盲注。确认回显类型会直接决定你后续payload怎么组织。
另外提一句,extractvalue的参数里有一个0x7e,就是波浪号~的十六进制写法。报错信息里先输出~再输出查询结果,是为了方便肉眼定位,也避免报错内容为空时难以判断。几乎所有报错注入payload里都会带这个标记,它不算技巧,但很实用。
4.2 第二步:确定列数并排除union干扰
在不能使用union的前提下,仍然需要知道users表有多少列,因为后面的join报错要和它的列数匹配。最稳的办法是用order by逐次测试:
admin' order by 1-- - admin' order by 2-- - admin' order by 3-- -当order by 3正常、order by 4报错时,说明查询结果是3列。注意order by后面跟的数字一般不会被WAF拦截,因为它是纯数字;如果数字也被拦,那就要考虑用ord('a')之类的表达式替代,但这种情况比较少见。
确定了列数之后,我们把users这个可能的表先拟定为三列:id、username、password。当然这只是猜测,真正判断要用下一步的join报错。列数在很多题里是最容易被忽略的变量,很多人拿到注入点直接套payload,结果join或者union一直报列数不匹配,回头才想起来还要number列数,白白浪费时间。
4.3 第三步:利用join报错爆破真实列名
这一步是整道题的核心。先构造一个带候选别名的派生表:
select 1 as id, 2, 3把它和users表做join:
select * from (select 1 as id,2,3) x join (select * from users) y如果users表中真的存在id列,MySQL会立刻报错Duplicate column name 'id'。把这个子查询塞进extractvalue的报错通道,就形成了完整注入payload:
admin' and extractvalue(1,concat(0x7e,(select * from (select 1 as id,2,3) x join (select * from users) y limit 1)))-- -这里有几个细节要解释。
为什么要limit 1?join的结果是两张表的笛卡尔积,如果不限制行数,报错信息里可能被大量行干扰,甚至让MySQL报“Subquery returns more than 1 row”。limit 1让子查询只返回一行,只要执行到join并触发重复列名报错就达到目的,行数越少越稳定。
为什么要包一层extractvalue?因为纯SQL报错不一定直接回显到页面,报错注入函数本身就是把子查询的结果通过XPath错误信息带出来的通道。把整个join写在concat的第二个参数里,一旦触发重复列名,错误信息会跟着~一起回显。如果你的目标表不是users,就把(select * from users)换成对应的表名,列数也相应调整。
爆破完id后,把别名换成下一个候选,比如username:
admin' and extractvalue(1,concat(0x7e,(select * from (select 1,2 as username,3) x join (select * from users) y limit 1)))-- -为什么select 1,2 as username,3中,1和3没起别名也行?因为临时表三列会分别叫1、username、3,只要username和users表的真实列名冲突就会报错。一次爆破一个候选列名,轮流测,最终能把users表的全部列名测出来。这个过程确实慢,但胜在一不依赖information_schema、二不需要反引号,正好绕开WAF两道封锁。
4.4 第四步:读取数据并拼出Flag
假定爆破出来的列名是id、username、password,那读取数据就很简单了:
admin' and extractvalue(1,concat(0x7e,(select password from users limit 1)))-- -如果password列内容是字符串,按原样返回;如果flag被拆在多行或分段存储,就再用limit 1 offset 0、limit 1 offset 1一条条读,或者用group_concat拼接。如果逗号被过滤,group_concat可以直接写成group_concat(password separator 0x3a)这种形式,把分隔符写进去而不用逗号。limit也可以用limit 1 offset 0代替limit 1,0。
这里还有一个经验:报错注入的XPath错误信息有长度限制,差不多32个字符,超长内容会被截断。数据较长时要用mid或substr分段取。遇到逗号被过滤,mid(password from 1 for 20)这种写法能绕开逗号,也能完成分段截取。把每一段结果拼起来,最终就能得到完整flag。
到这里,一条完整的绕过链路就走通了:注入点确认 -> 过滤面识别 -> 列数确认 -> 绕过information_schema -> 爆破列名 -> 读取数据。整个过程没有使用任何元数据表,也避开了反引号依赖,这就是unfinish最有价值的地方。
5. 实战卡点速查:这些坑我每个都踩过
5.1 没反应/不报错:先检查空格与注释符
第一次做的时候,我注册admin' and extractvalue(...) -- -,页面毫无反应。排查了半天才发现,不是注入不成立,而是空格被WAF过滤,整条SQL变成and extractvalue后面带着非法字符,直接报语法错误被吞掉了。换成/**/替代空格之后立刻生效。
这类题里,最常见的就是“payload明明对,环境不买账”。建议在注入之前先做一个filter探测:注册一个admin'/**/and/**/1=1-- -和admin'/**/and/**/1=2-- -,通过两个结果对比判断布尔注入是否成立。这一步花不了30秒,但能省下后面大量试错时间。
5.2 页面报错但看不到列名:检查报错函数与XPath长度
extractvalue的报错信息有长度限制,大概在32个字符左右。如果列名很长或者数据很长,后半段会被截断,导致看起来“没报出东西”。解决办法是concat前面加0x7e作为标记,再配合mid分段读取。
还有一次我遇到“报错信息只有~没有后续内容”,问题出在子查询返回了多行,导致extractvalue直接说“Subquery returns more than 1 row”,真正的数据根本没进XPath。加limit 1后问题解决。所以在构造join payload时,limit 1不是可选项,而是必须项。
5.3 join报错只显示数字列名:把别名换成真实列名再试
有读者试我的payload,说select * from (select 1,2,3) x join (select * from users) y limit 1报的错是Duplicate column name '1',不是id。这个现象很正常:临时表三列叫1、2、3,和users表的id并不冲突,报1只是MySQL把数字列名当成了一个列名,但这个列名不是我们要的users列名。要想爆users表列名,必须把临时表的列名主动改成候选值,也就是select 1 as id,2,3这种写法。记住这个区别,很多人卡在这里就是“为了绕而绕”,丢了最初目的。
5.4 列数不一致导致注入失败:用order by重新确认
如果成功爆破出列名但取数时一直报错,很可能是列数没有对齐。比如users表其实是5列,你一直按3列去构造select 1,2,3,join时列数不匹配,MySQL会报“The used SELECT statements have a different number of columns”。这时候回到order by那一步,用逐次递增的方法重新数列数。这个坑很容易出现在“猜表结构”的阶段,因为注册登录场景默认users表是3列,但实际表可能多了join_time、status之类的字段。
5.5 非报错回显环境下怎么变通
有些复现环境会屏蔽MySQL报错,extractvalue走不通。此时可以把无列名注入套在时间盲注里:把子查询的报错条件改成if表达式,比如if((select ...) like 'a%', sleep(1), 0),通过延迟判断结果。时间盲注速度慢,但思路完全一致。实际比赛里如果报错通道被堵死,这也是唯一的出路。
6. 这道题对我后续做题思路的影响
6.1 从背payload到理解MySQL行为
刷完unfinish后,我最大的改变是不再死记information_schema那条链路。MySQL的派生表机制、join列名冲突行为、报错函数的长度限制,这些都是可以当武器用的知识点。只要理解了这几条底层机制,就算WAF把information_schema、反引号、空格、等号全部拉黑,至少还能组合出一套可用的绕过方案。它让我意识到:真正值钱的是“为什么”,不是“那条payload”。
后来我遇到不少考无列名注入的变体题,有的把information_schema换成mysql.innodb_table_stats这类的替代表,有的直接禁掉union和反引号。但只要掌握了临时表和join冲突这两个底层机制,这些变体都只是换皮,解法骨架完全一样。这也是unfinish能成为经典题的原因:它教的不只是特定WAF的绕过姿势,而是一类通用思维能力。
6.2 无列名题的通用武器库清单
我把这套思路整理成一个固定流程,后面遇到“information_schema被禁”的题直接套:
- 探测注入点与回显类型,确定走报错、布尔还是时间盲注。
- 用order by确定列数。
- 尝试数字列名引用
select \1` from (select 1,2,3)x`,如果反引号可用就优先走这条。 - 反引号不可用时,用join报错爆破列名:
select * from (select 1 as 候选列名,2,3)x join (select * from 目标表)y limit 1。 - 拿到列名后,用报错注入或union按位置读取数据,分段处理超长字段。
这个流程不只在CTF里有用。真实授权测试中,很多WAF同样会对information_schema做精确拦截;面对这类场景,无列名注入是绕开元数据限制的有效手段之一。它会让你在看一份payload时不再只关心“能不能跑”,而是关心“它利用了MySQL的什么行为”。我个人实测下来,养成这种思维习惯后,后面处理同类WAF的提速非常明显,也少走了很多“换一个payload再试一次”的弯路。