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 立場無關。文章內的信息僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。
歡迎加入 Web3Caff 官方社群:X(Twitter)賬號丨微信讀者群丨微信公眾號丨Telegram訂閱群丨Telegram交流群