
CTF打了几年Web题里SQL注入从来都是“常青树”。不管是新手入门还是打到决赛几乎每场赛事都能碰上几道跟注入相关的题有的直接让你登录绕过有的藏得很深得通过各种过滤机制层层突破才能把flag拿出来。平时群里经常有人问为什么同一个注入套路换个题就不好使了为什么明明有注入点payload却全部被吞掉这次我把CTF里SQL注入从基础判断、绕过过滤到非常规手法的实战思路完整梳理一遍把我踩过的坑和沉淀下来的写法讲清楚希望对准备打CTF、或者正在刷靶场的朋友有点帮助。这篇文章适合三类人看一是准备入门CTF Web方向想知道SQL注入到底怎么解题的新手二是已经能跑通联合查询、报错注入但一遇到过滤就卡壳的进阶选手三是在企业做安全测试想补充一些绕过WAF思路的从业者。内容偏向CTF赛事场景所有示例都建议在本地靶场或CTF平台上复现验证不要用于未授权的系统。1. 先把出题人的思路盘清楚CTF里的SQL注入怎么考1.1 为什么SQL注入在CTF里经久不衰SQL注入的本质其实很简单程序在拼接SQL语句时没有把用户输入当作数据而是直接当成了SQL代码的一部分。你传进去的引号、括号、关键字被数据库解释成了语法结构于是执行逻辑就脱离开发者的预期了。不过话说回来CTF里考SQL注入并不是单纯考“你会不会拼一个 or 11 --”。出题人真正想考察的是三件事你能不能快速判断出注入点类型搞清楚参数是数字型还是字符型。你能不能摸清过滤规则知道哪些字符、哪些关键字被拦了然后找到替代方案。你能不能根据数据库类型和当前环境选择最高效的利用手法把数据一步步带出来。这也是为什么同样是SQL注入在不同题里难度天差地别。有的题直接把查询结果展示在页面上联合查询一把梭有的题页面只回显“是”或“否”你得用布尔盲注一点点猜有的题过滤了空格、注释符、关键字甚至把单引号转义了那就得用宽字节、内联注释、等价替换这类非常规手法。1.2 题目类型与考察维度我在实战中大致把CTF的SQL注入题分成五类每类的考察重点不太一样题目类型典型特征常用解法主要难点联合查询型页面直接回显查询结果字段数可控order by判断列数union select带出数据定位回显位、处理列数不匹配报错注入型数据库错误信息直接输出可用报错函数带数据extractvalue、updatexml、floor等报错手法过滤函数与长度截断盲注型页面不回显数据只回显真/假或时间差布尔盲注、时间盲注字符逐一猜测脚本编写堆叠注入型后端使用多语句执行函数可执行多条SQL分号分隔执行select、update等后续语句过滤了常见语句时需要变形绕过滤型题目明确过滤了空格、关键字、引号等内联注释、双写、等价值替换、编码绕过多种过滤叠加需要组合思路这里要多说一句分类不是死的。实战里一道题往往是好几类的组合比如“宽字节注入报错注入堆叠注入”你只掌握单一套路肯定不够得会根据环境现场切换。1.3 拿到一道SQL注入题先摸清这四件事不管题目包装得多花哨我拿到一道Web注入题第一步永远是先确认以下四件事顺序基本固定第一判断注入点类型。尝试传入单引号、双引号、数字运算等特殊值观察页面异常或回显变化。如果参数是数字型通常可以直接拼接如果是字符型需要绕过引号闭合逻辑。第二确认数据库类型。不同数据库的语法差异很大。MySQL支持information_schema、--注释符、database()函数PostgreSQL用pg_catalog、::类型转换MSSQL的注释符是--报错函数也不同。CTF里MySQL最常见但偶尔也会遇到其他数据库的题判断错类型后面全白搭。第三摸清过滤规则。多试几个字符看看空格、等号、单引号、select、union、sleep这些词有没有被过滤。过滤的方式通常有三种黑名单直接拦截、正则替换为空值、转义特殊字符。这一步直接决定你后续用哪种绕过姿势。第四选定注入手法。联合查询能出数据当然最快但如果不回显就转报错或盲注如果过滤了空格就先解决空格问题如果用了转义函数看看能不能宽字节突破。核心原则是能用简单手法就别上复杂的能把数据直接带出来就别去盲猜。2. 绕过过滤机制从基础到绕黑洞2.1 空格和注释符都没了怎么“呼吸”CTF里最常见的过滤就是黑掉空格。很多正则表达式会直接匹配空白字符一检测到就返回错误这时候你传select * from users基本就是送人头。空格被过滤后我最常用的替代方案有四个第一个方案是使用内联注释/**/。MySQL会把/**/直接当成空格处理所以select/**/password/**/from/**/users是可以正常执行的。注意大部分场景下/*和*/都不在黑名单里因为它还承担着注释的作用。第二个方案是使用括号。如果你的注入点在where条件附近可以尝试用括号把条件表达式包起来比如where(username(a))空格被括号替代后照样能构造成合法语法。联合查询也可以这么玩select(name)from(users)字符串作为函数参数传能省掉不少空格。第三个方案是使用特殊字符。MySQL和部分数据库支持在SQL语句中使用Tab%09、换行%0a、回车%0d等隐藏字符来替代空格。在URL里这些字符可以被编码后随请求直接传过去很多过滤规则都没考虑到。第四个方案是结合注释符断句。如果过滤机制拦的是--注释你可以改用#MySQL下或者使用--%20后面跟一个实际空格。有时候/*和*/还能用来混淆判断比如select/**//*foo*/password中间加一个无害注释正则匹配select.*password时就被搞乱了。2.2 关键字被过滤靠“等价替换”和“变形”解决空格之后过滤最多的就是SQL关键字。常见的黑名单包括select、union、from、where、sleep、and、or等。拦这个的思路一般是正则匹配但正则匹配总有绕过的空间。双写是最经典的绕过方式。如果过滤逻辑是把匹配到的关键字替换为空字符串且只替换一次那ununionion经过替换后会变回union。我在SQLi-Labs靶场里用这招做过很多题。关键是要先确认过滤逻辑是“替换为空”而不是“拦截报错”如果直接报错双写就无效了。大小写混合也是老套路。UnIoN SeLeCt在MySQL里直接能跑因为SQL关键字不区分大小写。但如果过滤器是先转小写再匹配大小写就失效了。所以实际利用前要测试一下过滤器的处理顺序。最核心的思路是等价替换。关键字本质上是实现某个功能的语法糖你可以找功能相同的替代品。比如sleep(5)被过滤可以用benchmark(10000000,sha1(1))替代。and被过滤有时候可以用替代or可以用||替代。被过滤可以用like、in、regexp、不等于反推替代。substring被过滤可以用left、right、mid替代。information_schema被过滤较新版本MySQL里可以用mysql.innodb_table_stats或sys.schema_auto_increment_columns替代。这些等价替换不是凭空想出来的得靠平时多积累。我自己的习惯是打靶场时每遇到一种过滤方式就把可替代的写法记下来时间长了遇到新题就能条件反射式地换招。2.3 单引号被转义宽字节注入来破CTF里经常见到addslashes、mysql_real_escape_string这类转义函数它们会把、、\前面加一个反斜杠导致输入的单引号变成\无法闭合SQL语句中的引号。这时候就要用宽字节注入。这个手法在GBK编码下很好使原理是MySQL在使用GBK编码时会把两个字节看成是一个汉字。当你输入%df%27时转义函数会在%27单引号前面加一个反斜杠%5c最终变成%df%5c%27。而在GBK编码下%df%5c会被解析成一个汉字字符后面的%27就独立出来变成了真正的单引号成功逃逸。我在靶场里常用的payload是这样的?id1%df%27 union select 1,2,3--注意宽字节注入的使用前提是数据库连接字符集是GBK。如果后端设置了UTF-8%df和%5c不会组合成一个字符这个手法就无效了。判断方法很简单试试%df如果报错信息里出现中文乱码或者单引号闭合成功的迹象就有戏。2.4 引号和字符串都不用愁编码绕过有时候过滤规则直接禁止出现单引号但你又需要传入字符串常量比如查询特定表名、特定字段值。这时有几个办法十六进制编码代替字符串。MySQL里字符串常量可以直接用十六进制表示比如admin可以写成0x61646d696einformation_schema可以写成对应的十六进制。数据库会自动把十六进制转换成字符串整个过程不需要出现一个引号。char()函数拼接。这个方法的原理跟十六进制类似把字符的ASCII码变成数字再通过char()函数拼出来。比如char(97,100,109,105,110)就是admin。这个方法比十六进制更灵活因为很多过滤规则不会检查数字和括号。反引号包表名。MySQL支持用反引号来标识表名和列名。如果过滤规则只是拦了双引号你可以在SQL语句里用反引号代替比如selectpasswordfromusers。注意反引号只适用于MySQL在别的数据库里可能不通用。3. 非常规注入手法常规联合查询走不通时换招3.1 报错注入数据库爆错flag自己吐出来页面不回显查询结果时很多人第一反应就是盲注但其实报错注入往往更快。前提是数据库错误信息会直接输出到页面比如PHP环境没关闭display_errors。CTF里最常见的报错注入函数是extractvalue和updatexml它们本身是MySQL的XML处理函数当传入的XPath表达式格式错误时会报错并把传递的参数内容带出来。以updatexml为例典型payload长这样?id1 and updatexml(1,concat(0x7e,(select database()),0x7e),1) --解释一下updatexml(xml_document, XPath字符串, new_value)三个参数中第二个参数要求是合法的XPath路径。我们传进去concat(0x7e,(select database()),0x7e)0x7e是波浪号~的十六进制用来当分隔符让报错信息更清晰。因为路径不合法MySQL报错时会输出完整的路径内容也就是我们查询到的数据。另一个常用的是floor报错利用的是分组聚合时主键冲突报错输出数据?id1 and (select 1 from (select count(*),concat((select database()),floor(rand(0)*2))x from information_schema.tables group by x)y) --这个方法相比updatexml更复杂但某些题目会把extractvalue和updatexml加入黑名单floor就成了备选方案。用报错注入有两点要特别注意一是报错输出有长度限制MySQL的extractvalue报错最多显示32个字符updatexml也是32个字符查询结果太长时要用substr或mid分段截取二是报错信息可能被HTML实体编码比如和会被转义所以查询的内容尽量用 hex 编码后输出。3.2 堆叠注入一条select不够就多来几条常规注入里union select只能拼接查询语句而且要求前后列数一致。但堆叠注入不一样它利用分号将两条完全独立的SQL语句拼接在一起执行比如?id1; select load_file(/etc/passwd); --这条语句在支持多语句执行的场景下会先执行查询再执行select load_file(/etc/passwd)读取服务器文件。堆叠注入的适用条件比较苛刻因为后端必须使用支持多语句执行的API比如PHP的mysqli_multi_query而常规的mysql_query函数只允许执行一条语句分号后面的内容会被直接忽略甚至报错。所以实战中要先测试分号是否有反应。一旦确认支持堆叠注入利用价值就大了很多因为它不只可以查询还可以插入、更新、删除甚至创建临时表存储数据。CTF里常见的利用姿势包括写入webshell后门文件配合文件操作权限。set test ...; prepare ...预编译语句绕过过滤。结合select load_file()读取服务器敏感文件。这里要提醒的是堆叠注入的payload里通常不能出现空格、注释符等被过滤的字符所以往往需要先叠加各种绕过技巧比如用/**/替代空格用%0a替代换行等。3.3 时间盲注与布尔盲注的进阶写法页面完全没有数据回显也没有报错信息时只能走盲注。盲注的核心思路是通过可观察的差异来猜测数据要么是页面真假差异布尔盲注要么是响应时间差异时间盲注。布尔盲注的典型写法是?id1 and (select ascii(substr(database(),1,1)))100 --如果页面正常说明数据库名的第一个字符的ASCII码大于100如果异常说明小于等于100。二分法逐位猜测效率还行但遇到长字符串会很耗时间。时间盲注更隐蔽不依赖页面真假而是通过延时判断条件是否成立?id1 and if((select ascii(substr(database(),1,1)))100,sleep(3),0) --条件成立时数据库会等待3秒通过响应时间就能判断真伪。CTF里经常把sleep函数加入黑名单这时可以用benchmark替代。比如benchmark(10000000,sha1(1))会执行1000万次哈希计算产生可感知的延迟。值得留意的是benchmark的返回结果不是时间函数能不能产生明显延迟取决于服务器性能需要多调几次数量级。另外我在实际做题时很少手工逐位盲注太慢了。通常是用Python脚本或者Burp Suite的Intruder模块自动化跑。写脚本时要注意处理URL编码、会话保持、频率限制等问题稍后我专门讲一下实战中遇到的坑。3.4 二次注入入口处写脏数据出口处触发二次注入在CTF里出现的频率不算最高但一旦出现就很有迷惑性。它的特点是恶意输入在第一次进入数据库时被安全处理了没有造成伤害但存进数据库的却是“脏数据”后续代码从数据库里取出这条数据在拼接到SQL语句时没有做任何处理导致注入触发。一个典型的场景是用户注册功能。假设注册时用户名经过转义函数处理单引号被过滤但转义后的admin仍然存进了数据库。之后修改密码功能把用户名拼进SQL语句去查询用户信息而这个用户名没有再经过过滤这时admin就成了注入点。CTF里遇到二次注入最关键的是找到“数据流入”和“数据流出”两个位置。注入点通常不在第一次输入的参数上而是藏在后面某个看似无关的查询里。所以做题时要特别注意功能之间的联动不要只盯着当前参数。3.5 利用文件函数和预编译语句扩大战果还有一种非常规思路是突破数据库本身的限制去读取服务器文件或者执行系统命令。MySQL里有两个关键函数load_file()可以读取服务器上的任意文件into outfile可以把查询结果写入文件二者配合可以尝试写webshell。典型的读文件payload?id1 union select 1,load_file(/var/www/html/index.php),3 --典型的写文件payload?id1 union select 1,?php eval($_POST[1]);?,3 into outfile /var/www/html/shell.php --这两个功能能否成功取决于当前数据库用户的文件权限、MySQL的secure_file_priv配置、以及web目录的写权限。CTF里如果开了这些权限往往能直接拿下RCE比纯数据提取痛快得多。另外MySQL 5.0以上版本支持预处理语句可以用来绕过很多静态过滤。比如一次性构造出来的字符串会包含select关键字但如果用set sql concat(sele,ct ...)动态拼接再通过prepare执行就能绕过基于全文匹配的过滤器。4. 实战演练一道综合过滤模拟题从注入点到flag4.1 题目描述与环境探测光讲理论不够下面我构造一道综合性的模拟题把前面提到的技术串起来走一遍。题目设定是这样的一个基于PHPMySQL的登录页面username参数存在注入但后端做了多层过滤并且关闭了错误显示只通过登录成功与否来反馈结果。拿到题目后我先做了最基础的探测。输入admin提交页面没有任何异常输入admin也没反应输入admin和密码123提示用户名不存在。这里有个细节传admin没报错很可能单引号被转义了也就是存在类似addslashes的处理。然后我试了宽字节注入admin%df页面提示变成了密码错误——说明单引号成功闭合注入点存在。接下来判断数据库类型和过滤规则。输入admin%df and 11 --密码随便填个错误值页面提示密码错误改成admin%df and 12 --页面提示用户不存在。布尔盲注的条件成立。再试union、sleep、extractvalue这些词均被拦截。这题的过滤规则初步判断是单引号被转义宽字节可突破union、and、or、sleep、extractvalue、updatexml全部被过滤。4.2 绕过组合宽字节开道floor报错带数据既然布尔盲注成立理论上可以慢慢猜数据但太慢了。我先尝试报错注入因为即使页面不显示报错信息某些情况下还是会泄露详细错误到日志里不过这里实测下来确实没有输出。既然报错行不通那就用布尔盲注但需要先把and、or被过滤的问题解决掉。测试admin%df 11 --页面提示密码错误说明没有被过滤可以替代and。测试admin%df || 11 --同样有效。union被过滤的话联合查询先放一边优先级最高的是构造一个能执行子查询的布尔注入payload。先确认当前数据库名长度。payloadusernameadmin%df (length(database()))3 --页面回密码错误说明条件为真数据库名长度大于3。继续二分法最终确定长度是4。然后逐位猜解数据库名字符usernameadmin%df (ascii(substr(database(),1,1)))100 --通过多次调整阈值猜出第一位是cASCII码99整个数据库名是ctf4。4.3 表名与字段名的猜测脚本拿到库名后要查表名。因为不能直接select table_name from information_schema.tables出回显我写了一个简单的Python脚本自动化跑布尔盲注。核心思路是逐位猜测用二分法提高效率import requests url http://your-target.com/login.php cookies {PHPSESSID: your_session} def blind(payload): data {username: payload, password: wrongpass} r requests.post(url, datadata, cookiescookies) return 密码错误 in r.text def get_char(sql, pos): # 二分猜测当前字符的ASCII码 low, high 32, 126 while low high: mid (low high) // 2 payload fadmin%df (ascii(substr(({sql}),{pos},1))){mid} -- if blind(payload): low mid 1 else: high mid return chr(low) def get_string(sql, length): result for i in range(1, length 1): result get_char(sql, i) print(result) return result # 猜表名长度 for i in range(1, 20): payload fadmin%df (length((select table_name from information_schema.tables where table_schemadatabase() limit 0,1))){i} -- if not blind(payload): print(ftable name length {i}) break这里我解释一下脚本里几个关键点admin%df用来宽字节突破引号转义替代被过滤的andinformation_schema.tables查表名table_schemadatabase()限定当前库limit 0,1取第一张表。每次请求只判断一个字符的一个大小比较通过二分法逐步逼近真实值。实测跑完表名字段得到第一张表叫flag_table然后继续猜字段名最终确定字段是secret_flag。接下来直接构造payload读取内容admin%df (ascii(substr((select secret_flag from flag_table),1,1)))50 --逐位跑完最终拼出flag。整个过程跑了大概300多次请求脚本耗时几分钟比手工快得多。4.4 这场实战总结出的三个细节第一个细节是宽字节注入的payload里%df后面接的SQL语句不能再出现原始的单引号否则闭合逻辑会乱。所以字符串常量一律用十六进制表示比如database()不涉及引号select secret_flag from flag_table也不涉及没踩坑。第二个细节是URL编码层级。提交表单数据时浏览器和PHP会自动做一次URL解码所以%df会还原成原始字节还原成单引号。但如果你用Burp Suite直接改包要注意设置好Content-Type和编码方式避免双重编码导致payload失效。第三个细节是二分法判断条件。我习惯先测试mid成立或不成立来缩小范围而不是老老实实逐个ASCII码试效率完全不同。如果目标服务器响应较慢可以先把二分区间缩小到常见字符集比如先判断是数字、小写字母还是特殊符号再进入对应区间。5. 踩坑与排错这些细节决定你能不能拿分5.1 盲注脚本常见的“玄学”问题写盲注脚本时最容易踩的坑是响应判断条件不够稳定。有的页面不管注入成不成功返回内容都差不多只是长度差几个字节这时候直接用密码错误 in r.text判断可能出问题。我建议用响应长度或状态码作为依据并且多请求一次做交叉验证。更大的坑是请求频率。CTF平台通常有WAF或限速机制脚本跑得太快很容易被ban。我一般会在每次请求之间加time.sleep(0.1)既保证效率又不容易触发防护。如果还是被ban就改用Session复用连接、减少重复请求。另外被过滤的字符可能不止关键词本身还有URL编码后的形态。比如服务器端可能先做一次urldecode再过滤你直接传%27会被还原成然后触发拦截。这时候可以考虑二次编码比如把%27编码成%2527看服务器解码几次。5.2 报错截断与实际操作中的切分策略前面提到extractvalue和updatexml报错只能显示32位字符这个限制在实际做题中特别容易坑人。比如你想查出一整张表的数据结果只看到前32个字符后面的全被截断了。解决办法是用substr或mid分批截取。我常用的做法是先确定总长度再按照30个字符一组分批查询?id1 and updatexml(1,concat(0x7e,substr((select group_concat(column_name) from information_schema.columns where table_name0x666c6167),1,30),0x7e),1) --这里用0x666c6167代替flag字符串避免引号问题。如果过滤了group_concat可以用limit逐行读取。总之报错注入里“切分读取”是一个必备技巧。5.3 别只盯着参数值请求头和参数名也可能藏注入很多时候我们习惯只关注URL参数和POST表单里的值但CTF出题人有时候会把注入点藏在很隐蔽的位置比如Cookie里的某个值被拼接到SQL语句。User-Agent、Referer等请求头被写入数据库二次查询时触发。JSON请求体里的键名甚至参数名本身。这个排查思路非常重要。我有一个固定流程拿到题目先用Burp Suite的Site Map把所有请求列出来挨个检查参数包括Headers、Cookies、Body手动或半自动地测试每个输入点。很多看似无关紧要的字段恰恰是出题人埋的雷。5.4 工具和自己的脑子怎么平衡关于sqlmap我说点实际感受。sqlmap在普通Web漏洞测试里效率很高但在CTF题里往往会碰壁原因有三第一CTF题目经常会用自定义过滤逻辑sqlmap的payload库未必能识别第二很多题目限速或者有反爬机制sqlmap的大规模请求会被直接拦截第三CTF的过滤规则经常是“组合拳”不是标准WAF能覆盖的。所以我建议的打法是先用人工判断注入点类型、数据库、过滤规则能手工跑完就手工跑完确实需要自动化用自己写的Python脚本比硬上sqlmap更可控。sqlmap可以作为辅助工具加上--tamper参数处理部分已知过滤但不要依赖它。5.5 靶场练习路线技术这东西光看是练不出来的我整理了一条适合循序渐进的靶场路线靶场重点练习内容适合阶段SQLi-Labs基础注入、报错注入、盲注、二次注入入门到进阶Pikachu综合SQL注入、与其他漏洞组合利用进阶DVWASQL注入盲注、sqlmap配合入门BUUCTF真实CTF赛题环境综合绕过滤比赛前冲刺练的时候不要只追求打通每个关卡打完后都要做复盘这题用了哪几个绕过技巧为什么这个payload有效如果把过滤规则再加强还能不能打这样每打一题收获才完整。最后分享一个我的个人习惯每道题的payload和绕过思路都单独记在一个笔记里标注当时的环境和踩坑点。时间久了再去刷题或者比赛遇到类似过滤机制就能直接调出自己的数据库省了很多试错时间。SQL注入在CTF里是一个巨大且不断演变的主题没有一劳永逸的解法但把基础打牢、把绕过思路的“套路库”做厚绝大多数题都是能解的。