前言
2022 年 2 月 6 日,一大早醒来又有桥暴雷了,继 Qubit Finance
的 QBridge
被黑之后,又有一个跨链桥被黑了。为什么我这次要提 Qubit Finance
呢?原因在于,这两桥被黑的细节太相识了,所以放在了一起。和往常一样,话不多说,直接开始技术分析。
技术细节分析
本次被黑的桥是用于 BSC
到 MOON
链的一个桥,从 ps 透露的交易来看(https://moonriver.moonscan.io/tx/0x5a87c24d0665c8f67958099d1ad22e39a03aa08d47d00b7276b8d42294ee0591),这里已经是跨链接过去到 MOON
链进行销赃的交易了。
其中的 BNB.bsc
和 ETH
都是真实的代币。同时为了验证我们的想法,我们通过查看攻击者的交易记录,不难发现这里已经是第二案发现场了
同时攻击者的代币流转记录也应证了这一点,钱无端端就变出来了 😀
看过 Qubit
被黑分析的朋友都会知道,这里很明显不是第一案发现场,那么为了寻找第一案发现场,结合这个桥是 BSC
到 MOON
的桥,我们自然要去 BSC
上寻找踪迹。通过查询攻击者同个地址(0x8d3d13cac607b7297ff61a5e1e71072758af4d01
)在 BSC
上的操作,我们不难找到攻击者的第一案发现场。
如上图所示,攻击者是直接调用了 Meter.io
的 Deposit
函数进行充值。我们选取其中的一笔交易,竟发现攻击者什么代币都没有转 :D,妥妥的 零元购
行为啊!
联想 Qubit
被黑的那次是 EOA
的问题,这次会是同样的问题吗?为了探究这个问题,我们需要深入到合约代码中进行细节的发现,由于调用的是 deposit
函数,我们直接对 deposit
函数进行分析
function deposit(uint8 destinationChainID, bytes32 resourceID, bytes calldata data) external payable whenNotPaused {
uint256 fee = _getFee(destinationChainID);
require(msg.value == fee, "Incorrect fee supplied");
address handler = _resourceIDToHandlerAddress[resourceID];
require(handler != address(0), "resourceID not mapped to handler");
uint64 depositNonce = ++_depositCounts[destinationChainID];
_depositRecords[depositNonce][destinationChainID] = data;
IDepositExecute depositHandler = IDepositExecute(handler);
depositHandler.deposit(resourceID, destinationChainID, depositNonce, msg.sender, data);
emit Deposit(destinationChainID, resourceID, depositNonce);
}
通过分析代码,不难发现,其实 deposit
函数什么都没有做,具体的逻辑是在 #13 行 depositHandler
的 deposit
函数进行实现的。在经过充值之后,就会声明一个事件出来,这个事件的作用就很明显了,就是给 relayer
作为跨链消息来用的。
由于这个 deposit
函数的逻辑实现和 Qubit Finance
实在是太像了,我不得不顺手看了下有没有 depositEth
函数,结果还真的找到了。。
function depositETH(uint8 destinationChainID, bytes32 resourceID, bytes calldata data) external payable whenNotPaused {
uint256 fee = _getFee(destinationChainID);
require(msg.value >= fee, "Insufficient fee supplied");
address handler = _resourceIDToHandlerAddress[resourceID];
require(handler != address(0), "resourceID not mapped to handler");
uint256 value = msg.value - fee;
uint256 amount;
assembly {
amount := calldataload(0x84)
}
require (amount == value, "msg.value and data mismatched");
address wtokenAddress = IERCHandler(handler)._wtokenAddress();
require(wtokenAddress != address(0), "_wtokenAddress is 0x");
IWETH(wtokenAddress).deposit{value: value}();
IWETH(wtokenAddress).transfer(address(handler), value);
uint64 depositNonce = ++_depositCounts[destinationChainID];
_depositRecords[depositNonce][destinationChainID] = data;
IDepositExecute depositHandler = IDepositExecute(handler);
depositHandler.deposit(resourceID, destinationChainID, depositNonce, msg.sender, data);
emit Deposit(destinationChainID, resourceID, depositNonce);
}
抛开这个函数所有的中间逻辑,不难发现这两个函数实际上都是声明同一个 Deposit
事件的,那么顺着上次 Qubit
被黑的思路,是不是一下子就能想到可以通过充值 ERC20
来达到充值 ETH
的效果 :D。难道说这次又是 EOA
的问题?先不着急下结论,继续深入看 depositHandler
的逻辑实现。
function deposit(
bytes32 resourceID,
uint8 destinationChainID,
uint64 depositNonce,
address depositer,
bytes calldata data
) external override onlyBridge {
bytes memory recipientAddress;
uint256 amount;
uint256 lenRecipientAddress;
assembly {
amount := calldataload(0xC4)
recipientAddress := mload(0x40)
lenRecipientAddress := calldataload(0xE4)
mstore(0x40, add(0x20, add(recipientAddress, lenRecipientAddress)))
calldatacopy(
recipientAddress, // copy to destinationRecipientAddress
0xE4, // copy from calldata @ 0x104
sub(calldatasize(), 0xE) // copy size (calldatasize - 0x104)
)
}
address tokenAddress = _resourceIDToTokenContractAddress[resourceID];
require(_contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");
// ether case, the weth already in handler, do nothing
if (tokenAddress != _wtokenAddress) {
if (_burnList[tokenAddress]) {
burnERC20(tokenAddress, depositer, amount);
} else {
lockERC20(tokenAddress, depositer, address(this), amount);
}
}
_depositRecords[destinationChainID][depositNonce] = DepositRecord(
tokenAddress,
uint8(lenRecipientAddress),
destinationChainID,
resourceID,
recipientAddress,
depositer,
amount
);
}
定位到 depositHandler
函数的实现,不难发现在 #30-34行有关于 token
逻辑的一些处理,其中包含了一个对 _wtokenAddress
的判断,如果不是 _wtokenAddress
的话,会根据 tokenAddress
进行 burn
或者 lock
的处理。但如果是 _wtokenAddress
的话,这个逻辑就直接不走了。那么回想上文的 Bridge
中的 deposit
逻辑,由于 deposit
函数本身没有充值逻辑的检查,那么如果我们可以充值一个 tokenAddress != _wtokenAddress
的代币,是不是就可以实现正确的 Deposit
事件声明,并且不需要转移任何代币呢?听起来是不是和攻击者的行为很像?
通过代码,不难发现 tokenAddress
是由 resourceID
进行获取的,那么为了知道对应的 tokenAddress
地址,就需要从攻击者交易的 resourceID
进行入手,通过检查对应的交易,我们拿到了对应的 resourceID
数据(0x0000000000000000000000bb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c01
),然后通过合约查询到对应的 tokenAddress
为 0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c
(大家现在查是查不到的,因为这个 ID 已经被改了 :D),正好是 _wtokenAddress
的地址,而这个地址正好是 WBNB
的地址
这下就舒服了,也就是说攻击者用 deposit
函数充值了 WBNB
代币,然后直接掠过了 depositHandler
的检查。真正实现了 零元购
总结
虽然 Meter.io
的架构和 Qubit
很像,但是出问题的点却是不一样的,但同样的问题都是用非预期的函数实现了预期之外的功能。
原文始发于微信公众号(蛋蛋的区块链笔记):BNB 零元购? — Meter.io 桥被黑分析