MySQL无列名注入实战:从information_schema被过滤到WAF绕过

发布时间:2026/10/16 7:11:28
MySQL无列名注入实战:从information_schema被过滤到WAF绕过 网鼎杯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_schemadatabase()第三步查列名select group_concat(column_name) from information_schema.columns where table_nameusers第四步才是最终查数据select username,password from users。放到字符型注入里写成联合注入大概是这样假设目标表3列username-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()-- -注意这条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/**/11-- -如果回显正常说明空格可以用注释符替代、注入成立再换test/**/and/**/12-- -如果无结果或报错就确认了布尔条件生效。这里还有一个容易被忽略的点注册时写入的数据会先经过一次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表做joinselect * from (select 1 as id,2,3) x join (select * from users) y如果users表中真的存在id列MySQL会立刻报错Duplicate column name id。把这个子查询塞进extractvalue的报错通道就形成了完整注入payloadadmin and extractvalue(1,concat(0x7e,(select * from (select 1 as id,2,3) x join (select * from users) y limit 1)))-- -这里有几个细节要解释。为什么要limit 1join的结果是两张表的笛卡尔积如果不限制行数报错信息里可能被大量行干扰甚至让MySQL报“Subquery returns more than 1 row”。limit 1让子查询只返回一行只要执行到join并触发重复列名报错就达到目的行数越少越稳定。为什么要包一层extractvalue因为纯SQL报错不一定直接回显到页面报错注入函数本身就是把子查询的结果通过XPath错误信息带出来的通道。把整个join写在concat的第二个参数里一旦触发重复列名错误信息会跟着~一起回显。如果你的目标表不是users就把(select * from users)换成对应的表名列数也相应调整。爆破完id后把别名换成下一个候选比如usernameadmin 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/**/11-- -和admin/**/and/**/12-- -通过两个结果对比判断布尔注入是否成立。这一步花不了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,3join时列数不匹配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再试一次”的弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询