☰
fastjson反序列化批量检测工具JsonExp的设计与实现
2026/11/2 5:23:58 网站建设 项目流程

1. 从一次内部演练说起:为什么要自己动手做批量检测

去年年底,某公司做了一次内部安全演练,红队只用了不到两个小时,就从一台对外业务系统的日志里翻出了几条可疑的请求记录——全是精心构造的JSON报文,目标直指一个老生常谈的组件:fastjson。蓝队复盘的时候发现,问题不在于没人知道fastjson有反序列化风险,而在于没人能快速说清楚内网到底有多少个应用、多少个接口在用它、用的又是哪个版本。手工一个个接口去试?几百个服务,试到天亮也试不完。

这就是我后来动手写JsonExp的直接动机。它不是什么高深的东西,本质上就是一个针对fastjson反序列化风险的批量检测工具:给定一批目标地址,自动发送构造好的探测报文,根据响应特征判断目标是否存在可利用的反序列化入口,并把结果整理成一份能直接拿去汇报的清单。它解决的核心问题就三个字——规模化。单点验证谁都会,难的是在几十上百个目标里快速筛出真正有问题的那些,把人力从重复劳动里解放出来。

这篇文章适合两类人看。一类是做安全测试、渗透评估的同行,手里有一堆目标需要快速摸底;另一类是做甲方安全建设的同学,想搞清楚自己负责的资产面到底暴露了多少风险。哪怕你之前没写过检测工具,只要懂一点HTTP请求和JSON格式,跟着往下看也能把整套逻辑吃透。我会把设计思路、报文构造、批量调度、结果判定、踩过的坑全部摊开讲,代码和参数都给到能直接复现的程度。

需要提前说明的是,本文讨论的所有技术细节仅用于授权范围内的安全测试与自查,任何未经授权的探测行为都是不合规的,这一点没有商量余地。

2. 整体设计:一个检测工具该长什么样

2.1 核心需求拆解:先想清楚要解决什么

动手写代码之前,我习惯先把需求拆成几条硬指标,不然写着写着就跑偏了。JsonExp的需求我列了这么几条:

  • 输入要灵活:既能接受单个URL,也能吃一个URL列表文件,最好还能支持从其他扫描器的结果里导入。
  • 探测要准:不能瞎发请求,得能区分"目标根本没解析JSON"和"解析了但没触发风险"这两种情况,否则误报能把人逼疯。
  • 速度要快:几百个目标串行跑,每个目标再发好几个探测报文,时间成本受不了,必须并发。
  • 结果要能看:输出不能是一堆原始响应糊在屏幕上,得结构化,最好能导出成表格,方便后续跟进。
  • 要能扩展:fastjson的利用链和探测手法一直在演进,工具得留出加新payload的口子,不能写死。

这几条需求里,探测准确性是最难也最关键的。很多人写这类工具,图省事就发一个经典payload,看响应里有没有特定字符串,有就报"存在漏洞"。这种做法在真实环境里误报率高得离谱,因为目标可能压根没走到反序列化那一步,或者被WAF拦了返回了个假页面。所以我在设计时把"判定逻辑"单独拎出来当成一个核心模块来做,后面会详细讲。

2.2 技术选型:为什么用Python而不是别的

工具用什么语言写,我纠结过一阵。候选方案有三个:Python、Go、Java。

Go的并发模型确实香,编译出来单文件分发也方便,但写这类工具涉及大量HTTP细节处理和字符串操作,Go的语法相对啰嗦,快速迭代的成本高。Java本身就是fastjson的宿主语言,写起来有天然亲和力,但启动慢、依赖重,做个轻量工具有点杀鸡用牛刀。

最后选了Python,理由很实在:requests库处理HTTP太顺手,concurrent.futures做并发池几行代码搞定,字符串和JSON处理是Python的看家本领。而且这类检测工具的使用者大多是安全从业者,Python环境基本人手一个,拿到就能跑,不用折腾编译。性能上Python确实不如Go,但检测工具是IO密集型任务,瓶颈在网络往返而不是CPU,多线程足够把并发拉起来。

提示:如果你的目标规模特别大(上千个),或者对单机资源极其敏感,可以考虑用asyncio+aiohttp重写网络层,吞吐能再上一个台阶。但对我遇到的场景,线程池方案已经够用,没必要过度设计。

2.3 模块划分:把大问题切成小块

整个工具我拆成了五个模块,各司其职:

模块职责关键点
输入解析读取URL列表、去重、规范化处理协议头缺失、路径补全
报文构造生成各类探测payload版本适配、编码处理
请求调度并发发送、超时控制、重试线程池大小、异常兜底
结果判定分析响应特征、给出结论多特征交叉验证、降误报
结果输出结构化存储、导出表格化、可追溯

这种切法的好处是每个模块都能单独测试、单独替换。比如后来我想加一种新的探测手法,只需要在"报文构造"里加一个函数,判定逻辑里加一条规则,其他模块完全不用动。这种低耦合在工具演进过程中省了太多事。

3. 核心细节:探测报文到底怎么构造

3.1 先搞懂原理:fastjson的风险从哪来

要构造有效的探测报文,得先明白fastjson的反序列化机制。简单打个比方:普通JSON反序列化就像照着快递单把东西放进对应的盒子,盒子类型是固定的;而fastjson支持一种特殊语法,允许你在JSON里用@type字段指定这个数据要还原成什么类。这个设计本意是为了方便,但问题在于——如果攻击者能指定任意类,而某些类在被实例化或调用setter方法时会执行危险操作(比如加载远程资源、执行命令),风险就产生了。

所以探测的核心思路就是:构造一个带@type的JSON,指向一个"如果被解析就会产生可观测副作用"的类。如果目标真的用fastjson解析了这个报文,副作用就会发生,我们通过响应或带外通道观察到它,就能确认风险存在。

这里有个关键认知:不同版本的fastjson对@type的限制不一样。早期版本几乎不设防,后来陆续加了黑名单、白名单、safeMode等机制。所以探测报文不能一招鲜,得针对不同版本准备不同的"探针"。

3.2 探测payload的分类与选择

我把payload分成三类,各有各的用途:

第一类:回显型探针。这类payload触发后,会在HTTP响应里留下明显痕迹,比如抛出一个包含特定字符串的异常。优点是判断直接,不需要额外通道;缺点是一旦目标关闭了错误回显,就失效了。

第二类:延时型探针。触发后让服务端处理变慢,通过响应时间差来判断。比如构造一个会触发大量计算的类。优点是隐蔽性好,不依赖回显;缺点是网络抖动会干扰判断,需要多次采样取中位数。

第三类:带外型探针。触发后让目标主动向一个我们可控的地址发起请求,通过是否收到请求来确认。这种方式最可靠,但需要额外搭建接收端,部署成本高一些。

实际工具里我主要用前两类,第三类作为补充。选择逻辑是:先发回显型,如果响应特征明显就直接下结论;如果响应模棱两可(比如返回了500但没看到预期字符串),再补发延时型做二次确认。这种"先快后稳"的策略在准确率和效率之间取得了不错的平衡。

3.3 报文构造的实操细节

构造报文时有几个细节特别容易翻车,我一个个说。

编码问题。fastjson在解析前会做各种转义处理,如果你的payload里含有特殊字符,可能被目标的多层解析逻辑吃掉。我的做法是对关键字符做Unicode转义,比如把@type写成\u0040type,这样能绕过一些简单的关键字过滤,同时保证fastjson仍能正确识别。

Content-Type。必须设成application/json,否则很多框架压根不会把请求体当JSON处理。但有些老系统对Content-Type不敏感,所以我在探测时会准备两个变体:一个标准JSON头,一个text/plain,覆盖不同情况。

路径选择。探测报文发给哪个接口?理想情况是发给已知会解析JSON的业务接口。但批量检测时我们往往不知道具体接口,所以我的策略是:优先探测根路径和常见API路径(如/api、/login、/user),同时允许用户自定义路径列表。这里有个经验——登录接口和表单提交接口是重灾区,因为这类接口天然要处理结构化数据。

下面是一段报文构造的核心代码示意:

import json def build_probe_payload(probe_type, target_class): """构造探测报文 probe_type: 'echo' | 'delay' target_class: 探测指向的类名 """ base = {"@type": target_class} if probe_type == 'echo': # 回显型:附加一个会触发异常标记的字段 base["marker"] = "JsonExpProbe" elif probe_type == 'delay': # 延时型:附加触发耗时操作的字段 base["count"] = 100000 # 对@type做Unicode转义,规避简单关键字过滤 raw = json.dumps(base, ensure_ascii=False) raw = raw.replace('"@type"', '"\\u0040type"') return raw

这段代码看着简单,但ensure_ascii=False和手动转义这两步是踩过坑才加上的。早期版本直接用json.dumps默认参数,中文和特殊字符全被转成\uXXXX,结果payload结构被破坏,探测全失败。

3.4 版本适配:不同版本用不同探针

fastjson的版本差异是绕不开的坎。我整理了一张对照表,根据目标可能使用的版本区间选择探针:

版本区间主要限制机制推荐探针策略
1.2.24及以下基本无限制经典回显探针即可
1.2.25 - 1.2.47黑名单逐步加强需用绕过黑名单的类
1.2.48 - 1.2.68黑名单+autoType限制延时探针更可靠
1.2.69及以上safeMode等强限制需确认是否开启safeMode

实际检测时我们不知道目标版本,所以策略是按版本从低到高依次尝试,一旦某个探针命中就停止。这样既能覆盖老版本,又不会在已修复的目标上浪费太多请求。当然,请求数量要控制,否则批量跑起来对目标压力太大,也容易触发防护。

4. 批量调度与结果判定:工具好不好用全看这两块

4.1 并发调度:快而不乱的关键

批量检测的性能瓶颈几乎全在网络IO上。假设单个目标探测需要发5个请求,每个请求平均耗时300毫秒,100个目标串行就是150秒,加上超时重试可能翻倍。用线程池并发后,理论上能压到几秒到十几秒。

我用的是concurrent.futures.ThreadPoolExecutor,核心参数是max_workers。这个值设多少有讲究:设太小并发上不去,设太大又会把目标打挂或者被限流。我的经验值是20到50之间,具体看目标环境的承受能力。如果是内网自查,可以开到50;如果是对外评估,建议保守点用20,并且加上请求间隔。

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_scan(targets, max_workers=30, timeout=5): results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(scan_single, t, timeout): t for t in targets } for future in as_completed(future_map): target = future_map[future] try: results.append(future.result()) except Exception as e: results.append({"target": target, "status": "error", "detail": str(e)}) return results

这里有个细节:as_completed配合future_map能保证即使某个任务抛异常,也不会影响其他任务,而且能记录下是哪个目标出的错。早期我图省事直接用executor.map,结果一个目标超时抛异常,整个批次都中断了,排查半天才发现是这个原因。

注意:并发数不是越大越好。我实测过,把max_workers从30提到100,总耗时只降了不到20%,但目标端出现连接拒绝的概率明显上升。原因是目标服务器的连接池和线程资源有限,你发得再快它也处理不过来,反而触发保护机制。

4.2 超时与重试:别让一个慢目标拖垮全局

超时设置是批量工具的命门。设太短,正常但稍慢的目标会被误判为失败;设太长,一个卡住的目标能把整个批次拖住。我的做法是分级超时:连接超时设3秒,读取超时设5秒,整体请求超时设8秒。这样即使目标响应慢,也不会无限等待。

重试策略上,我只对"连接类错误"重试(比如连接被重置、超时),对"明确的HTTP错误响应"不重试——因为后者说明目标已经给了答复,重试没意义还增加负担。重试次数最多2次,且每次重试前加一个随机延时(0.5到1.5秒),避免重试风暴。

4.3 结果判定:多特征交叉验证降误报

这是整个工具最需要打磨的部分。前面说过,简单看响应里有没有某个字符串,误报率高得吓人。我总结了几条判定规则,按可靠性从高到低排列:

规则一:响应时间差。延时型探针触发后,响应时间应该显著高于基线。我的判定标准是:探测请求耗时 > 基线耗时 × 3,且绝对差值 > 2秒。为了排除网络抖动,基线取3次普通请求的中位数,探测也发2次取中位数。这条规则可靠性最高,因为时间差很难被伪造。

规则二:特定异常特征。回显型探针触发后,响应体或响应头里会出现特定类的异常信息。我维护了一个特征库,匹配到就加分。但要注意,有些目标会把异常统一包装成通用错误页,这时候特征就匹配不上了,所以这条规则单独用不够,得配合其他规则。

规则三:状态码异常。正常请求返回200,探测请求返回500,且响应体结构和正常错误页不同,这是一个弱信号,作为辅助判断。

最终的判定逻辑是加权打分:规则一命中记3分,规则二命中记2分,规则三命中记1分,总分≥3判定为"疑似存在",≥5判定为"确认存在",低于3判定为"未发现"。这套打分机制比单一规则靠谱得多,实测下来误报率能压到很低。

判定结果分数区间后续动作建议
确认存在≥5立即人工复核,准备修复方案
疑似存在3-4人工二次验证,补充探测
未发现<3记录归档,定期复测
探测失败异常检查网络、目标可达性

4.4 结果输出:让人看得懂才算完成

工具跑完,结果得让人一眼看明白。我设计的输出包含这些字段:目标地址、探测时间、判定结果、命中规则、响应耗时、备注。默认输出到控制台用表格展示,同时支持导出CSV,方便导入到其他管理系统里跟进。

这里有个小技巧:给每个结果加上"证据链"。比如判定为"确认存在"的,把触发它的具体请求报文和响应片段也存下来。这样后续人工复核时不用重新跑一遍,直接看证据就行。这个设计在汇报场景下特别有用,领导问"你凭什么说这个有问题",直接把证据甩出来。

5. 实操全流程:从零跑通一次批量检测

5.1 环境准备与依赖安装

先把环境搭起来。Python版本建议3.8以上,依赖就三个:requests、concurrent.futures(标准库自带)、tabulate(用于表格输出)。

pip install requests tabulate

目录结构我习惯这样组织:

jsonexp/ ├── main.py # 入口 ├── scanner.py # 核心扫描逻辑 ├── payloads.py # 报文构造 ├── judge.py # 结果判定 └── targets.txt # 目标列表

targets.txt每行一个目标,支持带协议头或不带,工具会自动补全:

http://192.168.1.10:8080 https://app.example.internal/api 10.0.0.5:9090

5.2 参数配置与选择依据

工具启动时接受几个关键参数,我逐个说明选择依据:

  • --workers:并发线程数,默认30。内网自查可调到50,对外评估建议20。
  • --timeout:单请求超时,默认8秒。目标响应普遍慢的话调到12秒。
  • --retry:重试次数,默认2。网络不稳定时调到3。
  • --paths:探测路径列表,默认/、/api、/login。可根据目标业务特点自定义。
  • --output:结果输出文件,默认result.csv。

这些默认值都是我在多次实战中调出来的,不是拍脑袋定的。比如超时8秒,是因为我统计过大量目标的响应时间分布,95%的请求在3秒内返回,设8秒能覆盖绝大多数情况又不至于等太久。

5.3 一次完整的检测过程记录

拿一个模拟的内网环境做演示,目标列表里有20个地址,其中3个是已知存在风险的测试靶机,其余是正常服务。

启动命令:

python main.py --targets targets.txt --workers 30 --timeout 8 --output result.csv

运行过程中控制台会实时打印进度,每个目标完成后输出一行状态。20个目标跑完用了大约40秒,平均每个目标2秒。这个速度在可接受范围内。

跑完看结果,3个靶机全部被判定为"确认存在",其余17个正常服务判定为"未发现",没有误报。这个结果符合预期,说明判定逻辑是可靠的。

导出CSV后,用表格软件打开,字段清晰,证据链完整。把这份结果交给负责修复的同事,他们能直接定位到具体接口,不用再来回问"到底是哪个地址有问题"。

5.4 探测路径的自定义技巧

默认路径覆盖不了所有场景。比如某个业务系统的JSON接口全在/gateway/下面,那默认路径就探不到。这时候用--paths指定:

python main.py --targets targets.txt --paths /gateway/order,/gateway/user,/gateway/pay

怎么知道该探哪些路径?我的经验是先做一轮信息收集:看看目标首页的JS文件里有没有API路径线索,或者用浏览器开发者工具抓一下正常业务请求的路径。这一步花几分钟,能让后续探测的命中率大幅提升。

6. 踩坑实录:那些文档里不会写的问题

6.1 常见问题速查表

现象可能原因排查与解决
全部目标判定失败网络不通或代理配置错误先用curl手动测一个目标
大量目标超时并发过高或目标限流降低workers,加请求间隔
误报率高判定规则太宽松提高打分阈值,增加交叉验证
探测报文被拦截目标有WAF或关键字过滤用Unicode转义变体,换探测路径
结果里出现重复目标输入列表未去重读取时用set去重
延时探针不稳定网络抖动大增加采样次数,取中位数

6.2 几个印象深刻的坑

坑一:目标返回200但内容是错误页。早期版本我只判断状态码,结果一堆目标返回200,我以为是正常的,后来一看响应体全是"系统维护中"。教训是:状态码只能作为辅助信号,必须结合响应体特征判断。现在我会检查响应体里有没有预期的业务关键字,没有的话就标记为"响应异常",不参与打分。

坑二:Unicode转义把payload搞坏了。有次我为了绕过过滤,把整个报文都做了Unicode转义,结果fastjson解析失败,探测全灭。后来才明白,只能对@type这个关键字做转义,其他结构必须保持原样,否则JSON结构就乱了。这个细节文档里不会写,只能自己试出来。

坑三:并发太高把测试环境打挂了。有次在内网测试,workers开到80,结果把一台测试服务器打到无响应,重启才恢复。从那以后我给自己定了规矩:任何批量操作,先小规模试跑(5个目标),确认没问题再全量。这个习惯救了我好几次。

坑四:结果文件被覆盖。默认输出文件名固定,连续跑两次,第一次的结果就被覆盖了。后来改成输出文件名带时间戳,比如result_20240115_143022.csv,再也没丢过数据。

6.3 独家避坑心得

分享几条我总结的、常规文档里看不到的经验:

  • 探测前先做存活检测。对每个目标先发一个普通GET请求,确认能通再发探测报文。这样能把"目标不可达"和"目标无风险"区分开,避免把网络问题误判成安全结论。
  • 给探测请求加随机User-Agent。有些防护设备会拦截默认的Python-requests UA,换个常见浏览器的UA能提高通过率。
  • 记录基线响应。每个目标在探测前先记录一次正常请求的响应时间、状态码、响应体长度,作为后续判定的参照。没有基线,延时判定就是空中楼阁。
  • 结果一定要人工复核。工具再准也只是辅助,最终结论必须人工确认。我见过太多人直接拿工具结果去汇报,结果被问细节时答不上来,很尴尬。

7. 工具的边界与后续演进方向

7.1 这个工具能做什么、不能做什么

得把话说清楚,免得有人对它期望过高。JsonExp能做的:快速批量筛查fastjson反序列化风险,给出疑似目标清单和证据,大幅压缩人工排查时间。它不能做的:精确判定具体利用链、评估实际危害程度、替代专业渗透测试。工具给的是"线索",不是"结论"。

另外要强调,检测到风险不等于一定能利用。有些目标虽然存在反序列化入口,但受限于类路径、依赖版本等因素,实际可利用性很低。工具判定"确认存在"只是说明"这里有个口子",至于这个口子能撬开多大的门,得靠人工深入分析。

7.2 可以继续扩展的方向

这个工具目前是个够用的版本,但还有不少可以打磨的地方。比如把带外检测通道集成进来,进一步提高判定准确性;比如加一个指纹识别模块,先判断目标是不是Java应用、有没有用fastjson,再决定要不要发探测报文,减少无效请求;再比如把结果对接进常见的漏洞管理系统,实现从检测到跟踪的闭环。

我个人在实际操作中的体会是:这类工具的价值不在于技术多复杂,而在于能不能真正融进工作流。一个能一键跑、结果能直接用的简单工具,远比一个功能强大但配置繁琐的工具受欢迎。所以后续演进我会优先考虑易用性,而不是堆功能。

最后分享一个小技巧:如果你也要做类似的批量检测工具,建议先把单个目标的检测逻辑跑通、跑准,再考虑批量。很多人一上来就搞并发框架,结果单点逻辑一堆bug,批量跑起来全是噪音,排查成本翻倍。单点稳了,批量只是加个线程池的事,顺序不能反。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询