CertiK 与 TON 联合发布了最新的 TON 生态开发者指南,旨在帮助开发者在使用 Tact 语言编程时避免常见错误与陷阱。

作者:Certik

封面:Photo by ilgmyzin on Unsplash

TON(The Open Network)以其创新特性和强大的智能合约性能,不断拓宽区块链技术的边界。基于早期的区块链平台(如以太坊等)的经验与教训,TON 为开发者提供了一个更加高效且灵活的开发环境。其中推动这一进步的关键要素之一便是 Tact 编程语言。

Tact 是专为 TON 链设计的一种全新编程语言,以高效与简洁为核心目标。它易于学习和使用,并与智能合约完美契合。Tact 是一种静态类型语言,拥有简单的语法和强大的类型系统。

尽管如此,开发者在使用 FunC 时遇到的许多问题,在 Tact 开发中仍然存在。以下将结合审计实践案例,分析 Tact 开发中的一些常见错误。

数据结构

可选地址

Tact 语言简化了声明、解码和编码的数据结构。然而,开发者仍需保持谨慎。我们来看一个例子:

这是根据 TEP-74[1]标准用于转移 jetton 的内部传输(InternalTransfer)消息声明。请注意 response_destination 的声明,它是一个地址(Address)类型。在 Tact 中,要求地址必须是非零地址。然而,jetton 标准的参考实现 [2]允许零地址(addr_none),它由两个零位表示。这意味着用户或其他合约可能会尝试发送带有零响应地址的 jetton,而该操作会意外失败。

此外,如果用户发送给其钱包的 Transfer 消息允许设置 response_destination,而从发送方钱包到接收方钱包的 InternalTransfer 消息却不支持该参数,那么 jetton 将会 “飞出”,意味着 jetton 无法到达目标地址,最终导致丢失。稍后,我们将讨论一种例外情况,即如何正确处理被退回的消息。息会被妥善处理。

在这种情况下,允许零地址的更好结构声明应为 Address?,但在 Tact 中,将可选地址传递到下一条消息目前较为繁琐。

数据序列化

在 Tact 中,开发者可以指定字段的序列化方式。

本例中,totalAmount 将序列化为 coins,而 releasedAmount 将序列化为 int257(默认为 Int)。releasedAmount 可以是负值,并且将占用 257 位。在大多数情况下,省略序列化类型不会带来问题;然而,如果数据涉及到通信,这就变得至关重要。

以下是我们审计的项目中的一个例子:

该数据结构是由 NFT 项目用作对链上 get_static_data[3]请求的回复。根据标准,回复应该是:

上述索引是 uint256(而不是 int257),这意味着返回的数据将被调用者错误解读,从而导致不可预测的结果。很可能的结果是 report_static_data 处理程序会发生回滚,消息流也会因此中断。这些例子说明了为什么即使在使用 Tact 时,考虑数据序列化也是至关重要的。 

有符号整数

不指定 Int 的序列化类型可能会导致比上述示例更严重的后果。与 coins 不同,int257 可以为负值,这常常会让程序员感到意外。例如,在 Tact 的实时合约中,看到 amount: Int 是极其常见的。

这种写法本身并不一定意味着存在漏洞,因为该金额(amount)通常会被编码到 JettonTransfer 消息中,或传递到 send(SendParameters{value: amount}),后者使用的是 coins 类型,不允许负值。然而,在一个案例中,我们发现一个拥有大量余额的合约,它允许用户将所有值设置为负数,包括奖励、手续费、金额、价格等。因此,恶意行为者可能利用这一漏洞进行攻击。

并发

以太坊链的开发者必须注意重入攻击,即在当前函数执行完成之前,能够再次调用同一个合约的函数。而在 TON 链上,重入攻击是不可能的。

由于 TON 是一个支持异步和并行智能合约调用的系统,追踪处理动作的顺序可能变得更加困难。任何内部消息都会被目标账户接收,交易结果会在交易本身之后处理,但并没有其他保证(有关消息传递的更多信息请参见相关文档 [4])。

我们无法预测消息 3 或消息 4 哪个会先被送达。

在这种情况下,中间人攻击(Man-in-the-Middle Attack)[5]在消息流中是高发的攻击类型。为了确保安全,开发者应该设定每条消息的传递时间为 1 到 100 秒,在此期间,任何其他消息都有可能被传递。以下是一些可以提高安全性的其他注意事项:

1. 不要检查或更新合约状态以供消息流的后续步骤使用。

2. 使用携带值模式(carry-value pattern)[6]。不要直接发送有关值的信息,而是与消息一起发送。

以下是一个存在漏洞的真实例子:

在上述示例中,发生了以下步骤:

1. 用户通过 collection_jetton_wallet 向 NftCollection 发送 jetton。

2.TransferNotification 被发送到 NftCollection 合约,合约记录了 received_jetton_amount。

3. 合约将 jetton 转发给 NftCollection 的所有者。

4. 向 NftCollection 发送 Excesses 消息,作为 response_destination。

5. NftItem 在 Excesses 处理程序中部署,使用 received_jetton_amount。

这里有几个问题需要注意:

首先,Excesses 消息并不能保证按照 jetton 标准被送达。如果没有足够的 gas 费来发送 Excesses 消息,它将被跳过,消息流将停止。

其次,更新 received_jetton_amount 并在后续使用它会使系统容易受到并发执行的影响。其他用户可能会同时发送另一个金额并覆盖已保存的金额,这也可能会被恶意利用以从中获利。

在并发的情况下,TON 与传统的中心化多线程系统相似。

处理退回消息

许多合约忽视了退回消息的处理。然而,Tact 使这一过程变得简单明了:

要决定消息是否应以可退回模式发送,可以考虑两个因素:

1. 如果消息失败,谁应该收到附加的 Toncoin?如果目标应该接收这些资金,而不是发送合约,那么就以非可退回模式 [7]发送消息。

2. 如果下一个消息被拒绝,消息流会发生什么?如果通过处理退回的消息可以恢复一致的状态,那么最好进行处理。如果不能恢复,最好修改消息流。

以下是 jetton 标准 [8]中的一个例子:

1. Excesses 消息以非可退回模式发送,因为合约不需要返回 toncoins。

2. 以非可退回模式发送 TransferNotification 消息,因为 forward_ton_amount 属于调用者,合约不会保留它。

3. 相反,BurnNotification 是以可退回模式发送,因为如果它被 jetton 主合约退回,钱包需要恢复其余额,以保持 total_supply 一致。4. InternalTransfer 也是可退回的。如果接收方拒绝资金,发送方的钱包必须更新余额。请记住以下几点:

1. 退回消息仅接收 256 位 [9]的原始消息;在消息识别之后,有效数据仅有 224 位。因此,你将得到有限的关于失败操作的信息,通常是存储为 coins 的某个金额。

2. 如果没有足够的 gas 费,退回的消息将无法送达。3. 退回消息本身无法再次被退回。 

返回 Jetton

在某些情况下,撤销和处理退回消息不是一个选项。最常见的例子是当你的合约收到 TransferNotification 关于到达的 jetton 时,退回该消息可能会导致 jetton 永远被锁定。相反,你应该使用 try-catch 块 [10]来处理。

让我们来看一个例子。在 EVM 中,当一笔交易被撤销时,所有结果都会被回滚(除了 gas——它会被矿工收取)。但在 TVM 中,“交易” 被分解为一系列消息,因此只回滚其中一条消息很可能会导致 “合约组” 状态不一致。

为了解决这个问题,必须手动检查所有条件,并在紧急情况下来回发送修正消息。然而,由于在没有异常的情况下解析有效载荷非常繁琐,因此最好使用 try-catch 块。

下面是一个典型的 Jetton 接收代码示例:

请注意,如果 gas 费不足,即使是将 jettons 发送回去也无法正常工作。此外,需要注意的是,我们是通过 sender() 的 “钱包” 返还 jetton,而不是通过我们合约的实际 jetton 钱包返还。这是因为任何人都可以手动发送 TransferNotification 消息来欺骗我们。

管理 Gas 费

在审计 TON 合约时,最常见的问题之一就是 gas 费管理问题。主要原因有两个:

1. 缺乏 gas 费控制可能导致以下问题

消息流执行不完整:部分操作会生效,而另一部分由于 gas 不足而被回滚。例如,如果奖励获取操作在 jetton 钱包中完成,但销毁份额操作在 jetton 主合约中被忽略,那么整个合约组将变得不一致。

用户可以提取自己的合约余额:此外合约中可能会积累过多的 Toncoin。2. TON 合约开发者难以管理和控制 gas:Tact 的开发者需要通过测试来获得 gas 消耗量,并在开发过程中每次更新消息流时都更新相应的数值。

我们建议的做法如下:

1. 确定 “入口点”:这些是所有可以接受来自 “外部” 消息的消息处理器,即来自终端用户或其他合约(如 Jetton 钱包)。

2. 对于每个入口点,绘制所有可能的路径并计算 gas 消耗。使用 printTransactionFees()(可在 @ton/sandbox 中找到,该工具随 Blueprint[11]一起提供)。

3. 如果可以在消息流过程中部署合约,则假设它将被部署。部署将消耗更多的 gas 费和存储费用。4. 在每个入口点,根据情况添加最低的 gas 要求。

5. 如果处理器不发送更多消息(消息流在此终止),那么最好返回 Excesses,如下所示:

不发送 Excesses 也是可以的,但对于像 Jetton Master 这样的高吞吐量合约,存在大量 BurnNotification 消息或大量传入转账的合约,累计金额可能会迅速增长。

6. 如果处理器只发送一条消息——包括 emit(),实际上是一个外部消息——最简单的方式是通过 forward() 传递剩余的 gas 费(见上文)。

7. 如果处理器发送多条消息,或者如果通讯中涉及 ton 数量,那么计算应发送金额比计算应剩余金额要更容易。

在下一个例子中,假设合约希望将 forwardAmount 发送给两个子合约作为押金:

正如你所看到的,gas 费管理需要高度关注,即使是在简单的情况下。请注意,如果你已经发送了消息,则不能在 send() 模式中使用 SendRemainingValue 标志,除非你故意想要从合约余额中支出资金。

结论

随着 TON 生态系统的发展,Tact 智能合约的安全开发将变得越来越重要。虽然 Tact 提供了更高的效率和简洁性,但开发者必须保持警惕,避免常见的陷阱。通过了解常见错误并实施最佳实践,开发者可以充分开发 Tact 的潜力,创建强大而安全的智能合约。持续学习并遵循安全实践指南,将确保 TON 生态的创新能力得到安全有效地利用,从而为更安全、可信的区块链环境作出贡献。

[1] TEP-74: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md#1-transfer

[2] 参考实现: https://github.com/ton-blockchain/token-contract/

[3] get_static_data: https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md#2-get_static_data

[4] 相关文档: https://docs.ton.org/develop/smart-contracts/guidelines/message-delivery-guarantees#message-delivery

[5] 中间人攻击: https://docs.ton.org/develop/smart-contracts/security/secure-programming#3-expect-a-man-in-the-middle-of-the-message-flow

[6] 携带值模式: https://docs.ton.org/develop/smart-contracts/security/secure-programming#4-use-a-carry-value-pattern

[7] 非可退回模式: https://docs.ton.org/develop/smart-contracts/guidelines/non-bouncable-messages

[8] jetton 标准: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md#1-transfer

[9] 仅接收 256 位: https://docs.tact-lang.org/book/bounced/#caveats

[10] try-catch 块: https://docs.tact-lang.org/book/statements#try-catch

[11] Blueprint: https://github.com/ton-org/blueprint?tab=readme-ov-file#overview

免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。