周末翻出某公开CTF题库平台上的一道老题,2019年某高校赛的Web1,重新跑了一遍。这题从登录后台、找注入点、爆表名,一路都很常规,真正卡人的是最后一步:明明知道flag表存在,却拿不到列名。于是把无列名注入、group_concat、/**/注释符这三样东西串起来才解出来。今天就把这条完整链路拆开写一遍,适合已经会基础SQL注入、但对无列名注入还停留在“见过没用过”状态的读者。
经常刷Web题的人应该都有这种感觉:SQL注入的常规套路背得滚瓜烂熟,information_schema三步走就能拿数据,但一旦过滤规则把某个关键环节掐死,就容易卡住。这题就是典型——列名信息直接被过滤规则干掉了,逼着你绕开information_schema.columns,改从联合查询的临时列名下手。说白了,无列名注入就是“知道表名、不知道列名”时取数据的最后一根稻草。
1. 无列名注入到底在解决什么问题
1.1 常规注入流程里最容易被卡的一环
平时做SQL注入,思路基本固定:先确认注入点,再判断列数,然后从information_schema.schemata拿库名,从information_schema.tables拿表名,从information_schema.columns拿列名,最后拼接查询拿数据。这套流程在绝大多数题目里都成立,但有一个前提——information_schema相关查询能被正常执行。
一旦过滤规则把columns这个关键词干掉了,或者当前数据库用户对information_schema没有读取权限,整个流程就会断在“拿列名”这一步。我见过不少题目会把information_schema或column直接置空,就是为了逼选手换思路。还有一类场景是出题人故意不给列名,比如直接告诉你数据在flag表里,但列名叫什么就是不让你通过常规手段查。
无列名注入解决的就是这个问题:我不去查列名,而是利用联合查询时系统自动生成的临时列名,按列的位置顺序直接取数。这就好比你要找一个陌生人的手机号,正常做法是翻通讯录目录按姓名查,但目录被锁了,你只能拿到整本通讯录,那就按“第几页第几行”去找——虽然笨,但确实能找到。
1.2 联合查询的隐藏规则:临时列名是可以“借用”的
无列名注入的核心原理,藏在MySQL和MariaDB对union select的处理规则里。执行select 1,2,3 union select * from flag时,第一行结果集的三个常量会被自动当成列名,也就是临时列名变成了1、2、3。这个行为在MariaDB和MySQL里是一致的,属于SQL解析的默认规则。
有了这层规则,就能做一件有意思的事:把select 1,2,3 union select * from flag整个包成一个子查询,再给这个子查询起个别名,就可以用别名.数字去引用对应位置的列。比如:
select a.2 from (select 1,2,3 union select * from flag) a这段SQL的意思是:先通过联合查询构造一个临时表a,这个表有三列,列名分别是1、2、3,然后从里面取第二列的数据。这里的a.2不是小数,而是“表别名a的名为2的列”,SQL解析器能正确区分。
用无列名注入有前提条件:第一,你得知道目标表大概有几列,联合查询两边列数不一致会直接报错;第二,你得知道目标表名;第三,目标表的列不能太多,因为构造联合查询时你得把列数补齐。CTF里常见的flag表一般就两到三列,用select 1,2,3或者select 1,2,3,4完全够用。
1.3 group_concat与/**/这两个“老伙计”能干什么
group_concat在注入里的作用很简单:把多行结果合并成一行,默认用逗号分隔。这个函数在联合查询只显示一行结果的时候几乎是必备的——CTF注入点的输出位往往只有一个,如果不把多行数据拼成一行,limit 0,1只能一条条翻,效率低不说,还容易触发各种过滤。用group_concat可以直接把所有表名或所有数据一次性拉出来。
/**/则是一个SQL注释符,在MySQL和MariaDB里,/*和*/之间的内容会被当作注释忽略。但它有个特殊用法:在SQL语法里可以代替空格作为分隔符。也就是说,当过滤规则把空格删掉时,select 1,2,3会变成select1,2,3导致语法错误,但写成select/**/1,2,3就能正常执行。
不少过滤规则只处理空格字符,不处理注释符,所以/**/就成了绕过空格过滤最稳定的手段。它比%0a换行符、%09制表符更通用,因为有些题目的过滤规则会把%0a也干掉,但过滤/**/的题目反而少。而且/**/的可读性最好,payload写出来自己能看懂,排查问题也方便。
无列名注入和这两个工具凑在一起,就是一个完整的组合拳:group_concat负责把多行数据压成一行,/**/负责让payload在空格被过滤的环境里正常执行,无列名注入负责绕过列名获取。三者结合,就能在被过滤规则逼到墙角的时候,硬生生把数据抠出来。
2. 题目环境还原:过滤与注入点
2.1 登录后台与功能梳理
这道题的环境大致是这样的:进入站点后有一个普通的登录页面,注册一个账号进去即可。登录之后是广告管理后台,可以发布广告,广告有标题和内容两个字段,列表页有一个排序功能,排序的参数从URL传入。整个功能不多,但注入点就藏在看起来无害的排序功能里。
我在复现的时候先按常规流程走了一遍:打开广告列表页,URL末尾有个sort参数,试着改成sort=1页面正常,改成sort=1'页面直接报错,说明存在SQL注入。再往深处试,发现很多输入会被“处理”掉,具体表现是某些字符传进去之后在SQL语句里消失了,页面要么空白要么报错。
注册登录这个环节没什么好说的,重点是登录之后的功能面。广告列表的排序参数虽然不起眼,但这类参数在后台SQL里往往直接拼接进order by子句,本身就比较危险。如果开发者再偷懒不加过滤,那注入就是白送。这道题确实是白送了注入点,但过滤规则把后续的常规路堵死了。
2.2 过滤规则探测过程
我在实际探测时,习惯先列一个测试清单,把所有可能被过滤的字符和关键词依次试一遍。先测空格:sort=1 order by 3,页面报错;用sort=1/**/order/**/by/**/3,页面正常。这就基本确定空格被过滤,/**/可用。
再测注释符:sort=1'#和sort=1'-- -都不行,说明#和--这类行注释也被处理了。然后测关键词:union能用,select能用,and和or被过滤,需要靠&&和||或者直接改用其他语法绕。最后试information_schema.columns,发现columns这个词被置空,整个查询报废。
我整理了一份过滤规则表,方便对照:
| 输入内容 | 执行结果 | 说明 |
|---|---|---|
sort=1 order by 3 | 报错 | 空格被过滤 |
sort=1/**/order/**/by/**/3 | 正常 | /**/可替代空格 |
sort=1'# | 报错 | #注释被过滤 |
sort=1'-- - | 报错 | --注释被过滤 |
sort=1 union select 1,2,3 | 正常 | union可用 |
sort=1 and 1=1 | 报错 | 关键字and被过滤 |
| `sort=1 | 1` | |
information_schema.columns | 结果为空 | column关键词被置空 |
这组过滤规则看起来零零散散,但组合起来就把常规注入的路堵得差不多了:空格没了,注释符没了,列名信息拿不到。唯一的好消息是/**/和union还能用,这就给无列名注入留了口子。
2.3 为什么说这是MariaDB环境
判断数据库类型在注入里是个关键步骤。这道题的报错信息和函数行为都指向MariaDB,比如version()函数返回的字符串带MariaDB标识,information_schema的表结构也和标准MySQL有些细节差异。虽然MariaDB和MySQL在很多语法上完全兼容,但在注入场景里有些行为差异值得留意。
无列名注入在MariaDB下完全可用,这跟它是MySQL的分支有关系。联合查询生成临时列名的规则、派生表必须起别名的规则,MariaDB都继承了。我在本地用一个MariaDB 10.x的靶场环境验证过,select a.2 from (select 1,2,3 union select * from user)a这种写法能正常返回数据,说明这个技巧在MariaDB环境里没有兼容性坑。
还有一个细节:MariaDB的information_schema里,columns表同样存在,但这题的过滤规则把column关键词直接置空了,所以即使MariaDB本身没做限制,你也查不到列名。这也是出题人刻意为之——环境用MariaDB,考点却是无列名注入,两者搭配得刚刚好。
3. 完整解题链路与关键payload拆解
3.1 用/**/绕过空格拿库名和表名
确定/**/可用之后,第一步是获取当前数据库名。我比较推荐用联合查询,因为输出位明确,调试方便。先判断列数,用order by逐级递增:
sort=1/**/order/**/by/**/1 sort=1/**/order/**/by/**/2 sort=1/**/order/**/by/**/3 sort=1/**/order/**/by/**/4从1试到4,order by 4时报错,说明查询结果一共三列。然后构造联合查询:
sort=-1/**/union/**/select/**/1,2,3把sort改成负数让前面查询结果为空,这样联合查询的第二部分就能显示出来了。页面显示第二列有回显,那2就是输出位。接下来把2替换成database()就能看到库名。拿到库名后,用一个子查询把表名全列出来:
sort=-1/**/union/**/select/**/1,group_concat(table_name/**/separator/**/0x7e),3/**/from/**/information_schema.tables/**/where/**/table_schema=database()这里有个细节:过滤规则没有动information_schema.tables,所以表名信息还能拿到。返回结果里有一个明显的flag表,还有一个ads表。第一步到此完成:知道库名,知道目标表名是flag。
3.2 列名被过滤后,怎么判断该用无列名注入
按照常规流程,下一步应该查flag表的列名,也就是访问information_schema.columns。但前面已经测试过,column关键词会被置空,所以这个查询基本没戏。我不死心又试了大小写混写、注释符分隔等方式,全部碰壁。
这里有一个判断逻辑值得展开:当常规拿列名的路被堵死时,先别急着放弃,可以先确认目标表的列数和数据分布。列数可以通过order by的无列名版本来探测,也可以直接用构造联合查询的方式来试。比如:
sort=-1/**/union/**/select/**/1,(select/**/1,2,3/**/union/**/select/**/*/**/from/**/flag),3如果flag表恰好是三列,1,2,3和select *两边列数一致,嵌套查询就不会报错。如果报错,就调整数字的个数。这一步的目的不是直接拿数据,而是确认“这个表能用无列名注入搞定”。
确认列数后还有关键一步:确认这个表有数据,而且数据可读。我试着直接select * from flag看回显,但联合查询的语法限制导致这个操作得包在子查询里才能继续处理。不管怎样,到这里已经能确定,flag表就是最后的目标,列名拿不到,必须走无列名注入。
3.3 最终的无列名payload逐段拆解
最终解题payload长这样:
sort=-1/**/union/**/select/**/1,(select/**/group_concat(a.2)/**/from/**/(select/**/1,2,3/**/union/**/select/**/*/**/from/**/flag)a),3这个payload看起来长,其实拆开就三层。最内层是select 1,2,3 union select * from flag,作用是构造一个三列的临时表,临时表的列名是1、2、3,数据行是flag表里的内容。注意,这一步必须保证两边的列数一致,所以前面的列数探测是必要的。
中间层是(select 1,2,3 union select * from flag)a,包在括号里并起别名a。这个别名绝对不能省,MariaDB和MySQL都强制要求派生表必须有自己的别名,不写会在执行时报错Every derived table must have its own alias。我当时在这上面卡了十分钟,一直以为payload写错了,其实是漏了别名。
外层是select group_concat(a.2) from (...)a,作用是把临时表a的第二列数据用group_concat拼成一行。之所以套group_concat,是因为flag表里可能有多行数据,不合并的话联合查询只回显一行,超过一行的数据就丢了。a.2就是取第二列,如果flag的第二列才是真正的flag字符串,这一步就能直接读出来。
我实际执行后,回显位置吐出了一串字符串,隔一段就出现~分隔符,这就是用separator/**/0x7e设置的。仔细看能看到flag{...}的片段,但被分隔符截开了。这说明flag表不止一列数据,于是我把a.2换成a.1、a.3,一分列拉出来后拼完整。
3.4 拿到flag后的数据清洗
CTF题目拿到flag只是“万里长征走完一半”,很多时候数据是分散的、被截断的、带分隔符的。我就遇到过group_concat拼接结果太长,被默认的group_concat_max_len截断的情况。虽然实际输出没爆,但最好有准备。
清洗方法有几个实操要点。第一,用separator指定一个不容易混淆的分隔符,比如0x7e就是~的十六进制,比默认逗号好辨认得多。第二,如果数据里本来就有逗号或波浪号,换一个更冷门的分隔符,比如0x23是#。第三,数据太长被截断时,不要硬拼,改用limit一行一行取,或者用substring分段截取。
还有一个容易被忽略的地方:flag表如果有多列,不要把希望全押在一列上。我第一轮只看到第二列有东西,以为大功告成,结果拿到的只是字符串的一部分。后来把三列都拉出来,才发现真正的flag分散在第一列和第三列里,需要拼接才完整。多列数据可以这样一次取多列:
select group_concat(concat(a.1,0x7e,a.2,0x7e,a.3)) from (select 1,2,3 union select * from flag)a这样每行数据都会带上分隔符,一眼就能看出哪列有货。
4. 实战中的坑与排查速查表
4.1 子查询必须要有别名
这是无列名注入最容易踩的坑。派生表是SQL标准里一个特殊结构,MySQL和MariaDB都强制要求它必须带别名。写from (select 1,2,3 union select * from flag)不加别名,执行时直接报错。
我排查这类问题有一个习惯:如果payload本身写得没错但一直报错,先从语法细节找,优先看有没有漏掉别名。报错信息里出现Every derived table must have its own alias,那就是别名问题,老实加个a在后面就行。这个坑不仅出现在无列名注入里,任何子查询套嵌都可能遇到,记住了能省不少时间。
4.2 数字列名的引号问题
无列名注入里用a.2引用第二列,这里的2不是字符串,是标识符。在大多数情况下,a.2这种写法可以直接用,但某些场景下2会被解析器当成数字而不是列名,导致报错Unknown column '2' in 'field list',这时候需要给列名加反引号:
select group_concat(a.`2`) from (select 1,2,3 union select * from flag)a反引号在MySQL和MariaDB里是标识符引用符,加上之后解析器就明确知道2是列名而不是常量。我问过不少朋友,他们都在这里栽过跟头。实际使用建议是:先不加反引号直接试,报错了再加,省得画蛇添足。
4.3 过滤逗号时的替代方案
有些题目把逗号也过滤了,那union select 1,2,3这步就麻烦。解决办法是用join语法代替逗号分隔,比如用union select 1 join 2 join 3?严格来说select 1 join 2不是标准写法,实际更常用的是利用from后面的多个表或join关键字来传多个拼接内容。
不过如果只是group_concat要指定分隔符,那本身不需要逗号:group_concat(a.2 separator 0x7e)就能搞定。如果是limit 0,1被逗号卡住,改成limit 1 offset 0就行。这些变体都属于“过滤规则逼出来的语法体操”,建议提前记熟,遇到类似的题直接套用。
4.4 无列名注入与报错注入的混搭
如果题目环境不显示联合查询结果,只显示报错信息,无列名注入也能配合报错函数使用。典型写法是用updatexml或extractvalue触发报错,并在报错内容里带上无列名子查询的结果:
sort=1/**/and/**/updatexml(1,concat(0x7e,(select/**/a.2/**/from/**/(select/**/1,2,3/**/union/**/select/**/*/**/from/**/flag)a),1)这个payload把无列名子查询的结果放进了concat,报错信息会显示~<数据>。我测试后发现,这种方式在order by注入场景下尤其好用,因为排序参数后面接and和表达式是合法的,报错回显又能绕过显示位限制。可见无列名注入不是只能配合联合查询,它是一个通用取数工具,只要有任何一种回显渠道,就能把数据带出来。
5. 事后复盘:我踩过的坑和一些个人体会
这道题真正有价值的地方不在于难,而在于它把几个知识点串得恰到好处。空格过滤逼你用/**/,列名信息被禁逼你走无列名注入,输出行数限制逼你用group_concat。三个过滤点没有任何一个单独看是致命的,但合在一起就把常规思路全堵死了,逼着你换角度思考。
我个人在复现时最有收获的一点,是意识到“无列名注入”不是一个专属于CTF的奇技淫巧,它本质上是对SQL联合查询机制的理解。只要你理解了“联合查询自动生成临时列名”这个规则,很多变体都能推导出来。比如flag表只有一列时,select 1 union select * from flag后取a.1;有三列时,取a.1、a.2、a.3;数据多行时,套group_concat;数据拖太长时,用limit分段取。这些都是同一个原理的排列组合,不需要死记硬背。
如果你也想练这类题,建议本地搭一个MariaDB环境,把过滤规则模拟出来,然后手写payload一遍遍地试。我试到第三遍才彻底理解a.2的引用逻辑和group_concat的位置关系。纸上得来终觉浅,SQL注入这种东西,真的得自己打一遍才能形成直觉。
最后再分享一个小技巧:无列名注入里如果flag表列数很多,不要急着猜,直接用二分法通过order by 5、order by 10这种递增方式快速圈定列数范围,再用联合查询嵌套验证,比自己一个一个猜数字高效得多。这题虽然到这里就结束了,但这个思路换个过滤规则、换道题,照样能用。