做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, headlessTrue): 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, encodingutf-8) as f: json.dump(self.events, f, ensure_asciiFalse, indent2)这段代码里有几个细节是踩过坑之后才补上的。第一响应体不能全部保存大文件会把内存打爆所以我只对部分类型资源取前500个字符。第二记录请求和响应时必须用URL做关联因为Playwright的response.text()不能在一个请求事件里同步拿到只能靠回查匹配。如果你想在发现可疑请求时直接修改响应体page.route()会更直接。例如def handle_route(route): if route.request.url.endswith(/api/token): # 这里只是一个示例实际请按授权范围操作 route.fulfill( status200, content_typeapplication/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_scriptalert(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\]*, rcom\.[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, headlessTrue, max_pages10): self.start_url start_url self.headless headless self.max_pages max_pages self.collector RequestCollector(headlessheadless) 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(headlessself.headless) context browser.new_context( ignore_https_errorsTrue, # 测试环境常用正式环境不建议 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, depth0) browser.close() self._run_rules() self._export_report() def _crawl(self, page, url, depth): if depth self.max_pages: return page.goto(url, timeout20000) page.wait_for_load_state(networkidle, timeout10000) # 收集所有链接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(timeout3000) 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, encodingutf-8) as f: json.dump(self.report_data, f, ensure_asciiFalse, indent2) 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_errorsTrue只建议在明确的授权测试环境里使用。对真实生产环境开启这个开关会有很大的中间人攻击风险别在没把握的系统上乱用。4. 关键细节与进阶优化把探测器打磨得更顺手4.1 登录态和会话管理是探测覆盖率的分水岭为什么两个测试人员用同一个工具扫同一个目标结果会差很多绝大多数情况是会话管理不一样。未登录状态下能跑到的接口少得可怜登录状态下才能把业务逻辑摸全。Katana-agent在处理登录态时我推荐用Playwright的storage_state机制。第一次手工登录成功后把整个上下文状态保存到一个文件里context.storage_state(pathlogin_state.json)之后每次启动新会话时直接加载这个状态文件context browser.new_context(storage_statelogin_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(r1[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-depsChromium启动崩溃提示GPU相关错误服务器环境没有GPU加速启动时加--disable-gpu参数页面全部超时目标地址不可达或代理配置异常先排除网络连通性再检查browser.new_context里是否错误设置了代理参数报告里请求记录为空page.on(request)绑定时机太晚确认拦截器绑定在page.goto之前完成Python版本太低导致API不兼容Playwright新版要求Python 3.8升级到3.105.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自动走通一个完整的登录到查询的流程把流量记录好再慢慢加规则。步子别迈太大功能越简单越容易在实际环境里落地。