本文是天工实验室安全研究员陈浩,在看雪SDC 2024上发表的议题《Rust的安全幻影:语言层面的约束及其局限性》。该议题深入分析了Rust编程语言的编译二进制文件及其现有的安全问题,揭露了Rust安全机制的局限性与潜在漏洞。
一、Rust介绍
二、Rust 不会出现漏洞吗?
三、案例一:对操作系统行为的认知错误
四、案例二:对特性的认知错误
五、案例三:对生命周期的错误认知
六、总 结
Rust语言自其发布以来就备受人们关注,作为一门现代的系统级编程语言,Rust在安全性方面引起了人们的极大兴趣。它与其他语言相比,引入了一系列创新的安全特性,旨在帮助开发者编写更可靠、更安全的软件。在这个基础上,许多大厂开始纷纷在自己的项目中引入Rust,比如Cloudflare
的pingora
,Rust版的git — gitxoide
,连微软都提到要将自家的win32k模块用rust重写,足以见得其火爆程度。
之所以人们对Rust那么充满兴趣,除了其强大的语法规则之外,Rust提供了一系列的安全保障机制也让人非常感兴趣,其主要集中在以下几个方面:
-
内存安全:Rust通过使用所有权系统和检查器等机制,解决了内存安全问题。它在编译时进行严格的借用规则检查,确保不会出现数据竞争、空指针解引用和缓冲区溢出等常见的内存错误。
-
线程安全:Rust的并发模型使得编写线程安全的代码变得更加容易。它通过所有权和借用的机制,确保在编译时避免了数据竞争和并发问题,从而减少了运行时错误的潜在风险。
-
抽象层安全检测:Rust提供了强大的抽象能力,使得开发者能够编写更加安全和可维护的代码。通过诸如模式匹配、类型系统、trait和泛型等特性,Rust鼓励使用安全抽象来减少错误和提高代码的可读性。
Rust强大的编译器管会接管很多工作,从而尽可能的减少各种内存错误的诞生。
在 Rust 的各类机制下,开发人员在编译阶段被迫做了相当多的检查工作。同时在 Rust 的抽象机制下,整体的开发流程得到了规范,理论上应该是很难再出现漏洞了。然而,安全本质其实是人,错误本质上是由人们的错误认知引发的。即便是在 Rust 的保护之下,人们也是有可能犯错,从而导致新的问题的出现。对于这种场景,我们可以用一种宏观的方法论来概括,那就是认知偏差。这里可以用一个图来大致描述一下这个认知偏差:
换句话说,在使用Rust开发中,人们认为Rust能够提供的防护和Rust实际上提供的防护,这两者存在一定的差异。具体来说,可以有一下几种场景:
-
Rust 检查时,能否防护过较为底层的操作状态?
-
Rust 自身特性是否会引入问题?
-
Rust 能否检查出作为mod 或者 API被其他人调用时,也能完全保护调用安全吗?
为了能够更好的了解认知差异,接下来我们就介绍几种比较典型的 Rust 下容易出现的漏洞。
再进行开发过程中,Rust 通常会需要与操作系统底层进行交互。然而在这些操作过程中,本质上是对底层的API 或者对底层操作系统的操作,此时考察的是开发者对于操作系统的理解。而Rust编译器的防护机制并无法直接作用于这些底层的操作系统对象,从而会导致错误的发生。
一种常见的认知偏差就是默认操作系统提供的特性,比如说接下来要提到的特殊字符过滤规则。
BatBadBut(CVE-2024-24576)
在2024年4月,安全研究员RyotaK公开了一种他发现现有大部分高级语言中常见的漏洞类型,取名为BatBadBut
,其含义为batch文件虽然糟糕,但不是最糟糕的。
batch files and bad, but not the worst
在Windows下,想要执行bat文件就必须要启动一个cmd.exe,所以执行的时候通常会变成cmd.exe /c test.bat
。
每个高级语言在Windows平台下需要创建新的进程的时候,最终都会调用Windows的APICreateProcess
。为了防止命令注入,它们大多数会对参数进行一定的限制,然而Windows平台下的CreateProcess存在一定的特殊行为,使得一些常见的过滤手段依然能够被绕过。作者给了一个nodejs的例子,在nodejs中,当进行进程创建的时候,通常是这样做的:
这种做法通常是没问题的,此时由CreateProcess
创建的进程为echo
,参数为后续的两个参数。同时,这个调用过程中伴随的如下的过滤函数,会将"
过滤成"
此时,上述的指令会形成如下的指令:
然而,当遇到如下代码的时候,情况会发生变化:
因为 Windows 并没有办法直接的启动一个bat文件,所以实际上启动的时候,Windows执行的实际逻辑变成了:
而实际上,在Windows中的并非是我们理解的那种能将所有符号进行转义,转义字符。其只能转义
本身,类似于作为路径的时候,以及转义换行符。所以,上述的命令实际上等价于:
此时命令解析模式如下:
可见依然发生了命令注入。实际上,如果想要在Windows下进行我们常规理解下的命令转换,要使用^
符号,例如将上述指令修改成如下的形式,即可防止命令注入:
作者给出了他测试过的受到影响的语言:
-
Erlange
-
Go
-
Haskell
-
Java
-
Node.js
-
PHP
-
Python
-
Ruby
-
Rust
这些语言的内置Execute
或者Command
函数都或多或少会受到影响。
Rust CVE-2024-24576
Rust也有这样的问题,所以进行了紧急修复,但是Rust一开始似乎是意识到了.bat
的特异性行为,还给出了相关处理函数:
然而它在处理的过程中,并未对双引号正确处理,而是同样使用了:
在这边,错误的使用了\
作为过滤字符,所以同样导致了问题的出现。
这里参考网上流传的poc:
当我们传入"&calc.exe
时候就能弹出计算器,此时观察命令行的参数可以看到如下的内容:
Rust给出的修复在这边。经分析,可以知道主要是引入了函数append_bat_arg
,在对各种字符串做了过滤之后,假设遇到双引号,则再次插入另一个,从而阻止绕过的发生:
认知错误分析
实际上,这个漏洞本身和Rust关联不大,但是我们仍然可以用这个认知模型对这个漏洞进行分析:
-
开发人员认知:Windows中,
与Linux下含义相同
-
实际运行环境:Windows中的
^
与Linux下语义相同
这种对于操作系统的认知差异导致了这个问题的出现。
内存重排序问题
在之前的文章中提到过,Rust的结构体的变量顺序可能会由于内存对齐问题进行重排序,这边简单复习一下,假设存在结构体:
上述结构体如果在C里面写的话,可以写作如下的形式:
此时,这个结构体的大小是什么呢?
实际上,假设我们打印结构体的大小和偏移,会得到这个答案:
因为结构体对齐的时候,会遵顼三个原则:
-
第一个成员的起始地址为0
-
每个成员的首地址为自身大小的整数倍
-
总大小为成员大小的整数倍
由于b的起始地址必须是4对齐,所以所有的变量都被迫进行了4字节对齐,从而形成了这个状态。
那么这个结构体在Rust中的内存排布是如何的呢?
如果我们尝试打印他们的偏移的话,可以得到如下的结果:
从IDA中看,也可看到类似的结果:
可以看到field2和field3的偏移发生了变化,其原因源自于之前提到过的对齐特性,Rust会尽可能的缩小结构体大小,会因此调换结构体成员的变量顺序,从而保证结构体尽可能地小。
repr
然而在实际开发中,我们有时候不需要编译器对我们的结构体进行操作,此时可以通过声明#[repr(C)]关键字,强行让结构体排序不发生变化。
同时,我们可以看到,Rust还是尽可能地保证了结构体在4/8字节上的对齐,然而在某些场景中,我们可能希望结构体能够尽可能地小,此时可以声明#[repr(packed)]来强行要求结构体不要保留padding。这两种做法都是非常常见的。
RUSTSEC-2024-0346
这个漏洞出现在Rust下的一个zerovec模块中,这个模块特点为零拷贝,本质上是对现有对象进行引用以及一些序列化相关的操作。
根据文档,zerovec的底层核心是一个叫做ULE
的特征辅助实现的,其实现大致如下:
这个特征要求利用unsafe 的函数直接的获取对应的字节流是否有效。在这基础上,如果我们将要包含的数据大小是不定长的,则需要实现VarULE
这一个特性:
这个函数会要求对底层的数据进行一些操作从而完成进行数据拷贝,所以底层可以理解会存在【序列化】的过程。
该漏洞提供的POC如下:
上述这段代码逻辑基本上就是做了一个时间格式化,然而在底层却触发了断言,导致了崩溃。经过开发者定位,最终确定有问题的数据结构如下:
在这段代码中,成员dates_to_eras
对应的ZeroVec
所关联的结构体就是Tuple(EraStartDate, TinyStr16)
,这两个结构体定义如下:
可以看到,EraStartDate
为8字节,而TinyStr16
为16字节。而在ZeroVec
中,其使用了宏来处理这种情况:
可以看到,当遇到tuple
类型的时候,ZeroVec
会使用packed
关键字将其封装,从而保证数据占用空间的大小不会太大。由于是tuple,所以这里的结构体中不存在对应的实际成员,使用的时候只能使用self.0
或者self.1
来操作对应的EraStartDate
和TinyStr16
。然而,这里的tuple却并不如我们看到的那样排序,当加上packed
关键字后,其会发生一个重排序的过程,例如下面代码:
实际运行起来的时候,我们会得到如下的结果:
此时可以发现,EraStartDate
和TinyStr16
的顺序发生了颠倒。那么此时在使用tuple
来操作对象的时候,原先位于0
位置的EraStartDate
就变成了TinyStr16
,从而造成了漏洞的产生。
修复策略与认知差异分析
实际上,这个漏洞的修复非常简单,是需要将声明改成#[repr(C,packed)]
即可强迫Rust使用C语言的内存排序对其进行严格的顺序声明,从而阻止这类漏洞的产生。
如果从认知差异的角度触发,这个漏洞其实就是一种非常典型的认知差异,表现为对语言特性理解的差异:
-
开发人员认知:Rust中,
packed
字段未提及重排序,所以不会发生重排序问题 -
实际运行环境:Rust中,
packed
字段不会决定排序,所以可能发生重排序
实际上,现在去Rust官网阅读文档也会发现,packed
关键字明确提到了可能会发生重排序。然而开发者们在开发的过程中,依然可能存在记忆混淆等认知错误的问题,从而导致漏洞本身的出现。
CVE-2024-27284
这个漏洞是Casandra-rs中的问题。
由于没有PoC,所以只能推测漏洞点的大致位置
这个库是一个分布式数据库的Rust封装实现。由于数据库操作不可避免的要与数据库操作,所以有大量底层数据操作,因此引入了unsafe
关键字,并且有很多迭代器存在,这个过程中就会导致漏洞的出现。
漏洞的关键点在于迭代器的误用,分析patch可以看到这样的逻辑:
这里有几个比较明显的修复特征:
-
Iterator 切换成了 LendingIterator
-
Item定义有了微妙的变化,但是始终和
Raw<'a>
相关 -
get_row
的返回值由Row<'a>
换成了Row
我们这里可以全部过一遍这些修复点:首先是这里的LendingIterator
,其实是其内部实现的一个特殊的数据结构:
这样声明后,迭代器的生命周期就会被强制与其Item
指向的对象生命周期保持一致。
其次,这里提到了Raw<'a>
定义如下:
可以看到,原先的Row
关联的生命周期为CassResult
,而新的Row
关联的生命周期为_Row
。
然后,这个get_row
所操作的是一个unsafe
对象,这个对象来自cpp部分:
get_row
会返回一个Row
对象,这个Row
对象来自于ResultIterator
这个结构体中定义的Row
对象,此时我们可以知道,结构体关系如下:
此时可以得出一个结论:
ResultIterator
和Row
公用一段内存空间
同时,根据之前修改的代码,可以观察到这里修改:
这两个迭代器虽然都关联了Row<'a>
,但是这个对象的定义同样发生了变化:
原先的Row<'a>
声明的时候,与CassResult
关联,这个CassResult
即为我们调用数据库查询功能后,能够得到的类型,而新版本的Row<'a>
则是与_Row
关联,这个_Row
就是前文提到过的Row
指针。同时,next
函数就是迭代器在迭代过程中会自动调用获取下一个迭代对象的指针,如果我们罗列一下之前提到过的所有函数获得对象的关系,是这样的:
-
get_result
能够获取CassResult
-
CassResult.iter()
能够获取ResultIterator
-
ResultIterator
在递归过程中,通过next
获取Row
然而我们在修复前,CassResult
和Row
的生命周期一致,但是ResultIterator
和Row
对象未强制要求生命周期一致, 此时漏洞触发的原因就呼之欲出:
由于未强制关联
Row
与ResultIterator
,而ResultIterator
和Row
共用一套内存,当ResultIterator
被销毁,Row
未被销毁的场合,就会引发漏洞
总结一下,PoC形式如下:
此时,由于get_result
获取的CassResult
未被销毁,此时对应的Row<'a>
也就是tmp_row
不会被Rust认为超出生命周期,然而此时的result.iter()
获取的ResultIterator
已经被销毁了,最终导致了UAF的产生。
我们根据上述的模型,写了一个类似的POC,形式如下:
此时能够成功的触发一个UAF问题
认知错误总结
从认知差异的角度触发,这个漏洞其实是一种基于逻辑错误而导致的内存问题。其虽然与unsafe
关键字关联,但是实际上它从设计层面就出现了问题,概括来讲就是
-
开发人员认知:
Row
与Iterator
存放在同一内存中,两者可在同一时刻释放,不会发生内存问题;Row
生命周期与CassResult
关联,从而保证两者生命周期长度一致,防止内存泄漏; -
实际运行环境:
Row
与Iterator
可能不在同一个声明域中使用,可能存在Iterator
提前释放的场景。
可以猜到,在开发的时候,开发者应该着重考虑了内存泄露的问题,并且假定迭代器创建的对象会被用户拷贝,抑或是保留在指定的生命周期中,然而实际开发过程中,错误的生命周期声明会导致检查的失效,从而导致UAF问题的出现。
Rust虽然是一个相当安全的语言,但是其安全范围是有限的,问题尤其会在人们错误的理解Rust提供的安全能力这种认知错误场合中出现。
根据我们前文的漏洞,可以总结出以下几种脱离了Rust防护机制的情形:
-
漏洞与操作系统底层关联,Rust编译器无法感知
-
Rust 本身的特性导致的问题出现
-
开发者错误声明Rust生命周期的场合
通过对此类边界的观测,能够更加容易发现漏洞点,同样也能借此观测软件的防护情况, 加强软件防护。
【版权说明】
本作品著作权归陈浩所有
未经作者同意,不得转载
天工实验室安全研究员,Datacon 2023 出题人,主攻二进制漏洞挖掘。
原文始发于微信公众号(奇安信天工实验室):Rust的安全幻影:语言层面的约束及其局限性