平时做授权测试,最磨人的不是漏洞利用,而是越权这类逻辑漏洞的批量化验证。一个接口在低权限账号里能正常访问,另一个接口换了高权限账号看起来也正常,可只要把两个身份互换一下,问题就冒出来了。手动在Burp Suite里反复改Cookie、克隆请求、切换标签页,一天下来真能让人怀疑人生。这也是我写这个Burp Suite越权检测辅助插件的起因:与其每次都靠眼神对比响应,不如把Burp自身的扩展接口利用起来,让插件自动完成身份切换、请求重放和响应比对,把分钟级的手工操作压缩到几秒钟。
如果你也是做安全测试、接口测试或研发排查的人,应该经常被“用户A能看到用户B的数据却没人觉得有问题”这种场景困住。这个插件不是一个全自动的越权扫描器,更准确说它是一个辅助工具:帮你把需要多个账号、多轮请求的对比工作流程固化下来,让Burp从“抓包改包工具”升级为“能做权限矩阵验证的半自动平台”。下面我会把从设计思路到具体实现、再到实操中踩过的坑都摊开讲,想自己写一个的话,可以直接照着抄。
1. 越权检测的场景与插件定位
1.1 越权问题为什么会成为漏网之鱼
越权漏洞在现在的Web应用中依然高发,主要原因不是开发者不知道要校验权限,而是校验逻辑往往放在了“看起来没问题”的地方。比如一个订单详情接口,前端根据当前登录用户动态拼参数,后端也做了登录校验,但忘记判断“这个订单是否属于当前用户”。这种缺陷用常规的SQL注入、XSS扫描器根本探不出来,因为请求本身是合法的,钓鱼点在于“用了别人的身份发这个请求”。
水平越权和垂直越权是两大类。水平越权最常见,典型场景是普通用户之间互相访问订单、地址、聊天记录;垂直越权则表现为低权限用户能调管理员接口,或者普通用户能修改自己的角色。无论是哪一种,手工验证都需要至少两个账号:一个“受害身份”,一个“攻击身份”。在Burp里最原始的做法是登录两个账号,分别抓取Cookie,然后把两个请求复制到两个Tab里,手动替换参数,再肉眼看响应。一次两次还行,接口一多,这个流程的失误率极高,很容易把“已修复”和“未修复”搞混。
另一个容易被忽略的原因是响应差异的判断。很多开发同学在修复越权时只是把接口返回改成“无权限”,但没有使用标准403状态码,而是包了一层“业务失败”的JSON。这时候只看状态码或者只看响应长度,都可能误判。真正有效的检测必须把“身份切换前后响应中与对象唯一性相关的数据”提出来做语义比较,这恰恰是普通扫描器不会做的事。
1.2 辅助插件到底解决什么问题
我的定位非常明确:插件不是要替代渗透测试人员,而是把“多身份多请求的重复劳动”自动化。它要解决三个具体问题:第一,会话管理。把多个账号的Cookie、Token、自定义Header集中管理,避免测试时手忙脚乱地复制参数。第二,请求改写。基于一个原始请求,自动克隆并替换身份相关字段,生成“A身份访问”“B身份访问”两个版本。第三,响应比对。把两个响应做标准化处理,提取关键差异,输出是否疑似越权。
插件不做的部分也很重要。它不做用户名密码爆破,不主动去枚举对象ID,也不负责判断业务上“该不该访问”。插件只负责回答一个问题:把请求里的身份上下文换成另一个角色后,服务端给出的响应和基准响应之间是否存在可感知的差异。最终的确认和利用,仍然需要测试人员介入。这样设计的好处是误报可控,不会因为自动化而丢失对业务语义的判断。
2. 插件设计的三个核心决策
2.1 会话矩阵与身份标识方案
第一个要设计的是会话怎么组织。越权检测最少需要两个身份,实际加上管理员、多租户、多用户,可能有五六个身份。如果每个身份都硬编码成变量,代码会变得不可维护。我采用了一个简单的“会话矩阵”:插件启动时加载一个配置文件,里面描述每个身份名称以及对应的认证信息。
配置格式大致是这样:
[role.LOW] cookie: JSESSIONID=AAAA123 header.Authorization: Bearer token_low header.X-User-Id: 10086 [role.HIGH] cookie: JSESSIONID=BBBB456 header.Authorization: Bearer token_high header.X-User-Id: 1这个设计有几个好处。第一,不局限于Cookie。很多系统现在用JWT或者自定义Header传递身份,尤其在前后端分离的项目里,Cookie反而不是主路径。第二,可以灵活扩展。要测多账号时,直接复制一个[role.LOW2]即可。第三,配置和代码分离,非开发人员也能维护。
身份标识不能只认“登录态”。我见过很多系统在Cookie之外还额外校验一个X-User-Id,或者请求体里的userId字段。这种“双重身份标识”如果只替换Cookie,插件测出来全是越权,因为服务端可能根据参数里的X-User-Id决定返回谁的数据。所以我在会话配置里留了一个“身份关联参数”列表,插件在改写请求时,不仅会替换认证头,还会替换URL参数、POST参数中对应名称的值。
2.2 请求改写与重放逻辑
请求改写是插件的核心动作。Burp的IExtensionHelpers提供了完整的请求分析能力,可以先解析出URL、Header、参数列表,再按需修改。具体流程我分了五步:
- 从Burp的Scanner调用或右键菜单拿到一个被选中的
IHttpRequestResponse对象。 - 使用
helpers.analyzeRequest()解析请求头结构。 - 删除原请求中所有认证相关Header,包括Cookie、Authorization以及配置中列出的标识Header。
- 从对应身份配置里读取新的Header,重新构建请求。
- 如果配置中指定了“身份关联参数名”(比如
userId),则还需要同时替换请求参数中的该字段。
重放时必须注意请求体的Content-Type。如果是application/x-www-form-urlencoded或JSON,参数在请求体里的位置不一样,替换逻辑要区分。大多数项目我会优先用helpers.getRequestParameter()和updateRequestParameter()这套API,它能统一处理URL参数和Body参数。如果请求是一个JSON文档,字符串替换反而更稳,因为JSON里的userId字段名一般是固定的。
我写的重放逻辑会生成两个请求:一个用“基准身份”访问,一个用“目标身份”访问。但这里有一个容易被忽略的细节:到底谁作为基准,谁作为目标?针对水平越权,正确做法是“用目标用户身份去访问目标用户自己的资源,再用当前用户身份去访问同一个对象ID”。也就是说,先把请求中的资源ID换成B的,然后用B的会话发送,得到基准响应;然后用A的会话发送同一个请求,得到对比响应。如果A也能拿到B的订单数据,说明存在越权。
2.3 结果对比与误报收敛
响应比对是决定插件可用性的关键。早期版本我直接用响应长度作为差异指标,结果误报率接近60%。原因很简单:很多接口返回的都是固定结构,user_id变了但响应体长度不变,或者错误提示和正常数据刚好一样长。后来我把比对引擎改成两层:
第一层是结构特征提取。先拿响应状态码、Location头、Content-Type,然后提取响应体中的“关键对象标识”。比如订单接口的响应里如果有orderId字段,插件会把这个字段的值提取出来,而不是整段比较。提取规则使用正则或JSON路径配置。
第二层是语义归一化。把响应体里的时间戳、随机串、CSRF Token、Session ID全部替换成占位符,然后计算两个响应之间的相似度。我用的算法是比较两个字符串的Token重合率,加上一个简化版的编辑距离。如果两个响应经过归一化之后高度重合,但关键对象标识不同,说明接口“透传”了用户数据;如果状态码从200变为403,那属于“有权限控制”,不报告越权。只有当目标身份拿到了基准身份才对返回的关键业务数据时,才判定为疑似越权。
为了收敛误报,我还会做一次“自校验”。把目标身份请求重发两次,如果两次响应差异较小,说明目标身份本身稳定;再将目标身份请求中的对象ID换成一个不存在的ID,如果响应仍然返回相同业务数据,那这个接口可能压根没做对象校验,问题更大,但也要人工确认是不是全局缓存或伪静态页面。
3. 开发实现:基于Burp Extender API的关键代码
3.1 工程骨架与菜单挂载
我用Java写这个插件,因为Burp对Java的原生支持最稳,打包成Jar之后放进Extender即可。工程结构很简单,只有一个主类实现IBurpExtender、IScannerCheck和IContextMenuFactory。
最基础的骨架长这样:
package com.example.authcheck; import burp.IBurpExtender; import burp.IBurpExtenderCallbacks; import burp.IExtensionHelpers; import burp.IContextMenuFactory; import burp.IScannerCheck; public class BurpExtender implements IBurpExtender, IScannerCheck, IContextMenuFactory { private IBurpExtenderCallbacks callbacks; private IExtensionHelpers helpers; @Override public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) { this.callbacks = callbacks; this.helpers = callbacks.getHelpers(); callbacks.setExtensionName("越权检测辅助插件"); callbacks.registerScannerCheck(this); callbacks.registerContextMenuFactory(this); callbacks.printOutput("[越权检测辅助插件] 加载完成"); } }registerScannerCheck注册之后,Burp的Active Scanner和Passive Scanner都会调用插件。registerContextMenuFactory能让用户在某个请求上右键选择“发送到越权检测”,这样比单纯依赖Scanner更可控。我实际使用中,90%的情况下是通过右键菜单发起的,因为Scanner自动扫描会同时覆盖大量非目标请求,容易干扰判断。
3.2 被动扫描监听与主动扫描入口
IScannerCheck有两个关键方法:doPassiveScan和doActiveScan。被动扫描适合插件盯住特定特征自动触发,主动扫描适合对某个自定义插入点进行越权验证。
我不想插件对所有流量都做被动重放,否则会造成大量并发请求。所以在doPassiveScan里设置了过滤规则:只处理包含/api/或/admin/路径、且请求中存在数字参数或userId/orderId/xxxId字段的请求。过滤条件放在配置文件中,可随时调整。
doPassiveScan的伪代码如下:
@Override public List<IScanIssue> doPassiveScan(IHttpRequestResponse baseRequestResponse) { String url = helpers.analyzeRequest(baseRequestResponse).getUrl().toString(); List<IParameter> params = helpers.analyzeRequest(baseRequestResponse).getParameters(); if (!isTargetUrl(url, config.getUrlPatterns())) { return null; } // 只处理存在身份相关参数的请求 Set<String> identityParams = config.getIdentityParamNames(); boolean hasIdentityParam = params.stream() .anyMatch(p -> identityParams.contains(p.getName())); if (!hasIdentityParam) { return null; } // 根据配置的会话对,执行一次跨身份重放 List<IScanIssue> issues = AuthzReplayExecutor.replay(baseRequestResponse, config); return issues.isEmpty() ? null : issues; }要跑通这个流程,还需要实现replay:用低权限身份访问高权限对象,用高权限身份访问高权限对象,再把响应交给比较器。
3.3 越权特征提取与响应差异算法
响应比较器我单独抽了一个类,里面核心是两个方法:normalizeResponse(String body)和calcSimilarity(String a, String b)。
归一化的作用很直接:把响应里的动态内容替换成固定标记,否则相同接口在不同时间返回会带上时间戳、随机盐、会话标识,导致误判。我维护了一个“动态字段正则列表”,可以匹配:
"time": "2025-02-20 10:00:00""timestamp": 173..."csrfToken": "[a-f0-9]{32}"jsessionid=[A-Z0-9]+
代码片段:
public String normalizeResponse(String body) { String normalized = body; for (String pattern : config.getDynamicPatterns()) { normalized = normalized.replaceAll(pattern, "#DYNAMIC#"); } return normalized; } public double calcSimilarity(String responseBase, String responseTarget) { String base = normalizeResponse(responseBase); String target = normalizeResponse(responseTarget); // Token重合度 + 编辑距离归一化 double overlap = 0d; Set<String> baseTokens = new HashSet<>(Arrays.asList(base.split("[\\s,;\"]+"))); Set<String> targetTokens = new HashSet<>(Arrays.asList(target.split("[\\s,;\"]+"))); if (!baseTokens.isEmpty() && !targetTokens.isEmpty()) { Set<String> common = new HashSet<>(baseTokens); common.retainAll(targetTokens); overlap = common.size() / (double) Math.max(baseTokens.size(), targetTokens.size()); } int editDistance = computeEditDistance(base, target); double similarity = overlap * 0.6 + (1 - editDistance / (double) Math.max(base.length(), 5)) * 0.4; return similarity; }在实际测试中,相似度超过0.8的请求基本可以认为没有越权,因为响应内容几乎一致;相似度低于0.6但目标身份返回中包含对象ID的请求,需要重点排查。注意,响应页面很大的时候,编辑距离计算会很慢,所以我限制最多只取前2000个字符做计算,或者先按JSON键排序后再比较。
3.4 配置文件的组织与加载
配置文件我是用一个简单的键值映射实现的,放在Burp插件目录下,文件名authz_roles.properties。
# 配置文件:身份与会话 # 每个role前缀代表一个角色 role.low.cookie=JSESSIONID=low_session_value role.low.header.Authorization=Bearer low_token role.low.header.X-User-Id=10086 role.low.ident.params=userId,user_id role.high.cookie=JSESSIONID=high_session_value role.high.header.Authorization=Bearer high_token role.high.header.X-User-Id=1 role.high.ident.params=userId,user_id # 插件行为控制 target.url.pattern=/api/,/admin/ dynamic.pattern=time:(.*?)timestamp=(.*?)csrf match.param.names=orderId,userId,user_id,id加载配置的方法很简单,用java.util.Properties按role.前缀分组。每次启动插件后,如果测试环境账号换代,只需更新配置文件再点击“重新加载配置”。我把加载入口做进了右键菜单,这样不用重新编译Jar。
4. 实操中的常见问题与排查技巧
4.1 Cookie池与Session失效的连锁反应
实测中遇到最多的坑是Session并发失效。插件在检测时会同时发送多个请求,有些服务端的安全策略会把短时间内来自同一用户的大量不同来源请求判定为异常,进而让Session直接失效。我一开始只配置一个低权限账号、一个高权限账号,测到第三个接口后,后面所有请求都被踢到登录页,响应比对自然完全失真。
解决方案是给每个角色维护一个“会话池”。比如低权限角色下配置3个账号,插件在重放时从池里随机取一个,降低单个Session被风控的概率。同时增加一个“失效检测”:如果响应中出现登录跳转、或响应Body里出现“未登录”“登录已过期”等关键词,插件会暂停当前角色测试,并打印提示让你重新登录账号。
限速也很关键。我默认把每个角色的并发请求数限制为2,请求间隔至少500毫秒。虽然慢了一点,但换来的是测试过程稳定,不用一边跑一边盯抓包看session是否过期。
4.2 响应对比失真:不只是长度的事
只对比响应长度这个教训我记得很清楚。某个接口在越权成功时返回的是一串手机号打码后的字符串,未越权时返回的是“无权限操作”的固定文案,两者的字数恰好一致。用长度对比,这个明显越权被直接漏掉了。
后来我把比对逻辑改成提取“业务对象特征”。先尝试从响应中提取配置过的参数名(比如orderId、userId、mobile),再比较提取值是否与请求中使用的对象ID一致。还存在一种情况:响应虽然包含对象ID,但该值只是请求参数的回显,不代表返回了对象数据。这需要人工复核,插件只能标记为“可疑”。
我还在插件里专门处理了重定向。如果高权限身份访问时返回200数据,而低权限身份访问时返回302且Location指向登录页,那反而说明权限控制生效,应该自动跳过,不算越权。只有两个身份都拿到差不多的200数据,但数据内容存在差异时,才产生告警。
4.3 多角色与多租户的配置复杂度
很多后台系统不是简单的“用户/管理员”两级,而是多租户、多层组织架构。直接上所有角色做笛卡尔积测试,请求量会爆炸。举个例子,如果系统里有10个租户,每个租户有3种角色,那就是30个身份,两两组合会产生450种情况。我的建议是不要全量跑,而是根据业务风险挑有代表性的组合:
- 同租户不同用户的水平越权;
- 跨租户同角色用户的水平越权;
- 低角色到高角色的垂直越权;
- 高角色到低角色是否存在信息泄露(有些系统高权限会额外返回某些字段,但低权限不能看)。
我整理过一个简单的测试矩阵,供配置参考:
| 测试场景 | 基准身份 | 目标身份 | 预期结果 |
|---|---|---|---|
| 同租户水平越权 | 租户A用户1 | 租户A用户2 | 目标身份应无法访问用户1资源 |
| 跨租户水平越权 | 租户A用户1 | 租户B用户2 | 目标身份应无法访问用户1资源 |
| 垂直越权(低到高) | 普通用户 | 管理员 | 普通用户应无法访问管理接口 |
| 垂直越权(高到低) | 管理员 | 普通用户 | 管理员接口是否返回更多敏感字段 |
配置多个角色时,插件的会话矩阵会生成一个组合列表,并在UI上显示当前进度。这个进度条非常有用,否则长跑时你根本不知道卡在哪个请求。
5. 从插件到工作流的进一步扩展
5.1 把主动验证升级成批量模糊测试
当插件的基本检测稳定后,我给它加了一个“身份字典”模式。这个模式不再局限于两个角色,而是从一个用户列表中循环替换userId等参数,逐个重放。这实际上变成了一个小型越权枚举器,可以检测到更隐蔽的越权,比如“虽然不会直接返回别人数据,但会影响他人数据”的写操作越权。
写操作越权检测比读操作复杂得多,因为重放一个写请求可能产生脏数据。我给插件加了“写操作确认开关”,默认关闭。开启后,插件会先发送一个创建请求拿到新资源ID,然后用其他身份尝试修改或查看这个资源,测试完成后再调用删除接口清理数据。这个流程需要在配置里明确指定哪些接口属于创建、查看、修改、删除,否则自动化容易把系统数据搞乱。
5.2 一些经验沉淀
我在实际试用这个插件过程中,总结出几条值得记住的经验。第一,会话配置一定要在测试环境重新登录获取,不要拿一个长期未用的Cookie硬测,否则结果几乎不可信。第二,越权检测的“业务上下文”比技术实现更重要。比如某个接口返回了别人的手机号,但手机号字段做了掩码,只显示前三位后四位,这种算不算越权要结合业务和数据合规要求判断,插件只能帮你把差异摆出来。第三,插件产生的告警要保留原始请求和响应快照。我习惯把每次重放的请求、响应完整导出成JSON文件,这样即使当轮没有发现问题,后续补测或写报告时也能追溯。
如果你打算自己动手写,建议不要一上来就追求功能完整。先把“两个身份、一个请求、一次重放、一个对比结果”跑通,再逐步加配置管理、会话池、多角色矩阵、写操作保护。插件永远是一个辅助工具,真正判断越权是否成立的,仍然是人对业务逻辑的理解。
到目前为止,我个人的体会是:别指望一个插件能覆盖所有越权场景,它最大的价值是把“重复劳动”从你身上卸下来。等你不必再盯着几十对响应发呆时,你会发现更多精力可以用来思考那些更复杂的逻辑漏洞,比如基于状态机的越权、基于时间窗口的越权、基于异步回调的越权。把基础流程自动化,才有空去碰更高级的玩法。