研究了rust调用/被调用外部库中堆块内存传递造成的安全问题
问题
rust调用外部函数库比如C/C++写的库可能会导致不安全,即使外部包是纯rust编写的包(不使用FFI),也可能存在漏洞 现在又很多方法来增强FFI的安全性,比如rust-bndgen,safer_ffi 但是都只能帮助编写正确的数据接口。在跨过FFI使用堆分配和free造成的内存漏洞仍然是一个未解决的问题 此外,Rust有一个独特的内存管理所有权系统,它创造了自己的内存安全问题范式。因此,现有的关于其他内存安全编程语言滥用FFI的工作,如Java和Python,已不再适用。
背景知识
rust所有权,借用和生命周期
-
Rust 中的每一个值都有一个 所有者(_owner_)。 -
值在任一时刻有且只有一个所有者。 -
当所有者(变量)离开作用域,这个值将被丢弃。
借用
引用(_reference_)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1 给 calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
}
引用还不能悬垂引用和跨过作用域
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
现有工作
rust静态分析
许多现有的研究扩展了现成的静态分析引擎,对Rust编译器生成的LLVM IR进行错误检测。Lindner等人使用符号执行引擎KLEE来验证一个程序是否无panic-free SMACK将LLVM IR翻译成Boogie中间验证语言。 Rust2Viper和Prusti利用用户提供的规范和Viper符号执行引擎来验证功能正确性 CRUST将包含unsafe代码的函数翻译成C语言,然后生成测试并由CBMC模型检查器检查。 还有不少研究是在rust的中间语言上MIR上做分析,比如MirChecker,Safe Drop等工具
跨语言缺陷检测与预防
Mergendahl等人提出了一个威胁模型来推理跨语言的攻击。他们还在Rust和Go上演示了这些攻击。 Kondoh等人使用静态分析来检测使用Java Native Interface(JNI)时的常见错误和不良编程实践。 Tan等人应用静态分析,对Sun公司的Java开发包(JDK)中的一部分本地代码进行了经验性的安全研究。 JET是一个静态分析工具,它对通过JNI在本地代码中引发的Java异常执行异常检查并报告错误。
JET将一个JNI代码包作为输入,它由Java类文件和本地代码组成。该包被输入到两个阶段的异常分析。对于每个本地方法实现,异常分析将输出其实际的异常效果。然后,JET将实际效果与从Java类文件中提取的已声明的异常效果进行比较。如果出现不匹配,警告生成器会发出警告。请注意,我们的分析并没有检查Java代码,而只检查Java类文件中的本机代码和类型签名。
Jinn [18] 是一个独立于编译器和虚拟机的错误检测工具,适用于JNI和Python/C。 Galeed[33]和PKRU-Safe[15]使用英特尔内存保护密钥(MPK)在运行时隔离堆内存,这样,不安全(外部)代码就不能破坏安全语言组件专用的内存。 由Rust所有权系统和C/C++之间的互动所引入的新的错误模式超出了所有现有的检测或预防工作的范围。
本文研究点
研究了跨FFI边界的堆内存管理问题的安全影响,特别是那些由Rust的基于所有权的内存管理和C/C++的手动内存管理相结合造成的问题。 使用静态分析技术来检测跨越FFI边界的潜在内存管理错误。基于抽象解释的理论。设计了一个增强的污点分析算法来跟踪堆内存的状态,它捕捉了基于所有权的内存管理所产生的范式。我们实现了名为FFIChecker的工具,它自动收集所有生成的Rust和C/C++代码的LLVM中间代码(IR),然后进行静态分析并输出诊断报告。然后,安全分析师可以检查这些报告,并确定是否有任何真正的bug。我们的评估表明,FFIChecker 可以在可接受的时间内以合理的精度成功地检测出内存安全问题。据我们所知,我们的工作是第一个解决Rust程序中跨FFI边界的内存管理问题的努力。我们将我们的贡献总结如下。
-
展示了当程序员通过FFI将Rust和C/C++混合在一起时,潜在的安全和内存管理问题 -
我们提出了一个在所有权原则内存管理方案中捕获内存状态的增强抽象域。 -
设计并构建了FFIChecker,这是一个自动化的静态分析器,可以检测Rust包中跨越FFI边界的潜在内存管理错误,并报告信息性的诊断消息。源代码可在线获取,这可以作为未来其他研究的基础。 -
我们在Rust生态系统中进行了广泛的评估。我们评估了从官方软件包注册表中抓取的987个软件包,在12个软件包中发现了34个bug。所有检测到的bug都经过人工确认并报告给了作者,其中15个bug在写作时已经被修复。
证明FFI是不安全的参考[12,41,24] 官方Rust包注册表(crates.io)上超过72%的包至少依赖于一个不安全的FFI-绑定包
漏洞类型
在本文中,我们只考虑在Rust中分配堆内存并传递给C/C++的情况能分析出来的漏洞为三种:
-
常见内存损坏 Box::into_raw会将指向堆块的智能指针变成裸指针,然后将裸指针传入FFI中,同时这个堆块也就不属于Box来管理了,需要程序员手动管理,emd库就没有管理裸指针,造成了内存泄露。
let mut cost = Vec::with_capacity(X.rows());
for x in X.outer_iter() {
let mut cost_i = Vec::with_capacity(Y.rows()); // Allocate a vector
for y in Y.outer_iter() {
cost_i.push(distance(&x, &y) as c_double);
}
// Forget the memory using `Box::into_raw`
cost.push(Box::into_raw(cost_i.into_boxed_slice()) as *const c_double);
}
// Call FFI function
let d = unsafe {
emd(X.rows(),
weight_x.as_ptr(),
Y.rows(),
weight_y.as_ptr(),
cost.as_ptr(),
null())
};
-
异常安全 rust没有try-catch,rust的异常处理机制是,所有可恢复的错误必须被处理或传播回调用者函数,而所有不可恢复的错误则通过终止执行和解开堆栈来处理。所有堆栈对象的析构器将在解开堆栈时被调用,以防止资源泄漏。 然而,当跨越FFI边界传递堆内存并与外部代码合作时,开发人员通常必须通过不安全的代码暂时创建不健全的状态(例如,创建暂时未初始化的数据)。然后在外部代码完成后,开发人员手动清理这些状态。如果中间发生了一些错误,执行就会停止,堆栈就会解开,所以清理程序就不会被执行。剩余的不健全状态可能会导致安全问题。下面的代码是在libtaos库中出现的此问题。
3-8行的不安全块中,内存被传递给FFI。注意,第5行和第7行的问号运算符(?)意味着如果操作失败,函数会提前返回并将错误传播给调用者函数。因此,如果函数提前返回,内存可能被泄露,因此第10行的free函数将不会被调用。
pub fn bind(&mut self, params: impl IntoParams) -> Result<(), TaosError> {
let params = params.into_params();
unsafe {
let res = taos_stmt_bind_param(self.stmt, params.as_ptr() as _);
self.err_or(res)?;
let res = taos_stmt_add_batch(self.stmt);
self.err_or(res)?;
}
for mut param in params {
unsafe { param.free() };
}
Ok(())
}
-
混合内存管理机制造成的未定义行为 比如使用不同内存管理机制来分配/回收内存。比如用rust的BOX来申请内存,却用c的free来回收。rust在linux中内存管理使用的是jemalloc,而c用的是ptmalloc,两个chunk结构体是不一样的
struct arena_chunk_s {
arena_t *arena; /* chunk属于哪个arena. */
rb_node(arena_chunk_t) dirty_link; /* 用于arena chunks_dirty(rb tree)的链接节点. 如果某个chunk内部含有任何dirty page, 就会被挂载到arena中的chunks_dirty tree上 */
size_t ndirty; /* 内部dirty page数量. */
size_t nruns_avail; /* 内部available runs数量. */
size_t nruns_adjac; /* 该数值记录的就是可以通过purge合并的run数量. */
arena_chunk_map_t map[1]; /* 动态数组, 每一项对应chunk中的一个page状态 */
}
这是jemalloc的结构体
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk; /* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
这是ptmalloc的结构体,而且还有Box结构体的其他参数,比如析构函数等 C和Rust采用完全不同的内存管理机制,两者在不同的层次上运行。具体来说,Rust为构造的对象调用构造函数/析构函数,而C只处理原始内存。下面是jyt库代码中的漏洞
// Rust code:
pub unsafe extern "C" fn to_json(from: ext::Ext, text: *const c_char) -> *const c_char {
... ...
// CString internally allocates heap memory
let output = CString::new(ext::json::serialize(&value.unwrap()).unwrap()).unwrap();
let ptr = output.as_ptr();
mem::forget(output); // Memory is "forgotten" by the ownership system
ptr // The raw pointer will be passed across the FFI boundary
}
// C code:
int main() {
... ...
const char* output = to_json(Yaml, input);
... ...
free((char*)output); // Memory allocated in Rust is freed by free()
return 0;
}
系统设计
-
用户接口和驱动程序 -
入口点和外来函数收集器 -
静态分析器和错误检测器
用户接口
用户向接口传入一个rust包,其中可以包含一个或多个rust crate和c/c++源文件,这个接口程序以驱动程序的形态运行,将源文件分配给rust编译器或者c/c++编译器,使用llvm将源代码编译成llvm比特码。用户接口的意义是提供一个友好的用户界面,可以轻松集成到日常开发中,同时下载整个依赖关系所有源码然后对源码做初步处理,处理成llvm字节码。 可以在用户接口这边设置错误检测的警告精度等一些参数。
入口点和外来函数收集器
使用静态分析需要一个函数作为入口点。收集入口点函数的范围是rust程序公共函数/方法(因为这些对于攻击者可见)。在rust调用的c/c++函数列表,这些部分收集后传递给静态分析器。收集器的例程被当作一个回调函数插入rust编译器
静态分析器和错误检测器
静态分析期通过遍历LLVM 比特码提供的CFG来进行分析,错误检测模块则会根据静态分析模块的分析结果生成诊断信息,这些信息通过用户指定规则过滤来抑制假阳性。用户可以根据最后输出的信息检查源码来找出潜在错误
抽象域
概念定义
在所有权原则内存管理方案中捕获内存状态的增强抽象域 在LLVM IR中,单个函数被建模为控制流图(CFG),静态分析在一定的抽象域中对程序的执行进行建模,域中的每个元素代表一定的执行状态,被称为抽象状态。它首先为每个变量和基本块分配抽象状态,然后遍历CFG并根据每条指令的语义更新这些状态。抽象域根据不同的目的而不同。我们将抽象域设计如下,以捕获堆内存的所有权状态。 对于每个CFG,我们把CFG中出现的所有变量的集合表示为Var,把CFG中所有基本块的集合表示为Block。为了区分一个变量是否存储在堆内存以及它在所有权系统中的状态(例如,是否被借用或移动),我们将状态MState定义为一个有5个元素的网格,元素之间关系有两种,一个顺序关系符和一个连接符一开始底层元素()是所有变量的默认值。当一个变量被堆内存分配程序初始化时,我们把它标记为Alloc。请注意,一个堆内存可以通过获取引用(borrowed)或忘记其所有权(moved)传递给FFI。我们用相应的状态Borrowed和Moved来区分它们。为了保守起见,当状态无法确定时,我们将其设置为顶层元素()
为每个基本块b维护一个查找表。抽象状态AState:一个由Var到MState的所有映射组成的表格,由所有组成。 AState中的每一个元素都是一个查找表,它描述了执行程序的当前基本块后每个变量的抽象内存状态。顺序关系定义为:表示变量状态经过中的变换后再经过的变换 连接符定义为:标识变量可能是中任意一种状态映射
/// Compute the least upper bound of two `BlockState`, the logic is a bit complex, see the comments.
/// The performance may be slow.
pub fn union(&self, other: &Self) -> Self {
// The result we want to compute
let mut res_state = BlockState::default();
// Get all variables from both `self` and `other`
let mut all_vars = vec![];
for alloc in self.state.keys().chain(other.state.keys()) {
for var in &alloc.set {
all_vars.push(var);
}
}
for var in all_vars {
let mut var_state = res_state.get_memory_state(var);
// If `var` is already in the resulting state, compare its state in `self` and `other`, take the maximum one
if var_state > MemoryState::Untainted {
let alloc = res_state.get_allocation(var).unwrap();
let var_state_self = self.get_memory_state(var);
let var_state_other = other.get_memory_state(var);
var_state = var_state.union(var_state_self).union(var_state_other);
res_state.state.insert(alloc, var_state);
} else {
// If `var` is not in the resulting state, try to find the corresponding `Allocation` in `self` and `other`
if let Some(alloc1) = self.get_allocation(var) {
if let Some(alloc2) = other.get_allocation(var) {
// If `var` is in both `self` and `other`, take the union of the `Allocation`s
let alloc: BTreeSet<_> = alloc1.set.union(&alloc2.set).cloned().collect();
// The state is also computed via taking the union
let var_state = self.state[&alloc1].union(other.state[&alloc2]);
res_state.state.insert(Allocation { set: alloc }, var_state);
} else {
// If `var` is only in `self`
res_state.state.insert(alloc1.clone(), self.state[&alloc1]);
}
} else {
if let Some(alloc2) = other.get_allocation(var) {
// If `var` is only in `other`
res_state.state.insert(alloc2.clone(), other.state[&alloc2]);
} else {
// If `var` is neither in `self` nor `other`, should be impossible to happen
unreachable!(
"`var` is neither in `self` nor `other`, should be impossible to happen"
);
}
}
}
}
res_state
}
抽象域:所有基本块Block到AState的映射,也被定义成AState的幂集和,i.e.,Domain=论文中定义状态只能向上”升长“,也就是union只能保证状态不变或者向上生长。
转换函数
在静态分析中,转换函数是分析LLVM IR的指令来更新抽象状态 转换函数关注一下指令
-
影响数据流的指令,如load,store,Get Element Ptr -
调用其他函数的指令,如Call和Invoke
算法
算法主主要由三部分组成
-
定位算法:遍历CFG并执行转换函数 -
上下文敏感的程序分析算法 -
错误检测算法:确定是否有潜在错误的检测算法
定位算法
经典的遍历算法,遍历给定的CFG,并迭代运行转换函数更新抽象状态。(附录1)是一个存放CFG所有基本块的列表。 从中选择一个基本块对执行转化函数,如果状态更新,则将b的所有后继块加入列表,等待被重新分析 重复上述过程直到变成空(有符号,状态会“上升”也就是从初始化状态往上走;网格的高度有限,最终会被耗尽)
/// Start the fixed point algorithm until a fixed point is reached
pub fn iterate_to_fixpoint(&mut self) {
let mut old_state = self.taint_domain.clone();
let mut worklist = VecDeque::from(self.function.basic_blocks.clone());
let mut iteration = 0;
while let Some(bb) = worklist.pop_front() {
self.analyze_basic_block(&bb);
let new_state = self.get_state_from_predecessors(&bb);
if old_state.get(&bb.name) == None || !(new_state <= old_state.get(&bb.name).unwrap()) {
debug!("old: {:?}", old_state);
old_state.insert(bb.name.clone(), new_state);
debug!("new: {:?}", old_state);
let mut successors = self.get_successors(&bb);
debug!(
"Adding successors of {} to the worklist: {:?}",
bb.name, successors
);
worklist.append(&mut successors);
// worklist.append(&mut self.get_successors(&bb));
}
// To make stop analysis if it takes too much time
iteration += 1;
if iteration > MAX_ITERATION {
break;
}
}
// loop {
// // if self.iteration >= MAX_ITERATION {
// // // If the maximum iteration limit is reached
// // break;
// // }
// self.iteration += 1;
// let basic_blocks = self.function.basic_blocks.clone();
// for bb in &basic_blocks {
// self.analyze_basic_block(bb);
// }
// if self.taint_domain <= old_state {
// // A fix point is reached
// break;
// } else {
// debug!("old: {:?}", old_state);
// debug!("new: {:?}", self.taint_domain);
// old_state = self.taint_domain.clone();
// }
// }
}
分析函数调用
分析时,不同的函数可能需要不同的处理
-
exchange_malloc
这代表申请堆内存的操作,就将抽象状态标记为Alloc -
函数被借用(e.g. vec::as_mut_ptr
)或移动所有权(e.g.Box::into_raw
),将抽象状态标记为Borrowed或moved -
分析使用FFI调用外部函数,这个是分析的重点 -
对于IR中出现的LLVM 内置函数我们不做分析,因为这部分是由编译器后端来实现的,编译器往往会对这部分优化,甚至很多内置函数不存在于LLVM IR中。Rust标准库函数也不是分析对象,因为它高度抽象和复杂,我们的目标是发现第三方库中的bug。所以FFIChecker内部维护此类函数与处理程序之间的映射,只执行,不分析 -
对于其他函数初始化一个新的不动点来启动上下文敏感的程序间分析(附录2)
使用了基于摘要的方法来避免对同一函数重复分析,将摘要存在一个查找表cache:((f,in_state),out_state),它将调用的上下文(f,in_state)映射道一个输出out_state,f是一个函数,in_state是输入的抽象状态,out_state是对应是输出
摘要用的是HashMap,key字段是(函数名,内存状态变化列表),val字段是上述的((f,in_state),out_state)
首先检查有没有已经计算好的摘要,如果有则跳过定点算法,直接返回结果,如果没有,则执行结果,分析结果缓存道查找表中
错误检测算法
在定点算法结束后,检查是否有堆内存被传递给FFI变量,如果有则可能存在漏洞,需要进一步分析来确定bug类型。FFIChecker对有堆块内存跨过FFI边界的函数开启一个新的分析实例,并在外部函数检查此堆块是否被释放,然后根据所有权状态生成警告。警告类型如下。
未定义行为(UB)是执行程序的结果,在计算机代码所遵循的语言规范中,其行为被规定为不可预测的。
表中对不同的警告有一个危险等级的评判,主要是很多时候对于一个外部LLVM IR,很难通过静态分析的手法判断出调用哪一个函数或其他操作(比如动态链接库或者函数指针等),这样无法继续分析,就会给出一个等级较低的预警,这样设计有助于减少误报(作者定义了一个过滤预警的过滤器,只有预警等级高于阈值才会报告给用户)
实验评估
从rust包网站crate.io上下载987个包(使用FFI),产生222条警告,经过人工检查后发现34个漏洞(19个内存泄露,3个异常相关错误,12个未定义行为) 测量了FFIChecker对所有987个包的执行时间和内存使用情况。我们在8个并行线程中运行评估,FFIChecker可以在5.2小时内完成所有的分析,最多消耗4.1GB内存。平均来说,FFIChecker可以在116.9个CPU秒内分析一个包,内存消耗为1,056.6MB。请注意,执行时间和内存使用量与代码行数或接口数量没有关系,因为定点算法的收敛性主要取决于CFG的结构。总的来说,FFIChecker具有足够的扩展性,可以用合理的计算资源来分析真实世界的Rust包。
数据中222个警告只有34个漏洞,假阳性高,出现这种情况有以下几个原因
-
rust从动态链接库中调用外部函数情况很常见,这导致外部函数代码的LLVM IR不可用,FFIChecker不能继续使用。 -
由于Rust编译器可能会对变量所有权的借用/移动操作进行优化,FFIChecker不能一定通过LLVM IR区分变量是借用还是移动
总结
虽然我们专注于Rust与C/C++的结合,但FFIChecker的想法和威胁模型可以扩展到其他跨语言的情况。特别是,静态分析器被设计成在LLVM IR上运行的独立二进制。因此,通过改变系统的Rust特定部分,我们的方法可以适用于分析其他FFI,只要它们支持LLVM后台的代码生成,例如,Haskell、Julia和Swift等语言。
疑问
-
定位算法中,怎样避免循环?
由于变量对应的内存的状态只有一个所有者,状态只能上升,所以不停的循环,一定会有结束循环的时候
-
实验评估中为什么动态链接的外部库会造成假阳性高
可能是rust源码中调用了c库代码不一定按照依赖全部下载下来了,比如glibc中的函数,或者某些特定库是在linux环境中安装配置的库,这部分源码没有收集到,此时动态连接调用这些库函数是无法进一步分析,会有预警。
-
为什么原本的用于java,python的方案用不了?
原本的大部分方案是用于java的,所采用的方案是针对于jni编程不规范,JNI在本地代码中引发的Java异常执行的异常类型检查。对于rust并不适配。
-
这个模型如何拓展,与先存的方案有什么差异?
原来的方案是用与java,python等,此论文提出的方案,其中使用LLVM IR的方式可以应用于支持LLVM 后端的语言,这样的好处是讲两种不同的语言变到了同一种层面,便于分析(汇编也是同一层面为什么不好分析)同时llvm本身提供了很多操作llvm IR的api,本身LLVM IR语言也是介于高级语言与汇编语言之间的,所以分析难度会比直接从汇编层面简单很多。
-
为什么错误检测中内存被moved进入c中,没有freed也会有一个leak的警告
moved意味这块内存被放入c,完全由c接管,rust不再负责。但是由于rust的智能指针和jemalloc等原因,c中直接处理这部分堆块(free)最后都会造成leak
原文始发于微信公众号(EchoSec安全团队):检测 Rust 中的跨语言内存管理问题
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论