SQL注入实战:从联合查询到绕过过滤拿Flag

发布时间:2026/10/11 17:00:41
SQL注入实战:从联合查询到绕过过滤拿Flag
1. 场景初探与任务定位1.1 拿到题目后的第一反应SQL注入在CTF里属于那种“入门门槛低、但玩深了照样能卡你半天”的方向。这次作业三拿到手看了半天标题没有多余的介绍只有一个目标描述某内部系统的查询接口存在注入点需要拿到管理员账号对应的加密凭证段。任务一、二大概是热身任务三直接上了三段式过滤关键字过滤、大小写混写过滤、还有注释符缺失的场景。说实话这类题目最烦人的地方不在于注入本身而在于过滤规则的不可见。你根本不知道服务端把输入过成什么样子只能靠一轮轮请求去试探、推演和验证。很多新手喜欢一上来就掏自动化工具结果工具跑半天报了一堆错最后发现过滤规则把工具的特征串也一并拦了。我的习惯是先把环境弄清楚再决定是手工还是工具甚至很多时候手工一把梭比工具更省时间。1.2 环境部署与基础探测拿到题目之后第一件事是确认环境可访问性。我用浏览器打开目标页面发现确实是一个查询表单输入框前面写着“请输入订单编号或客户标识”后面有一个查询按钮。正常输入一个数字比如1001页面会返回一条记录包含订单号、客户名、支付状态三个字段。接下来我快速做了一组基础探测请求输入1001页面返回500错误提示SQL语法异常输入1001 --恢复正常结果输入1001 and 11 --返回结果与1001相同输入1001 and 12 --页面无数据到这里基本可以确定这是一个典型的单引号闭合型注入点而且后端直接把异常信息抛了出来。这种“报错型”环境比盲注环境友好太多——至少每一步都有反馈不用靠猜。1.3 三个作业关卡的整体思路作业三一共三道题难度递进。第一题考基础注入第二题考盲注脚本能力第三题则把过滤拉满。三个题目共享同一个后端逻辑但过滤层不同。这里我先把整体思路理出来第一题联合查询直接绕过验证拿数据重点是order by确定列数第二题布尔盲注需要写脚本跑字符编码重点是效率和质量第三题关键字过滤加注释缺失需要灵活用等价替代、双写绕过和拼接技巧每一题的解法虽然不同但底层的查询结构是一样的SELECT 订单字段 FROM 表 WHERE 条件。理解了这层结构后面的所有绕过滤操作都是在这个框架上做文章。2. 注入类型识别与手工测试2.1 注入点检测的三种基础手法很多人拿到一个输入框第一反应就是输入看报不报错。这个思路没错但不够系统。我平时会按顺序做三组测试每组的反馈对应不同的信息第一组是“闭合测试”。输入1001、1001、1001)等不同闭合方式看哪种组合能让SQL语法被破坏。返回500表示闭合有效返回空表示闭合类型不对。这一组测试能确定两件关键信息引号类型和是否包在括号里。第二组是“恒真恒假测试”。输入1001 and 11和1001 and 12如果真时返回正常记录、假时返回空说明注入点在 WHERE 条件里且逻辑能被控制。这一步确认的是“能不能通过布尔条件影响查询结果”。第三组是“注释符测试”。如果恒真恒假生效下一个问题就是尾部的引号怎么处理。--、#、/*这些注释符都能截断后续SQL但不同的数据库和配置对注释符的支持不一样。手动试一下就知道哪种可用。这三组测试做完注入点的位置、闭合方式、注释方式基本就定了剩下的就是按部就班地构造 payload。2.2 判断数据库类型和查询特征在明确注入点之后还需要确认数据库类型。这一步很关键因为不同数据库的元数据表、注释符、字符串拼接方式完全不同。没有提前确认数据库类型就盲目套模板大概率各种报错。一个简单有效的判断方法是用数据库版本函数输入1001 and version()0 --如果反馈正常说明是MySQL输入1001 and (SELECT version())0 --适用于PostgreSQL的格式输入1001 and dbms_random.value0 --适用于Oracle我当时测试下来页面反馈里出现了SQLite关键字的报错信息。SQLite 的特点是注释符支持--和/* */但不支持#还有独特的sqlite_master系统表。这个发现直接决定了后续拿表名和字段名的思路。2.3 用一份请求把注入类型定死确认了数据库和注入点后我再做一次综合验证把注入类型正式定下来。我构造了这样一条请求1001 order by 5 --order by后面跟数字如果数字大于实际列数SQL就会报错“第 N 列不存在”。我从1开始试到6发现order by 5正常、order by 6报错说明查询语句一共有5列。这个数字就是联合查询的关键——union select必须凑够5个字段否则查询不合法。这一步做完整个注入的“地基”就打好了单引号闭合、SQLite数据库、5列查询、--注释可用。3. 核心实战从数据泄漏到拿Flag3.1 联合查询的标准操作流程第一道题是最典型的联合查询注入。既然已经确认了5列接下来就是逐列确认显示位置。我发了这么一条请求1001 union select 1,2,3,4,5 --页面返回了正常数据但多了一行“1,2,3,4,5”的结果。逐列看下来只有第2列和第3列在页面上正常渲染其他列的数据没有显示出来。这意味着我只能在2和3这两个位置上拿数据。第二列可以拿字符串第三列也可以。为了确定表名我查了sqlite_master1001 union select 1,name,3,4,5 from sqlite_master --这里解释一下为什么能这样用union select的左右两侧必须列数一致但列的类型不需要完全一致。SQLite对类型比较宽松所以左侧是原本查询的订单记录右侧是sqlite_master里的表名记录两者拼在一起返回。如果数据库是MySQL还要注意union前后查询的字段顺序和类型不能冲突否则会报错。查询结果里出现了一个名为secret_admin的表。这个表名很直白大概率就是目标数据所在的地方。继续查这个表的字段结构1001 union select 1,sql,3,4,5 from sqlite_master where namesecret_admin --这一步拿到的是secret_admin表的建表语句字段名一目了然id、username、password_hash。接下来的事情就简单了1001 union select 1,username,password_hash,4,5 from secret_admin --页面回显里出现了管理员账号和一串疑似MD5格式的密文。把这个密文拿去解析一下第一题的Flag就到手了。3.2 布尔盲注的脚本化思路第二题把报错信息关掉了所有的请求都返回同一个页面模板唯一的区别是返回内容里有没有“查询结果”这一行。这是标准的布尔盲注环境真条件返回数据假条件不返回数据。这种环境不适合手工一点一点猜效率太低。我当时写了个Python脚本核心逻辑是这样的import requests import string url http://目标地址/query charset string.ascii_lowercase string.digits _ def is_true(payload): data {keyword: f1001 and ({payload}) -- } resp requests.post(url, datadata) return 查询结果 in resp.text def extract_number(sql_expr): result 0 for i in range(1, 10): if is_true(fascii(substr(({sql_expr}),1,1)){result}) if False else None: pass # 实际用二分法 low, high 0, 127 while low high: mid (low high) // 2 if is_true(fascii(substr(({sql_expr}),1,1)){mid}): low mid 1 else: high mid return chr(low)这里面有两个关键点。第一个是用substr结合ascii函数逐字符把数据“挤”出来。第二个是二分查找把原来最多127次请求压缩到7次左右。当时跑完整个表的数据总共发了两百多个请求完全在可接受范围内。脚本跑出来的结果是一个新表名flag_store里面存了第二题的Flag字段。这一步验证了盲注的适用范围不管页面反馈多朴素只要布尔条件能影响回显数据就一定能拿到只是时间问题。3.3 报错注入到提取完整数据第三题的核心难点不是注入本身而是过滤。题目描述里说“关键字大小写不敏感”“注释符被剥离”实际测试下来确实如此输入UNION会被直接拦截输入union也被拦截注释符--和/* */都会被替换为空这种情况下常规的“签名型”payload基本全废。我换了一种思路既然 UNION 关键字不能直接出现那就用双写绕过——在SQL关键字中插入相同关键字使过滤函数将中间部分删掉后剩余部分刚好拼成合法的SQL关键字。实测确认了过滤逻辑是简单地用str_replace把关键字替换成空字符串而且不递归。这就好办了1001 uniunionon selselectect 1,username,3,4,5 from secret_admin --当服务端把中间的union和select删掉后实际执行的SQL变成了union select效果完全一样。这个“双写绕过”的原理很简单过滤函数只处理一次输入不会去检查替换后的结果里是否还残留关键字。但这里有个细节第三题连注释符也被过滤了。也就是说payload 末尾的--或#都会被删掉。没有注释符尾部闭合就成了问题。当时的情况是原本查询语句结尾可能还有一段AND statusactive之类的尾巴。如果注释不了这段尾巴我 insert 进去的union select就会跟后面的语句纠缠在一起产生语法错误。解决的办法是“注释缺失时改用括号闭合”。如果整个 WHERE 条件部分被包裹在括号里可以直接在结束位置构造一个闭合括号把后面的逻辑“关掉”1001 union select 1,username,3,4,5 from secret_admin where 11注意这里最后用了where 11来平衡引号相当于把尾部引号“吸收”掉后面的尾巴照样执行但不会影响查询结果。这种手法在注释符不可用时非常实用。三个小关卡到这里全部打通。4. 工具配合与手工结合的完整方案4.1 使用自动化工具时的关键参数选择对于SQL注入类的题目很多选手习惯直接上自动化工具。工具确实能把手工几十步的工作压缩成一条命令但如果参数对不上工具会盲目地跑一大堆请求效率极低还可能触发目标系统的请求频率限制。以第三题的过滤环境为例如果要用工具必须设置几项关键参数注入点标记工具需要知道测试参数在哪个位置一般用*号指定技术类型第三题不适合联合查询得指定布尔盲注绕过脚本工具的tamper模块可以针对关键字过滤做双写编码我当时试了几种常见设置如果直接默认参数跑工具会被关键字的过滤规则卡死加载了双写tamper之后能跑通但请求量是手工做法的几十倍。所以我的结论是工具适合快速验证注入存在性真要拿数据手工加小型脚本反而更精准、更可控。4.2 手工SQL与自动化脚本的取舍原则玩了这些年CTF我有一个很深的体会手工能力决定了你用工具的上限。如果你不理解双写绕过的原理就算工具给出了正确结果你也不明白为什么对如果目标把工具的请求特征全都封了你只能回到手工。我的取舍原则是这样的判断注入类型时手工做三组请求就能定位没必要上工具确认是联合查询且没过滤时优先手工一条payload直接出数据盲注且数据量不大时写小型脚本简单直接数据量极大且有明确的过滤规则时才考虑上工具但也要先手工验证payload可用4.3 从拿数据到稳定的完整链路完整的解题链路可以总结为五步探测闭合方式确定注入存在确认数据库类型和查询列数根据回显方式选择注入技术设计payload逐步枚举库表字段提取目标数据完成题目这个链路跟目标系统的复杂度无关。越到后面的题过滤越重、反馈越少但链路的骨架是不变的。你能做的就是在每个环节多准备几条备用路径注释被过滤就走闭合括号UNION被过滤就走双写报错被过滤就走布尔条件。路径多了题目自然就通了。5. 踩坑记录与排查技巧5.1 三个经典坑位这一整套题目做下来我遇到了几个典型问题估计很多选手也会碰到。第一个是“联合查询列数不匹配”。有时候union select的列数跟目标查询不一致页面会报错但不会提示到底是“列数太多”还是“列数太少”。解决办法就是用order by N从1往上逐级试探直到报错为止再取报错前一档的数字。第二个是“过滤规则里的正则陷阱”。第二题一开始我以为只是单纯的关键字替换结果测试发现select关键字里的el被单独过滤了。也就是说服务端会把某些子串单独拎出来做过滤双写整个关键字没用需要在子串那一层做文章。这种情况我后来是用selselectect里套el的双写来解决的实际上就是多做一层双写嵌套。第三个是“盲注延迟时间过长”。第三题的某一个布尔条件在服务器端要执行一次全表扫描单次请求耗时接近一秒。如果跑字典payload整个枚举过程会拖得很长。遇到这种问题我的优化方式是把substr的提取范围从1改成limit 1 offset 0这样的分页方式先把单个目标记录锁定避免每次判断都把整张表遍历一遍。5.2 排查顺序与定位方法如果一条payload发过去结果不符合预期我习惯按下面的顺序排查先看闭合方式是否对引号类型、括号层数再看注释符是否生效如果被过滤就换闭合括号接着看关键字过滤用双写或者等价函数替换最后检查编码问题比如URL编码、字符编码是否被服务器错误解析这三个坑每一个都让我多花了半小时左右。尤其是第二个“子串过滤”的坑如果不仔细看报错信息光靠盲猜很难定位到el这一层。后来我总结出经验遇到过滤时永远先试探最小的生效单元而不是直接试整个关键字。6. 从这道题延伸出去的一些想法SQL注入这三道作业做完我最大的感触是注入题的价值不在于“拿到Flag那一刻的爽感”而在于推演过程的严密性。每一次构造的payload本质都是对后端逻辑的一次猜测而每一次回显或报错都是对猜测的验证。这种“提出假设-验证假设-修正假设”的循环恰恰是安全测试工作中最核心的能力。最近这些年Web应用的安全防护越来越强参数化查询和ORM框架逐步普及传统的SQL注入场景越来越少。但SQL注入的思想并没有过时数据与指令边界模糊导致的问题在任何地方都可能重新出现。比如日志注入、模板注入、命令注入本质上都是在处理“用户输入被当作代码执行”的问题。吃透了SQL注入的闭合、绕过滤、数据提取这套思路迁移到其他注入类型只是换一层皮而已。另外说句实在话CTF题里的场景一般都会故意把过滤规则设计得比较“直白”方便选手找到突破口。真实环境里的过滤通常更复杂甚至会有多层WAF叠加。但越是这样你在一道题里积累的“备用路径”就越有价值。这次第三题如果不备着闭合括号这一手注释被过滤的时候就得卡半天了。如果再往深一点说我觉得这类题目训练的最有价值的能力是“系统性的耐心”。不是快而是每一步都有目的每一次试探都有记录不会因为连续几个payload失效就慌乱。你心里清楚只要注入点存在、数据库能处理你输入的语句数据就一定能被取出来剩下的只是时间和路径选择的问题。三道题全部收工后我把自己的payload过程整理成了一份简短的手记把每种过滤手段对应的绕过方法单独列成一个表下次再遇到类似题目就直接对照查。这种“造轮子但不止于造轮子”的做法才是把CTF训练的价值真正带进工作里的方式。