本文介绍了Move语言的二进制格式和汇编语言,强调了智能合约审计人员需要了解此类低级编程的必要性。文章详细讨论了Move虚拟机模型、主要的类型规则以及Move的验证器,提供了实例和工具来简化Move汇编的编写过程。最后,文中展示了如何在Move沙箱中部署和测试模块,包括添加逻辑炸弹后门的示例。
这是针对真正程序员的 Move 教程。每个人都知道真正的程序员只用汇编语言来编写代码。在这种情况下,是 Move 字节码。
说回正题,智能合约审核员熟悉 Move 二进制格式和 Move 汇编语言是非常有用的,因为最近基于 Move 的链,如 Sui 和 Aptos,变得非常流行。在这篇博客中,我们将介绍 Move 二进制格式和 Move 汇编语言,以及一个可以简化编写 Move 汇编代码的工具。
在 Move 语言中,主要有两种类型的程序:模块和脚本。模块是永久部署的程序,旨在链上运行;脚本是临时程序,旨在由用户从链外运行。基于 Move 的链可能还有自己的自定义类型。总体而言,本讨论适用于这两种情况。然而,也有一些特定类型的细节。我们将讨论最一般的程序类型,即模块。
Move 二进制格式是一个非常简单的格式。它以魔数和版本开始;接着是表Handle的列表,包含文件中表的位置和长度;最后是表本身,以及一个自引用索引(稍后解释)。可以把表想象成一系列原始二进制数据,不同于 ELF 节头。虽然可以轻松编写自定义工具解析它,但使用 Move 的 crate 来实现这个目的是更简单的↗。
这些表包含可能通过(通常是 16 位)索引从其他表引用元素的条目列表。在反序列化时,检查表以确保每个字节在文件的表区域中恰好出现一次。文件中包含表的部分的每个字节必须属于恰好一个表,并且所有表必须是连续的。对于模块,描述该文件中的模块的模块Handle(所有在该文件中引用的模块都有模块Handle)的索引(自引用索引)位于文件的表之后。对此类值,将其放在文件开头(在文件头中)是有意义的,但它在文件的末尾。甚至 Move 开发者也认为这很不寻常。
// 加载模块索引(自引用 ID) - 位于二进制的末尾。为什么?
表之间的关系以及表的目的非常复杂(且繁琐),因此我们不会在这里完全描述它们。请参见 源代码↗ 获取完整列表和一些内联文档。在 Move 基于链使用的不同 Move 版本之间,二进制格式得到了相当好的保留。
Move 虚拟机是一个基于栈的机器。与传统的机器架构不同,Move 虚拟机不是在字节级别操作,而是在对象级别操作。机器级对象是可以存储的最小数据单元。对象包括复杂数据类型,如结构体,以及原始数据类型,如 u256
。
进入函数时,有若干寄存器(“局部变量”)可用,栈的大小是有限的(由父链配置)。所有需要操作数的指令都要求先将操作数放到栈上。栈可以包含任何数据类型,但寄存器是有类型的,这意味着它们只能包含该类型的对象。每个函数声明可用的寄存器的数量和类型。进入函数时,前 x 个寄存器包含传给该函数的 x 个参数。因此,前 x 个寄存器的类型由函数的参数类型定义。
指令 | 栈之前 | 栈之后 |
---|---|---|
调用 | arg1, arg2, arg3, …, argn | return_value |
弹出 | val | |
LdTrue | True | |
注意:栈顶在右侧。 |
Move 字节码是高级的,Move 函数不是单态化的。Move 函数除了常规参数外,还接受类型参数。在使用类型参数时,必须确保类型参数符合函数的不变条件。
至于 Move 汇编,当前 源代码↗ 是最好的文档来源。
在这个水平上工作的主要动机之一是审核验证器。编译器不会生成能破坏验证器的代码,除非编码错误。撇开这一点,也有必要理解验证器,以便编写能在 Move 链上运行的汇编代码。与大多数汇编语言不同,Move 字节码有一套非常严格的规则,必须遵循这些规则才能通过验证器。我们将在此详细解释这些规则。
在这一点上,有必要澄清我们正在使用核心 Move。然而,验证器在不同的 Move 链之间大致保持不变。有相当多的函数用作进入 Move 验证器的入口点。调用者可以传入参数,以确定施加的限制。不同的链倾向于使用不同的入口点和选项。在撰写本文时,核心 Move 中的所有此类选项是数值限制,仅用于防止资源枯竭攻击。总体而言,很难意外达到这些限制,因此我们将忽略它们。
验证器工作的最小单元是基本块。基本块是 Move 汇编代码的基本单元。它是一个不包含任何跳转或跳转目的地的连续 Move 字节码部分。然而,调用指令并不被视为跳转,尽管控制权转移。
验证器的主要要求如下:
真正的引用规则是极为复杂的,并涉及到借用图的广泛讨论。我们在这里提供一个简化的版本。有关完整引用规则,请参见 源代码↗。
了解了所有这些,编写 Move 汇编时,仍然需要手动将 Move 二进制格式输入到 Rust 代码中,并调用序列化器。为了使这个过程更容易,我们创建了 movetool↗。此工具允许将 Move 二进制模块反编译为自定义汇编格式,并将源代码组装回二进制模块。它目前存在一些小问题,但也能完成任务。
编写 Move 汇编的主要动机之一是审核 Move 验证器。为此,我们还包含了一种直接调用验证器并在工具中打印结果的方法。
Move 编译器通常不会生成无效果的代码。我们创建的模块将无法使用 Move 编译器创建。我们将编写一个简单的函数,添加两个数字并返回结果,还计算这两个数字的异或但丢弃该结果。
Move 模块涉及大量的样板代码,因此我们将从创建一个新的 Move 项目并进行修改开始。
move new addxor
我们在 sources/addxor.move
中编写以下代码。
module addxor::addxor {
public fun add(a: u64, b: u64): u64 {
a + b
}
}
然后我们必须在 Move.toml
中设置 addxor
的地址,如下所示,以便项目能够成功构建。
[addresses]
addxor = "0x1337"
然后我们构建并反汇编它。
move build
movetool dis < build/addxor/bytecode_modules/addxor.mv > addxor.mvasm
我们在 addxor.mvasm
中得到以下输出。
.type module
.version 6
.self_module_handle_idx 0
.table module_handles
; address_idx identifier_idx
0 1
.endtable
.table struct_handles
; abiltiies,cdsk module_idx identifier_idx
.endtable
.table function_handles
; module_idx identifier_idx parameters_sig_idx return_sig_idx type_parameters...,cdsk
0 0 0 1
.endtable
.table field_handles
; struct_def_idx member_count
.endtable
.table friend_decls
; address_idx identifier_idx
.endtable
.table struct_def_instantiations
; struct_def_idx type_params_signature_idx
.endtable
.table function_instantiations
; function_handle_idx type_params_signature_idx
.endtable
.table field_instantiations
; field_handle_idx type_params_signature_idx
.endtable
.table signatures
; arrays of types, for specifics see source code
[u64, u64]
[u64]
[]
.endtable
.table identifiers
; literal string identifiers
add
addxor
.endtable
.table address_identifiers
; addresses in hex
00000000000000000000000000000000
00000000000000000000000000000000
.endtable
.table constant_pool
; type encoded_value_in_hex
.endtable
.table metadata
; key value
.endtable
.table struct_defs
; structs can be native or declared
.endtable
.table function_defs
; function_handle visibility_public_private_friend is_entry
.func 0 public false
; indices of struct definitions acquired by this function
.acquires
.locals 2
moveloc 0
moveloc 1
add
ret
.endfunc
.endtable
反汇编的模块大致遵循二进制模块的布局。我们关心的表是函数定义表,包含实际的字节码。从那里,我们可以看到参数的值被移动到栈上并相加。当返回指令运行时,返回值位于栈顶。
我们现在将添加代码来计算异或并丢弃结果。为此,我们必须复制参数,因为需要将参数保留以便进行加法计算。将以下代码添加到 .locals 2
行后。
copyloc 0
copyloc 1
xor
pop
然后我们组装代码并验证它。
movetool asm < addxor.mvasm > addxor.mv
movetool verify < addxor.mv
接下来,我们将其部署到 Move 沙箱中。首先,通过部署原始合约来设置 Move 沙箱。
move sandbox publish
Move 沙箱将其合约部署到“区块链”的“storage/”中,addxor.mv
存储在 storage/0x00000000000000000000000000001337/modules/addxor.mv
中。我们可以通过将二进制模块复制到相应位置来替换该合约。
cp addxor.mv storage/0x00000000000000000000000000001337/modules/addxor.mv
我们可以编写如下脚本并将其放入 sources/calladdxor.move
中以测试该程序。由于某种原因,std::debug
在 Move 沙箱中不起作用,因此我们将使用一个 abort 来检查返回值。
script {
use addxor::addxor;
fun main() {
let x = addxor::add(1, 2);
assert!(x != 3, 1);
}
}
我们按以下方式运行它,发现确实会中止。
move sandbox run sources/calladdxor.move
执行在交易脚本中中止,代码为 1
现在让我们在 addxor
模块中添加一个后门,如果第一个参数是 1337 则中止。我们将函数的代码更改如下。
copyloc 0
copyloc 1
xor
pop
copyloc 0
ld64 1337
eq
br_true 12
moveloc 0
moveloc 1
add
ret
ld64 1338
abort
我们编译它并像之前一样将其加载到 Move 沙箱中。
movetool asm < addxor.mvasm > addxor.mv
movetool verify < addxor.mv
cp addxor.mv storage/0x00000000000000000000000000001337/modules/addxor.mv
然后,我们更改脚本,通过将第一个参数改为 1337 来触发后门。运行脚本将给出以下结果。
执行在模块 00000000000000000000000000001337::addxor 中中止,代码为 1338。
显然,我们使用 Move 汇编添加的后门被触发了。
Move 的威胁格局不断演变。越来越多基于 Move 的链正在发布,每条链都有其独特的原生对象和潜在的新虚拟机功能。我们已经提供了关于 Move 的二进制格式、机器模型和验证器的介绍,以及一个让你开始的工具——但还有很多需要研究和探索的内容。是时候行动了!
Zellic 专注于确保新兴技术的安全。我们的安全研究人员在最具价值的目标中发现了漏洞,从财富 500 强到去中心化金融巨头。
开发者、创始人和投资者信任我们的安全评估,快速、自信地发布,没有关键漏洞。凭借我们在现实世界攻防安全研究中的经验,我们发现了别人遗漏的内容。
与我们联系↗ 获取比其他地方更好的审计服务。真正的审计,而非走过场。
- 原文链接: zellic.io/blog/introduci...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!