
如果你平时用 BurpSuite 调试接口、抓 App 流量或者做安全评估应该经常遇到这种局面Repeater 可以把单个请求反复折腾但它本质上还是“手动改”。一旦需求变成“让每个经过代理的请求自动加上某个 Header、替换一段 Body、满足特定条件才篡改”光靠界面点击就完全玩不转了。这个时候写一个 HTTP 请求层面的“中间人”插件是最干净的解法。这篇博文会从零开始基于 Montoya API 这套新架构创建一个 BurpSuite 插件在请求进入服务器之前截获它、修改它、再放行同时在响应回来时也能做同样的处理。内容适合有一定 Java 基础、想把 BurpSuite 从手工工具升级成自动化工具的开发者也适合刚接触插件开发、想快速搞懂新版 API 用法的朋友。1. 为什么需要 HTTP 中间人插件1.1 中间人能力解决什么问题举一个最常见的场景某个接口协议要求所有请求必须带一个版本号头比如X-API-Version否则服务器直接返回 400。你在 Postman 里加一次容易但当你用 Burp 的浏览器代理做前端联调时页面发出的几十个请求全都缺这个头于是在 Network 面板里一片红。用 Repeater 一个个补头显然不现实更可靠的做法是让插件在请求流经 Burp 的那一瞬间自动把版本号头补上。另一个更典型的场景是移动端抓包。很多 App 的接口会校验时间戳或者签名你在调试的时候希望固定某个参数、临时替换 token甚至动态注入一个 trace-id 用于全链路排查。这类需求如果靠人工在代理工具里改几乎不可能重复操作只有让代码在请求经过代理时接管逻辑才能做到“无感修改”。这就是中间人插件的核心价值它在 BurpSuite 的 HTTP 处理链路上加了一个可编程的关卡请求进去时你能看、能改、能放行响应返回时你同样能看、能改、能放行。它把 BurpSuite 从一个手动测试工具变成一个具备自动干涉能力的流量处理平台。1.2 为什么选 Montoya API 而不是旧版 APIBurpSuite 的扩展开发经历过两代接口。旧时代的主力是IBurpExtender你需要实现一个registerExtenderCallbacks方法然后在回调对象上挂各种I开头的接口比如IHttpListener、IHttpRequestResponse、IExtensionHelpers。这套接口用了很多年能跑但问题也很明显所有回调都是全局状态命名风格不统一类型设计比较弱一个新人翻开旧文档学的时候经常会迷失在十几个I接口里。Montoya API 是 BurpSuite 为插件开发者准备的新一代扩展接口。它最大的变化是提供了一个统一的门面对象MontoyaApi插件只需要实现一个BurpExtension接口在initialize方法里拿到这个门面然后通过api.http()、api.proxy()、api.scanner()、api.logging()这类模块化入口去调用功能。整个代码组织感强了很多接口命名也更直白不再有一堆毫无意义的I前缀。新版要求自然是更高的需要 JDK 17 或更高版本BurpSuite 本身也得是较新的稳定版否则插件加载时会直接报类版本错误。但对一个新插件项目来说选择 Montoya API 是明确的决定——你不用再去维护一堆过时的回调写法而且 Burp 后续的新特性只会更多围绕这套 API 展开早用早省心。2. 项目搭建JDK 17 与 Gradle 环境2.1 项目结构与 Gradle 依赖配置写 BurpSuite 插件本质上是写一个 Java 工程然后用 Gradle 或 Maven 构建成 JAR 包再加载。我习惯用 Gradle结构简单打包也顺手。先建一个普通 Java 项目目录大致长这样my-burp-plugin/ ├── build.gradle ├── settings.gradle └── src └── main └── java └── example └── interceptor ├── RequestInterceptor.java └── RequestModifier.javabuild.gradle是整个项目最关键的文件之一。注意这里有一个重要选择Montoya API 的 jar 包只在编译时需要不应该打包进最终的插件 JAR因为 BurpSuite 运行时已经自带这些类了。所以依赖声明要用compileOnly意思就是“编译期可见运行期由外部提供”。plugins { id java } group example version 1.0.0 repositories { mavenCentral() } dependencies { compileOnly net.portswigger.burp.extensions:montoya-api:2024.12 } java { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 }有两点我需要提醒你。第一net.portswigger.burp.extensions:montoya-api这个依赖坐标里的版本号更新很快我这里写的只是一个示例版本你动手前最好去中央仓库搜索这个坐标拿最新的稳定版号替换上去否则可能跟你的 BurpSuite 版本不匹配。第二如果你在插件里引用了其他第三方库比如 JSON 解析库那这些依赖就必须打进最终 JAR否则运行时会报NoClassDefFoundError。打包方式后面会说到。2.2 加载编译产物到 BurpSuite代码写完之后构建插件。如果没有任何第三方依赖直接执行gradle jar生成的文件在build/libs目录下一个名为my-burp-plugin-1.0.0.jar的包。如果插件里带了第三方依赖就要用 fat JAR 方式把所有依赖的 class 打到一个包里面。社区里常用的方案是 Shadow 插件在plugins里加上com.gradleup.shadow之后执行gradle shadowJar产物会变成xxx-all.jar。加载到 BurpSuite 的路径很固定打开 Burp - Extensions - AddExtension Type 选 Java然后选中你构建出来的 JAR。加载成功后Output 面板里如果出现你自己日志输出的启动信息就说明插件被正常识别了。如果 Burp 的版本偏老无法识别 Montoya API 的类这里就会直接报清单很长的一段AbstractMethodError或者NoClassDefFoundError这大概率不是代码逻辑问题而是 API 版本和 Burp 版本不匹配先检查这两个。3. 核心接口拆解从入口到处理链3.1 BurpExtension 生命周期与初始化Montoya API 的入口类写法非常固定。你需要实现burp.api.montoya.BurpExtension接口并在initialize(MontoyaApi api)方法中完成所有初始化工作。这个方法会在插件加载时被 Burp 调用一次类比的话它就是插件的main方法。来看最小可运行的入口代码package example.interceptor; import burp.api.montoya.BurpExtension; import burp.api.montoya.MontoyaApi; public class RequestInterceptor implements BurpExtension { Override public void initialize(MontoyaApi api) { api.extension().setName(HTTP Request Interceptor); api.http().registerHttpHandler(new RequestModifier()); api.logging().logToOutput(HTTP Request Interceptor loaded.); } }这里几行代码各有所用api.extension().setName(...)是设置插件在 Burp 扩展列表里的展示名api.http().registerHttpHandler(...)是把我们自定义的处理器挂到 HTTP 链路上这一步不做的话后面所有拦截逻辑都不会生效api.logging().logToOutput(...)则是往 Burp 的 Output 面板打一行日志方便确认插件真的加载了。需要留个心眼的点是initialize方法不要做任何耗时操作比如发网络请求、读取大文件、密集计算。虽然它只执行一次但如果这里慢下来Burp 会在启动插件时表现出明显的卡顿。把该注册的注册好、该初始化的数据结构建好就够了重活留给处理器去干。3.2 HttpHandler 请求响应处理链HTTP 处理器是整个中间人能力的核心对应接口是burp.api.montoya.http.handler.HttpHandler。你要实现它的两个方法handleHttpRequest(HttpRequest request)每个请求从客户端发出、经过 Burp 处理时会调用。handleHttpResponse(HttpResponse response)每个响应从服务器返回、经过 Burp 处理时会调用。这两个方法的返回值约定很关键可以说是写这类插件的第一个理解门槛。返回值如果是null表示“我不需要修改”Burp 会把原本的对象原样放行如果返回一个新的请求或响应对象则后续链路和发送动作都会使用你替换过的版本。很多新手第一次写的时候在方法里调用了一个withHeader方法却没意识到返回值没有赋给任何变量于是耗时半天发现毫无变化——这正是因为没有理解“修改逻辑的产物是新对象而不是原地改动”。HttpHandler的完整雏形长得像这样package example.interceptor; import burp.api.montoya.http.handler.HttpHandler; import burp.api.montoya.http.message.requests.HttpRequest; import burp.api.montoya.http.message.responses.HttpResponse; public class RequestModifier implements HttpHandler { Override public HttpRequest handleHttpRequest(HttpRequest request) { // 返回 null 表示不修改返回新对象表示替换 return request.withHeader(X-Interceptor, true); } Override public HttpResponse handleHttpResponse(HttpResponse response) { // 这里先不做响应修改 return null; } }实际执行时机很好理解开启 Burp 的代理并让浏览器或 App 走 127.0.0.1:8080 之后所有 HTTP 流量都会经过 Burp 的 HTTP 处理链。你的 handler 就挂在链上请求到达时先过一遍handleHttpRequest响应返回时再过一遍handleHttpResponse。你在 Burp 的Proxy-HTTP history里看到的报文其实已经是处理器链跑完一遍之后的结果。3.3 不可变请求对象与修改范式Montoya API 里的HttpRequest和HttpResponse都是不可变对象。这跟 Java 里String的规则一样你不能在一个已有对象上直接“改动”每次修改都会产生一个新的对象。因此所有形如withXxx的方法返回值才是修改后的对象原对象保持原封不动。举几个常用的方法帮助建立修改直觉// 添加或替换请求头 HttpRequest r1 request.withHeader(X-API-Version, 2); // 修改请求方法 HttpRequest r2 request.withMethod(POST); // 修改请求体ByteArray 是字节数据封装 HttpRequest r3 request.withBody(ByteArray.stringToBytes(nametestcount1));这种设计有实际的好处。当多个处理器串联协作时每个处理器接收到的都是上一个处理器返回的新对象而不是被某个环节污染过的共享对象线程安全方面也省心很多因为没有任何人可以中途改动同一个对象。代价就是你得习惯“每次修改都刷新一下变量引用”的写法我自己早期写代码就经常犯懒写完一行request.withHeader(...)直接回车然后发现逻辑根本没生效。4. 第一个可运行插件请求拦截与修改4.1 注册处理器并完成请求头修改在环境准备好之后把上面两个类组合起来就是一个能用的插件了。启动 BurpSuite、加载 JAR浏览器配置代理到127.0.0.1:8080然后随便访问一个 HTTP 网站。此时你在 Burp 的 HTTP history 里打开任意一条请求记录展开请求头列表会看到多出来一个X-Interceptor: true。这个改动虽然微小但已经证明了整条链路是通的请求从浏览器出来经过 Burp我们的 handler 被调用修改后的请求再继续往后走。从零到一的第一步是先看到“我的代码真的被 Burp 执行了”这比任何复杂功能都重要。我见过不少朋友一上来就写几百行的加密解密逻辑结果插件加载完都没意识到自己的 handler 根本没被调用白白排查了很久。一个更实用的变体是只给某些域名的请求加 Header。做法很简单在handleHttpRequest里读取请求的 URL 做判断Override public HttpRequest handleHttpRequest(HttpRequest request) { if (request.url().getHost().toLowerCase().contains(api.example.com)) { return request.withHeader(X-API-Version, 2); } return null; }request.url()返回的是 URL 对象里面有 host、path、port 等信息这是请求判断的常用入口。只有当目标主机名匹配时我们才修改否则返回null让请求保持原样。这个模式会贯穿后续几乎所有实战插件。4.2 修改请求方法、路径与请求体请求头修改只是开胃菜。很多时候你需要连方法、路径、请求体一起调整。举个实际场景在接口联调环境里你希望把所有 GET 请求转换成 POST并给请求体加入默认参数同时把路径中的/v1前缀改写掉。这些操作在HttpRequest上都有对应的withXxx方法可以链式调用。package example.interceptor; import burp.api.montoya.http.handler.HttpHandler; import burp.api.montoya.http.message.requests.HttpRequest; import burp.api.montoya.http.message.responses.HttpResponse; import burp.api.montoya.http.message.ByteArray; public class RequestModifier implements HttpHandler { Override public HttpRequest handleHttpRequest(HttpRequest request) { String path request.url().getPath(); // 只处理 /v1/ 路径 if (!path.startsWith(/v1/)) { return null; } // 1. 方法改成 POST HttpRequest modified request.withMethod(POST); // 2. 路径从 /v1/api/list 改成 /v2/api/list String newPath path.replaceFirst(/v1/, /v2/); modified modified.withPath(newPath); // 3. 写入新的请求体 ByteArray body ByteArray.stringToBytes(sourceinterceptormodedebug); modified modified.withBody(body); // 4. 修正 Content-Length modified modified.withHeader(Content-Length, String.valueOf(body.length())); return modified; } Override public HttpResponse handleHttpResponse(HttpResponse response) { return null; } }关于Content-Length你千万不要忽略。改动请求体之后长度就变了如果还用旧的长度值服务器在读取请求体时会一直等待最终超时。Burp 的处理器链路不会自动帮你重新计算 HTTP 报文长度这个问题必须自己在代码里处理。同理修改响应体之后也要手动维护响应里的Content-Length。这个坑我踩过一次症状是浏览器转圈很久最后才发现请求体长度和实际内容对不上。4.3 修改响应内容请求能改响应当然也能改。handleHttpResponse的用法和请求侧完全对称。一个常见的需求是把响应体里的某些字符串替换掉比如把测试环境返回的版本号统一替换成固定文案或者把敏感接口返回里的某段字段做脱敏处理。示例package example.interceptor; import burp.api.montoya.http.handler.HttpHandler; import burp.api.montoya.http.message.requests.HttpRequest; import burp.api.montoya.http.message.responses.HttpResponse; import burp.api.montoya.http.message.ByteArray; public class ResponseModifier implements HttpHandler { Override public HttpRequest handleHttpRequest(HttpRequest request) { return null; } Override public HttpResponse handleHttpResponse(HttpResponse response) { String oldBody response.body().toString(); if (oldBody.contains(default-version)) { String newBody oldBody.replace(default-version, custom-version); HttpResponse modified response.withBody(ByteArray.stringToBytes(newBody)); modified modified.withHeader(Content-Length, String.valueOf(newBody.length())); return modified; } return null; } }这里有一个很关键的隐患如果你的目标站点启用了 gzip 或 br 压缩响应体的实际字节是压缩后的数据你拿到的body()是压缩字节流直接做字符串替换就会产生乱码甚至替换操作根本匹配不到。稳妥的做法是先判断响应头里的Content-Encoding如果发现是gzip就先解压、替换、再重新压缩然后更新Content-Length。别嫌麻烦生产环境里所有 CDN 和大型站点几乎都在启用压缩不做这步的插件基本只对本地小服务有效。4.4 处理多个 Handler 的顺序BurpSuite 的 HTTP 链路允许注册多个HttpHandler。你可以把一个复杂的拦截逻辑拆成几个模块比如一个模块管鉴权头一个模块管请求体标准化一个模块管响应脱敏。每个模块各自实现HttpHandler然后在initialize里全部注册。多个 handler 的执行顺序遵循注册顺序前面的 handler 返回的新请求会作为下一个 handler 的输入。这就意味着你要留心修改的层次如果你在第一个 handler 里面加了X-API-Version第二个 handler 如果想判断这个头是否存在它是能看到的。反过来如果两个 handler 同时给同一个请求添加同名的头后注册的那个会覆盖之前的值。设计模块时最好明确每个 handler 的职责边界比如“A 只负责加认证头B 只负责改请求体”这样排错会轻松很多。另外registerHttpHandler还有一个带布尔参数的重载用来控制 handler 在链路中是否作为最终处理器。这个参数的具体语义在不同版本里有细微差别你在编译时最好看一眼前后的 Javadoc或者直接在源码里确认。我通常用最普通的单参数版本已经能覆盖绝大多数场景。5. 实操中踩过的坑与排查方法5.1 插件加载失败类问题先说最常见的插件压根加载不上的情况。症状是 Burp 加载 JAR 后Extensions 面板里出现红色错误信息。这类问题里九成是环境不匹配不是代码逻辑问题。错误特征可能原因解决方式UnsupportedClassVersionError插件用高版本 JDK 编译Burp 运行时无法识别确保 JDK 17 环境或把编译目标降到与 Burp 内置 JRE 一致AbstractMethodError/NoClassDefFoundErrorMontoya API 依赖版本与 Burp 内置版本不一致去中央仓库查最新版依赖或换用与当前 Burp 匹配的版本加载后没有任何日志输出插件类没有被正确识别为BurpExtension检查实现接口是否写成了旧版IBurpExtender或 JAR 内是否有重复 class我建议做一次纯粹的最小验证拿一个只实现BurpExtension、只写一行日志的 JAR 加载到你的 Burp 里看能不能跑通。如果最小插件能加载你就能确认环境没问题剩下的就是自己代码的问题如果最小插件都失败那就是版本链问题先修环境再继续。5.2 修改不生效类问题插件成功加载了但流量经过时你的改动完全没出现这也是高频问题。以下几种情况我都遇到过你对照排查就好。首先看 handler 方法返回值。handleHttpRequest返回null等价于“不改动”如果你在方法最后写了return null;那前面做的一堆withHeader操作就全部白费。修改逻辑必须让返回值指向最后一次修改后的对象。其次看是否更新了对象引用。前面强调过不可变性有些人会写request.withHeader(X-Foo, bar); return request;withHeader返回了一个新的对象但你没接住返回的还是原来的request自然没变化。正确写法是HttpRequest modified request.withHeader(...); return modified;。第三如果你修改了请求体却没有同步更新Content-Length头结果是服务器读不到完整的请求体不会立刻报错但接口行为会变得很奇怪——超时、返回空响应、甚至直接中断连接。排查这类问题时可以把问题定位从“我的插件没生效”转成“我的插件改了但改得不完整”。用 Burp 自带的历史记录对比一下原始报文和最终报文很快就能看出差距。5.3 性能隐患与递归陷阱中间人插件是在流量必经之路上执行的任何耗时操作都会被放大。比如你在handleHttpRequest里做了一个同步的外网 API 调用那么每一个请求经过时用户都要停下来等你这个外呼返回。在并发场景下这相当于把整个代理链路拖慢了几百毫秒体验非常糟糕。如果确实需要远程数据建议预加载缓存、异步更新数据或者只在特定触发条件下做远程调用。另一个隐蔽的坑是处理逻辑“非幂等”。如果你的修改逻辑是无条件执行的比如每次请求都往 Header 里追加一个当前时间戳那么修改后的请求在 Burp 的某些处理阶段中可能又会被 handler 调用一次从而再追加一个时间戳。一旦链路存在这种循环你可能看到同一个请求被反复修改甚至陷入无限循环。最好的习惯是在修改前先做条件判断如果目标 Header 已经存在了就直接返回原对象不要重复修改。还有一点ByteArray与 Java 字符串之间的编码转换要使用正确的字符集。响应体里的二进制内容并不都是 UTF-8 文本如果你用toString()方法强行转字符串再替换遇到非文本字节就会损坏内容。稳妥的路线是先判断响应类型比如只处理Content-Type: text/html、application/json这类的文本响应对图片、字体这类二进制响应直接放行。6. 更进一步把中间人能力变成生产级插件6.1 与 UI、存储结合到这一步你的中间人插件在功能上已经能跑但还是个“硬编码插件”改什么、什么时候改全部写在代码里每次调整都要重新编译 JAR。生产级插件的下一个明显改进是给插件加一个配置界面。Montoya API 也覆盖了 UI 这块。你可以用api.userInterface()注册一个自定义 Tab里面放一个普通的 Swing 面板。面板上可以放复选框、输入框用于控制拦截开关、目标域名、要注入的 Header 内容等等。相关的配置值可以用api.persistence().preferences()做持久化存储这样插件重启之后配置还能保留下来不需要每次重新输入。把硬编码改成配置项收益是非常直观的你在公司内部给别人用这个插件时对方不需要看代码、不需要改代码只需要在面板上勾一勾、填一填就能适配自己的场景。插件也因此从“为某个项目定制的脚本”变成“可复用的小工具”。6.2 结合扫描器与自动化中间人能力还可以往更深一层走——既然你能在请求到达服务器之前修改它那你同样可以在扫描器发起主动检测时注入自定义逻辑。Montoya API 提供了扫描扩展能力你可以通过api.scanner().registerScanCheck(...)注册自定义的扫描检查项把中间人插件的修改逻辑和扫描策略结合起来。举个例子你写了一个自动给请求加时间戳签名的中间人插件那么在 Intruder 或者主动扫描里跑一个字典时以前你会遇到“字典跑了一堆但签名不对服务器全返回 401”的尴尬但现在处理器在扫描请求发出前已经把签名加好了扫描器拿到的就是合法请求。这样做既缩短了测试准备时间也让扫描结果更接近真实攻击路径。自动化工具之间的边界在这里其实是可以打通的。6.3 可扩展方向与维护建议把一个能运行的最小插件打磨成长期维护的项目我最后想分享几条个人体会。一是版本的维护要主动。BurpSuite 和 Montoya API 都在频繁迭代插件用了哪个版本的 API 编译、在哪个版本的 Burp 上验证过最好在 README 里写清楚。我自己就经历过一次 Burp 升级后插件接口行为变得不一致排查了半天才意识到是新版本对registerHttpHandler的调用时机做了调整。版本写清楚能避免半年以后你自己都忘了当时的运行环境。二是开发过程中保持修改逻辑的幂等性。无论一个请求会经过多少次处理你都应该保证“改一次”和“改一百次”的结果是一样的。这个习惯不仅是为了安全也是为了在复杂的过滤器链中保持行为可预期。三是善用日志。在 handler 的入口和出口各打一行日志记录请求路径和是否做了修改能大幅减少排错时间。生产插件里的日志级别最好做区分不要每个请求都无脑打全量报文否则日志面板会瞬间被刷爆。如果你正准备做一个能长期使用的请求处理插件我的建议是从今天这个最小版本出发先把请求头修改跑通再逐步加上条件判断、请求体处理、响应处理、配置界面。每一步都验证到位最终你会得到一个非常顺手的“流量改写工具”它做的是那些 Repeater 手动操作永远做不了的事情。最后再分享一个我的使用习惯这类中间人插件我一定会加一个总开关默认关闭。只有明确开启时才修改流量这样在平时抓包、排障时插件不会干扰正常观察需要自动化改造时再打开观察完又可以关上。这个习惯帮我挡掉了不少“验证半天发现是插件改坏了数据”的问题你也可以试试。