☰
从日志文件快速定位SQL注入:CTF实战与Python脚本
2026/10/21 5:53:15 网站建设 项目流程

CTF实战:如何从日志文件中快速揪出SQL注入攻击(附Python脚本)

打CTF比赛的时候,Web题是最容易出SQL注入的,但很多新手拿到日志文件就懵了——几千行访问日志摆在眼前,根本不知道从哪下手。尤其是在AWD(Attack With Defense)模式里,别人的攻击 payload 已经打进你的日志了,你要是不赶紧分析出来、补上漏洞,下一轮还会被继续打穿。

我折腾了这么多年CTF,总结了一套从日志文件里快速定位SQL注入的实操方法,今天完整分享一下。文末附一个可以直接拿去用的Python脚本,能帮你把攻击流量从正常请求里自动分离出来。

先说明白:这套方法和脚本不只是比赛能用,平时自己搭的站点被扫描、被攻击,同样可以用它来排查,本质是一样的。

1. 日志分析的核心思路:你究竟在日志里找什么

很多人拿到日志的第一反应是用grep去搜"select""union""sleep"这些关键词。这个方向没错,但只靠它远远不够,原因有两个。

第一,攻击者不会把SQL关键字直接裸奔在URL里。现在稍微有点经验的攻击者都会做编码,最常见的是URL编码,比如把'编码成%27,空格编码成%20。更高级的会用二次编码(%2527),或者用注释符(/**/)代替空格来绕过简单的字符串匹配。你要是只搜明文关键字,很容易漏掉真正攻击的请求。

第二,正常业务请求里也可能出现这些关键字。比如有个搜索功能,用户搜索"union"这个词,日志里就会有一条包含union的请求,但这个请求是完全正常的。如果你不加判断地拦截或者告警,误报率会高得离谱,反而不利于分析。

所以我的核心思路是三层过滤:

第一层,先做高频特征粗筛,把明显包含分号、单引号、UNION、SELECT等特征的请求先捞出来;第二层,再用请求结构分析去判断参数位置是否异常、特殊字符是否集中在参数值里,把正常业务请求识别掉;第三层,对筛选出的可疑参数做语义层面的二次确认,比如去重、聚类、追踪同一个IP的连续请求,找出真正的攻击模式。

这三层过滤打完,基本就能把攻击流量从海量日志里捞干净了。

2. 特征库设计:哪些Pattern真正指向SQL注入

在写脚本之前,先得搞清楚一件事——什么特征的请求应该被标记为SQL注入嫌疑。我把特征分成三大类:SQL关键字、SQL符号特征、请求结构异常。这三类互相配合,能覆盖绝大多数常见注入手法。

2.1 SQL关键字特征

这一层大家都很熟,但要注意大小写和编码变形。我建议在检测前统一对URL做一次URL解码(decode),再对解码结果做判断,而且匹配的时候用不区分大小写的模式。

典型关键词包括:

  • SELECT、UNION、INSERT、UPDATE、DELETE、DROP
  • SLEEP、BENCHMARK、WAITFOR、IF
  • INFORMATION_SCHEMA、SCHEMA()
  • OR 1=1、AND 1=1

需要说明的是:SLEEP()和BENCHMARK()这类函数在盲注里极其常用,因为时间盲注的唯一判断依据就是响应时间;INFORMATION_SCHEMA则是数据表结构探测的必经之路。所以它们一旦出现在访问日志里,基本就是攻击信号,优先级非常高。

2.2 SQL符号与语法特征

光有关键字还不够,因为环绕关键字两侧的符号才是SQL注入的灵魂。常见的特征符号组合如下:

  • 单引号'、双引号":字符串闭合的直接信号
  • 注释符--、#、/*:用于截断原SQL语句
  • 分号;:多语句拼接
  • 括号(与):函数调用或子查询
  • 不等式组合>、<、=:布尔盲注的典型特征

特别要留意的是--。它是MySQL注释符,后面通常跟着一个空格或直接到行尾。在URL里--经常出现,比如正常业务分页功能可能也有page=2--这种用法(部分框架会用--分割参数),所以--要跟其他特征联合判断,单独出现时建议降低置信度。

2.3 请求结构异常特征

这是我个人比较看重的一层。攻击者在打注入的时候,通常会在一个参数内堆入大量特殊字符,而正常请求的参数值往往都是干净的字符串。

所以我设计了一个“特殊字符密度”的概念:对每个参数值统计单引号、双引号、括号、分号、等号、空格、注释符的数量,如果单个参数内出现的危险符号数量超过阈值,就标记为疑似注入。

打个比方,正常搜索参数可能是?keyword=iphone,里面一个危险符号都没有;但注入请求可能是?keyword=iphone' AND SLEEP(5)-- -,同一个参数里塞进了单引号、AND、SLEEP、括号、注释符,密度瞬间拉满。这个特征在误报率控制上非常好用,因为正常用户几乎不可能在搜索词里输入这些符号。

3. Python脚本实战:从Nginx日志中自动提取SQL注入攻击

来,直接上干货。这个脚本我起名叫sql_log_hunter.py,它的定位是分析Nginx/Apache的access日志,输出可疑请求、攻击类型分类、攻击者IP统计。

3.1 脚本完整代码
#!/usr/bin/env python3 # -*- coding: utf-8 -*- # sql_log_hunter.py # 用途:从Web访问日志中提取SQL注入攻击特征 # 用法:python3 sql_log_hunter.py /path/to/access.log [--top 20] import re import sys import argparse import urllib.parse from collections import defaultdict, Counter # ---------- 特征库 ---------- SQL_KEYWORDS = re.compile( r"(select|union|insert|update|delete|drop|" r"sleep|benchmark|waitfor|if|substring|" r"information_schema|schema\(\)|" r"or 1=1|and 1=1|order\s+by|group\s+by)", re.IGNORECASE, ) SQL_SYMBOLS = re.compile(r"['\"\;\-\-#\*\(\)=<>]") SUSPICIOUS_PARAM_PATTERN = re.compile( r"((?:%27|%2527|\x27)|(?:--)|(?:#)|(?:/\*)|" r"(?:sleep\s*\()|(?:benchmark\s*\()|" r"(?:union\s+select)|(?:and|or)\s+\d+\s*=\s*\d+)", re.IGNORECASE, ) # ---------- 日志行解析 ---------- # 兼容 Nginx 默认格式: # $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" LOG_PATTERN = re.compile( r'(?P<ip>\S+)\s+\S+\s+\S+\s+\[(?P<time>[^\]]+)\]\s+' r'"(?P<request>[^"]+)"\s+(?P<status>\d+)\s+(?P<size>\d+)' ) def parse_log_line(line): match = LOG_PATTERN.match(line) if not match: return None data = match.groupdict() request = data["request"] method, url, protocol = request.split(" ") data["method"] = method data["url"] = url data["path"] = url.split("?")[0] if "?" in url else url data["params_raw"] = url.split("?", 1)[1] if "?" in url else "" data["params"] = urllib.parse.parse_qs(data["params_raw"], keep_blank_values=True) return data def decode_payload(value): # 一次解码,必要时二次解码(处理双重编码) decoded = urllib.parse.unquote(value) if "%25" in value: decoded = urllib.parse.unquote(decoded) return decoded def analyze_record(record): url_decoded = urllib.parse.unquote(record["params_raw"]) issues = [] # 1. 关键字检测 if SQL_KEYWORDS.search(url_decoded): issues.append("SQL_KEYWORD") # 2. 符号密度检测 for key, values in record["params"].items(): for val in values: decoded_val = decode_payload(val) symbols = SQL_SYMBOLS.findall(decoded_val) if len(symbols) >= 3: issues.append(f"SYMBOL_HIGH_DENSITY:param={key}") break # 3. 高可疑组合模式 if SUSPICIOUS_PARAM_PATTERN.search(url_decoded): issues.append("SUSPICIOUS_PATTERN") # 4. 时间型盲注特征(sleep/benchmark) if re.search(r"(sleep|benchmark|waitfor)\s*\(", url_decoded, re.IGNORECASE): issues.append("TIME_BASED_BLINDSQL") # 5. 报错型特征(updatexml/extractvalue) if re.search(r"(updatexml|extractvalue|floor\s*\()", url_decoded, re.IGNORECASE): issues.append("ERROR_BASED_BLINDSQL") # 6. 联合查询特征(union+select) if re.search(r"union\s+(all\s+)?select", url_decoded, re.IGNORECASE): issues.append("UNION_SQLI") return issues def main(): parser = argparse.ArgumentParser(description="从日志中提取SQL注入攻击流量") parser.add_argument("logfile", help="访问日志文件路径") parser.add_argument("--top", type=int, default=20, help="显示Top N攻击IP,默认20") args = parser.parse_args() suspicious_records = [] ip_counter = Counter() attack_type_counter = Counter() with open(args.logfile, "r", encoding="utf-8", errors="ignore") as f: line_num = 0 for line in f: line_num += 1 record = parse_log_line(line) if not record: continue # 只分析 GET/POST 请求 if record["method"] not in ("GET", "POST"): continue issues = analyze_record(record) if issues: record["issues"] = issues record["line_num"] = line_num suspicious_records.append(record) ip_counter[record["ip"]] += 1 for issue in issues: attack_type_counter[issue] += 1 print("=" * 70) print(f"扫描完成,共扫描 {line_num} 行日志") print(f"发现可疑记录 {len(suspicious_records)} 条") print("=" * 70) # 输出攻击类型分布 print("\n【攻击类型分布】") for attack_type, count in attack_type_counter.most_common(): print(f" {attack_type}: {count}") # 输出Top攻击IP print(f"\n【Top {args.top} 攻击来源IP】") for ip, count in ip_counter.most_common(args.top): print(f" {ip}: {count} 次") # 输出前50条可疑记录详情 print("\n【可疑记录详情】") for record in suspicious_records[:50]: print("-" * 70) print(f"行号: {record['line_num']}") print(f"时间: {record['time']}") print(f"IP: {record['ip']}") print(f"请求: {record['method']} {record['url']}") print(f"命中: {', '.join(record['issues'])}") if __name__ == "__main__": main()
3.2 脚本的设计思路说明

为什么这么写?几个关键点我展开讲一下。

第一,统一做URL解码。大部分注入payload在打进来时都会被URL编码,如果直接对params_raw做字符匹配,会漏掉绝大多数攻击。脚本里对参数值做了单独的解码处理,并对含%25的情况做二次解码,就是为了防双重编码绕过。

第二,分类打标签,而不是只输出"可疑"两个字。把UNION_SQLI、TIME_BASED_BLINDSQL、ERROR_BASED_BLINDSQL分开统计,是因为不同类型对应不同的漏洞成因和修复方式。你在AWD里看到一堆TIME_BASED_BLINDSQL,基本可以断定对方在打时间盲注,第一反应应该是去看查询是否拼接了参数;看到UNION_SQLI,则说明对方在Union注入,得重点查输出点是否在页面上回显。

第三,IP聚合统计。攻击者在试探阶段通常会在短时间内对同一个URL发起大量请求,这些请求来自同一个IP。IP聚合结果能帮你判断是单点攻击还是分布式扫描,同时也能给你留下证据链。

3.3 脚本的使用方法与输出解读

用法很简单:

python3 sql_log_hunter.py access.log --top 30

跑完之后,输出分为三块:攻击类型分布、Top攻击IP、可疑记录详情。

我拿一个模拟的日志片段来演示一下输出效果。假设access.log里有这么几行:

192.168.1.10 - - [20/Jan/2025:10:23:45 +0800] "GET /product.php?id=1 HTTP/1.1" 200 2345 192.168.1.10 - - [20/Jan/2025:10:23:46 +0800] "GET /product.php?id=1' AND SLEEP(5)-- - HTTP/1.1" 200 2345 192.168.1.10 - - [20/Jan/2025:10:23:47 +0800] "GET /product.php?id=1' UNION SELECT username,password FROM users-- - HTTP/1.1" 500 1024 203.0.113.5 - - [20/Jan/2025:10:25:00 +0800] "GET /search.php?q=hello HTTP/1.1" 200 3456 203.0.113.5 - - [20/Jan/2025:10:25:01 +0800] "GET /search.php?q=hello' AND 1=1-- - HTTP/1.1" 200 3456 198.51.100.9 - - [20/Jan/2025:10:30:12 +0800] "GET /index.php?page=1 HTTP/1.1" 200 5678

第一、五、六行分别代表正常请求或低危请求:id=1参数干净、page=1参数干净、q=hello' AND 1=1虽然有注入特征但状态码是200且参数值长度较短,结合其他特征才能判断。第二行明显是时间盲注(SLEEP),第三行明显是联合查询注入,脚本会准确打出对应标签。IP聚合后192.168.1.10会排在第一位。

4. 日志分析中的高级手法:聚类与溯源

日志文件动辄几十万行,纯靠特征匹配可能漏掉一些定制化payload。所以我在比赛中通常还会叠加两个高级手段:攻击指纹聚类和攻击链还原。

4.1 攻击指纹聚类:找相似但不相同的payload

攻击者为了绕过WAF,经常会做变异。比如同一句UNION SELECT,可能变形成UnIoN/**/SeLeCt、UNION%0aSELECT、UNION DISTINCT SELECT等。特征词表能兜住一部分,但无法穷举所有变异。

我的办法是参数值归一化后做相似度聚类。归一化的操作有:

  • 将所有数字替换为N
  • 将所有SQL关键字(不区分大小写)替换为K
  • 将连续特殊符号压缩为单个S

这样处理后,UNION/**/SELECT和UnIoN%0aSELECT会变成同一个归一化指纹(类似K S K),聚到一起后你只需要看一个代表样本,就能知道这一类攻击的完整形态。

这个思路的本质是把"字符串精确匹配"升级为"结构近似匹配",能有效对抗简单变形,而且实现难度不高。在CTF里遇到流量大的AWD轮次,用这个技巧能把分析时间从半小时压到几分钟。

4.2 攻击链还原:别只看单条日志

单条日志只能告诉你"这里有攻击",但没法告诉你攻击者完整的攻击路径。比如攻击者先扫到了/product.php,然后试了id=1、id=1'、id=1' AND 1=1,最后成功利用UNION SELECT拖走了数据。这个过程被分散在几十条日志里,单独看每一条都是碎片。

要还原攻击链,我通常按IP分组,然后按时间排序,把同一个IP的所有可疑请求按照时间线拼接起来。拼接完就能看到非常清晰的攻击节奏:

14:00:01 GET /product.php?id=1 # 正常访问,探测站点 14:00:02 GET /product.php?id=1' # 测试单引号是否会报错 14:00:03 GET /product.php?id=1' AND 1=1 # 布尔真条件 14:00:04 GET /product.php?id=1' AND 1=2 # 布尔假条件 14:00:05 GET /product.php?id=1' UNION SELECT 1,2,3-- - # 确认列数 14:00:06 GET /product.php?id=1' UNION SELECT username,password FROM users-- - # 拖数据

到了第6步,攻击者已经把数据拖走了,你的重点就变成了"这个漏洞为什么能打成?出网通道在哪?数据是从哪个字段回显的?"——这些问题全部可以从完整攻击链里找到答案。

我建议所有做AWD的选手,事后复盘时都把日志按IP聚合后再按时间排序打出来看一遍,你对自己的防守盲区会有非常直观的认知。

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

这部分我直接整理成"踩坑实录",都是我实际操作中遇到过的情况。

5.1 日志格式不对导致解析不到数据

这是使用脚本时最常遇到的问题。Nginx的日志格式如果被改过,比如加了$request_time、$http_user_agent,默认的正则就匹配不上了。

解决办法是:先看一眼日志实际长什么样,然后调整LOG_PATTERN正则。比如原始格式是:

1.2.3.4 - admin [10/Oct/2024:13:55:36 +0800] "POST /api/login HTTP/1.1" 200 512 "-" "Mozilla/5.0"

你的正则需要把$http_user_agent排除掉或者单独捕获。我给的小技巧是:先跑一下脚本,如果发现解析到的记录数量为0,赶紧复制一行日志手工检查字段顺序。

5.2 漏检:编码后的payload没被解码

漏检的主要原因是双重编码(double encoding)。攻击者把'编码为%27,再把%编码为%25,得到%2527。如果只解码一次,得到的是%27,依然不是单引号,特征匹配就会漏掉。

脚本里已经做了二次解码,但如果你要分析的是更极端的场景(比如三层编码),建议写个循环解码:

def full_decode(value, max_rounds=3): for _ in range(max_rounds): decoded = urllib.parse.unquote(value) if decoded == value: break value = decoded return value

循环解码虽然有点暴力,但配合"最多解码N轮"的限制,在比赛里非常实用。

5.3 误报:正常业务流量被判为攻击

最典型的是分页参数?page=1--2和搜索参数?q=我想--你两种情况。--在URL里并不罕见,比如一些框架用它做参数分隔。

处理方式有两种:一是调整阈值,要求"高危特征"和"高危符号"同时出现才告警;二是维护白名单,对已知的page、limit、sort参数项单独降低权重。我个人更推荐第一种,因为白名单会掩盖真实攻击,防御思路太危险。

5.4 大量IP轮换攻击:怎么溯源

云上攻击者经常用代理池轮换IP,单一IP的请求量很小,按IP聚合根本排不出威胁。这时候要换一个维度:按路径聚合。攻击者的目标是固定的,即使他换IP,打的目标URL还是那一两个。按路径+参数名聚合,一下子就能暴露"某个URL在短时间内被大量来源IP探测"的异常现象。

实际操作里,可以把脚本的ip_counter改成按(ip, path)组合计数,或者直接加一个path_counter统计Top路径。我在真实排查中经常是先看到某个路径上榜,然后反向把该路径的所有请求拉出来,再去追踪来源IP集合。

5.5 比赛中的"日志被清空"问题

AWD里对手有时会清日志。我的习惯是:比赛开始后每5分钟把日志归档一份,用cp或rsync同步到独立目录。这样即使主日志被清了,手里还有历史快照可以用,不至于分析到一半发现"现场被破坏"。

6. 日志分析之后:漏洞修复与规则沉淀

日志分析只是第一步,揪出攻击之后必须做修复和沉淀,否则下轮还是会被打穿。

对于确认存在SQL注入的接口,修复优先级如下:

  1. 最紧急(立即执行):改用参数化查询或预处理语句(Prepared Statements)。这是根治方案,从原理上消除拼接注入的可能。
  2. 次紧急(当天做):如果有自写的SQL拼接代码,先加一层白名单校验,比如主键参数强制int类型、搜索参数过滤危险字符并转义。
  3. 后置规则(半小时内):在WAF或网关层加规则,将已识别的攻击payload指纹加入拦截清单。比如SLEEP(、UNION SELECT、INFORMATION_SCHEMA等特征词,直接拉黑。

从小白的角度看,修复SQL注入最稳妥的直接方案就是"不要手动拼SQL"。很多新手在比赛压力下会图省事,比如写"SELECT * FROM user WHERE id = " + id,这种代码在CTF初赛里可能没问题,但在AWD里就是送分题。

我在实战中处理过一个情况:某次比赛,对方拿到WebShell之后留了一个后门文件,我第一时间看日志,发现后门是利用一个文件上传接口的拼接漏洞打进来的。我修复的办法是先把上传接口加白名单后缀校验,然后用日志里的payload复盘,给自己写了三条WAF规则,之后对方再打同一接口就全被拦了。整个过程的核心驱动力就是日志分析。

6.1 沉淀成自己的规则库

把日志分析中识别出的payload模式整理成一份规则库,长期积攒下来非常有用。我的规则库大概长这样:

  • (union|select).*(from|information_schema)— 联合查询
  • (sleep|benchmark)\s*\(— 时间盲注
  • updatexml|extractvalue|floor\s*\(— 报错注入
  • (\')|(%27).*(and|or).*\d.*=.*\d— 布尔盲注
  • order\s+by\s+\d+— 列数探测

每次比赛完我都会把新见到的payload模式补进去,这个库的覆盖面越广,后续分析日志的自动化程度就越高。

7. 进阶:一条命令快速验证可疑请求

有时候你不想跑完整脚本,只想快速验证某一条请求是否真的有注入点。这里分享两个命令行小技巧。

一是用curl直接重放请求,对比正常参数和注入参数在响应时间、响应长度上的差异:

# 正常请求 curl -s -o /dev/null -w "%{time_total}\n" "http://target/product.php?id=1" # 注入请求 curl -s -o /dev/null -w "%{time_total}\n" "http://target/product.php?id=1' AND SLEEP(5)-- -"

如果第二个响应时间比第一个大了5秒左右,基本可以确认存在时间盲注。这里的响应时间判断是核心:SLEEP(5)会让数据库执行延迟5秒,整体响应时间会显著拉长。

二是用grep快速从日志里筛出指定IP的完整请求序列:

grep "1.2.3.4" access.log | awk '{print $4, $6, $7}' | sort

这两招在比赛中排查单个攻击者的行为路径时特别高效,不需要等脚本跑完,一条命令就能看到对方在你站点上做了什么。

8. 写在最后的几点体会

日志分析这个事,说到底就是一场和攻击者的心理博弈。攻击者会想方设法把payload藏在海量正常请求里,而你要做的就是用特征匹配和结构分析把它捞出来。

我个人在实际操作中有三点体会特别深。

第一,工具只是辅助,思路才是核心。脚本可以帮你筛掉95%的噪声,但最后5%的确认工作还是要靠人去看上下文。比如一条请求里带着SLEEP(5),如果它只是被打在了一个静态页面的路径上、参数根本没进数据库查询,那它其实是无害的探测行为;可如果它精确打在了/product.php?id=这种明显有参数拼接的接口上,那就必须立刻介入。

第二,日志分析要配合响应状态码来看。我脚本里目前没有对状态码做分类输出,但实际操作中建议留意:如果大量注入请求返回500,说明目标SQL语句确实报错了,漏洞大概率真实存在;如果统一返回200,可能是服务端做了统一错误处理,也可能是盲注场景。状态码和响应长度的变化,能帮你判断注入是否真的被带进了SQL语句。

第三,别忽视POST请求的body日志。很多日志默认不记录POST请求体,但SQL注入最常发生在登录、搜索等POST接口。如果比赛提供的日志里没有POST body,就只能依赖GET型注入来判断漏洞了,这会影响你的攻击面判断。有条件的话,建议在Nginx配置里加上$request_body的日志记录字段,这会让你在回溯攻击时多一条非常重要的线索。

脚本我会持续维护更新,后续准备加入POST body解析、时间序列聚类、以及针对报错注入的SQL错误指纹提取。各位如果在使用中遇到问题,可以顺着文中的排查思路自己改一版,工具这东西,改着改着就变成最适合自己的了。

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

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

立即咨询