☰
SQL注入实战:从联合查询到绕过过滤拿Flag
2026/10/11 17:00:18 网站建设 项目流程

1. 场景初探与任务定位

1.1 拿到题目后的第一反应

SQL注入在CTF里属于那种“入门门槛低、但玩深了照样能卡你半天”的方向。这次作业三拿到手,看了半天标题,没有多余的介绍,只有一个目标描述:某内部系统的查询接口存在注入点,需要拿到管理员账号对应的加密凭证段。任务一、二大概是热身,任务三直接上了三段式过滤:关键字过滤、大小写混写过滤、还有注释符缺失的场景。

说实话,这类题目最烦人的地方不在于注入本身,而在于过滤规则的不可见。你根本不知道服务端把输入过成什么样子,只能靠一轮轮请求去试探、推演和验证。很多新手喜欢一上来就掏自动化工具,结果工具跑半天报了一堆错,最后发现过滤规则把工具的特征串也一并拦了。我的习惯是:先把环境弄清楚,再决定是手工还是工具,甚至很多时候手工一把梭比工具更省时间。

1.2 环境部署与基础探测

拿到题目之后,第一件事是确认环境可访问性。我用浏览器打开目标页面,发现确实是一个查询表单,输入框前面写着“请输入订单编号或客户标识”,后面有一个查询按钮。正常输入一个数字比如1001,页面会返回一条记录,包含订单号、客户名、支付状态三个字段。

接下来我快速做了一组基础探测请求:

  • 输入1001',页面返回500错误,提示SQL语法异常
  • 输入1001' --,恢复正常结果
  • 输入1001' and 1=1 --,返回结果与1001相同
  • 输入1001' and 1=2 --,页面无数据

到这里基本可以确定,这是一个典型的单引号闭合型注入点,而且后端直接把异常信息抛了出来。这种“报错型”环境比盲注环境友好太多——至少每一步都有反馈,不用靠猜。

1.3 三个作业关卡的整体思路

作业三一共三道题,难度递进。第一题考基础注入,第二题考盲注脚本能力,第三题则把过滤拉满。三个题目共享同一个后端逻辑,但过滤层不同。这里我先把整体思路理出来:

  • 第一题:联合查询,直接绕过验证拿数据,重点是order by确定列数
  • 第二题:布尔盲注,需要写脚本跑字符编码,重点是效率和质量
  • 第三题:关键字过滤加注释缺失,需要灵活用等价替代、双写绕过和拼接技巧

每一题的解法虽然不同,但底层的查询结构是一样的:SELECT 订单字段 FROM 表 WHERE 条件。理解了这层结构,后面的所有绕过滤操作都是在这个框架上做文章。

2. 注入类型识别与手工测试

2.1 注入点检测的三种基础手法

很多人拿到一个输入框,第一反应就是输入'看报不报错。这个思路没错,但不够系统。我平时会按顺序做三组测试,每组的反馈对应不同的信息:

第一组是“闭合测试”。输入1001'、1001"、1001')等不同闭合方式,看哪种组合能让SQL语法被破坏。返回500表示闭合有效,返回空表示闭合类型不对。这一组测试能确定两件关键信息:引号类型和是否包在括号里。

第二组是“恒真恒假测试”。输入1001' and '1'='1和1001' and '1'='2,如果真时返回正常记录、假时返回空,说明注入点在 WHERE 条件里且逻辑能被控制。这一步确认的是“能不能通过布尔条件影响查询结果”。

第三组是“注释符测试”。如果恒真恒假生效,下一个问题就是尾部的引号怎么处理。--、#、/*这些注释符都能截断后续SQL,但不同的数据库和配置对注释符的支持不一样。手动试一下就知道哪种可用。

这三组测试做完,注入点的位置、闭合方式、注释方式基本就定了,剩下的就是按部就班地构造 payload。

2.2 判断数据库类型和查询特征

在明确注入点之后,还需要确认数据库类型。这一步很关键,因为不同数据库的元数据表、注释符、字符串拼接方式完全不同。没有提前确认数据库类型就盲目套模板,大概率各种报错。

一个简单有效的判断方法是用数据库版本函数:

  • 输入1001' and version()>0 --,如果反馈正常,说明是MySQL
  • 输入1001' and (SELECT version())>0 --,适用于PostgreSQL的格式
  • 输入1001' and dbms_random.value>0 --,适用于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. 核心实战:从数据泄漏到拿Flag

3.1 联合查询的标准操作流程

第一道题是最典型的联合查询注入。既然已经确认了5列,接下来就是逐列确认显示位置。我发了这么一条请求:

1001' union select 1,2,3,4,5 --

页面返回了正常数据,但多了一行“1,2,3,4,5”的结果。逐列看下来,只有第2列和第3列在页面上正常渲染,其他列的数据没有显示出来。这意味着我只能在2和3这两个位置上拿数据。

第二列可以拿字符串,第三列也可以。为了确定表名,我查了sqlite_master:

1001' 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 name='secret_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": f"1001' and ({payload}) -- "} resp = requests.post(url, data=data) return "查询结果" in resp.text def extract_number(sql_expr): result = 0 for i in range(1, 10): if is_true(f"ascii(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(f"ascii(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 status='active'之类的尾巴。如果注释不了这段尾巴,我 insert 进去的union select就会跟后面的语句纠缠在一起产生语法错误。

解决的办法是“注释缺失时改用括号闭合”。如果整个 WHERE 条件部分被包裹在括号里,可以直接在结束位置构造一个闭合括号,把后面的逻辑“关掉”:

1001' union select 1,username,3,4,5 from secret_admin where '1'='1

注意这里最后用了where '1'='1来平衡引号,相当于把尾部引号“吸收”掉,后面的尾巴照样执行,但不会影响查询结果。这种手法在注释符不可用时非常实用。三个小关卡到这里全部打通。

4. 工具配合与手工结合的完整方案

4.1 使用自动化工具时的关键参数选择

对于SQL注入类的题目,很多选手习惯直接上自动化工具。工具确实能把手工几十步的工作压缩成一条命令,但如果参数对不上,工具会盲目地跑一大堆请求,效率极低,还可能触发目标系统的请求频率限制。

以第三题的过滤环境为例,如果要用工具,必须设置几项关键参数:

  • 注入点标记:工具需要知道测试参数在哪个位置,一般用*号指定
  • 技术类型:第三题不适合联合查询,得指定布尔盲注
  • 绕过脚本:工具的tamper模块可以针对关键字过滤做双写编码

我当时试了几种常见设置,如果直接默认参数跑,工具会被关键字的过滤规则卡死;加载了双写tamper之后能跑通,但请求量是手工做法的几十倍。所以我的结论是:工具适合快速验证注入存在性,真要拿数据,手工加小型脚本反而更精准、更可控。

4.2 手工SQL与自动化脚本的取舍原则

玩了这些年CTF,我有一个很深的体会:手工能力决定了你用工具的上限。如果你不理解双写绕过的原理,就算工具给出了正确结果,你也不明白为什么对;如果目标把工具的请求特征全都封了,你只能回到手工。

我的取舍原则是这样的:

  • 判断注入类型时手工做,三组请求就能定位,没必要上工具
  • 确认是联合查询且没过滤时优先手工,一条payload直接出数据
  • 盲注且数据量不大时写小型脚本,简单直接
  • 数据量极大且有明确的过滤规则时,才考虑上工具,但也要先手工验证payload可用

4.3 从拿数据到稳定的完整链路

完整的解题链路可以总结为五步:

  1. 探测闭合方式,确定注入存在
  2. 确认数据库类型和查询列数
  3. 根据回显方式选择注入技术
  4. 设计payload,逐步枚举库表字段
  5. 提取目标数据,完成题目

这个链路跟目标系统的复杂度无关。越到后面的题,过滤越重、反馈越少,但链路的骨架是不变的。你能做的就是在每个环节多准备几条备用路径:注释被过滤就走闭合括号,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训练的价值真正带进工作里的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询