Python实战:从零构建多线程目录扫描器,深入HTTP请求与并发编程
1. 项目概述:为什么我们要自己动手写一个目录扫描器?
在网络安全测试或者日常的网站资产梳理中,目录扫描是一个基础但至关重要的环节。市面上有像 dirsearch、gobuster 这样的成熟工具,功能强大,开箱即用。那为什么我们还要“从零开始”用 Python 写一个呢?这就像学开车,直接给你一辆自动挡的顶级跑车,你当然能开,但你可能永远不知道离合器、变速箱是怎么配合工作的。自己动手写一个目录扫描器,核心目的不是要造一个比它们更好的轮子,而是为了深入理解这个“轮子”的构造原理、轴承怎么转、辐条怎么受力。
通过这个项目,你将彻底搞懂几个关键问题:HTTP 请求在代码层面是如何发送和接收的?多线程/多协程是如何大幅提升扫描效率的?如何处理网络请求中的超时、重定向、异常状态?如何设计一个灵活可扩展的字典管理和结果过滤机制?这些知识点,是你看十篇工具使用教程也未必能内化的。当你亲手实现一遍,以后再使用任何扫描工具,你都能一眼看穿它的核心逻辑,甚至能根据特定场景快速定制或修改。这对于想从事安全研发、自动化测试,或者单纯想提升 Python 工程能力的开发者来说,是一次绝佳的练手机会。
2. 核心需求与设计思路拆解
2.1 核心功能定义
一个最基本的目录扫描器,需要完成以下核心功能:
- 字典加载 :从一个文本文件中读取可能的目录或文件路径(如
/admin,/backup.zip,/config.php等),构成待扫描的队列。 - 目标拼接与请求 :将字典中的每一项与目标网站的基础 URL 进行拼接,形成完整的 URL,然后发起 HTTP 请求。
- 响应分析 :根据 HTTP 响应状态码(如 200、403、404、500)、响应内容长度、标题等特征,判断该路径是否存在或可访问。
- 结果输出 :将有效的发现(例如状态码为 200、302 的路径)以清晰的方式输出到屏幕或文件。
- 并发控制 :为了提高扫描速度,必须支持同时发起多个 HTTP 请求,这就需要引入多线程、多进程或异步 IO 机制。
2.2 技术方案选型与考量
为什么用 Python?因为它拥有极其丰富和易用的网络库(如 requests , aiohttp ),并发编程库( threading , asyncio ),以及强大的标准库支持,能让开发者更专注于逻辑本身而非底层细节。
- HTTP 客户端 :初期学习和快速原型验证,首选
requests库。它语法简洁直观,错误处理完善,适合理解基础流程。当我们追求极致性能时,可以升级为异步版本的aiohttp。 - 并发模型 :
- 多线程 (
threading) :易于理解,适合 I/O 密集型任务(网络请求正是此类)。但 Python 的全局解释器锁(GIL)会限制 CPU 密集型任务的并行,不过对我们的扫描任务影响不大。需要注意线程间的资源竞争(如共享的结果列表、计数器)。 - 线程池 (
concurrent.futures.ThreadPoolExecutor) :比直接操作threading模块更高级、更安全,它帮我们管理了线程的生命周期和任务队列,是推荐的选择。 - 异步 IO (
asyncio+aiohttp) :性能最高,资源消耗最小,是生产级别工具的选择。但学习曲线稍陡,涉及async/await语法和事件循环概念。
- 多线程 (
- 字典管理 :一个好的扫描器应支持从文件加载字典,并能方便地添加自定义条目或进行动态规则生成(如根据响应内容生成新的猜测)。
- 结果过滤 :需要能过滤掉常见的“误报”,比如虽然返回 200 状态码,但内容却是统一的“404 Not Found”页面(通过内容长度或关键字匹配来识别)。
基于“从零学习”的目标,我们将采用 requests + ThreadPoolExecutor 的方案,在保证性能的同时,让代码逻辑足够清晰,便于理解和调试。
3. 基础版本实现:单线程扫描器
让我们先搭建一个最基础的、没有并发功能的扫描器,把核心流程跑通。
3.1 环境准备与依赖安装
首先,确保你的 Python 环境(建议 3.6 以上)已经就绪。我们需要安装 requests 库。
pip install requests
3.2 核心代码逐步实现
我们创建一个名为 simple_scanner.py 的文件。
第一步:导入库和定义基础参数
import requests
import sys
from urllib.parse import urljoin
class SimpleDirScanner:
def __init__(self, target_url, wordlist_path):
"""
初始化扫描器
:param target_url: 目标网站基础URL,如 http://example.com
:param wordlist_path: 字典文件路径
"""
self.target_url = target_url.rstrip('/') # 去除末尾可能存在的斜杠
self.wordlist_path = wordlist_path
self.results = [] # 存储扫描结果
# 设置一个会话对象,可以复用 TCP 连接,提升效率
self.session = requests.Session()
# 设置一个合理的超时时间,避免长时间等待
self.timeout = 5
# 自定义请求头,有些网站会校验 User-Agent
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
这里使用 requests.Session() 是一个小技巧。相比每次请求都新建连接,Session 可以保持一个连接池,复用 TCP 连接,对于需要发送大量请求到同一主机的场景,能显著减少开销。
第二步:加载字典文件
def load_wordlist(self):
"""从文件加载字典,并做基础清理"""
try:
with open(self.wordlist_path, 'r', encoding='utf-8', errors='ignore') as f:
# 读取每一行,去除两端空白字符,过滤掉空行和注释行(以#开头)
words = [line.strip() for line in f if line.strip() and not line.strip().startswith('#')]
print(f"[*] 成功加载字典,共 {len(words)} 个条目。")
return words
except FileNotFoundError:
print(f"[!] 错误:字典文件 '{self.wordlist_path}' 未找到。")
sys.exit(1)
except Exception as e:
print(f"[!] 加载字典时发生未知错误:{e}")
sys.exit(1)
注意 errors='ignore' 参数,它能处理字典文件中可能存在的非 UTF-8 编码字符,避免程序因编码问题意外崩溃。过滤空行和注释是提高字典质量的好习惯。
第三步:发送请求与判断逻辑 这是最核心的函数。
def probe_path(self, path):
"""探测单个路径"""
# 拼接完整URL
full_url = urljoin(self.target_url + '/', path.lstrip('/'))
try:
# 发送GET请求,带上自定义头部和超时设置
response = self.session.get(full_url, headers=self.headers, timeout=self.timeout, allow_redirects=False)
# 基础判断逻辑:状态码为 2xx (成功) 或 3xx (重定向) 通常表示资源存在
if response.status_code in [200, 201, 204, 301, 302, 307, 308]:
# 记录结果:URL,状态码,响应内容长度
result = {
'url': full_url,
'status': response.status_code,
'length': len(response.content)
}
self.results.append(result)
# 实时输出发现
print(f"[+] {response.status_code:3d} - {len(response.content):7d} - {full_url}")
# 403 状态码也值得关注,它表示路径存在但禁止访问
elif response.status_code == 403:
print(f"[*] 403 - {len(response.content):7d} - {full_url} (禁止访问)")
except requests.exceptions.Timeout:
print(f"[!] 超时 - {full_url}")
except requests.exceptions.ConnectionError:
print(f"[!] 连接错误 - {full_url},可能目标不可达或路径非法。")
except requests.exceptions.RequestException as e:
print(f"[!] 请求异常 - {full_url}: {e}")
这里有几个关键点:
urljoin函数能智能地处理 URL 拼接,避免出现http://a.com//admin这样的双斜杠错误。allow_redirects=False非常重要。如果我们允许自动重定向,那么扫描/admin时,如果它跳转到/login.php,我们收到的状态码将是 200(登录页面的),这会导致误判。设置为 False 后,我们收到的是 302 状态码,能更准确地知道/admin这个端点本身的行为。- 我们不仅关注状态码,还记录了响应内容长度。这对于后续的智能过滤至关重要(比如,10 个不同的路径都返回长度相同的 200 页面,那很可能是统一的错误页)。
第四步:主循环与结果输出
def run(self):
"""启动扫描"""
words = self.load_wordlist()
print(f"[*] 开始扫描目标:{self.target_url}")
print("[*] 格式:[状态码] - [内容长度] - [URL]")
print("-" * 60)
for i, word in enumerate(words, 1):
self.probe_path(word)
# 简单进度提示,每扫描100个路径打印一次
if i % 100 == 0:
print(f"[*] 进度:{i}/{len(words)}")
print("-" * 60)
print(f"[*] 扫描结束。共发现 {len(self.results)} 个潜在有效路径。")
# 可以将结果保存到文件
if self.results:
with open('scan_results.txt', 'w') as f:
for r in self.results:
f.write(f"{r['status']} - {r['length']} - {r['url']}\n")
print("[*] 结果已保存至 scan_results.txt")
if __name__ == '__main__':
# 简单的命令行参数处理
if len(sys.argv) != 3:
print("用法: python simple_scanner.py <目标URL> <字典文件>")
print("示例: python simple_scanner.py http://testphp.vulnweb.com common.txt")
sys.exit(1)
target = sys.argv[1]
wordlist = sys.argv[2]
scanner = SimpleDirScanner(target, wordlist)
scanner.run()
现在,你可以找一个测试网站(例如 http://testphp.vulnweb.com )和一个常见的字典文件(如 common.txt ),运行 python simple_scanner.py http://testphp.vulnweb.com common.txt 来体验基础扫描。
实操心得 1:关于测试目标 千万不要在未经授权的情况下扫描任何非你所有的线上网站!这是违法的,且极不道德。可以使用专门为安全测试搭建的靶场,如 DVWA、bWAPP,或上面提到的
testphp.vulnweb.com。自己用 Python 的http.server模块快速搭建一个本地服务器来测试也是好方法:python -m http.server 8080,然后扫描http://localhost:8080。
4. 性能飞跃:引入多线程并发扫描
基础版本是顺序执行的,扫描 1000 个路径就要发起 1000 次网络请求,大部分时间都在等待网络响应,效率极低。接下来我们引入线程池进行改造。
4.1 使用 ThreadPoolExecutor 进行重构
我们创建新文件 threaded_scanner.py ,复用大部分基础代码,主要修改 run 方法。
import concurrent.futures
# ... 保留之前的导入和 SimpleDirScanner 类定义,但我们将类名改为 ThreadedDirScanner
class ThreadedDirScanner(SimpleDirScanner):
def __init__(self, target_url, wordlist_path, max_workers=20):
"""
初始化多线程扫描器
:param max_workers: 线程池最大工作线程数
"""
super().__init__(target_url, wordlist_path)
self.max_workers = max_workers
# 用于线程安全地操作结果列表
import threading
self.lock = threading.Lock()
def probe_path(self, path):
"""重写 probe_path 方法,注意线程安全"""
full_url = urljoin(self.target_url + '/', path.lstrip('/'))
try:
response = self.session.get(full_url, headers=self.headers, timeout=self.timeout, allow_redirects=False)
if response.status_code in [200, 201, 204, 301, 302, 307, 308]:
result = {
'url': full_url,
'status': response.status_code,
'length': len(response.content)
}
# 使用锁来确保同时只有一个线程操作结果列表
with self.lock:
self.results.append(result)
print(f"[+] {response.status_code:3d} - {len(response.content):7d} - {full_url}")
elif response.status_code == 403:
print(f"[*] 403 - {len(response.content):7d} - {full_url} (禁止访问)")
except requests.exceptions.Timeout:
print(f"[!] 超时 - {full_url}")
except requests.exceptions.ConnectionError:
# 连接错误可能很频繁,可以选择静默或统一记录
pass
except requests.exceptions.RequestException:
pass
def run(self):
words = self.load_wordlist()
print(f"[*] 开始多线程扫描目标:{self.target_url} (线程数:{self.max_workers})")
print("[*] 格式:[状态码] - [内容长度] - [URL]")
print("-" * 60)
# 使用线程池映射执行
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# executor.map 会按顺序将words中的每个元素传递给probe_path,但执行是并发的
# 它返回一个迭代器,我们通过list()来消费它,等待所有任务完成
list(executor.map(self.probe_path, words))
print("-" * 60)
print(f"[*] 扫描结束。共发现 {len(self.results)} 个潜在有效路径。")
if self.results:
with open('threaded_scan_results.txt', 'w') as f:
for r in self.results:
f.write(f"{r['status']} - {r['length']} - {r['url']}\n")
print("[*] 结果已保存至 threaded_scan_results.txt")
if __name__ == '__main__':
if len(sys.argv) not in [3, 4]:
print("用法: python threaded_scanner.py <目标URL> <字典文件> [线程数]")
print("示例: python threaded_scanner.py http://testphp.vulnweb.com common.txt 30")
sys.exit(1)
target = sys.argv[1]
wordlist = sys.argv[2]
workers = int(sys.argv[3]) if len(sys.argv) == 4 else 20
scanner = ThreadedDirScanner(target, wordlist, max_workers=workers)
scanner.run()
4.2 关键改进与原理
- 线程池 (
ThreadPoolExecutor) :它管理着一个固定数量的工作线程。我们将words列表中的每个路径提交给线程池,线程池会自动分配空闲线程去执行probe_path函数。这避免了频繁创建和销毁线程的开销。 - 线程安全 (
threading.Lock) :当多个线程同时尝试向self.results列表追加数据时,可能会发生数据错乱。Lock锁机制确保同一时间只有一个线程能执行with self.lock:块内的代码,从而安全地修改共享资源。 - 异常处理简化 :在多线程环境下,如果每个连接错误都打印,会导致控制台输出混乱。我们选择静默处理常见的网络异常,只输出成功或值得关注的发现。你也可以选择将错误记录到一个单独的日志文件中。
-
executor.map:这是一个非常方便的函数,它接收一个函数和一个可迭代对象,并发地对每个元素应用该函数,并返回一个结果迭代器。我们用list()来迭代它,主要是为了等待所有future完成(因为map返回的是Future对象),_表示我们不关心它的返回值(我们的结果存在self.results里)。
实操心得 2:线程数的设置
max_workers并非越大越好。线程数过多会导致大量的线程上下文切换,反而降低性能,并且会给目标服务器带来巨大压力,可能触发对方的防护机制(如 IP 封禁)。一般建议设置在 20-50 之间,具体取决于你的网络环境和目标服务器的承受能力。可以先从 20 开始测试。
5. 功能增强:智能过滤与结果去重
现在我们的扫描器能跑了,但结果中可能包含大量“误报”。比如,目标网站对所有不存在的路径都返回一个自定义的 200 状态码“404页面”,我们的扫描器就会把它们全部记录下来。我们需要引入智能过滤。
5.1 基于内容长度和关键字的过滤
我们新增一个 Filter 类,并在扫描器中集成它。
class ResponseFilter:
def __init__(self):
# 存储已知的“错误页面”特征
self.error_lengths = set() # 错误页面的内容长度集合
self.error_keywords = [] # 错误页面包含的关键字列表
def learn_from_404(self, response):
"""从一个明确的404响应中学习特征"""
self.error_lengths.add(len(response.content))
# 可以提取页面标题或特定文本作为关键字,这里简单示例
# 实际中可以更复杂,如计算页面相似度
if \"404\" in response.text or \"Not Found\" in response.text:
self.error_keywords.append(\"404\")
self.error_keywords.append(\"Not Found\")
def is_error_page(self, response):
"""判断一个响应是否是错误页面"""
# 规则1:内容长度匹配已知的错误页面长度
if len(response.content) in self.error_lengths:
return True
# 规则2:响应内容包含已知的错误关键字
content_text = response.text.lower()
for keyword in self.error_keywords:
if keyword.lower() in content_text:
return True
return False
class SmartDirScanner(ThreadedDirScanner):
def __init__(self, target_url, wordlist_path, max_workers=20):
super().__init__(target_url, wordlist_path, max_workers)
self.filter = ResponseFilter()
# 首先探测几个肯定不存在的随机路径,学习错误页面特征
self.learn_error_pattern()
def learn_error_pattern(self):
"""学习目标站点的错误页面模式"""
print(\"[*] 正在学习目标站点错误页面特征...\")
test_paths = [f\"wp-admin-{random.randint(10000,99999)}\", f\"api-test-{random.randint(10000,99999)}\"]
for path in test_paths:
url = urljoin(self.target_url + '/', path)
try:
resp = self.session.get(url, headers=self.headers, timeout=3, allow_redirects=False)
if resp.status_code >= 400: # 404, 403 等都可以作为学习样本
self.filter.learn_from_404(resp)
except:
pass
print(f\"[*] 学习完成。已记录 {len(self.filter.error_lengths)} 个错误页面长度特征。\")
def probe_path(self, path):
"""重写 probe_path,加入过滤逻辑"""
full_url = urljoin(self.target_url + '/', path.lstrip('/'))
try:
response = self.session.get(full_url, headers=self.headers, timeout=self.timeout, allow_redirects=False)
# 新增过滤判断
if self.filter.is_error_page(response):
# 被识别为错误页面,静默跳过
return
if response.status_code in [200, 201, 204, 301, 302, 307, 308]:
result = {
'url': full_url,
'status': response.status_code,
'length': len(response.content)
}
with self.lock:
self.results.append(result)
print(f\"[+] {response.status_code:3d} - {len(response.content):7d} - {full_url}\")
elif response.status_code == 403:
print(f\"[*] 403 - {len(response.content):7d} - {full_url} (禁止访问)\")
except requests.exceptions.Timeout:
pass
except requests.exceptions.ConnectionError:
pass
except requests.exceptions.RequestException:
pass
5.2 结果去重与排序
扫描完成后,我们可能发现多个路径指向同一个实际页面(比如 /admin 和 /admin/ 可能都返回 200)。我们可以对结果进行简单的去重和排序,让报告更清晰。可以在 run 方法的最后,保存结果前添加:
def run(self):
# ... 前面的扫描代码 ...
print(f\"[*] 扫描结束。原始发现 {len(self.results)} 个路径。\")
# 去重:根据 (状态码, 内容长度, URL) 去重,更精确的去重可以只根据URL
unique_results = []
seen = set()
for r in self.results:
# 创建一个唯一标识符,这里用状态码+长度+URL
identifier = (r['status'], r['length'], r['url'])
if identifier not in seen:
seen.add(identifier)
unique_results.append(r)
# 排序:按状态码和URL排序
unique_results.sort(key=lambda x: (x['status'], x['url']))
print(f\"[*] 去重后剩余 {len(unique_results)} 个唯一路径。\")
if unique_results:
with open('smart_scan_results.txt', 'w') as f:
for r in unique_results:
f.write(f\"{r['status']} - {r['length']} - {r['url']}\\n\")
print(\"[*] 结果已保存至 smart_scan_results.txt\")
注意事项:过滤策略的权衡 智能过滤是一把双刃剑。过于激进的过滤可能会漏掉一些真正有价值的发现,比如一个设计独特的 404 页面可能没有被我们的学习样本捕捉到。因此,在生产环境中,通常会采用“宽进严出”的策略:扫描时记录所有非 404 状态码的响应,在后期分析阶段再通过更复杂的算法(如相似度比对)进行过滤。我们当前实现的是一种简易的实时过滤,适合快速扫描和初步判断。
6. 进阶优化:支持更多协议与扩展性设计
一个成熟的扫描器不会只支持 HTTP GET 方法。我们还可以考虑以下扩展方向,这些可以作为你后续迭代的练习:
6.1 支持多种 HTTP 方法
有些接口或管理后台可能只响应 POST、PUT 等请求。我们可以扩展字典格式和请求逻辑。
- 字典格式 :可以约定在字典条目中使用前缀,如
GET:/api/user,POST:/login.php。 - 请求分发 :在
probe_path函数中解析方法,然后调用session.post(),session.put()等。
6.2 递归扫描
发现一个目录(如 /admin )后,可以自动将其作为新的基础路径,加载字典进行下一层扫描(如 /admin/config , /admin/backup )。这需要维护一个待扫描队列,并注意避免循环扫描和深度控制。
6.3 报告生成
将结果输出为 HTML、JSON 或 Markdown 格式的报告,包含更丰富的信息,如响应头部、页面标题截图(需要集成浏览器引擎)等。
6.4 配置化管理
将线程数、超时时间、过滤规则、请求头等参数抽取到配置文件(如 YAML、JSON)或命令行参数中,使工具更灵活。
6.5 性能监控与速率限制
添加扫描进度条(如使用 tqdm 库)、实时显示扫描速度(请求数/秒),并实现可配置的请求速率限制( time.sleep ),以避免对目标造成过大压力。
7. 常见问题与排查技巧实录
在实际编写和运行过程中,你肯定会遇到各种问题。这里记录一些典型问题和解决方法。
问题 1:程序运行后立刻崩溃,报错 ModuleNotFoundError: No module named 'requests'
- 原因 :
requests库没有安装。 - 解决 :在命令行中执行
pip install requests。如果使用虚拟环境,请确保在正确的环境中安装。
问题 2:扫描速度非常慢,甚至不如单线程。
- 原因 :
- 线程数 (
max_workers) 设置过高,导致大量线程竞争 CPU 和网络资源,上下文切换开销巨大。 - 目标服务器响应慢或网络延迟高。
- 字典文件过大,内存加载慢。
- 线程数 (
- 排查与解决 :
- 降低线程数 :尝试将
max_workers设置为 10、20、30 分别测试,找到性能拐点。 - 调整超时时间 :适当降低
timeout(如设为 3 秒),让慢请求更快失败,释放线程。 - 检查网络 :使用
ping或traceroute检查到目标服务器的网络状况。 - 优化字典 :使用更精简、更相关的字典。可以边读边扫,而不是一次性加载全部到内存(使用生成器)。
- 降低线程数 :尝试将
问题 3:扫描结果中出现了大量 403 状态码,但没有 200。
- 原因 :目标服务器配置了严格的访问控制,禁止目录浏览,并对大多数路径返回 403 Forbidden。
- 分析 :403 状态码本身就是一个重要发现,它表明路径是存在的,只是当前无权访问。这可能意味着需要身份认证(如 cookies, tokens)或存在其他绕过方式。
- 应对 :在扫描器中集成 Cookie 或自定义 Header 支持,模拟已登录状态再进行扫描。
问题 4:程序运行一段时间后,控制台不再输出,但也没有结束,像是“卡住”了。
- 原因 :可能是遇到了一个长时间无响应的请求,线程被挂起;或者发生了未处理的异常导致某个线程崩溃,但线程池还在等待。
- 排查 :
- 添加更详细的日志 :在异常捕获块中打印更多信息,尤其是
RequestException。 - 使用调试器 :在 IDE 中设置断点,查看线程状态。
- 检查目标 :手动用浏览器访问几个字典中的路径,看是否正常响应。
- 添加更详细的日志 :在异常捕获块中打印更多信息,尤其是
- 解决 :确保
timeout参数已设置,并考虑为整个扫描任务设置一个总超时。
问题 5:扫描自己本地搭建的服务器,发现所有路径都返回 200。
- 原因 :很多简单的 HTTP 服务器(如 Python 的
http.server)对于不存在的文件,会返回一个列出目录内容的页面(状态码 200),而不是 404。 - 解决 :这正是我们实现“智能过滤”的意义所在。通过先学习错误页面特征(
learn_error_pattern),可以过滤掉这些统一的 200 响应。对于更复杂的情况,可能需要结合响应内容相似度算法。
通过这个从零构建 Python 目录扫描器的过程,我们不仅学会了如何使用 requests 和并发编程,更重要的是理解了网络扫描工具背后的核心思想: 高效地发送请求、智能地分析响应、安全地处理异常 。你可以以此为基础,不断添加新功能,比如集成更强大的异步框架 aiohttp ,添加递归扫描、子域名枚举模块,甚至设计一个图形化界面。记住,工具是死的,思路是活的。理解原理,你就能创造出适应各种场景的解决方案。
更多推荐

所有评论(0)