本文介绍了TON区块链的关键特性,如账户状态与存储费用、异步性以及Cells数据结构。同时文章还对比了TON与EVM在安全性方面的差异,如重入漏洞、溢出/下溢以及除零错误的处理。此外,文章还探讨了TON生态系统中常见的安全漏洞,比如:无限制存储,数据格式不一致,地址认证,和不当处理回弹消息(bounced message)。
你听说过 TON 吗?
了解这个区块链生态系统是获得新视角并学习异步区块链安全性的绝佳方式。
TON (The Open Network) 是最初由 Telegram 开发,现在作为开源项目维护的区块链协议。与基于 EVM 的链和其他链不同,TON 采用了一种独特的智能合约执行和状态管理方法,这对功能和安全性都有影响。在评估平台的安全模型时,理解这些架构选择至关重要。
我们将了解 TON 的智能合约范例,并讨论该生态系统特有的一些安全考虑因素。
TON 的编程模型受到分布式系统 Actor 模型的启发。智能合约是具有代码的状态 Actor。为了响应消息,Actor 可以更新自己的状态、发送新消息和创建新 Actor。
让我们来看看 TON 的三个主要特性:账户状态和存储费用、异步性和 Cells。
每个账户都有持久性数据,可以在响应消息时修改。维护这些数据会产生存储费用,该费用会随着存储的数据量而增加,并作为支付给网络的租金。 如果某个账户无法支付这些费用,它将进入临时冻结状态,失去处理交易或执行代码的能力。这个概念对于理解 Jetton↗ 钱包至关重要,因为它们必须保持活动状态才能管理 token 余额和交易。适当的协议设计包括正确构建支付费用的激励机制。 有关更多详细信息,请参阅有关 存储费用↗ 和 账户状态↗ 的 TON 文档。
在继续之前,我们应该澄清这一点:TON 没有清晰的 EOA(外部账户)概念。每个地址,即使是像发送或接收 token 这样目的最小的地址,也由智能合约支持。存在外部消息的概念,但它本质上与 EOA 不同:它并不意味着以太坊意义上的发送者的存在。 相反,外部组件(如用户或钱包应用程序)可以向区块链发送外部消息,以触发合约并启动交易。
Actor 之间的消息是异步的。在其他生态系统中,我们可能熟悉原子调用 —— 在智能合约的执行过程中,智能合约可以从不同的合约获取数据或对其产生影响。在 TON 中,合约无法等待消息被处理以获得响应;事实上,传出消息被放置在一个队列中,该队列仅在发送消息的合约执行完成后才会被处理。
例如,如果合约 A 向合约 B 发送消息,那么 A 将继续执行其逻辑,无论这意味着终止、执行更多工作还是发出更多消息,而无需等待查看 B 的操作。这种行为有点类似于协程或异步 fire-and-forget 模式。这给开发人员带来了一些挑战;另一方面,它允许执行中更多的并行化。
FunC 是一种用于在 TON 上编写智能合约的编程语言。在 FunC 中,这些消息可以按如下方式处理。
#include "imports/stdlib.fc";
() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
;; check if incoming message is empty (with no body)
;; 检查传入消息是否为空(没有主体)
if (in_msg_body.slice_empty?()) {
;; return successfully and accept an empty message
;; 成功返回并接受空消息
return ();
}
;; parse the operation type encoded in the beginning of msg body
;; 解析在消息主体开头编码的操作类型
int op = in_msg_body~load_uint(32);
;; handle op #1 = what we want to do
;; 处理 op #1 = 我们想做的事情
if (op == 1) {
;; call our utility functions to do something like writing persistent values to storage
;; 调用我们的实用函数来做一些像将持久值写入存储的事情
something_you_want(args, ...);
}
}
recv_internal
函数负责处理由其他合约生成的传入消息。按照惯例,传入消息主体的第一个四字节包含要执行的操作的 op
标识符。此标识符类似于 Solidity 函数选择器。虽然在 Solidity 的情况下,编译器会自动为所有 public
和 external
函数生成调度程序,但在 FunC 中,开发人员负责手动处理请求的操作。
当然,某些协议功能需要与另一个 Actor 进行双向交互 —— 在 EVM 中,这可能看起来像是具有观察到的返回值的外部调用。为了实现类似的行为,TON 合约中的一种常见模式是发布消息并侦听回调消息以继续执行。
收到消息并执行与 opcode 关联的所有操作后,合约将等待下一条消息(无论是内部消息还是外部消息)才能继续执行。
TON 基于异步消息的架构通过消除大多数情况下的顺序保证,最大限度地提高了可扩展性和效率。例如,与同步系统(其中调用链(例如,a → b → c → d)必须按顺序完成)不同,TON 将每条消息作为独立的交易处理。这确保了如果 a
向 b
发送消息,它可以立即完成其状态,而无需等待链中的后续步骤。通过解耦交易,TON 能够实现并行处理,从而可能提高网络性能。
如上图所示,在 EVM 中,只有在所有调用链都完成后,才会发出交易并最终确定合约的状态。相比之下,在 TVM 中,一旦单个消息的处理完成,就会发出交易。因此,即使并非所有调用链都已完成,也可以确定合约的状态。
我们希望在我们的状态和消息中包含有意义的数据。这就引出了 TON 如何表示数据的问题。在一方面,基于 EVM 的生态系统通常将数据视为字节序列。另一方面,像 Aptos 和 Sui 这样的生态系统具有结构化的资源。在 TON 中,状态和消息中的数据以 Cells 的形式结构化。这些 Cells 最多可以存储 1,023 个可单独访问的位,以及最多四个对其他 Cells 的引用。以下是 Cell 数据类型的结构。
通常,如何将数据序列化和反序列化为 Cell 的规范是通过一种叫做 TL-B↗ 的语言定义的。 以下是一个在 TL-B 语言中如何定义消息和事务的布局的例子。
需要注意的是,FunC 语言不允许通过这种 TL-B 语言进行(反)结构化;它仅仅通过允许开发者能够通过 TL-B 语言理解数据的结构来让开发变得更容易。
message$_ {X:Type} info:CommonMsgInfoRelaxed
init:(Maybe (Either StateInit ^StateInit))
body:(Either X ^X) = MessageRelaxed X;
transaction$0111 account_addr:bits256 lt:uint64
prev_trans_hash:bits256 prev_trans_lt:uint64 now:uint32
outmsg_cnt:uint15
orig_status:AccountStatus end_status:AccountStatus
^[ in_msg:(Maybe ^(Message Any)) out_msgs:(HashmapE 15 ^(Message Any)) ]
total_fees:CurrencyCollection state_update:^(HASH_UPDATE Account)
description:^TransactionDescr = Transaction;
Cells 不能包含循环引用,无论是直接的还是间接的,这意味着任何 Cell 都不能引用自身或其任何祖先。这允许将一组 Cells 表示为一个称为 Cells 包的字节数组。
我们还应该注意到,关于 Cells 还有更多的细微之处。有关 Cells 的更多信息,包括 Cell 级别、异构 Cell 类型以及 Cell 包序列化如何工作,请参阅 TON 文档中的 此页面↗。
虽然这种数据范例可能看起来有限,但这种设计有一些潜在的好处。回想一下,EVM 具有可变存储和内存,开发人员可以编写以引用语义与数据进行状态交互的合约。在 TON 中,Cell 布局适合不可变性(事实上,所有突变都限制在几个寄存器中),这在理论上可以鼓励开发人员编写更容易推理的代码。
Cells 树状结构的另一个有趣的特性是它们非常适合表示代数数据类型。TON 白皮书↗ 中提到了这个想法,它提到 TVM 可以轻松支持多种高级范例。它设想了类似 Java 的命令式语言、惰性函数式语言和渴望函数式语言。
为了帮助理解 TON 的安全性,我们可以将其与 EVM 的安全性进行比较。尽管这两个生态系统在架构上差异很大,但从安全性的角度来看,它们也有许多不同之处。
让我们看看可能在 EVM 的 Solidity 中出现的安全问题在 TON 的 FunC 中是如何表现的。
重入问题构成了基于 EVM 的智能合约中的一个主要漏洞类别。当一个函数(例如,withdraw
)在更新存储中的数据(例如,user_balance
)之前调用外部合约时,就会发生此漏洞。在这种情况下,如果外部合约函数回调到第一个合约,则代码可能会观察到不一致的状态并执行不正确的操作。在 withdraw
的情况下,重入问题可能导致多次提款,最著名的例子是现在臭名昭著的 DAO hack↗。
如果 TON 在状态更新发生之前与外部合约通信,你是否期望 TON 也存在重入漏洞?
如前所述,TVM 没有公开执行同步调用的方法。它只允许发送异步消息。因此,重入漏洞无法以与基于 EVM 的生态系统完全相同的方式表现出来。但是,TVM 合约可能存在类似的问题,即收到意外消息,破坏了合约的预期逻辑流程。也可能发生预期消息未被发送(或被反弹),而未正确重置合约的逻辑流程。
换句话说,有理由假设 TON 的通信模式排除了我们熟悉的重入漏洞。但另一方面,异步性为开发人员引入了新的安全考虑因素。
在 0.8.0 版本之前,Solidity 没有防范整数运算中的溢出和下溢,从而导致许多严重的漏洞。
众所周知,当整数超出可用于存储它的位数所能表示的最大值或最小值时,就会发生溢出和下溢。你认为 TON 将如何处理此漏洞?
在 TON 和 EVM 中,如果你尝试通过加法或减法超出最大值和最小值,两者都会返回有关整数的错误并恢复交易。特别是,TVM 将抛出 退出代码 4↗(整数溢出退出代码)并强制退出。这也是对基本漏洞的必要响应。
总之,在 TON 中,通过以整数溢出退出代码退出可以防止基本加法和减法的溢出/下溢。此外,比较 NaN 值也会返回溢出退出代码并终止 VM。这意味着像 EVM 一样,TON 也存在此漏洞。
这确实是正确的方法;但是,在 TON 中,有一个重要的注意事项需要考虑。如果由恢复触发的 bounced message
未被发送恢复后的交易的原始合约正确处理,则可能导致严重问题。我们将在下面的 未正确处理退回消息 部分中更详细地介绍这一点。
Solidity 中的除以零问题会完全恢复交易。这可以通过 require 语句来防止,从而更容易确定错误来源。这次,让我们考虑一下如何在 TON 中处理此漏洞。
与 EVM 一样,TON 也没有浮点数类型,这意味着无法在进行除法时计算出小数位的精确值。此外,对于有余数的除法(例如,5/2),结果会向下舍入并返回 2。
那么,如果你将某个值除以零会发生什么?是的,当然会抛出错误。并且它会抛出 TVM 退出代码 4。退出代码 4 表示整数溢出。这意味着,在发生除以 0 的 TVM 中,交易将恢复并返回整数溢出错误。
如果你想在你的本地环境中自行测试 TON 中的溢出、下溢和除以零,你可以在 此处↗ 查看测试代码并进行测试。
除此之外,你还应该注意 TON 生态系统中可能发生的常见漏洞模式。
其他生态系统中的一个常见问题是无限制的执行;由于我们通常在执行操作时支付 gas 费,因此像无限循环这样的长时间执行可能会导致失败。这些类型的问题也可能存在于 TON 中,但关于存储大小也有独特的考虑因素。
首先,开发人员应该对 Actor 存储负责。数据量和数据深度是有限的,协议设计中的错误或导致累积存储的编码错误可能会迅速导致合约失败。此外,必须小心对待用户输入。在典型的 EVM 合约中,特别是用 Solidity 编写的合约中,message 数据被读取为有界结构。但是在 TON 中,由于所有数据(甚至 message 数据)都存储在 Cells 中,因此外部输入可能会出乎意料地深。重要的是避免随意将用户输入插入到存储中,因为它可能比预期的要大。
在 TON 生态系统中,数据结构通常使用一种叫做 TL-B 的语言来描述。但是,即使存在机器可读的描述,FunC 也没有为开发人员提供任何帮助,并且需要以单个位的级别操作数据 —— 包括在存储到合约存储或从中读取数据、解析传入消息或创建传出消息时。
手动序列化和反序列化数据很复杂,并且很容易导致错误。重要的是确保数据的一致性和正确使用,无论是从外部传送的合约的原始消息,还是存储在全局存储中的数据。
看看下面的例子。你能发现错误吗?
global int balance;
global int owner;
global int admin;
global cell credential;
() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
if (op == ...) {
slice ds = get_data().begin_parse();
balance = ds~load_uint(64);
owner = ds~load_uint(256);
admin = ds~load_uint(256);
credential = ds~load_ref();
;; process we must do...
;; 我们必须做的处理...
set_data(
begin_cell()
.store_uint(balance, 32)
.store_uint(owner, 256)
.store_uint(admin, 256)
.store_ref(credential)
.end_cell()
);
return ();
}
}
正如你所看到的,此代码将余额存储为 32 位,但将其加载为 64 位。这意味着余额丢失了上面的 32 位,并且后续的变量也与之前的变量混合在一起。在这种情况下,问题很明显,因为没有太多代码并且数据结构很简单,但是在现实世界中,通常更难以审查代码并确保数据正确地序列化和反序列化。
TON 生态系统中的许多项目都使用两个函数,通常命名为 store_data
和 load_data
,负责序列化和反序列化合约的所有存储变量。通常还会定义类似的函数来反序列化传入消息和序列化传出消息。尽管仍然在位级别访问数据,但这种软件工程最佳实践使得审查代码和发现错误变得容易得多。
让我们再次看一下下面的例子:
global int global_balance;
global int global_owner;
global int global_admin;
global cell global_credential;
() store_data() impure {
set_data(
begin_cell()
.store_uint(global_balance, 64)
.store_uint(global_owner, 256)
.store_uint(global_admin, 256)
.store_ref(global_credential)
.end_cell()
);
}
() load_data() impure {
slice ds = get_data().begin_parse();
global_balance = ds~load_uint(64);
global_owner = ds~load_uint(256);
global_admin = ds~load_uint(256);
global_credential = ds~load_ref();
}
() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
int op = in_msg_body~load_uint(32);
if (op == ...) {
load_data();
;; process we must do...
;; 我们必须做的处理...
store_data();
return ();
}
}
上面的例子演示了基于预定比特大小的正确的加载和存储过程。它还在函数级别上结构化了数据加载和存储。通过坚持这些用于加载和存储的预定义位单元来保持数据格式的一致性至关重要。
此外,将代码分成函数单元极大地简化了出现问题时的故障排除。通过明确定义每个函数中的职责,可以更容易地查明和解决任何错误的根本原因。
在 TON 中,几乎所有的操作都是在合约的基础上执行的。例如,钱包也以合约的形式存在。因此,很自然地,地址管理在 TON 中也被认为非常重要。与许多生态系统一样,地址验证非常重要,尤其是确保所有生态系统(包括 EVM)都检查消息的发送者是谁。
最重要的是,即使在合约中,根据你想要执行的操作(大多数操作用 op
表示),你不仅需要彻底验证一个地址,还需要验证多个地址。
消息的发送者包含在 in_msg_full
Cell 中。该 Cell 包含源地址,后跟消息标志(在最开始的四位中)。验证此发送者时,我们通常按如下方式进行。
global slice verification::valid_addr;
() recv_internal(int msg_value, cell in_msg_full, slice in_msg_body) {
slice cs = in_msg_full.begin_parse();
;; or int flags = cs~load_uint(4); to check message flags.
;; 或 int flags = cs~load_uint(4); 检查消息标志。
cs~skip_bits(4);
slice sender_addr = cs~load_msg_addr();
int op = in_msg_body~load_uint(32);
if (op == ...) {
...
throw_unless(error::invalid_address, equal_slices(sender_addr, verification::valid_addr));
...
}
}
由于 TON 中的所有操作都是基于每个合约执行的,因此通常需要为每个操作准确验证发送者地址,这就是为什么我们应该像上面的例子一样验证地址。请务必仔细编写你的合约,因为如果你没有正确验证地址,你最终可能会让一个非预期的合约执行非常重要的操作(例如,提款、铸币、销毁)。
在上面的例子中,我们能够在合约状态中显式地持有授权地址。但这并非总是可能的。例如,一个协议可能会为它的每个用户部署一个合约。但是,正如我们之前所讨论的,将所有这些授权合约记录到存储中是不可行的。相反,我们利用地址的派生方式:Actor 通过其初始状态和代码进行寻址。
TON 的 token 标准 Jetton 是这种模式的一个典型例子。在 EVM 中,我们可以通过在单个中心化合约中存储余额来实现 token。但这在 TON 中是有问题的,因为存储的大小会因此随着用户数量的增加而增加。
相反,规范的 Jetton 实现允许用户部署一个钱包 Actor 来跟踪余额。但是,这些钱包 Actor 需要将彼此识别为可信的 —— 否则,有什么可以阻止恶意合约伪装成代表用户余额并启动转账?
解决方案很简单:我们可以检查钱包是否具有正确的代码和初始状态,相对于其用户。
storage#_ balance:Coins owner_address:MsgAddressInt jetton_master_address:MsgAddressInt jetton_wallet_code:^Cell = Storage;
Minter 合约的存储结构包括一个内容 Cell,其中包含关于 Token 的元数据,即使是试图欺诈的人也无法伪造,以及一个管理 Minter 的 admin_address
。合约使用此数据作为 init_data
部署。如前所述,合约地址是通过从 init_data
和 init_code
创建状态来生成的。这意味着地址由数据和合约代码决定,允许我们计算地址并验证给定的 Jetton Minter 是真品还是赝品。
钱包合约遵循相同的原则。钱包的存储结构包括 jetton_master_address
和 owner_address
,两者都不能更改。通过使用此信息,我们可以计算一个明确的地址,从而很容易确定地址是否正确和真实。
当 jetton_wallet
发送或接收 token 时,计算钱包地址至关重要。此过程在 jetton-utils.fc↗ 中定义。
cell pack_jetton_wallet_data(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return begin_cell()
.store_coins(balance)
.store_slice(owner_address)
.store_slice(jetton_master_address)
.store_ref(jetton_wallet_code)
.end_cell();
}
cell calculate_jetton_wallet_state_init(slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return begin_cell()
.store_uint(0, 2)
.store_dict(jetton_wallet_code)
.store_dict(pack_jetton_wallet_data(0, owner_address, jetton_master_address, jetton_wallet_code))
.store_uint(0, 1)
.end_cell();
}
slice calculate_jetton_wallet_address(cell state_init) inline {
return begin_cell().store_uint(4, 3)
.store_int(workchain(), 8)
.store_uint(cell_hash(state_init), 256)
.end_cell()
.begin_parse();
}
slice calculate_user_jetton_wallet_address(slice owner_address, slice jetton_master_address, cell jetton_wallet_code) inline {
return calculate_jetton_wallet_address(calculate_jetton_wallet_state_init(owner_address, jetton_master_address, jetton_wallet_code));
}
calculate_user_jetton_wallet_address
函数将钱包的 owner_address
、Jetton Minter 地址和 Jetton 钱包代码作为参数来生成实际地址。这允许我们确定发送或接收 token 的地址,并验证 token 是从哪个钱包转移的。
在实践中,当 Jetton Minter 铸造一个 token 时,它会计算钱包的地址并部署它(或将 token 发送到已部署的钱包地址),如下所示。
() mint_tokens(slice to_address, cell jetton_wallet_code, int amount, cell master_msg) impure {
cell state_init = calculate_jetton_wallet_state_init(to_address, my_address(), jetton_wallet_code);
slice to_wallet_address = calculate_jetton_wallet_address(state_init);
var msg = begin_cell()
.store_uint(0x18, 6)
.store_slice(to_wallet_address)
.store_coins(amount)
.store_uint(4 + 2 + 1, 1 + 4 + 4 + 64 + 32 + 1 + 1 + 1)
.store_ref(state_init)
.store_ref(master_msg);
;; pay transfer fees separately, revert on errors
;; 单独支付转账费用,恢复错误
send_raw_message(msg.end_cell(), 1);
}
Jetton 钱包通过调用 receive_tokens
函数来处理收到的 Jetton token 金额,该函数更新钱包合约的 balance
变量以存储新的 token 金额。
此外,它通过使用先前解释的地址计算方法验证传入的 Jetton 数额来确保 token 安全。此验证确认 token 是否来自 Jetton master (Minter) 或有效的钱包地址。
发送 token 的过程遵循类似的方法。
;; incoming transfer
;; 传入转移
if (op == op::internal_transfer()) {
receive_tokens(in_msg_body, sender_address, my_balance, fwd_fee, msg_value);
return ();
}
() receive_tokens (slice in_msg_body, slice sender_address, int my_ton_balance, int fwd_fee, int msg_value) impure {
;; NOTE we can not allow fails in action phase since in that case there will be
;; no bounce. Thus check and throw in computation phase.
;; 注意,我们不能允许在操作阶段失败,因为在这种情况下不会有反弹。因此,请在计算阶段进行检查和抛出。
(int balance, slice owner_address, slice jetton_master_address, cell jetton_wallet_code) = load_data();
int query_id = in_msg_body~load_uint(64);
int jetton_amount = in_msg_body~load_coins();
balance += jetton_amount;
slice from_address = in_msg_body~load_msg_addr();
slice response_address = in_msg_body~load_msg_addr();
throw_unless(707,
equal_slices(jetton_master_address, sender_address)
|
equal_slices(calculate_user_jetton_wallet_address(from_address, jetton_master_address, jetton_wallet_code), sender_address)
);
...
save_data(balance, owner_address, jetton_master_address, jetton_wallet_code);
}
在 TON 中,必须比其他生态系统更加谨慎地处理意外的交易失败。
由于 TON 虚拟机及其消息传递架构的设计,与多个合约交互需要发送多个消息,每个消息都会导致单独的交易。例如,像 A → B → C 这样的合约调用链将涉及两个不同的交易:一个是从 A 到 B,另一个是从 B 到 C。
现在,如果从 B 到 C 的交易失败会发生什么?
在 TON 中,合约 C 中的状态更改不会被提交 —— 它们会被丢弃。但是,由于 A 的消息而导致的合约 B 中的任何状态更改仍然会被提交。这会创建一个非对称状态:链的一部分成功,一部分失败。
如果 B 修改了任何敏感状态 —— 比如余额或访问权限 —— 即使下游合约 (C) 失败,这些更改也会持久存在。这可能导致意想不到的后果。
为了解决这个问题,TON 使用了一种叫做 退回消息 的机制。当合约调用失败时,TON VM 会向发送者发送一条退回消息,通知它失败。
如果一个期望收到退回消息的合约忽略了它 —— 或者更糟糕的是,错误地处理了它 —— 它可能会使其状态处于不一致或容易受到攻击的状态。这可能导致经济损失、权限错误,甚至可利用的错误。
正确处理退回消息不是可选项。这是 TON 上安全合约所必需的。
TON 与其他生态系统不同。尽管 FunC 受制于区块链上的一些常见漏洞,但其独特的设计使其容易受到不常见的威胁。尽管我们在这篇文章中深入研究了一些,但对 TON 的新介绍,例如 Tact 语言,可能会使其容易受到更多漏洞的影响。
在本系列的下一篇文章中,我们将探讨 TON 的新语言(如 Tolk 和 Tact)与 FunC 的不同之处,并且我们将更深入地研究 TVM 内部结构。
我们希望你通过全面的安全审计在 TON 生态系统中启动你的服务。无论是开发问题、设计模式问题、经济问题还是基本安全问题,我们都在这里↗提供帮助!
Zellic 专注于保护新兴技术。我们的安全研究人员发现了最有价值目标中的漏洞,从财富 500 强公司到 DeFi 巨头。
开发人员、创始人和投资者信任我们的安全评估,以便快速、自信地发布,而不会出现严重漏洞。凭借我们在现实世界攻防安全研究方面的背景,我们发现了其他人错过的东西。
联系我们↗ 进行比其他更好的审计。真正的审计,而不是橡皮图章。
- 原文链接: zellic.io/blog/ton-secur...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!