做Web安全测试这么多年,我还是头一回把一个浏览器自动化框架当成“探针”来使。这个叫Katana-agent的小项目,说白了就是基于Playwright做的一个智能Web安全测试代理,核心思路是让浏览器替我们干活:自动打开页面、自动点按钮、自动提交表单,然后在真实交互过程里收集请求和响应,用预设规则去匹配潜在漏洞特征。
如果你日常用requests去测接口、用老式扫描器测静态站点,那你一定知道这些东西到了如今遍地SPA、前端渲染大行其道的环境下有多无力。页面内容是JS动态拼的,请求是XHR异步发的,token是页面级动态计算的——传统工具根本看不到这些东西。Katana-agent的思路就是用真实浏览器把这些交互过程全部“走一遍”,在走的过程中做安全检测。这篇文章我会把整个设计思路、实现细节、踩过的坑全部展开,适合正在折腾Web安全自动化、或者想在测试流程里引入浏览器级Agent方案的工程师参考。
1. 项目思路拆解:为什么需要一个浏览器级的“安全测试代理”
1.1 传统Web扫描的盲区
先聊一个最直观的问题:你是不是也遇到过这种情况,拿常规扫描器扫一个内部管理系统,扫描报告里干干净净,但前端同事说这系统天天被人手工“fuzz”出问题。差别在哪?差别就在于常规扫描器只会发HTTP请求,看不懂JavaScript。现在的Web应用,登录后的首页数据是Vue/React在浏览器端拿token换来的,输入框的校验逻辑在前端代码里,甚至连请求参数都是动态拼出来的。扫描器没有浏览器内核,只能扫到入口HTML和少量静态资源,核心的API网络根本摸不进去。
我以前做过一次对比,同一个授权测试目标,用纯HTTP请求的方式去枚举接口,能找到的路径大概只有真实浏览器里看到的四分之一。那些隐藏在点击事件、键盘输入、滚动加载后面的接口,靠猜路径永远猜不全。这也是我开始认真对待“浏览器自动化”这个方向的原因——先让工具像人一样把系统完整操作一遍,然后才有资格谈安全测试。
1.2 “代理”这个词在项目里意味着什么
Katana-agent里的“代理”,不是指网络层面的HTTP代理,而是一种“动作代理”。它代替测试人员去操作浏览器,按照真实用户的使用习惯去点击、输入、上传,同时记录下浏览器视角看到的任何内容。这一点非常关键,因为安全测试不应该是在系统外面“隔空打拳”,而是要在系统内部真实的使用场景里发现异常。
代理的另一个好处是可以保持会话状态。登录、跳转、注销,整个过程中的cookie、localStorage、sessionID都被完整保留,这样探测出来的接口带上合法身份信息,返回的响应才是系统真实行为,更容易发现越权类和逻辑类问题。可以说,这个Agent提供了一个干净、可持续、可重放的用户视角。
1.3 核心设计目标:把“扫描”变成“使用”
我在设计Katana-agent的时候,第一件事就是把需求反过来写:不要问“这个系统有什么漏洞”,要问“这个系统有哪些关键操作流程,每个流程里有哪些输入点”。基于这个思路,项目的检测流程天然变成三步:
第一步,Agent在页面里找出所有可交互的元素,包括表单输入框、按钮、下拉框、文件上传控件;
第二步,针对每个交互元素执行一次或多次带探测数据的操作,同时记录请求和响应;
第三步,把记录的流量和DOM变化送到规则引擎里做匹配,输出判定为“可疑”的结果。
这听起来简单,但实现起来最大的难点是“顺序”和“上下文”。有些操作必须在登录后才能执行,有些参数依赖前一个接口的返回值。Playwright在这种场景下帮了大忙,它天然支持等待某个元素出现、等待某个请求发出、等待某个响应返回,完全可以按照真实用户的操作节奏去做检测。
2. 技术选型与架构设计:核心模块是怎么配合的
2.1 为什么选Playwright,而不是其他自动化方案
其实市面上的浏览器自动化方案有不少,例如Puppeteer或者Selenium,我也都试用过。最终留下来用Playwright,完全是出于几个非常现实的理由。
第一,Playwright的多浏览器支持非常省心。Chromium、Firefox、WebKit都能跑,很多Web应用在不同浏览器里渲染出来的DOM结构不一样。安全测试最怕的就是因为浏览器内核差异而导致请求漏抓,比如某个接口只在Firefox渲染的页面里才会触发。Playwright的这套多引擎方案让我一套代码能覆盖多个运行环境。
第二,自动等待机制是真省事。Selenium写脚本的时候,最痛苦的就是sleep和WebDriverWait满天飞,时间短了元素没出来,时间长了测试流程拖得特别久。Playwright的等待策略会自己判断元素状态,点按钮之前自动等到可点击状态,填写输入框之前自动等到可用状态。对Agent这种需要探索大量页面的场景来说,这个特性能省掉一大批“定位不到元素”的报错。
第三,路由拦截能力太强了。Playwright可以通过page.route()拦截每一个请求和响应,还能选择性修改请求头、响应体、返回状态码。这意味着我可以在不解包底层协议的情况下,完成很多以前需要中间人代理才能实现的检测逻辑。举个例子,我可以让Agent向某个接口注入一个异常的请求头,或者把响应体的内容替换成一个特征字符串,然后观察前端行为有没有异常。
2.2 Agent的整体模块划分
整个Katana-agent项目我拆成了五个模块,互相之间通过简单的数据接口通信,谁都不直接依赖谁:
第一个模块是控制模块,负责启动浏览器、创建上下文、管理页面的生命周期和任务调度。任务队列里存的就是“待访问的URL列表”和“待执行的交互动作”。
第二个模块是采集模块,负责监听所有网络事件,记录请求URL、请求方法、请求体、响应状态码、响应体摘要,同时记录DOM变化关键节点,比如弹窗、跳转、控制台报错。
第三个模块是规则引擎模块,它把采集到的流量和页面状态喂给一组检测规则。每条规则就是一个独立的判定函数,输入是特定类型的证据对象,输出是(是否命中, 置信度, 补充说明)三元组。
第四个模块是执行模块,负责根据页面元素的类型生成对应的操作指令。文本输入框填探测串,按钮触发点击,下拉框做选项值替换,文件上传控件上传模拟文件。执行模块的行为原则是“乱而不越界”,只做探测,不做任何真实提交。
第五个模块是报告模块,把所有命中的可疑点汇总成结构化数据,输出Markdown和JSON两种格式,方便接入现有的安全运营流程。
2.3 智能决策是怎么做出来的
很多人看到“智能Agent”会觉得神秘,但在Katana-agent里,智能主要体现在两个地方。
一个是“动态优先级”。Agent并不是机械地遍历页面上所有元素,而是先对元素做一次分类:能输入文本的、能触发的、能上传文件的、能进行选择的,分别对应不同的优先级。比如在登录页,优先级最高的操作是先探测表单字段,因为登录逻辑很可能出现SQL注入或逻辑绕过;而在搜索结果页,优先级最高的是翻页和排序按钮,因为这些交互经常会带出数据库排序相关的异常。
另一个是“响应上的异常识别”。Agent会在每次操作后做一次对比,看这个操作前后,页面有没有出现弹窗、跳转、或DOM异常渲染。这种从“行为维度”发现的异常,往往比单纯匹配请求参数更有价值。比如一个输入框,请求里完全看不出问题,但提交后前端弹出了异常堆栈信息,这其实就说明服务端把错误信息直接暴露到前端了,属于典型的信息泄露。
3. 实战搭建:一步步实现Katana-agent的核心流程
3.1 环境准备与依赖安装
我建议你在一个干净的Python虚拟环境里做搭建,避免把系统Python环境搞乱。本项目我用的Python版本是3.10+,依赖主要就是Playwright,其他都是一些基础库。
mkdir katana-agent && cd katana-agent python3 -m venv venv source venv/bin/activate pip install playwright playwright install chromium安装完了跑一下playwright install --dry-run确认一下浏览器已经就位。如果是在CI环境里跑,记得提前用playwright install-deps把系统依赖装上,不然启动Chromium的时候会报一堆动态库缺失的错。
3.2 实现请求拦截与流量记录
我在Agent里最依赖的写码技巧就是使用page.route()。这个方法可以在请求发出前拦截,也能在响应返回后接管。先看一段核心代码,这是整个采集模块的骨架:
# collector.py from playwright.sync_api import sync_playwright from datetime import datetime import json class RequestCollector: def __init__(self, headless=True): self.headless = headless self.events = [] def _on_request(self, request): """请求发出前记录""" self.events.append({ "type": "request", "url": request.url, "method": request.method, "headers": dict(request.headers), "post_data": request.post_data, "timestamp": datetime.now().isoformat() }) def _on_response(self, response): """响应返回时记录""" # 只记录html、json、js、css这几类常用资源 content_type = response.headers.get("content-type", "") if any(t in content_type for t in ["html", "json", "javascript", "css"]): try: body_preview = response.text()[:500] except Exception: body_preview = "<unreadable>" # 找到对应的请求记录,补上响应信息 for evt in self.events: if evt["type"] == "request" and evt["url"] == response.url: evt["status"] = response.status evt["response_body"] = body_preview break def intercept(self, page): page.on("request", self._on_request) page.on("response", self._on_response) def export(self, dest): with open(dest, "w", encoding="utf-8") as f: json.dump(self.events, f, ensure_ascii=False, indent=2)这段代码里有几个细节是踩过坑之后才补上的。第一,响应体不能全部保存,大文件会把内存打爆,所以我只对部分类型资源取前500个字符。第二,记录请求和响应时必须用URL做关联,因为Playwright的response.text()不能在一个请求事件里同步拿到,只能靠回查匹配。
如果你想在发现可疑请求时直接修改响应体,page.route()会更直接。例如:
def handle_route(route): if route.request.url.endswith("/api/token"): # 这里只是一个示例,实际请按授权范围操作 route.fulfill( status=200, content_type="application/json", body='{"token":"test_token_001"}' ) else: route.continue_() page.route("**/*", handle_route)这种能力在做接口容错测试时非常有用,可以模拟各种异常响应来观察前端是否做过安全校验。
3.3 设计探测规则引擎
规则引擎是整个Katana-agent的灵魂,我把规则设计成一个可扩展的函数集合,每条规则接收一个“证据对象”,返回一个“分析结论”。先看基本的规则基类:
# rules/base.py class BaseRule: """所有探测规则的基类""" name = "base_rule" description = "" def __init__(self): self.hits = [] def check(self, evidence): """ 参数 evidence 是一个字典,包含: - url: 请求地址 - method: 请求方法 - status: 响应状态码 - response_body: 响应体摘要 - dom_snapshot: 页面关键DOM信息 - action_type: 触发动作类型 """ raise NotImplementedError def report(self): """输出该规则的探测结果""" return { "rule": self.name, "description": self.description, "hits": self.hits, }接着拿几个实际检测场景来演示。第一个是反射型XSS的初级判定逻辑。这个规则的思路比较简单,往目标参数里注入一段特征标记,如果响应或DOM里原样返回了这段标记,就说明存在反射可能性。代码如下:
# rules/reflected_xss.py import re from .base import BaseRule class ReflectedXSSRule(BaseRule): name = "reflected_xss" description = "检测响应中是否存在反射型脚本标记" def __init__(self): super().__init__() self.marker = "katana_xss_<script>alert(1)</script>" def check(self, evidence): body = evidence.get("response_body", "") url = evidence.get("url", "") # 如果请求里带上了我们注入的标记,响应里又出现了标记 if self.marker in body and self.marker in url: self.hits.append({ "url": url, "evidence": "反射标记出现在响应体中", "severity": "medium", }) return True return False这里要说明一下,该规则的代码只是演示检测思路,具备合法授权的测试人员应根据具体场景自行扩展规则细节。直接拿完整攻击载荷在生产环境扫描,既不符合规范,也很容易导致业务系统异常。
第二个常用规则是敏感信息泄露检测。这个不是靠注入,而是靠分析响应体中的关键词。比如响应里出现堆栈异常的关键字、内部路径名、数据库报错片段,都可以打上“信息泄露”的标签。这种规则在审计版本敏感的接口时非常实用。
# rules/info_leak.py import re from .base import BaseRule class InfoLeakRule(BaseRule): name = "info_leak" description = "检测响应中可能出现的敏感调试信息" patterns = [ r"(?i)stack trace", r"(?i)exception in thread", r"(?i)sqlstate", r"/var/www/[^\s\"']*", r"com\.[a-z]+\.[a-z]+Exception", ] def check(self, evidence): body = evidence.get("response_body", "") url = evidence.get("url", "") for pattern in self.patterns: if re.search(pattern, body): self.hits.append({ "url": url, "evidence": f"命中敏感模式: {pattern}", "severity": "low", }) return True return False3.4 智能Agent主流程打通
配置好采集和规则模块后,主控制流程就可以串起来了。我自己实现的主类大概长这样:
# katana_agent.py from playwright.sync_api import sync_playwright from collector import RequestCollector from rules.base import BaseRule from rules.reflected_xss import ReflectedXSSRule from rules.info_leak import InfoLeakRule class KatanaAgent: def __init__(self, start_url, headless=True, max_pages=10): self.start_url = start_url self.headless = headless self.max_pages = max_pages self.collector = RequestCollector(headless=headless) self.rules = [ReflectedXSSRule(), InfoLeakRule()] self.report_data = { "target": start_url, "scanned_at": "", "findings": [], } def run(self): with sync_playwright() as p: browser = p.chromium.launch(headless=self.headless) context = browser.new_context( ignore_https_errors=True, # 测试环境常用,正式环境不建议 viewport={"width": 1280, "height": 720}, ) page = context.new_page() self.collector.intercept(page) # 记录所有触发的JS弹窗和异常信息 page.on("dialog", lambda d: d.accept()) page.on("console", lambda msg: self._record_console(msg)) self._crawl(page, self.start_url, depth=0) browser.close() self._run_rules() self._export_report() def _crawl(self, page, url, depth): if depth > self.max_pages: return page.goto(url, timeout=20000) page.wait_for_load_state("networkidle", timeout=10000) # 收集所有链接,BFS式遍历 links = page.eval_on_selector_all("a", "els => els.map(e => e.href)") for link in links: if self._in_scope(link): self._crawl(page, link, depth + 1) # 对当前页面做表单探测 self._scan_forms(page) def _scan_forms(self, page): form_count = page.locator("form").count() for i in range(form_count): form = page.locator("form").nth(i) # 这个逻辑只解析表单内部输入项,不直接提交 inputs = form.locator("input").count() for j in range(inputs): inp = form.locator("input").nth(j) inp_type = inp.get_attribute("type") or "text" if inp_type in ["text", "search", "email", "url"]: inp.fill("katana_probe_123") # 有些表单点击按钮后会触发异步请求 button_count = form.locator("button").count() if button_count > 0: form.locator("button").nth(0).click(timeout=3000) def _run_rules(self): for event in self.collector.events: if event["type"] != "response": continue evidence = { "url": event.get("url", ""), "method": event.get("method", ""), "status": event.get("status", 0), "response_body": event.get("response_body", ""), } for rule in self.rules: if rule.check(evidence): self.report_data["findings"].append({ "rule": rule.name, "url": evidence["url"], "severity": rule.hits[-1]["severity"], "detail": rule.hits[-1]["evidence"], }) def _export_report(self): with open("katana_report.json", "w", encoding="utf-8") as f: json.dump(self.report_data, f, ensure_ascii=False, indent=2) if __name__ == "__main__": agent = KatanaAgent("http://target-site.example.com/login") agent.run()这段主流程里有两个我特意处理过的地方。第一,page.wait_for_load_state("networkidle")不一定可靠,因为某些单页应用会保持长连接,导致一直等不到空闲状态,所以我在后面补了一个10秒的超时上限。第二,表单扫描的click()只是触发前端行为,不保证请求一定发出,因此采集模块必须同时监听console和dialog日志,这样才能捕捉到前端JS处理抛出的异常。
注意:以上代码里的
ignore_https_errors=True只建议在明确的授权测试环境里使用。对真实生产环境开启这个开关,会有很大的中间人攻击风险,别在没把握的系统上乱用。
4. 关键细节与进阶优化:把探测器打磨得更顺手
4.1 登录态和会话管理,是探测覆盖率的分水岭
为什么两个测试人员用同一个工具扫同一个目标,结果会差很多?绝大多数情况是会话管理不一样。未登录状态下能跑到的接口少得可怜,登录状态下才能把业务逻辑摸全。
Katana-agent在处理登录态时,我推荐用Playwright的storage_state机制。第一次手工登录成功后,把整个上下文状态保存到一个文件里:
context.storage_state(path="login_state.json")之后每次启动新会话时,直接加载这个状态文件:
context = browser.new_context(storage_state="login_state.json")这套做法和手工测试里“保存浏览器会话”是一个道理。只要目标系统的登录态没过期,Agent就能在非常接近真实用户的状态下做完整检测。注意这里有个难点:不同环境的账号状态不一样,所以这个state文件建议按环境隔离,不要跨环境复用,否则会导致身份混乱。
4.2 动态页面的等待策略:别被networkidle骗了
一开始写Agent的时候,我习惯用wait_for_load_state("networkidle")来确保页面加载完成,但后来发现这个等待策略在现代web应用里经常“失灵”。SPA页面里有些请求是用户滚动后才发出的,有些请求是定时器触发的,如果一直等networkidle,要么等到超时,要么错过了后续的动态内容。
更好的做法是用“关键元素等待”来替代“全页面等待”。例如,检测登录页是否加载完成,就等待用户名输入框可见;检测列表页是否加载完成,就等待第一行数据出现。这种等待方式更贴近真实用户行为,也更稳定。我对Katana-agent的采集模块做了改造,_crawl里不再依赖全局networkidle,而是为每个页面类型配置了一组等待器。
而且,在扫描的过程中要记得控制采集的粒度。如果一个页面有几万个元素,把每个都探测一遍,任务会耗时到不可接受。我一般会设置一个“行为预算”,比如每页最多触发20次交互操作,超了就不再继续探测当前页,避免整体流程卡死。
4.3 人机验证与风控逻辑该用什么态度对待
这个话题我必须多说两句。Katana-agent是安全测试工具,不是对抗风控的工具。在授权测试环境中,如果被测系统引入了验证码、滑块等验证逻辑,正确做法是找测试环境负责人确认是否可以临时关闭,或者申请专用测试账号跳过验证环节。用自动化脚本来绕过真实风控系统,既背离了安全测试的初衷,也可能踩到规则红线。
在代码层面,我处理验证码问题的方式是“识别并跳转”。如果弹出了人机验证相关区域,Agent记录下这个信号,然后直接跳过该页面,并把“存在验证机制”写进报告备注。这样既证明了系统有风控能力,也避免了探测行为制造额外风险。
4.4 并发与资源控制:别把被测系统跑挂了
安全测试工具最容易犯的错误,是探测时并发太高,把业务系统打崩。这个锅不应该由安全测试来背,但我们在做Agent设计时还是应该自带“刹车”。
我建议在Katana-agent里增加一个全局的请求速率限制器。最简单的实现是使用一个令牌桶,每秒钟最多放行N个请求。N的具体值根据被测系统的规模来定,一般我习惯从小到大测试,先跑一个很低的并发值,观察系统响应没异常后再逐步上调。
请求速率的控制逻辑甚至可以做得更朴素,直接在_on_request回调里加一个time.sleep()。这么做虽然不够优雅,但胜在极稳。
4.5 报告与数据脱敏
报告模块看起来简单,但有个点特别容易踩坑:如果Agent记录的响应体里包含手机号、身份证号等个人信息,直接塞进报告就会造成新的合规问题。我在设计报告模块时对输出数据做了一层脱敏处理,在写入最终报告之前,把匹配手机号、邮箱、身份证模式的字符串统一打码。这样做既不会影响安全测试的证据关联,又能避免敏感数据二次泄露。
脱敏的规则很简单,就是一组正则替换:
import re def mask_sensitive(data): data = re.sub(r"1[3-9]\d{9}", "[手机号]", data) data = re.sub(r"\w+@\w+\.\w+", "[邮箱]", data) data = re.sub(r"\d{17}[\dXx]", "[身份证]", data) return data别小看这步,报告可能转手给多人看,多一层保护总归没有坏处。
5. 常见问题与排查实录
5.1 环境类问题
下面这张表是我在搭建和使用Katana-agent过程中遇到的高频环境问题,按出现频率排好了:
| 现象 | 原因 | 解决方法 |
|---|---|---|
playwright install后启动报缺.so文件 | 系统缺少浏览器运行依赖 | 执行playwright install-deps |
| Chromium启动崩溃,提示GPU相关错误 | 服务器环境没有GPU加速 | 启动时加--disable-gpu参数 |
| 页面全部超时 | 目标地址不可达或代理配置异常 | 先排除网络连通性,再检查browser.new_context里是否错误设置了代理参数 |
| 报告里请求记录为空 | page.on("request")绑定时机太晚 | 确认拦截器绑定在page.goto之前完成 |
| Python版本太低导致API不兼容 | Playwright新版要求Python 3.8+ | 升级到3.10+ |
5.2 规则误报与漏报的排查思路
安全测试工具最怕误报,因为误报会消耗大量人工精力去复核。我在实践中总结了一条经验:规则判定里,永远要把“证据链”存下来。也就是说,规则命中时不仅要写url,还要把当时响应体里命中的片段完整摘录出来。否则回头排查单靠域名和规则名,根本没法判断是不是误报。
另一个常见问题是漏报。漏报的原因多半是探测数据没有真正作用到目标参数上。比如某个输入框使用了contenteditable而不是标准的input标签,Agent按常规表单逻辑就漏掉了它。解决办法是在采集模块里增加对contenteditable、textarea、select等元素的识别。这些元素的交互方式各不相同,需要单独适配。
5.3 Playwright相关的高频坑
我在这里集中列出一些容易被忽略的Playwright细节。
第一,locator.click()之前不要乱做page.wait_for_timeout(),Playwright的自动等待已经足够好,额外等待只会拖慢整个流程。
第二,多个页面标签页之间切换时,要注意当前活跃的Page对象可能已经不是之前那个了。Agent的实现在遍历弹窗时经常会产生这种问题,最好统一通过context.pages拿最新列表,而不是缓存Page对象。
第三,处理浏览器弹窗(alert/confirm)时,必须提前挂dialog事件监听器,否则弹窗出现时会阻塞整个自动化流程。我在上面的示例代码里已经写了一句page.on("dialog", lambda d: d.accept()),这句话能救你很多次。
第四,如果你在无头模式下运行,某些目标网站的反调试逻辑会检测浏览器特征。这种情况我不建议用工具去“对抗”,更合理的做法是联系测试环境侧开放白名单,或者把Agent切换到有头模式在桌面上运行。
5.4 从日志到报告:培养排查习惯
排查这些问题最大的帮手是日志。我强烈建议在任何Agent里都加上一个带时间戳的详细日志输出,记录Agent当前执行到哪个URL、发现多少表单、触发了多少次请求、有多少条规则命中。没有这个日志,遇到问题基本就是盲人摸象。
我在Katana-agent里加了一个很简单的logger装饰器,日志写到标准输出的同时保存到log/agent.log。调Bug的时候先看日志尾部,绝大多数问题都能在一两分钟内定位。
6. 下一步扩展方向
Katana-agent目前已经能在授权测试环境里稳定跑完一个中型Web应用的常规探测流程,但它离一个完善的自动化安全测试平台还有不少距离。我目前在自己环境里尝试的扩展方向有三个。
第一个方向是扩展规则库。把XSS、SQL注入、明文传输、CSRF、越权等检测规则全部拆成独立的配置化模块,做到“新增一种漏洞检测不需要改Agent主流程”。
第二个方向是引入任务编排能力。现在的单Agent一次只能跑一条探测链路,未来想做成一个任务队列,由调度器把多个Agent分配到不同的子系统里去并行探测,再汇总成一份全局报告。
第三个方向是接入CI流水线。写完代码自动触发一次轻量级安全探测,把结果以注释形式回写到代码评审页面。这条路走通了,安全测试就能从“事后救火”变成“事前预防”。
我做这个项目的最大心得是:安全测试的自动化不等于“扫描器满天飞”,更准确的表述是“把测试人员重复性的劳动抽出来,交给可靠的工具自己去跑,再由人来判断结果”。浏览器自动化在这里扮演的角色,不是攻击者,而是一个极其忠实的“模拟用户体验员”。只要方向摆正、权限约束清楚,这种Agent完全可以成为企业安全体系里一个既高效又克制的探针角色。
如果你也正在做类似的事情,建议从最小的场景入手:先让Agent自动走通一个完整的登录到查询的流程,把流量记录好,再慢慢加规则。步子别迈太大,功能越简单,越容易在实际环境里落地。