Zero Day Initiative — 从Autodesk Revit RFA文件解析崩溃到完整RCE漏洞利用
2025年10月08日 | Simon Zuckerbraun
漏洞发现与初步分析
2025年4月,我的同事Mat Powell在Autodesk Revit 2025中寻找漏洞。在对RFA文件进行模糊测试时,他发现了以下崩溃(CVE-2025-5037 / ZDI-CAN-26922,Autodesk于2025年7月修复):
|
|
这个崩溃是否可利用?从调试器输出看,尚不清楚是否有任何可控内容。
供应链漏洞背景
大约在同一时间,我的同事Nitesh Surana发现了Axis Communications Plugin for Autodesk Revit中一个影响重大的基于云的供应链漏洞。该漏洞使得恶意行为者能够将损坏的RFA文件强制分发给全球的Axis插件用户,当使用Axis Communications插件时,Autodesk Revit会解析这些文件。
由于Trend ZDI了解这个供应链漏洞,我们非常关注损坏的RFA文件是否会导致客户端机器上的远程代码执行。我开始确定上述崩溃的可利用性。最终,我能够证明它可以可靠地用于完整的远程代码执行。因此,除了显示Autodesk Revit的漏洞外,我们还毫无疑问地证明了Axis插件供应链漏洞的严重性。
根本原因分析
在调查崩溃时,我主要依赖的工具是IDA Pro和带有时间旅行调试(TTD)的WinDBG。由于Revit应用程序的模块化特性,功能分布在数十个DLL中,我能够依靠导出符号来帮助理解。
尽管Revit是一个非常庞大的应用程序,但我很高兴发现TTD在加载示例RFA时轻松捕获了进程的完整跟踪。在使用TTD时,我建议禁用页面堆。页面堆会增加大量内存开销,而好处有限:当使用TTD时,必要时可以使用时间旅行来确定堆分配和堆释放堆栈。
从崩溃点向后跟踪受污染的数据,很快从符号中明显看出涉及反序列化器。在崩溃点看到的受污染值是由构造函数写入的:
|
|
此代码构造一个大小为0x10的对象,并将其初始化为前8字节设置为0xffffffffffffffff,第二个8字节设置为0。通过时间旅行逐步回溯显示,构造函数sub_1810FDC70是从名为Utility!ARuntimeClass::createObject的方法调用的。
进一步跟踪显示,方法Utility!ArchiveClassMaps::loadClass负责从输入文件中读取类型。在输入文件中,类型由16位整数标识。我注意到loadClass将此索引值作为a2传递给以下函数:
|
|
这被证明是反序列化器已知的所有编号类型的"罗塞塔石碑"。它返回一个指向PersistentClass结构的指针,该结构描述了由索引a2指示的可序列化类型。前0xb类型的PersistentClass结构在静态地址unk_180F80FC0处顺序找到,而其余的指针在数组a1中找到。
通过更多逆向工程发现,PersistentClass的偏移0x8持有指向相应ARuntimeClass的指针,而ARuntimeClass的偏移0x0持有指向类型名称的指针。有了这些信息,我编写了一个WinDBG单行(实际上是两行)来转储反序列化器可用的所有类型的名称:
|
|
输出相当长,总共有4,611种类型。缩写输出如下:
|
|
在可用类型列表中,导致崩溃对象的类索引是0xc4,对应类型:
std::pair< ElementId, ElementIdSetWrapperClass >
我们现在可以理解上面描述的构造函数sub_1810FDC70的代码。它是一个pair的默认构造函数,其中pair的第一个元素是数值类型ElementId,第二个元素是指向ElementIdSetWrapperClass类型实例的指针(我假设)。构造函数将pair的第一个元素初始化为ElementId为-1,并将pair的第二个元素初始化为nullptr。
此外,我们现在可以对漏洞的根本原因进行有根据的猜测。应用程序从输入文件反序列化对象。它的期望是这些对象将是高级类的实例,每个类在偏移0x0处有一个vtable指针,并且vtable中的第一个条目是析构函数。然而,反序列化器也能够反序列化没有vtable的简单类型。在我们的案例中,反序列化的对象是std::pair< ElementId, ElementIdSetWrapperClass >,偏移0x0处的值不是vtable指针,而是-1(=0xffffffffffffffff)。当应用程序尝试调用对象的析构函数时,它在解引用该值时崩溃。那么我们遇到的就是类型混淆漏洞。
探索Autodesk RFA复合文档格式
从在十六进制编辑器中直观检查RFA文件,我推测整体格式是OLE复合文件。OLE复合文件充当存在于文件中的微型文件系统。它包含目录结构(称为"存储"),并且在存储中可以存在数据文件(称为"流")。
通过使用WinDBG对受污染数据进行艰苦的跟踪,我能够确定序列化数据来自流Global\Latest——即,位于名为Global的存储中的名为Latest的流,该存储本身位于RFA复合文件的根目录。然而,还有进一步的复杂性。Global\Latest不包含将呈现给反序列化器的字面字节。相反,我发现Global\Latest的内容首先由gzip类型解压缩例程处理,我能够通过导出符号识别这一点。未压缩的数据是馈送给反序列化器的数据,并且具有我们感兴趣的字节,例如上面讨论的至关重要的类索引。
为了继续对文件进行实验,我需要以下两项:
- 编辑OLE复合文件的方法,以便我可以修改Global\Latest流的内容
- 一种解压缩Global\Latest流数据的方法,以及在修改后再次使用与Revit软件兼容的压缩进行gzip压缩的方法
介绍CompoundFileTool
为了促进我的研究,我想要一个工具,可以让我轻松检查OLE复合文件的内容,并创建具有任意内容的新OLE复合文件。我对在线搜索找到的工具不满意,决定创建自己的工具。随着本文的发布,我们向社区开放此工具。
虽然OLE复合文件格式旨在作为实时、可编辑的文件系统,但以这种方式处理它并不总是最方便的。它也可以被视为另一种归档格式。我的工具允许检查OLE复合文件的方式是通过将其展开到正常文件系统中。它可以将OLE复合文件展开到磁盘上相应的文件夹和文件结构中。它还可以执行逆操作以创建新的OLE复合文件。
解码Revit的Gzip格式
Revit使用的gzip压缩让我困惑了一段时间。流包含一个简短的头部,后跟gzip压缩数据。在gzip压缩数据之后,我发现有某种由零组成的填充部分,最后是另一个高熵数据部分。
起初,我不理解这最后部分的重要性,因此尝试忽略它。我通过用我自己的gzip数据替换gzip数据部分来突变RFA,保持填充和最后数据部分不变。Revit拒绝此修改文件为损坏。
此外,我开始注意到令人担忧的差异。我通过标准gunzip实用程序运行来自Global\Latest流的gzip数据,并将其与在调试器内部看到呈现给反序列化器的内容进行比较。两个版本接近匹配,但不精确。
在困惑了一段时间后,我发现最后部分由纠错码组成。在Utility.dll中找到的公共符号和诊断日志文本字符串帮助我理解了这一点。
当Revit加载被篡改的文件时,例如被模糊测试器或手动插入 crafted 数据更改的文件,有三种可能的结果。如果损坏很小,数据可能会成功恢复。在这种情况下,模糊测试器所做的更改将完全恢复。对于稍微严重的损坏,纠错码将不完美地恢复原始数据。在广泛损坏的情况下,例如gzip数据被完全不同的数据替换的情况,Revit从失配的纠错码确定文件损坏无法恢复。
因此,为了正确控制将呈现给反序列化器的字节,能够为流生成正确的纠错码尾部至关重要。类似地,为了能够获取现有的模糊测试RFA文件并从中提取反序列化器看到的准确副本,我需要模拟Revit在执行纠错时的行为。为了执行这些任务,我为我的工具链创建了一个额外的工具。我通过从Revit安装复制最小子集的低级DLL和配置文件,并在C++中编写包装器来调用适当的方法来实现这一点。这一步同时处理了gzip/gunzip和纠错任务。
利用类型混淆
有了这些工具,我终于能够随意重写RFA文件。
如上所述,原始崩溃是由于反序列化索引为0xc4的类型引起的,即std::pair< ElementId, ElementIdSetWrapperClass >。我开始思考可用类型列表中哪种类型会给我带来任意代码执行的最佳优势。
回想一下,类型混淆错误导致从反序列化对象的偏移0x0读取指针值。软件假设这是一个vtable指针,并继续调用vtable中的第一个函数指针,该指针应该是对象的析构函数。
因此,为了成功利用,我们希望选择一个在偏移0x0处具有可控值的对象,该值将被用作vtable指针。
在对反序列化器进行了一段时间的实验后,我有了一个顿悟——存在一个绝对完美的候选者。该类型是AString,类索引为0x1f。
为什么是AString?AString完美满足我们的需求。其内存布局如下:
|
|
AString实例内偏移0x0处的数据保证是有效指针,避免崩溃。此外,它指向什么?指向包含攻击者控制数据的缓冲区,正如反序列化器从输入文件中读取的那样!对AString反序列化代码的分析显示,它从输入文件以长度前缀格式读取字符串数据,而不是以空终止格式。因此,字符串缓冲区的内容完全不受限制;我们甚至可以随意包含空字节。对于攻击者来说,这是纯金。我们现在准备开始使用ROP将类型混淆转化为远程代码执行漏洞利用。
ROP链的开发
在我们开始ROP利用之前,我们需要一些ASLR击败。检查加载到Revit进程中的模块,我发现有一个不支持ASLR:RWUXThemeSU2015.dll(SHA256 aab0a6ae39b7f503aa0a77d4c85b8603be11982ad5207dddfb0e2e154a411bf2)。一个模块就是全部所需。此模块的.text段大小为72 KB,足够大以提供多样化的ROP小工具选择。
RWUXThemeSU2015.dll导入LoadLibraryW和GetProcAddress。通过调用这两个函数,我们可以获取进程中任意DLL的任意导出的指针,而无需任何额外的偏移硬编码,这可能会引入脆弱性。我的计划是检索ucrtbase!system的地址并调用该函数以获得任意代码执行。
然而,在我开始组装ROP链之前,有一个主要的技术障碍。在最直接的ROP场景中,攻击者从堆栈粉碎开始,使攻击者有机会至少将ROP链的开始部分写入堆栈。在这种情况下,ROP链的第一个小工具指针覆盖堆栈上的返回地址,连续的小工具指针占据连续的更高堆栈位置。一旦目标进程执行ret,它不是获取合法的返回地址,而是获取第一个ROP小工具的地址并将控制权转移到那里。此时开始ROP执行。
我们的情况不同。我们没有堆栈粉碎,因此没有机会将ROP链的任何部分写入堆栈。相反,我们必须反过来做;我们必须修改rsp以指向我们控制的内存。这被称为"堆栈枢轴"。
然而,我们有一个鸡和蛋的问题。要启动ROP链,我们必须修改rsp。但是,要修改rsp,我们至少需要一些最小的代码执行能力,这意味着我们需要已经在运行ROP。
让我们使用IDA查看崩溃站点,以黄色突出显示:
![图1]
注意rbx是指向正在被析构的对象数组的指针;每次循环时,rbx指向下一个数组元素。代码将数组元素读取到rcx中,因此rcx是指向要析构的对象的指针。每次循环时,它将一个新的vtable指针获取到rax中,然后调用[rax],根据意图,这是相应的析构函数。
我们可以使用这个循环作为一个怪异机器。
没错——除了ROP,我们还有第二个怪异机器可供使用:0x1802851C0处的循环本身!在序列化文档中,我们可以指定多个AString对象。每次循环时,当它执行call qword ptr [rax]指令时,它有效地运行我们选择的单个小工具。理论上,也许,我们甚至不需要ROP!我们可以只指定许多AString对象,对于每个对象,我们都可以执行单个小工具。
现在,这不是最灵活的方法,因为我们需要确保我们的小工具都不会干扰循环所需的寄存器,尤其是rbx,其次是rdi。更糟糕的是,我们必须容忍循环所做的所有寄存器修改。然而,我们不需要使用这个怪异机器来获得任意代码执行。我们只需要使用它来获得堆栈枢轴。然后,在最后循环迭代时,我们可以启动传统的ROP。
一旦我的思维敢于欣赏这个新怪异机器的潜在力量,我的问题的解决方案变得容易了。我的想法是使用循环机器执行多个小工具,依次将值从(原始)rax移动到不同的位置,直到我最终可以将其移动到rsp。现在,如果你希望一个值最终出现在rsp中,有什么比rbp更好的寄存器首先移动它呢。我们能直接从rax移动到rbp吗?确实,我们可以!
|
|
接下来,我们需要一个将rbp移动到rsp的小工具。这也很容易找到:
|
|
leave等效于mov rsp, rbp ; pop rbp,并且作为一个单字节操作码,leave小工具相对容易找到。这完成了将(原始)rax值传输到rsp。控制永远不会返回到循环。相反,leave小工具的ret指令将控制权转移到传统的ROP链。ROP链位于第一个AString内,紧接在第一个循环小工具的指针之后。这是因为传输到rbp的rax值是与第一个AString关联的rax值,而不是第二个AString。
总之,我使用一个有些受限的、基于循环的怪异机器来执行堆栈枢轴,允许我继续执行传统的、完整的ROP怪异机器。
Windows x64 ROP的冒险
构建此漏洞利用的最后一个主要子任务是编写ROP链本身。
在64位Windows上,ROP往往比32位Windows上困难得多——即使在这里CET不活跃的情况下——并且似乎比64位Linux上的ROP更困难一些。
在32位Windows上,所有函数参数都通过堆栈传递,而在64位Windows上,前四个参数通过寄存器传递,即rcx、rdx、r8和r9。在调用任何API之前,必须使用小工具加载适当的寄存器。此外,所有这四个寄存器都是调用者保存的,也称为"易失性"寄存器,意味着被调用者不负责在返回前恢复它们。因此,在Windows上,编译的函数很少以对这些寄存器加载有用的指令序列结束。例如,我们需要一个小工具将参数加载到rdx中,但我们不会找到以pop rdx; ret结束的任何函数,因为函数不负责在返回前恢复rdx。
转基因小工具
如上所述,在64位Windows上进行ROP的一个特殊困难源于需要将函数参数加载到寄存器中。参数通过易失性寄存器(如rdx)传递,但由于这些寄存器是易失性的,并且在函数结束时不会恢复,通常缺乏像pop rdx; ret这样的小工具。这会使加载参数变得相当困难,因为所需的小工具可能不存在。
在上面关于我称之为"怪物小工具"的部分中,我讨论了我如何使用十六进制编辑器更深入地搜索可能有用的操作码序列,然后使用反汇编器分析这些序列是否会导致可以用作小工具的东西,无论多么非传统。在这种情况下,我更进一步,制作了一个不存在的小工具。
等等,什么?ROP的整个重点不是你还不能写入可执行内存,所以你必须完全依赖现有的操作码字节序列吗?
嗯,这是真的——几乎是真的。让我向你展示我找到了什么,以及在这种情况下我如何能够创建一个一开始不存在的小工具。
考虑以下代码,在0x00000001800040f9处找到:
![图3]
这里我们有一些加载rdx的代码,这是一个我们需要加载的易失性寄存器。它从rsi加载它,这是一个我们可以轻松加载的寄存器:由于rsi是被调用者保存的,形式为pop rsi; ret的小工具很丰富。当然,麻烦的是缺少最重要的元素:在加载rdx之后,代码不是返回到ROP链,而是调用其他函数RWOpenThemeData。这导致哪里?
![图4]
RWOpenThemeData,结果发现,是围绕调用存储在18001EE38的函数指针的包装器。作为DllMain初始化的一部分,模块使用通过调用GetProcAddress获得的UXTHEME! OpenThemeData地址填充此函数指针。不出所料,初始化后,模块将包含函数指针的页面保留在PAGE_READWRITE状态。总之,没有什么阻止我们的ROP链覆盖18001EE38处的函数指针,从根本上改变图3中代码的控制流。我们应该选择写入18001EE38什么?这个怎么样,我们的老备用:
|
|
在图4的jmp rax跳转到0x0000000180008e5f后,pop rax将安全地消耗由图3中的调用推送到堆栈的返回地址,并且执行将继续使用ret。净效应是我们已将图3中的代码变成了这个小工具:
|
|
瞧!即使一开始模块似乎不包含用于加载rdx的小工具,我能够通过改变函数指针"基因改造"创建一个。
寄存器加载的挑战
在考虑了众多排列后,我发现以下总体计划是可行的:
- 将基于堆栈的宽字符串ucrtbase.dll的地址加载到rcx中,并调用RWUXThemeSU2015.dll的LoadLibraryW导入。
- 将LoadLibraryW的结果从rax移动到更持久的存储。我发现rbx是一个不错的选择。
- 使用写-什么-在哪里原语(容易制作)将窄字符串system写入静态可写内存位置,然后弹出(加载)其地址到rsi。
- 调用前面描述的0x00000001800040f9处的基因改造小工具,将rsi移动到rdx。这设置了GetProcAddress的第二个参数。
- 将模块地址从rbx移动到rcx。如何完成这是我们尚未讨论的唯一主要挑战。
- GetProcAddress的正确参数现在在rcx和rdx中。调用RWUXThemeSU2015.dll的GetProcAddress导入。结果在rax中。
- 将基于堆栈的命令行字符串的地址加载到rcx中,并通过rax中的函数指针调用ucrtbase!system。
复杂寄存器传输解决方案
如上文步骤5所述,计划中仍有一个漏洞。我们需要找到一种方法将rbx移动到rcx,并且这样做而不干扰我们努力加载的rdx中的数据。由于rcx是易失性寄存器,这很难完成,尽管比加载rdx容易一些。我找到的解决方案相当复杂,但至少不涉及任何基因改造:
|
|
如你所见,我的解决方案使用了前面讨论的相同堆栈地址检索原语,加上四个新小工具的序列。
在设置rcx为堆栈地址后,我调用小工具0x0000000180006a25将rbx移动到rax作为第一步。接下来,我使用小工具0x0000000180011dc4,它将rax存储到堆栈上稍高的位置。(注意此小工具还包含不需要的xchg。这会写入ROP堆栈,但我勉强避免损坏,因为损坏的小工具地址恰好是最近读取的,也就是说,是当前正在执行的这个小工具的地址!由于地址已被读取,损坏它没有效果。)接下来,我使用由单个ret组成的小工具将堆栈指针向上调整8字节,最后我执行pop rcx; ret小工具以从存储到堆栈的数据加载rcx。
至此,最终为调用GetProcAddress准备了所有参数。
我完成ROP链如下:
- 我调用了GetProcAddress
- 我将GetProcAddress的结果从rax移动到rbx,因为rax不会被以下小工具保留
- 我最后一次使用堆栈地址原语将命令字符串的地址加载到rcx中
- 我使用小工具jmp rbx调用ucrtbase!system
对于命令字符串,我使用了带有-Command开关的powershell.exe,这为任意代码执行提供了充分的灵活性。作为最后说明,ucrtbase!system在生成的shell进程退出之前不会返回。因此,如果需要,生成的进程可以利用此机会更改Revit进程的内存并修复所有损坏,允许Revit进程继续运行而不会崩溃。
杂项说明和经验教训
在这里,我只想包括一些关于64位Windows上ROP的项目,这些项目在逻辑上不适合我上面的阐述,但可能对读者有用。
- 当调用任何函数时,请记住该函数可能需要大量的堆栈空间才能正确执行。如果你在堆栈上传递字面值,函数的执行可能很容易覆盖字面值,导致执行不当。因此,在调用之前立即执行一系列小工具,将堆栈指针充分推进以避免冲突。你将必须通过实验确定所需的数量。
- 类似地,如果你执行了如本文所述的堆栈枢轴,rsp可能指向堆块内部。如果你在调用之前没有充分推进堆栈指针,调用期间的堆栈增长可能损坏当前块下方的堆内存。然后,当前线程或任何其他线程上执行的堆例程(如HeapAlloc和HeapFree)可能检测到损坏并在漏洞利用完成之前突然终止进程。如上所述,解决方案是在调用之前充分推进堆栈指针。
- 在Windows x64调用约定中,被调用者有权使用堆栈上返回地址正上方的0x20字节区域作为暂存区域(“影子空间”)。因此,在函数返回后,你不能保证堆栈的下一个0x20字节的完整性。为了补偿这一点,当你从ROP调用函数时,下一个小工具必须立即将堆栈指针推进超过这个可能损坏的区域。
- 堆栈对齐:在Windows x64调用约定中,rsp始终是8字节对齐的;然而,16字节对齐的规则更微妙。在每个函数的第一个指令之前,rsp绝对不能是16字节对齐的。另一种说法是,在每个call指令之前,rsp必须是16字节对齐的;call将rsp递减8并将返回地址存储在新的rsp,以便被调用者的第一个指令将以16字节非对齐的rsp按预期执行。当从ROP调用函数(或其重要部分)时,必须考虑堆栈对齐。在执行到函数开始的call之前,确保rsp是16字节对齐的。相反,在执行到函数第一个指令的jmp之前,确保rsp不是16字节对齐的。如果你使用ROP执行到函数中间某条指令的call或jmp,你可能需要考虑堆栈对齐,并将rsp调整为预期代码预期的16字节对齐或16字节非对齐;除了序言和尾声代码外,大多数编译代码期望16字节对齐的rsp。(然而,对于绝大多数典型的短小工具,堆栈对齐无关紧要。)在ROP内从16字节对齐更改为16字节非对齐rsp或反之亦然,只需执行单个ret小工具即可。但是,如果一开始你不确定rsp对齐状态,则需要更高级的解决方案。
组合演示:Axis供应链漏洞 + Autodesk Revit RCE
请参阅我同事Nitesh Surana关于他在Axis Communications Plugin for Autodesk(ZDI-24-1181、ZDI-24-1328、ZDI-24-1329和ZDI-25-858)中发现的云漏洞的文章。如果攻击者利用了那些云错误配置,他们可以替换属于Axis的云存储帐户中的RFA文件。然后,每当用户在Autodesk Revit中将Axis产品添加到他们的模型时,这些RFA文件将被提供给全球恰好使用Axis插件的Revit用户。通过这种方式,攻击者将实现大规模"一键"远程代码执行。
以下视频模拟了供应链攻击的后果。唯一模拟的部分是通过Fiddler代理人为地将 crafted RFA文件引入Axis云存储和插件之间的网络流量。一旦RFA文件被传递,Revit应用程序会自动解析它,视频显示了ROP漏洞利用的实际执行。
结论
在本文中,我向你概述了我如何从Autodesk Revit文件解析崩溃的高度不确定潜力开始,并将其转化为即使在最新的Windows x64平台上也完全可靠的代码执行漏洞利用。由于Axis云错误配置可能导致在受影响产品的正常使用期间自动利用,此RCE具有异常大的影响。我希望你觉得这里讨论的技术对你的研究有信息和有益。
我要感谢我的同事Mat Powell和Nitesh Surana对这项研究的贡献。希望我们将来有更多的工具和技术发布。在那之前,请在Twitter、Mastodon、LinkedIn或Bluesky上关注团队,了解最新的漏洞利用技术和安全补丁。