科普:給人驚嚇的代碼

開發者需要考慮使用safe 函數族的風險,並考慮外部調用會怎樣跟自己寫下的代碼交互。

如果你也是軟件工程師,你應該聽過一些軟件工程的格言。雖然我並不主張嚴格遵守每一句格言,但有一些值得你放在心上。

今天我想講的是“ 最少驚嚇原理”。名字挺彆扭,但意思很簡單:在面對一份聲稱可以做某事的代碼時,大多數用戶都會對它的工作方式作一些假設;因此,你作為開發者,要做的事情就是確保自己的代碼與這些假設相匹配,這樣你的用戶就不會驚嚇連連。

這是個很棒的原則,因為開發者自己也要對一些東西作假設。如果你放出一個叫做calculateScore(GameState) 的函數(字面意思為“根據遊戲狀態計算分數”),那麼大部人都會正確地預期,這個函數只會從“遊戲狀態” 中讀取數據。如果你這個函數同時也修改了遊戲的狀態,你就給了大部分用戶一個驚嚇,他們會很困惑,想搞清楚為什麼遊戲的狀態會隨機地變掉。即使你在解釋文檔中說了,也沒法保證大家都看過文檔,所以,最好從一開始就保證你的代碼不會給人驚嚇。

- “調試代碼6 個鐘,就能省下5 分鐘閱讀文檔的時間喲” -– “調試代碼6 個鐘,就能省下5 分鐘閱讀文檔的時間喲” –

更安全就更好,對嗎?

回到2018 年頭,ERC-721 還在草案階段,有一個建議是實現“轉賬安全性”,以確保token 不會被困在(沒有設計好處理這種token)接收方合約裡。為此,提案的作者修改了transfer 函數的行動,檢查類接收方是否有能力支持這種token 的轉賬。這也引入了一個unsafeTransfer 函數,它可以跳過這個檢查,如果發送者自己要求的話。

但是,因為擔心向後兼容的問題,這個函數在後續的修改中重新命名了。這使得transfer 函數的表現在ERC-20 和ERC-721 上是完全相同的。但是,現在接收方的能力檢查就要挪到別的地方去了。因此, safe 函數被發明出來:safeTransfer 和safeTransferFrom。

這個問題是合理的,在此之前,已經有很多ERC-20 代幣被意外轉入根本沒有預期會接收代幣的合約,導致這些token 被鎖死的案例(一個很常見的錯誤是把代幣轉給它所屬的代幣合約,導致這些代幣完全鎖死)。毫不意外,當ERC-1155 標准在草案階段時,他們吸收了ERC-721 標準的啟發,不僅在轉賬中引入了接收方檢查,在鑄造中也加入了檢查。

這些標准在接下來幾年中大部分都處於無人問津的狀態,而ERC-20 標准在隔壁獨自精彩,直到最近,NFT 引發的gas 價格暴漲表明,ERC-721 和ERC-1155 標準迎來了開發者使用量上的暴漲。開發者的興趣捲土重來,而這些標准在設計時都考慮到了安全性,這當然是一件幸事,對吧?

再問一次,更安全,一定更好嗎?

OK,你們考慮是考慮到了,但這些函數如何能讓轉賬或者鑄造變得安全呢?不同的團隊對“安全” 的理解各有不同。對於一個開發者來說,安全的函數意味著這個函數里面沒有bug、不會引入額外的安全擔憂。對於用戶來說,安全性可能意味著程序做了充分的措施,可以保護他們不會不小心搬起石頭砸自己的腳。

事實證明,要按這樣來區分的話,這些函數更多是後者(保護用戶不受錯誤操作的困擾),而不是前者。因為,它給了開發者兩個選擇: transfer 和safeTransfer ,為什麼你不用“安全” 的那個?名字裡面都寫好了嘛。

嗯,一個理由是我們的老朋友,可重入漏洞(我一直在盡最大努力希望它能重命名為“不安全的外部調用”)。回想一下,任何外部調用都可能是不安全的,只要接受方賬戶是由攻擊者控制的;因為攻擊者也許可以讓你的合約轉變成一種沒有得到定義的狀態。從設計上來說,這些“safe” 函數扮演著對代幣接收方的一個外部調用,這個調用通常是由鑄造代幣或轉移代幣的發送者控制的。換句話說,這就是不安全調用的一個典型案例。

你可能會問,就是允許一個接收方拒絕一筆自己沒法處理的轉賬而已,能有多大事呢?我們用兩個簡單的案例來回答這個問題。

哈希掩碼

Hashmasks 是一種供給量有限的NFT。用戶在單筆交易中最多可以購買20 個NFT,雖然這些NFT 口罩在幾個月前就賣光了。這裡我們看看買口罩的函數:

  1. 函數 mintNFT(uint256 numberOfNfts) 公共應付 {

  2. require(totalSupply() < MAX_NFT_SUPPLY, "銷售已經結束");

  3. require(numberOfNfts > 0, “numberOfNfts 不能為 0”);

  4. require(numberOfNfts <= 20, "你不能一次購買超過 20 個 NFT");

  5. require(totalSupply().add(numberOfNfts) <= MAX_NFT_SUPPLY, "超過 MAX_NFT_SUPPLY");

  6. require(getNFTPrice().mul(numberOfNfts) == msg.value, “發送的以太值不正確”);

  7. for (uint i = 0; i < numberOfNfts; i++) {

  8. uint mintIndex = totalSupply();

  9. if (block.timestamp < REVEAL_TIMESTAMP) {

  10. _mintedBeforeReveal[mintIndex] = 真;

  11. }

  12. _safeMint(msg.sender, mintIndex);

  13. }

  14. /**

  15. * 隨機性的來源。 理論上的礦工拒絕操縱可能,但在實用意義上應該足夠了

  16. */

  17. if (startingIndexBlock == 0 && (totalSupply() == MAX_NFT_SUPPLY || block.timestamp >= REVEAL_TIMESTAMP)) {

  18. 起始索引塊 = 塊數;

  19. }

  20. }

如果你沒有預設,那這個函數看起來非常完美,有理有據。但是,如果你是個有心人,那你就能看出_safeMint 調用裡面藏著一些可怕的東西。

  1. 函數_safeMint(地址到,uint256 tokenId,字節內存_data)內部虛擬{

  2. _mint(to, tokenId);

  3. 要求(_checkOnERC721Received(地址(0),to,tokenId,_data),“ERC721:轉移到非ERC721Receiver實現者”);

  4. }

從安全性出發,這個函數對代幣的接收方調用了一個callback 函數,來確認接受方願不願意接收這個代幣。但是,如果你是代幣的接受方,那麼收到callback 調用的時候,要採取什麼行動完全是隨你的便,這其中就包括再次調用mintNFT。若是這麼做了,我們只需鑄造一個NFT 之後,就可以重入這個函數,意味著我們可以請求鑄造另外19 個口罩NFT。結果是我可以鑄造出39 個口罩,即使本來單次可以鑄造的最大數量是20 個。

ENS 域名封裝器

最近,ENS 團隊的Nick Johnson 聯繫了我們,希望我們看看他們正在開發的一個ENS 域名封裝器。這個封裝器允許用戶將手上的域名代幣化為一個新的ERC-1155 代幣,以此支持更細粒度的權限和更一致的API。

抽像地說,為了封裝任意的ENS 域名(準確來說是任意並非二級域名的.eth 域名),你必須先允許域名封裝器訪問你的ENS 域名。然後你調用wrap(bytes,address,uint96,address) 函數,一邊鑄造一個ERC-1155 代幣,另一邊託管了底層的ENS 域名。

這裡是封裝函數,可以說非常直接。首先,調用_wrap 函數做一些邏輯計算,返回哈希化的域名名稱。然後保證交易的發送者就是這個ENS 域名的擁有者,然後託管這個域名。注意,如果發送者並不擁有這個底層的ENS 域名,那麼整個交易應該回滾,取消_wrap 函數造成的所有變更。

  1. 功能包裝(

  2. 字節調用數據名稱,

  3. 地址包裹的所有者,

  4. uint96 _保險絲,

  5. 地址解析器

  6. ) 公共覆蓋 {

  7. bytes32 節點 = _wrap(name,wrappedOwner,_fuses);

  8. 地址所有者 = ens.owner(node);

  9. 要求(

  10. 所有者 == msg.sender ||

  11. isApprovedForAll(owner, msg.sender) ||

  12. ens.isApprovedForAll(所有者,msg.sender),

  13. “NameWrapper:域不歸發件人所有”

  14. );

  15. ens.setOwner(node, address(this));

  16. 如果(解析器!=地址(0)){

  17. ens.setResolver(節點,解析);

  18. }

  19. }

下面是_wrap 函數,看起來沒有任何特別的。

  1. 函數 _wrap(

  2. 字節內存名稱,

  3. 地址包裹的所有者,

  4. uint96 _fuses

  5. ) 私人回報(bytes32 節點){

  6. (bytes32 labelhash, uint256 offset) = name.readLabel(0);

  7. bytes32 parentNode = name.namehash(offset);

  8. 要求(

  9. parentNode! = ETH_NODE,

  10. “NameWrapper:.eth 域需要使用 wrapETH2LD()”

  11. );

  12. node = _makeNode(parentNode, labelhash);

  13. _mint(節點,名稱,wrappedOwner,_fuses);

  14. 發出 NameWrapped(節點,名稱,wrappedOwner,_fuses);

  15. }

不幸的是,_mint 本身可能會給不知情的開發者一個驚嚇。 ERC-1155 的規範裡面聲明,在鑄造代幣時,應該諮詢接收方是否願意接收這個代幣。在深入研究庫代碼(從OpenZeppelin 的基礎上稍微修改而來),我們可以看到確實是這樣的。

  1. 功能_薄荷(

  2. bytes32 節點,

  3. 字節內存名稱,

  4. 地址包裹的所有者,

  5. uint96 _fuses

  6. ) 內部的 {

  7. 名字[node] = 姓名;

  8. 地址 oldWrappedOwner = ownerOf(uint256(node));

  9. 如果(oldWrappedOwner != 地址(0)){

  10. // 銷毀並解開舊所有者的舊令牌

  11. _burn(uint256(節點));

  12. 發出 NameUnwrapped(node, address(0));

  13. }

  14. _mint(節點,wrappedOwner,_fuses);

  15. }

  16. 功能_薄荷(

  17. bytes32 節點,

  18. 地址 newOwner,

  19. uint96 _fuses

  20. ) 內部虛擬 {

  21. uint256 tokenId = uint256(節點);

  22. 地址所有者 = ownerOf(tokenId);

  23. 要求(所有者 == 地址(0),“ERC1155:現有令牌的鑄造”);

  24. require(newOwner != address(0), “ERC1155: mint to the zero address”);

  25. 要求(

  26. newOwner != address(this),

  27. “ERC1155:newOwner 不能是 NameWrapper 合約”

  28. );

  29. _setData(tokenId, newOwner, _fuses);

  30. 發出 TransferSingle(msg.sender, address(0x0), newOwner, tokenId, 1);

  31. _doSafeTransferAcceptanceCheck(

  32. msg.sender,

  33. 地址(0),

  34. 新業主,

  35. 令牌標識,

  36. 1、

  37. “”

  38. );

  39. }

但是這對我們來說到底有什麼用呢?這又是一個不安全的外部調用,我們可以用來激發重入的漏洞。具體來說,請注意,在callback 執行期間,我們還持有表示這個ENS 域名的ERC-1155 代幣,但域名封裝器還沒驗證完成我們是這個ENS 域名的所有者。這時候我們可以直接操作這個ENS 域名而無需是其所有者。舉個例子,我們可以要求域名封裝器解封這個域名,燒掉這個我們鑄造出來的代幣,然後獲得它所代表的ENS 域名。

  1. 函數解包(

  2. bytes32 父節點,

  3. bytes32 標籤,

  4. 地址新控制器

  5. ) 公共覆蓋 onlyTokenOwner(_makeNode(parentNode, label)) {

  6. 要求(

  7. parentNode! = ETH_NODE,

  8. “NameWrapper:.eth 名稱必須用 unwrapETH2LD() 解包”

  9. );

  10. _unwrap(_makeNode(parentNode, label), newController);

  11. }

  12. 函數 _unwrap(bytes32 節點,地址 newOwner) 私有 {

  13. 要求(

  14. newOwner != 地址(0x0),

  15. “NameWrapper:目標所有者不能是 0x0”

  16. );

  17. 要求(

  18. newOwner != address(this),

  19. “NameWrapper:目標所有者不能是 NameWrapper 合約”

  20. );

  21. 要求(

  22. !allFusesBurned(節點,CANNOT_UNWRAP),

  23. “NameWrapper:域不可解開”

  24. );

  25. // 銷毀令牌並融合數據

  26. _burn(uint256(節點));

  27. ens.setOwner (node, newOwner);

  28. 發出 NameUnwrapped(node, newOwner);

  29. }

現在我們拿到了目標ENS 域名了,可以為所欲為了,比如我可以註冊一個子域名,或者重設解析器。完成之後再退出call back 函數。域名封裝器這時候會獲取這個ENS 域名的所有者,也就是我們,發現匹配之後驗證完成,交易成功。就像這樣,我們可以暫時獲取向域名封裝器授權的任何ENS 域名的所有權並執行任意的修改。

結論

給人驚嚇的代碼可能造成惡劣的後果,在兩個案例中,開發者都理性地假設了safe 函數族(至少)會跟普通的函數一樣安全而不會增加攻擊面。隨著ERC-721 和ERC-1155 標準變得更加流行,這種攻擊可能會越來越頻繁。開發者需要考慮使用safe 函數族的風險,並考慮外部調用會怎樣跟自己寫下的代碼交互。

展開全文打開碳鏈價值APP 查看更多精彩資訊

Total
0
Shares
Related Posts