【免费下载链接】Cybersecurity-Projects
Building 70 Projects ranging from beginner to advanced so anyone can — learn from, build upon, use as a reference, or even copy directly. Gamified Cybersecurity learning 👇
本文是
deserialization-gadget-lab仓库中 04-CHALLENGES.md 的完整展开。它把仓库中一座已经完工的反序列化安全实验台(268 个测试、六道闸门、79 条断言全绿)变成了 14 道难度递增的动手练习:从给 Marshal 解析器新增一个 Sink 标签,到写一条真实 CVE 的 gadget 链,再到设法把载荷送过strict_allowlist检测器。读完本文并亲手完成这些挑战,你将同时具备攻击与防御两侧的实操能力:读懂 Marshal 二进制格式、写出不自我引爆的 payload、用 TracePoint 实现真正的加载前否决,以及用可复现的字节串证明一个检测规则是否真的有效。
开始之前:用命令行建立验收基准
原文档给出了一组入口命令,它们是所有挑战的公共前提,全部通过 justfile 执行,并在固定版本的ruby:4.0-slim容器内运行(--network none、只读挂载):
just check # 七个测试套件 + 独立控制脚本 just gate # 全部六道闸门,约十分钟,运行真实容器 just corpus # 每一个对抗性载荷,以及检测器对它的裁决 just scan # gadget 扫描器,扫描当前进程已加载的类图 just lint # rubocop,覆盖 37 个文件对照 justfile 的gate定义,可以看到完整链路是check matrix exploit detector target package六个阶段;corpus直接驱动test/support/adversarial_corpus.rb里的每一个用例并把 accept/reject 与 reason 打印成一张表;scan则是Marshalsea::Scanner.new(...).scan的一次裸调用。
每个挑战的验收标准:绿套件 + 一个会变红的测试
这是整个章节最重要的一段话,原文原文如此:
下面的每个挑战都应当以两种方式结束:一套全绿的测试,和一个在你改动之前会失败的测试。后一半才是重点。
理由写在数字里:这个项目曾在 110 个通过的测试之下带着两个检测器绕过 shipped(发布)。因此“套件全绿”本身什么也证明不了。任何一条新规则的验收方式都是同一句纪律——如果你加了一条声称“代码做了 X”的规则,那么先动手把 X 弄坏,看着一个测试变红,再去相信它。这一纪律与 03-IMPLEMENTATION.md 中“每条规则都要配一个能杀掉它的 mutant”的实践是一脉相承的。
热身(Warm-ups):四个小改动,建立肌肉记忆
四个热身题都刻意设计成“你几乎什么都不用碰”的规模,目的是让你亲手发现这座实验室的结构究竟把逻辑放到了哪里。
1. 给解析器新增一个它不知道的 Sink 标签
目前解析器认识的三个 Sink 标签是u、U、d,分别映射到_load、marshal_load、_load_data。这张表位于 lib/marshalsea/marshal/constants.rb 的Constants::SINK_METHODS:
SINK_METHODS = { TAG_USERDEF => "_load", # "u" -> Klass._load TAG_USERMARSHAL => "marshal_load", # "U" -> obj.marshal_load TAG_DATA => "_load_data" # "d" -> Klass._load_data }.freeze练习:从 Marshal 格式里再挑一个标签,决定它应该派发什么方法,并把它加进这张表。要证明它生效,你需要两条互逆的语料证据——一条必须被拒绝(该标签出现在流中),另一条必须在类名仅仅出现在 value 位置时不触发。这道题的点睛之处在于发现改动面有多小:SINK_METHODS是一张冻结常量,Node#sink?与Node#sink_method只是查表(见 node.rb),而检测器的违规阶梯从不直接指名任何标签(见 boundary_detector.rb),所以加一个标签只意味着加一个表项。
2. 把 reason 字符串预算改为可配置
检测器的拒绝理由(reason string)里会引用来自流的类名——类名是攻击者控制的输出,因此每条 reason 都是日志注入面。当前两个硬编码常量位于 boundary_detector.rb:
REASON_MAX_NAME_BYTES = 96 # 名字截断阈值,带显式 [truncated, +N bytes] 标记 REASON_MAX_NAMES = 8 # 名字列表上限,超出显示 ", and N more"任务是把它们挪到Limits上(lib/marshalsea/marshal/limits.rb 是承载全部 14 个资源上限的现有类,可作为挂靠点),让行预算较小的日志系统可以收缩它们。决定成败的对抗性测试是:一个含换行符的 10,000 字节类名,无论你把上限设在哪里,都必须产出一行、被截断、经inspect转义过的 reason——换行符不能借机伪造日志行。记住措辞:这是日志注入面,不是排版偏好。
3. 教扫描器一个额外的链接方法
扫描器的入口点表 scanner.rb 中,to_s和coerce被分类为GATE_LINK——即“链条中途可被 gadget 触达、但反序列化器绝不会派发的方法”,其formats: []表明它不属于任何格式的入口:
"to_s" => { gate: GATE_LINK, arity: 0, formats: [] }, "coerce" => { gate: GATE_LINK, arity: 1, formats: [] }从公开的 chain 文献中再找一个这样的链接方法,以gate: GATE_LINK, formats: []加进ENTRY_POINTS,然后确认:链接计数(links)上升,而可达计数(reachable)不动。如果 reachable 也动了,说明你分类错了——而“分错链接与入口”正是 gadget 扫描假阳性的最大来源,参见 02-ARCHITECTURE.md 对 53 links vs 140 entry points 的讨论。
4. 给你关心的扫描加一个--namespace过滤器
Scanner.new(namespace: "Gem")已经存在,in_namespace?的实现就在 scanner.rb:
def in_namespace?(name) namespace.nil? || name == namespace || name.start_with?("#{namespace}::") end而 justfile 的scan配方也已透传该参数,所以just scan Gem开箱即用。用它只扫描你自己应用的命名空间,再与全量扫描对比——通常最有意思的结果是:你自己的类几乎都不是候选,候选几乎全在依赖里。
中级(Intermediate):向 CVE 写链、追踪可达性
这五个题开始触碰“攻击侧”与“防御侧”的深层逻辑。
1. 针对另一个 CVE 编写第三条链
链条注册是目录即身份的:往lib/marshalsea/chains/丢一个文件,让它继承Base,inherited钩子会自动注册(见 chains.rb 的Dir[...]加载与Chains.register,以及 chains/base.rb)。你需要提供:
metadata:name、vector、cve、gem、受影响版本约束(affected constraints)、kind;generate:返回一个活的 object 或 document;serialize:仅当默认的Marshal.dump对你的入口不正确时才需要。
原文档点名的合理目标是RDoc 的 CVE-2024-27281:机制有据可查、受影响区间精确。真正的陷阱是——四个“已修复”版本里包含错误的修复,所以你的affected约束必须写成6.3.4.1而不是6.3.4。这与仓库里erb-def-module链的做法完全一致:它断言6.0.1affected、6.0.1.1不 affected(见 chains/erb_def_module.rb 与 02-ARCHITECTURE.md 的版本边界表)。验收方式就是双向版本边界测试。
2. 让扫描器把链接追踪成真实链
当前扫描器把入口点(entry points)和链接(links)作为两个并列的列表报告,从不把它们连起来。这是诚实的做法,但离真正的问题差一步:给定这个入口点,它能触达什么?任务:构建第二遍分析——对每个可达入口点,用 Prism 走读方法体,查找对 ivar 持有接收者的方法调用,输出候选的两步链。
诚实的交付物不是“链查找器”,而是一份带排序的列表加上对假阳性率的书面说明——因为静态走读无法知道这些 ivar 在加载时到底装了什么。用仓库里那条已知为真的链来实测这个比率。参考实现基础在 scanner.rb 的state_reference?/parse_definitions:Prism 解析、按source_location行号找DefNode、递归问子节点。
3. 关掉 LoadGuard 的#hash盲区,而不付出 strict 模式的代价
LoadGuard(lib/marshalsea/marshal/load_guard.rb)默认不监视#hash与#eql?,因为它们是 Ruby 里最热的方法;strict: true会监视它们并为此付费。要找到第三条路:
- 监视
#hash,但在做任何其他工作之前,先按接收者所属类是否在允许集合之外过滤TracePoint; - 或改用
TracePoint#enable(target:)来限定追踪范围,而不是在 handler 内部过滤。
然后必须测量,在真正关心的 payload 尺寸上测,并带着 payload 尺寸发布数字。如果你的版本并不比strict: true便宜多少,那也是一个真实结果,应当如实写下来——仓库自身的纪律就是“不带 payload 尺寸的倍率数字不算数”(参考 LoadGuard 在 45 字节 session cookie 上 40.4x、在 488 KB 文档上 1.0x 的公开数字)。
4. 构建认证会话信封
当前靶标直接反序列化一个完全没有签名的 base64 cookie——对教学靶标而言真实,但绝非真实应用该有的样子。任务:在Marshal.load前面加一个 HMAC 或 AEAD 信封,让字节在任何反序列化器之前先经过验证;然后写那个让教训落地的测试:
有效签名并不能让 payload 安全。用正确的密钥给一个真实的 gadget payload 签名,确认它仍然执行。
这就等于在你自己的代码里复现了 CVE-2019-5420 和 CVE-2018-15133:签名解决的是篡改(tampering),不是不可信来源(untrusted origin)——一个持有密钥的攻击者生产并由你签名的载荷,安全性分文不增。原文档特别强调:当前实验室刻意不做这一层,不是疏忽;去掉密码学层之后,“两条 allowlist 为何一成一败”的教训更清晰。
5. 把 Psych inspector 扩展到 Oj
Ruby 的ojgem 在:object模式下支持从 JSON 实例化对象,而这是它的默认模式。“一个开箱即用的 JSON 解析器居然具备对象注入能力”是整个领域最反直觉的事实,而本实验室目前对它零表达。任务:写第三个 reader,读取 Oj 模式 JSON 而不加载它,报告它会实例化哪些类,并用与另外两个 reader 相同的词汇表产出一个Decision。
设计约束才是重点:现有两个 reader(Marshal 与 Psych)刻意共享同一个Decision类,如果第三个 reader 需要不同的形状,那恰恰在告诉你这个抽象有问题。现有 inspector 的Reference/revival/key_position词汇表在 lib/marshalsea/psych/inspector.rb 中,是扩展的直接参照。
高级(Advanced):挑战设计边界
五个题都踩在该项目刻意留下的、带明确取舍的缺口上。原文档点名:其中两项是有记录的技术性延期(documented deferrals),带着真实取舍如实命名,而不是伪装成练习题。
1. 构建设计契约要求的 eager-load 阶段
扫描器只能看到已经被 require 的类:裸进程里是 691 个模块,boot 起来的 Rails 应用里是几千个。显然的修法是加一个阶段,在扫描前 require 每个已装 gem 里的每个文件——但它按设计就是任意代码执行:require 一个 gem 会运行它的顶层代码,所以对不可信 gem 运行 eager-load 就是把供应链武器对准操作员自己。
要交付的不只是循环,而是让它可以辩护的隔离:独立容器、无网络、只读挂载、超时、以及一个帮助文本明确说明其后果的显式 opt-in 标志。真正重要的交付物是书面的威胁模型。原文档直言这是整个项目最大的未决项,也正是因此被推迟。
2. 建模第二个 Ruby 解释器版本
解析器建模 Ruby 3.4 及更新版本,gem 的版本底线是>= 3.4,因为Marshal.load直到 3.4 才校验 bignum 符号字节。相关常量在 constants.rb:
BIGNUM_SIGN_POSITIVE = "+" BIGNUM_SIGN_NEGATIVE = "-" BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze这导致解析器与解释器在 3.3 上不一致,而just package(scripts/package-gate.sh)每次运行都在两个方向上证明这个边界。任务:让解析器感知版本——接受目标版本、在 3.4 以下放宽 bignum 符号检查、下调版本底线,并写差分测试在两个容器里跑同一个流,断言解析器与 3.3 和 3.4 的Marshal.load各自一致。你很快就会撞上真正的一课:“这条流是为哪个 Ruby 写的”是流本身回答不了的问题。
3. 攻击解析器的取证容错
解析器刻意在 CRuby 停止的地方继续解析,检测器则对记录下的异常一律拒绝——这个“解析器贴标签、检测器做裁决”的分裂(见 02-ARCHITECTURE.md)就是设计本身,但它同时是攻击面:解析器对格式的任何一处模型与 CRuby 行为不一致,都是一个潜在的差分。任务:去找一个。写一个模糊器,生成流,喂给解析器和沙箱化的真实Marshal.load,标记出“解析器报告的类/汇集合被解释器实际行为反驳”的所有案例。原文档的定位很直接:这是仓库里价值最高的安全工作,哪怕只确认一个差分,就是真实发现。
4. 编写一个击败 denylist 的检测器
01-CONCEPTS.md 用 26+ 个 picklescan 自身 CVE 和 CWE-184(Incomplete List of Disallowed Inputs)论证过:对序列化流做 denylist 扫描,输在架构上而不是执行上。认真对待它,设计替代方案——结构性策略(structural policy):不基于名字列表,而基于图的结构作裁决。一个只含原始类型、数组、键为原始类型的 hash 和字符串的流,无论存在哪些类,按构造就是安全的。把它实现为第四个策略,测量它拒绝多少真实世界 payload——那个数字会很高,而这正是诚实的代价——然后写下“按构造安全”与“有用”之间的边界到底在哪里。
5. 把靶标变成真实的漏洞应用语料库
靶标目前有四个端点、横跨两个反序列化器。任务:加入 CVE-2022-32224 的二阶 sink——一条路由把攻击者影响的数据写入存储,另一条不同的路由把它读出来并反序列化,这样 payload 永远不会出现在触发执行的那次请求里。这正是能击穿“以 HTTP 边界画信任边界”那种威胁模型的形状,而且比直连情形难推理得多。证明它的闸门阶段必须跨越两次请求——这本身就是一项值得拥有的构建物。
终极挑战(Capstone):让载荷穿过检测器
如果想用一个项目把整座实验室串起来,就做这个。检测器的LIMITATION_NOTICE已经白纸黑字写明:accept 决定只意味着“这些字节匹配了这条策略”。你的任务是把这句话变成具体的事实:找到一条被 strict-allowlist 检测器接受、但仍会做操作员绝不认可之事的流。
三个攻击角度都合法:
- 找一个值得放进 allowlist、却仍然危险的类。通知直接承认:“类 allowlist 比较的是序列化后的名字,它并不证明对应 Ruby 代码无害。”一个把
ERB加进 allowlist 的应用,就会接受那条已公开的 CVE 链——因为那个 payload携带零个 sink 标签(验证:Marshalsea::Chains::ErbDefMethod.canary(...)产出的 112 字节流sinks = 0,见 03-IMPLEMENTATION.md)。再找一个具备同一性质的类。 - 找一处解析器没有建模的派发。阶梯覆盖了 sink 标签、key 位置的
#hash/#eql?、Range端点的#<=>——五条规则里有三条是因为先有绕过发布才补上的(02-ARCHITECTURE.md 记录了这个过程)。没有任何理由认为列表是完整的:去读marshal.c,找第六个。 - 找一处解析器与解释器的差分。如果解析器和 CRuby 对一条流的内容意见不一,那么检测器裁决的是一张不会被加载的图。
游戏的规则,也就是项目拿来自我约束的规则:
- 你的绕过必须能从字节串复现,而不是手工搭好的对象图——写不成字节就不是 payload;
- 它必须在
strict_allowlist下被接受,只在deny_sinks_only下得手不算数——击败弱策略证明不了任何事; - 修复和 mutant 一起交付:先加捕获它的规则,再把规则抽掉,看着语料条目变红。杀不死的规则就是没测过的规则;
- 同时加一条阴性对照:形状相同但必须仍被接受的 payload——否则你无法把“新规则有用”和“检测器见啥拒啥”区分开。
原文档对这件事的判词值得抄下来:当你完成过一次之后,你对这类 bug 的理解会超过任何文章的教学——因为你站在了同一个文件的两侧。
下一步:Where to go next
带着 03-IMPLEMENTATION.md 的代码重读 01-CONCEPTS.md:一旦亲眼看过in_hash_key_position如何把 payload 逐字节拼接起来,“两条 allowlist 为什么一成一败”的论证读起来会完全不同;一旦知道确认一个单一论断需要多少工作,Equifax 辟谣读起来也完全不同。
然后运行just corpus,从那张表里随便挑一行,去找到把它写进表里的那个测试——它就在 test/corpus_test.rb 与 test/support/adversarial_corpus.rb 之间,而那条测试链的末端,是你刚在热身题里亲手摸过的同一张SINK_METHODS表、同一个ENTRY_POINTS常量、同一条LIMITATION_NOTICE。整座实验室的设计意图,就是用一轮轮“先写绕过、再补规则、再证明规则”的循环,把你训练成能从两个方向看同一份文件的人。
【免费下载链接】Cybersecurity-Projects
Building 70 Projects ranging from beginner to advanced so anyone can — learn from, build upon, use as a reference, or even copy directly. Gamified Cybersecurity learning 👇
相关推荐
Gadget Inspector:自动化检测Java反序列化漏洞的利器
Gadget Inspector:自动化检测Java反序列化漏洞的利器 项目介绍 Gadget Inspector 是一个用于检测Java库和类路径中gadge
探索Java反序列化漏洞的利器:实验室版实战指南
探索Java反序列化漏洞的利器:实验室版实战指南 在复杂而安全挑战频出的技术舞台上,Java作为应用广泛的语言,其反序列化过程中的安全隐患不容忽视。为此,我们聚
marshalsea 反序列化安全实验室实战:不解冻对象读懂 Marshal 与 YAML,并构建真实 CVE-2026-41316 利用链
marshalsea 反序列化安全实验室实战:不解冻对象读懂 Marshal 与 YAML,并构建真实 CVE 2026 41316 利用链 本文是 deser
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考