跨链桥合约漏洞汇总分析

前言

本文是本人在毕业论文研究期间,对2021至2022两年内发生的、与合约漏洞相关的重大跨链桥攻击事件的分析。主要分析这些事件中的攻击流程和漏洞利用细节。

跨链桥攻击事件主要来源:Rekt - Leaderboard

2021年7月THORChain#3【800万美元】

参考

时间:2021年7月22日

官方:https://thearchitect.notion.site/THORChain-Incident-07-22-874a06db7bf8466caf240e1823697e35

THORChain 连遭三击,黑客会是同一个吗? | 登链社区 | 区块链技术社区 (learnblockchain.cn)

本次攻击跟第二次攻击一样,攻击者部署了一个攻击合约,作为自己的 router,在攻击合约里调用 THORChain Router 合约。

攻击者这次利用的是 THORChain Router 合约中关于退款的逻辑缺陷,攻击者调用returnVaultAssets函数并发送很少的 ETH,同时把攻击合约设置为 asgard。然后 THORChain Router 合约把 ETH 发送到 asgard 时,asgard 也就是攻击合约触发一个 deposit 事件,攻击者随意构造 asset 和 amount,同时构造一个不符合要求的 memo,使 THORChain 节点程序无法处理,然后按照程序设计就会进入到退款逻辑。

THORCHAIN - 翻车2,其中包含一份来着Halborn 团队的详细报告

攻击过程:

  1. 攻击者创建了一个假的路由器(合约地址),然后在攻击者发送ETH时触发了一个存款事件。
  2. 攻击者向returnVaultAssets()传入少量的ETH,但路由器被判定为一个Asgard金库。
  3. 在Thorchain路由器上,它将ETH转发到假的Asgard上。
  4. 这创造了一个带有恶意memo的假存款事件。
  5. Thorchain Bifrost监听成一个正常的存款,并由于糟糕的memo定义而退款给攻击者。

修复:

  • Bifrost组件仅解析从THORChain Router合约抛出的事件。Commit
  • 阻塞有多个事件但to地址都不相同的交易。Commit

Halborn Team给出的建议:

  • 路由器合同应具有针对意外行为的暂停/取消暂停功能。实现一种可以暂时停止关键功能的机制。The Router contract should have pause/unpause functionality on the unintended behaviours. Implement a mechanism that can temporarily stop the critical functionalities.
  • 白名单机制应在每个Bifrost组件事件上实施。The white-listing mechanism should implement on the every Bifrost component events.
  • Enable Automatic Solvency Checker on the ETH transactions.
  • Only Router emitted events should parse from the component therefore an attacker surface will minimized with this action.
  • 使用代理机制。When smart contracts are deployed into the Ethereum blockchain, they are immutable and therefore, not upgradable. In the white-listing progress, router should be placed behind the proxy.
  • Implement a policy for tracking new bugs.
  • 监控。The monitoring should be added into the components. This component should monitor activity using the events.
  • Documentation should define all trust boundaries in the components. All counter-measure mechanisms should be defined on the attack vectors.

分析

攻击者地址:https://etherscan.io/address/0x8c1944fac705ef172f21f905b5523ae260f76d62

攻击合约:https://etherscan.io/address/0x700196e226283671a3de6704ebcdb37a76658805,下文简写为0x7001。由于只有字节码,可反编译为伪代码,结果请看:https://etherscan.io/bytecode-decompiler?a=0x700196e226283671a3de6704ebcdb37a76658805

THORChain Router被攻击合约:https://etherscan.io/address/0xc145990e84155416144c532e31f89b840ca8c2ce,下文简写为0xc145

THORChain金库地址:https://etherscan.io/address/0xf56cBa49337A624E94042e325Ad6Bc864436E370,下文简写为0xf56c

攻击者发起的攻击交易:

Untitled

攻击流程梳理:

  1. 先看第一笔交易0x10352e6ec052771a92f05f93e037e066873f64bb502d4488726697987f054595
    ,由攻击者发给攻击合约0x7001,时间为:Jul-22-2021 09:39:40 PM +UTC。

    分析input数据:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    0xf9f6318f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000028446f206e6f74207275736820636f6465207468617420636f6e74726f6c7320392066696775726573000000000000000000000000000000000000000000000000

    美化:
    0xf9f6318f
    000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
    0000000000000000000000000000000000000000000000000000000000000000
    0000000000000000000000000000000000000000000000000000000000000060
    0000000000000000000000000000000000000000000000000000000000000028
    446f206e6f74207275736820636f6465207468617420636f6e74726f6c732039
    2066696775726573000000000000000000000000000000000000000000000000

    它调用的是0xf9f6318f函数。结合反编译之后的0x7001合约伪代码,可分析出:

    • 第一个参数为0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48,是USDC合约地址,用于指代USDC token;
    • 第二个参数为0;
    • 第三个参数是一个动态类型,起始位置为0x60,长度为0x28,应该是一个字符串,将十六进制446f206e6f74207275736820636f6465207468617420636f6e74726f6c7320392066696775726573转换为字符串可得:**Do not rush code that controls 9 figures**。

    再分析0x7001合约伪代码中的这个函数,可以发现它有如下调用:

    1
    2
    3
    static call 0xc145990e84155416144c532e31f89b840ca8c2ce.0x3b6a673 with:
    gas gas_remaining wei
    args 0xf56cba49337a624e94042e325ad6bc864436e370, addr(_param1)

    可知,它调用了0xc145合约(被攻击合约)的0x3b6a673函数,参数依次为0xf56c(是thorchain一个金库vault的地址)、param1(也就是USDC token地址)。

    再借助etherscan的tx-decoder:https://etherscan.io/tx-decoder?tx=0x10352e6ec052771a92f05f93e037e066873f64bb502d44887266979,可得:

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_gTWLTU6j7W.png

    分析0xc145合约,可知vaultAllowance其实就是获取金库对应token的余额。

    因此,在这笔交易中,攻击者调用攻击合约0x7001的0xf9f6318f函数,其实就是获取金库0xf56c的USDC余额,并保存在合约的stor2中。此处,返回1858760326885,乘于0.9得到1,672,884,294,196.5

    1
    2
    stor1 = addr(_param1)
    stor2 = 9 * ext_call.return_data / 10
  2. 再看第二笔交易0x26109c1fb2a71485f47176c6046fe0217ec1a384dc1cf789b4aec5939d77d280
    ,是攻击者发给THORChain Router合约0xc145的。

    分析input数据,由于合约有源码,etherscan提供直接解码,解码如下:

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_bOgjpjY6BZ.png

    这笔交易调用returnVaultAssets函数,第一个参数为0,第二个参数为攻击合约地址0x7001,第三、四个参数都为空。

    同时,交易输出了一个log,如下:

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_u3brnKZWcE.png

    这个log对应的事件在0x7001合约中被触发,事件ID为0xef51….,事件的参数名称应该有误,按我的分析,各参数如下:

    • 第一个索引参数topics[1] 为金库地址。
    • 第二个索引参数topics[2] 为指定的token,这里就是上一笔交易分析中提到的USDC 合约地址。
    • data中第一个非索引参数为存款数量1672884294196,有没有发现这就是上一笔交易最后分析得出的数字!!!
    • data中第二个非索引参数为memo,是给thorchain传达跨链信息的,这里也是上一笔交易分析中出现的input字符串参数。

    我们再回看0x7001合约源码中的returnVaultAssets函数,如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function returnVaultAssets(address router, address payable asgard, Coin[] memory coins, string memory memo) external payable {
    if (router == address(this)){
    for(uint i = 0; i < coins.length; i++){
    _adjustAllowances(asgard, coins[i].asset, coins[i].amount);
    }
    emit VaultTransfer(msg.sender, asgard, coins, memo); // Does not include ETH.
    } else {
    for(uint i = 0; i < coins.length; i++){
    _routerDeposit(router, asgard, coins[i].asset, coins[i].amount, memo);
    }
    }
    (bool success,) = asgard.call{value:msg.value}(""); //ETH amount needs to be parsed from tx.
    require(success);
    }

    结合输入参数,可知,2-11行都跳过,直接执行12行,将调用asgard也就是攻击合约0x7001的fallback函数。

    再找到0x7001合约伪代码中的fallback函数,发现其中存在如下两行代码:

    1
    2
    3
    4
    5
    log 0xef519b7e: stor2, Array(len=2 * Mask(256, -1, stor3.length.field_1), data=mem

    ......

    log 0xef519b7e: stor2, Array(len=stor3.length % 128, data=mem

    stor2就是第一笔交易中设置的值,为1672884294196;mem是stor3,而stor3也是在第一笔交易中被构造的;其它的就分析不来了。

  3. 当Thorchain节点监听到Router合约抛出这个事件后,获取事件信息,但由于memo字段不符合规范,因此触发退款逻辑,thorchain发起了第三笔交易:https://etherscan.io/tx/0x8515ce6b174ba31a849ff420650720d42cc4c72d4929adf856343e7395bd2512

    这笔交易是金库0xf56c调用THORChain Router合约0xc145。逻辑非常简单,调用合约的transferOut函数,将1672794010957个USDC从金库0xf56c转移到攻击者0x8c19。

总结

三笔交易流程

  1. 攻击者先调用自己的攻击合约0x7001,通过访问THORChain Router合约0xc145,获取金库0xf56c的USDC余额,并保存到合约中。
  2. 攻击者调用THORChain Router合约0xc145的returnVaultAssets函数,该函数会调用攻击合约0x7001的fallback函数,抛出Deposit事件。事件表明,攻击者向金库0xf56c存了1672794010957个USDC,实际上他并没有。
  3. THORChain监听到Deposit事件,但memo非法,触发退款逻辑,从金库0xf56c退还金额给攻击者,攻击者成功空手套白狼。

后续攻击交易与上述流程基本一致,攻击者在盗取完USDC之后,又依次盗取了USDT、Sushi、XRUNE、ALCX、YFI。

漏洞总结:

  1. 参数检查漏洞。使用call方式调用用户传入的合约,未对合约地址进行检查。同时,对用户传入的其它参数也未作检查。
  2. 虚假存款事件。存款事件可被攻击者伪造,而链下节点未进行正确性验证。

2021年7月ChainSwap【440万美元】

参考

时间:2021年7月10日19点

官方:https://chain-swap.medium.com/chainswap-exploit-11-july-2021-post-mortem-6e4e346e5a32 Rekt - CHAINSWAP - 翻车

在以太坊网络上,每个要被桥接的代币都有自己的代理工厂合约。攻击者能够利用该合约,直接向不同的地址铸造代币,然后再把它们重新汇集到最初发送交易的钱包中。

  1. 调用工厂(铸币)合约的receive函数
  2. 每笔交易使用一个新的地址作为签名来躲避马虎的权限检查系统
  3. 支付0.005 ETH chargeFee
  4. 将参数设置为所需的地址,该地址接收铸造的代币
  5. 重复x次

https://twitter.com/cmichelio/status/1414035462164033541

攻击者必须在_chargeFee中支付 0.005 ETH作为费用。没有真正的身份验证检查,只需要1个签名,问题可能是_decreseAuthQuota函数,如果当天满足了签名人的配额,该函数将恢复。但似乎每个人都是从默认配额开始的。

ChainSwap 跨链黑客攻击事件及原因分析 - Grenade 手榴弹

MappingBase合约的receive接口使用多方签名(“多签”)的方式进行签名验证,但从黑客提交的交易讯息可以看到,此多签功能并未启用,实际上使用的是“单签”。 即单一签名验证通过即可完成跨链代币的铸造(mint)。

同时,合约中未限制签名人地址,只要签名合法,任何人都可通过验证。

更要命的是:任何一个新地址,都有 1,000,000 的可用额度。

代码中的这个写法逆了天了,相当于突然任何地址都有了几乎是无限铸造的权利!

我为自己代言:ChainSwap攻击事件分析

在分析代码和攻击之前,首先概述一下ChainSwap的实现机制:

  1. ChainSwap作为一个跨链资产桥,其设置了一个Factory用于管理和查询下属的项目。每个项目可以向ChainSwap对接自动成为跨链代币。
  2. ChainSwap为每一个验证者限定了配额(Quota),也就是说每个验证者验证的交易额在一段时间内是有限制的。但验证者会有一定的初始配额,且该配额会随着时间累积。

总结一下,receive函数实现的整个过程,都没有对传入的Signatory的合法性进行检查。因此攻击者只需要随机生成一个地址并生成对应的签名,即可骗过ChainSwap,自己为自己提供签名。同时,由于authQuotaOf在实现逻辑上的错误,在Signatory不合法时会返回一个非常大的值,导致了这次攻击事件的发生。而本次攻击事件发生的本质,是没有对映射索引的值进行验证。由于Solidity并不会在映射的键不存在时触发任何错误(键是否存在只能靠返回值是否为0进行判断),因此这类检查就显得非常重要。正是由于(荒谬地)缺少这样的检查,导致了这次损失超过800万美元的攻击。

分析

黑客在BSC和ETH链上都发动了攻击,通过几十笔交易盗取了各种代币。

黑客在BSC上的地址为:0xeda5066780de29d00dfb54581a707ef6f52d8113,最早发起的一笔交易为:0x3e487f4494fe2b354d6638fb4c6474cdf8ede6c7df560639669364dc0293c998,时间为:2021-07-10 19:17:15。

黑客在ETH上的地址也为:0xeda5066780de29d00dfb54581a707ef6f52d8113,最早的一笔交易为:0x075e2a8045344c66dc48776907d5fa6efab1636836a7dc3d8248724d7af3ae94,在2021-07-10 19:16:11。

将以ETH上的攻击为例进行分析,如下是黑客初始发起的一系列攻击交易。

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_-ZILKQQWsI.png

黑客攻击了多个合约,并且对同一合约也发动了多次攻击,每个合约都对应ChainSwap中的一种代币,黑客以此盗取了大量代币。

分析第一笔交易:0x075e2a8045344c66dc48776907d5fa6efab1636836a7dc3d8248724d7af3ae94,这笔交易是黑客发送给0x7ab088fedae4fa8ada4df638c07cef6c23aff002
合约的,这个合约是chainswap中负责管理DORA代币的代理合约,后文简称0x7ab0

  1. 交易的input数据解码如下,它调用了receive函数。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_5AffEAIT4U.png

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_YuMkpt41sl.png

  2. 分析0x7ab0合约源码。

    通过以太坊浏览器可获取0x7ab0合约源码,代码非常多,但实际上0x7ab0这个合约对应的是源码中的InitializableProductProxy,它只是一个代理合约,并不包含receive函数。chainswap使用代理模式,状态保存在代理合约中,而业务逻辑保存在逻辑合约中。

    由于函数不存在,会调用fallback函数,它定义在父合约的父合约Proxy中,具体代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    abstract contract Proxy {
    fallback () payable external {
    _fallback();
    }

    ...

    function _fallback() internal {
    if(OpenZeppelinUpgradesAddress.isContract(msg.sender) && msg.data.length == 0 && gasleft() <= 2300) // for receive ETH only from other contract
    return;
    _willFallback();
    _delegate(_implementation());
    }
    }

    它其实是将交易通过delegatecall的方式代理到_implementation()返回地址对应的合约。而_implementation函数又定义在ProductProxy合约中,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    contract ProductProxy is Proxy {
    ...

    function _name() virtual internal view returns (bytes32 name_) {
    bytes32 slot = NAME_SLOT;
    assembly { name_ := sload(slot) }
    }

    function _factory() internal view returns (address factory_) {
    bytes32 slot = FACTORY_SLOT;
    assembly {
    factory_ := sload(slot)
    }
    }

    function _implementation() virtual override internal view returns (address) {
    address factory_ = _factory();
    if(OpenZeppelinUpgradesAddress.isContract(factory_))
    return IProxyFactory(factory_).productImplementations(_name());
    else
    return address(0);
    }
    }

    这个函数根据事先定义好的name和factory,到对应的代理工厂合约中获取product合约的地址,也就是0x7ab0这个代理合约对应逻辑合约的地址。然后,再将函数调用代理到该逻辑合约。

  3. 寻找factory合约地址。

    分析0x7ab0合约的创建交易0xecbe63bbae99895761ff4fc3add11ca323fcf04d76ed6d280a827e03428f720d,它是chainswap的开发者0xc96e发送给合约0xbf515ff38d55737c56d62e8b6a8eea322ec38aa5的。它调用了源码中MappingTokenFactory合约的createTokenMapped函数,根据函数逻辑可推断:0x7ab0合约中事先定义好的代理工厂合约factory地址应该就是0xbf51。而0xbf51对应源码中的AdminUpgradeabilityProxy合约,也是一个代理合约。

  4. 寻找0xbf51对应的逻辑合约。

    分析创建0xbf51合约时的构造函数参数,如下:

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_m92mq3A-qk.png

    再结合AdminUpgradeabilityProxy 合约的构造函数,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    abstract contract UpgradeabilityProxy is BaseUpgradeabilityProxy {
    constructor(address _logic, bytes memory _data) public payable {
    assert(IMPLEMENTATION_SLOT == bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1));
    _setImplementation(_logic);
    if(_data.length > 0) {
    (bool success,) = _logic.delegatecall(_data);
    require(success);
    }
    }

    ...
    }

    contract AdminUpgradeabilityProxy is BaseAdminUpgradeabilityProxy, UpgradeabilityProxy {
    constructor(address _admin, address _logic, bytes memory _data) UpgradeabilityProxy(_logic, _data) public payable {
    assert(ADMIN_SLOT == bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1));
    _setAdmin(_admin);
    }

    ...
    }

    可知,0xbf51合约对应的逻辑合约地址为:0x3f985399E66fEEd935F4181f18bd434fEf4aD637,它对应源码中的MappingTokenFactory合约。

  5. 寻找0x7ab0合约对应的逻辑合约。

    回到最初调用receive函数的交易,根据第2步分析,0x7ab0代理合约会从factory合约中获取它对应的逻辑合约:IProxyFactory(factory_).productImplementations(_name())。结合以太坊浏览器的tx-decoder功能,如下图。可知,factory合约返回的逻辑合约为:0x2a8a3Cf57B89507E6E255f468e7b974f351EfABA,它对应源码中的TokenMapped合约。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_YxPyY88t4H.png

  6. 分析receive函数。

    TokenMapped合约的receive函数定义在父合约MappingBase中,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function receive(uint256 fromChainId, address to, uint256 nonce, uint256 volume, Signature[] memory signatures) virtual external payable {
    _chargeFee();
    require(received[fromChainId][to][nonce] == 0, 'withdrawn already');
    uint N = signatures.length;
    require(N >= MappingTokenFactory(factory).getConfig(_minSignatures_), 'too few signatures');
    for(uint i=0; i<N; i++) {
    for(uint j=0; j<i; j++)
    require(signatures[i].signatory != signatures[j].signatory, 'repetitive signatory');
    bytes32 structHash = keccak256(abi.encode(RECEIVE_TYPEHASH, fromChainId, to, nonce, volume, signatures[i].signatory));
    bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _DOMAIN_SEPARATOR, structHash));
    address signatory = ecrecover(digest, signatures[i].v, signatures[i].r, signatures[i].s);
    require(signatory != address(0), "invalid signature");
    require(signatory == signatures[i].signatory, "unauthorized");
    _decreaseAuthQuota(signatures[i].signatory, volume);
    emit Authorize(fromChainId, to, nonce, volume, signatory);
    }
    received[fromChainId][to][nonce] = volume;
    _receive(to, volume);
    emit Receive(fromChainId, to, nonce, volume);
    }

    (1)先收取一定的手续费,也就是0.005ETH。

    (2)判断签名个数是否达到配置要求,根据tx-decoder的显示,最小签名数配置为1。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_PCJrk6K-PE.png

    (3)依次验证每个签名。此处使用ecrecover函数从签名中恢复公钥对应地址,然后与参数中传入的签名者地址判断是否匹配。⚠注意:此处只验证了签名与签名者是否匹配,但并没有对签名者做限制,因此只要传入任意正确的签名都可以通过验证。

    (4)调用_decreaseAuthQuota函数减少签名者的份额,代码如下。如果signatory此前不存在,authQuotaOf[signatory]
    则为0,此处使用SafeMath的sub函数,而decrement又大于0,应该会报错抛出异常。但实际上当时交易并没有抛出异常,说明此时我看到的源码并不是当时的源码,chainswap修补了漏洞并将代码升级了。因此,需要结合当时的分析报告,才能了解当时的情况,具体可以参考:https://zhuanlan.zhihu.com/p/389738041

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    abstract contract MappingBase is ContextUpgradeSafe, Constants {
    using SafeMath for uint;

    mapping (address => uint) public authQuotaOf;

    ...

    function _decreaseAuthQuota(address signatory, uint decrement) virtual internal returns (uint quota) {
    quota = authQuotaOf[signatory].sub(decrement);
    authQuotaOf[signatory] = quota;
    emit DecreaseAuthQuota(signatory, decrement, quota);
    }
    }

    (5)最后receive函数通过IERC20(token).safeTransfer(to, volume)将代币转移到黑客账户。

总结

chainswap这次攻击,直接看源码分析起来并不难,但要理清chainswap的各个代理合约与逻辑合约之间的关系和作用,还是比较费时间(对我而言)。

漏洞总结:签名验证漏洞,没有对传入的签名者地址进行校验。

2021年8月 Poly Network【6.1亿美元】

参考

时间:2021年8月10日

Poly Network攻击关键步骤深度解析 - 知乎 (zhihu.com)

第一阶段:

攻击者首先在Ontology发起了一笔跨链交易,里面包含了一个攻击payload。其中包含了精心设计的函数名(图中以6631开头的数字,转换后即f1121318093),目的在于通过造成哈希冲突(hash collision)的方式调用putCurEpochConPubKeyBytes函数(属于以太坊上的EthCrossChainData合约)。

随后,该笔交易被Ontology Relayer接收,注意这里并没有很严格的校验。该交易会通过Relayer在Poly Chain成功上链。Ethereum Relayer会感知到新区块的生成。

然而,这笔交易被Ethereum Relayer拒绝了。原因在于Ethereum Relayer对目标合约地址有校验,只允许LockProxy合约作为目标地址,而攻击者传入的是EthCrossChainData地址。

因此,攻击者攻击之路在此中断。但如前所述,包含恶意payload的攻击交易已经在Poly Chain成功上链,可被进一步利用。

第二阶段:

攻击者手动发起交易,调用EthCrossChainManager合约中的verifyHeaderAndExecuteTx函数,将之前一步保存在Ploy Chain区块中的攻击交易数据作为输入。由于该区块是poly chain上的合法区块,因此可以通过verifyHeaderAndExecuteTx中对于签名和merkle proof的校验。然后执行EthCrossChainData合约中的putCurEpochConPubKeyBytes函数,将原本的4个keeper修改为自己指定的地址。

Rekt - POLY NETWORK - 翻车

来源:The initial analysis of the PolyNetwork Hack

“Poly有一个名为”EthCrossChainManager“的合约。它是一个有特权的合约,有权从另一个链上触发消息。这是一个跨链项目的标准配置。

它有一个名为verifyHeaderAndExecuteTx的函数,任何人都可以调用它来执行一个跨链交易。

它(1)通过检查签名来验证区块头是否正确(似乎另一条链是一个poa侧链),然后(2)通过Merkle证明来检查交易是否包含在该区块中。代码在这里

该函数做的最后一件事是调用executeCrossChainTx,它对目标合约进行了调用。这就是关键的缺陷所在。Poly检查目标是一个合约,但他们忘记了防止用户调用一个非常重要的目标……**EthCrossChainData**合约

通过发送这个跨链信息,用户可以调用EthCrossChainData合约欺骗EthCrossChainManager,通过onlyOwner检查。现在,用户只需要制作正确的数据,就可以触发改变公钥的函数……

唯一剩下的挑战是如何让EthCrossChainData调用正确的函数。现在,围绕着Solidity如何选择你要调用的函数,出现了一点点的复杂性。

交易输入数据的前四个字节被称为 “签名哈希”,或简称为 “sighash”。它是一个简短的信息,告诉Solidity合约你要做什么。

一个函数的sighash是通过取”()“的哈希值的前四个字节来计算的。例如,ERC20传输函数的sighash是”transfer(address,uint256)“的哈希值的前四个字节。

Poly的合约愿意调用任何合约。然而,它只会调用与以下sighash相对应的合约函数:

呃……但是等等……。这里的”**_method**”是用户的输入。攻击者为调用正确的函数所要做的就是找出”_method”的某个值,当它与其他的值结合在一起并经过哈希处理时,它的前四个字节与我们目标函数的sighash相同。

只要稍加琢磨,你就能轻易地找到一些能产生正确sighash的输入。你不需要找到一个完整的哈希碰撞,你只需要检查前四个字节。那么这个理论是否正确呢?

嗯……这里是目标函数的实际sighash:

ethers.utils.id(‘putCurEpochConPubKeyBytes(bytes)’).slice(0, 10)

‘0x41973cd9’

而攻击者精心制作的sighash…

ethers.utils.id(‘f1121318093(bytes,bytes,uint64)’).slice(0, 10)

‘0x41973cd9’

太棒了。不需要泄露私钥!只要制作正确的数据,然后……合约就会自己黑掉了!

人们需要从中吸取的最大的设计教训之一是:如果你有像这样的跨链中继合约,确保它们不能被用来调用特殊的合约EthCrossDomainManager不应该拥有EthCrossDomainData合约。

分别关注。如果你的合约绝对需要有这样的特殊权限,请确保用户不能使用跨链消息来调用这些特殊合约。”

分析

关于这个攻击事件,网上分析文章比较多,我就在此简单总结分析一下。

根据网上的分析,黑客攻击流程大致分为3步:

  1. 黑客首先Ontology链上发起了一笔恶意的跨链交易,这笔交易虽然被Ethereum拒绝,但成功在Poly Chain上链。
  2. 黑客手动调用Ethereum上EthCrossChainManager合约的verifyHeaderAndExecuteTx函数,传入第1步中的恶意交易。由于EthCrossChainManager合约是EthCrossChainData行业的owner,黑客可以通过EthCrossChainManager合约成功调用EthCrossChainData合约的putCurEpochConPubKeyBytes函数,将跨链验证人修改为它传入地址。
  3. 由于跨链验证人被修改,黑客可以伪造任意跨链交易,将资产盗取。

下文将从第2步开始,主要分析以太坊上的攻击。

以下是一些重要地址:

以下是黑客当时发起的一些交易。其中,第一笔交易0xb1f7黑客完成了第2步的攻击——修改跨链验证者,之后的交易都是第3步攻击——盗取资产。

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_c4HP9dBUkP.png

  1. 分析第一笔交易0xb1f7,它调用了0x838b合约的verifyHeaderAndExecuteTx函数。

    此处,可以借助以太坊浏览器的vmtrace功能,如下图,0xcf2a即为EthCrossChainData合约,input中的0x69d48074即为getCurEpochConPubKeyBytes函数的ID(获取合约abi,调用web3.js的w3.eth.abi.encodeFunctionSignature()函数可快速计算出每个合约函数的ID)。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_bQJWjB8Da8.png

    在verifyHeaderAndExecuteTx函数中,由于传入的是Poly Chain中已上链的交易,前面一系列校验都会正常通过,直至调用到_executeCrossChainTx函数,该函数会调用用户指定的合约及函数。

    1
    2
    3
    4
    5
    6
    7
    8
    function _executeCrossChainTx(address _toContract, bytes memory _method, bytes memory _args, bytes memory _fromContractAddr, uint64 _fromChainId) internal returns (bool){
    ...

    // The returnData will be bytes32, the last byte must be 01;
    (success, returnData) = _toContract.call(abi.encodePacked(bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))), abi.encode(_args, _fromContractAddr, _fromChainId)));

    ...
    }

    黑客正是在通过这段逻辑,调用到EthCrossChainData合约的putCurEpochConPubKeyBytes函数。为了准确调用到该函数,黑客通过哈希碰撞出前四字节相同的函数ID,详情可见网上其它博客的分析。总之,黑客成功调用到了该函数。

    putCurEpochConPubKeyBytes函数需要owner权限。但分析0x6984交易的input(如下),可知EthCrossChainData合约的owner是0x838b,也就是EthCrossChainManager合约。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_ume7T8BM8M.png

    因此,黑客能通过EthCrossChainManager合约调用到EthCrossChainData合约的函数。

    再分析交易的vmtrace,其中第6笔交易如下图。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_5gcijrQclo.png

    分析input内容:

    1
    0x41973cd9000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000001d010000000000000014a87fb85a93ca072cd4e5f0d4f178bc831df8a00b0000000000000000000000000000000000000000000000000000000000000000000014e1a18842891f8e82a5e6e5ad0a06d8448fe2f407000000000000000000000000

    0x41973cd9即为putCurEpochConPubKeyBytes函数的ID。这个函数只有一个参数,因此只分析第一个参数即可。第一个参数是动态参数,起始位置为第0x60=96个字节,长度为0x1d=29,值为0x010000000000000014a87fb85a93ca072cd4e5f0d4f178bc831df8a00b。这个值是按照poly的规范进行编码的,可参考ECCUtils合约的serializeKeepers函数。最终,黑客将跨链验证人地址修改为0xa87fb85a93ca072cd4e5f0d4f178bc831df8a00b。

  2. 分析第二笔交易0xad7a。在这笔交易中,黑客盗取了约2857个ETH。

    这笔交易同样调用了EthCrossChainManager合约的verifyHeaderAndExecuteTx函数,前面的逻辑都与第一交易相同,直到_executeCrossChainTx函数。这笔交易调用了LockProxy合约,合约地址为0x250e76987d838a75310c34bf422ea9f1ac4cc906

    结合vmtrace分析,第6项如下:

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_KaTCLaTv4y.png

    分析input内容:

    1
    0x06af4b9f000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004a14000000000000000000000000000000000000000014c8a65fadf0e0ddaf421f28feab69bf6e2e5899632662f145d8d496e79a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001434d4a23a1fc0c694f0d74ddaf9d8d564cfe2d430000000000000000000000000

    0x06af4b9f为LockProxy合约的unlock函数,其它参数就不在这里具体分析了,直接分析unlock函数的逻辑。

    首先,该函数有一个onlyManagerContract修饰器,它通过调用IEthCrossChainManagerProxy合约(地址为:0x5a51e2ebf8d136926b9ca7b59b60464e7c44d2eb),获取EthCrossChainManager合约的地址,并判断交易发送者是否为该合约,显然通过。

    然后,该函数对参数进行解析和一系列验证,都安全通过了。

    最后,该函数调用_transferFromContract函数交易代币。在这笔交易中,参数toAssetHash为0x0000000000000000000000000000000000000000,toAddress为0xc8a65fadf0e0ddaf421f28feab69bf6e2e589963(黑客地址),amount为2857486346845890372134(通过查看交易的log可知)。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_3p4licbxPS.png

总结

从以太坊合约的角度分析,导致这次攻击事件的原因有:

  1. 参数校验漏洞。根据用户传入的函数名直接进行函数调用,存在哈希碰撞的风险,且未对用户传入的参数做检查。
  2. 可修改存放跨链验证者的合约的owner。owner不应该是另一个合约,应该由多签账户控制。
  3. 监测不及时。在以太坊链上,从owner被黑客修改(2021-08-10 9:48:40),到最后一笔资产被盗(2021-08-10 10:27:38),过去将近40分钟,Poly并没有采取任何措施。

2021年9月pNetwork【1000万美元】

参考

官方文档:pNetwork Post Mortem: pBTC-on-BSC Exploit

这些智能合约创建了一系列事件日志:其中一个是合法的挂起请求,而其他的是攻击者的智能合约而不是pToken智能合约发出的错误挂起请求。

由于Rust代码中负责提取这些日志事件的部分存在错误,合法日志和错误日志都被提取并错误处理。

技术团队迅速发现了一个潜在的恶意操作并进行了干预。袭击于世界协调时下午5点30分开始,行为不端于世界协调时间下午5点33分首次被注意到,在世界协调时晚上5点40分,主要桥梁被叫停,到下午5点59分,所有桥梁都被叫停。该团队开始了一项调查,并确定了被利用的漏洞——在对根本原因进行进一步调查和修复的同时,桥梁暂时处于暂停状态。

合约漏洞:pNetwork被黑事件分析

整个攻击写在攻击合约的构造函数中,并在攻击完成后调用selfdestruct()函数销毁合约,使得无法看到攻击合约的细节内容。通过交易的事件(Event)并结合PToken合约源码可知,攻击者首先以amount:0,userData:0x,underlyingAssetRecipient:3LngKgsXQAnm5cLP43PZUGGvMau9uUzhky.作为输入数据委托调用redeem函数。

随后通过攻击合约发送多个Redeem(_msgSender(), amount, underlyingAssetRecipient, userData) event事件。

触发的redeem事件都是向攻击者的多个比特币地址转账相同数量1.38个左右的bitcoin,这是跨链攻击中重要的环节。

通过pToken的介绍可知,跨链转账中只是通过查询相关的deposit或redeem事件这种方式来确定btc的转账地址与数量,并没有进行其他的检查!使得黑客利用这一漏洞,在BSC上触发多次redeem事件,窃取大量的BTC。

分析

黑客地址:0x2bf5693dd3a5cea1139c4510fdce120cf042c934

被攻击合约:

其中一笔攻击交易:https://bscscan.com/tx/0xe79e3ff4ef01a29475e6387a44c550df3e4c0a80177249bfdc9bbd66376b9ff6

总结

这次事件也属于“虚假存款事件”漏洞,黑客在攻击合约中抛出伪造的Redemm事件,pNetwork不经检查就相信黑客抛出的事件。

2022年1月Multichain【600万美元】

参考

Multichain用户资金被盗漏洞分析

整个交易的trace挺长的,但是都是在不断调AnyswapV4Router.anySwapOutUnderlyingWithPermit函数,只是传参不同。

  1. 可以看到,攻击者在anySwapOutUnderlyingWithPermit函数里的token参数中传入了攻击者自己部署的恶意合约的地址,在to参数中传入了攻击者自己的地址。
  2. 然后调用了Exp2合约(0xb4f8)的underlying函数,返回的是WETH的地址
  3. 接着,调用WETH的permit(函数不存在)并transferFrom从一个未知地址向Exp2合约转WETH
  4. 最后调用了Exp2合约的depositVault、burn函数,但是这两个函数并不存在

很明显,又是一个参数可控导致的问题,关键点在于token参数可控,并对token地址进行了函数调用,函数返回指定的地址,然后AnyswapV4Router就会调用这个地址的permit、transferFrom函数。

这里有个问题是,假设underlying函数返回WETH的地址,WETH合约的permit函数会被调用,但是WETH并没有实现EIP-2612,也就是permit函数

Multichain(原anyswap)2022.1.18攻击事件分析

dedaub公司将这一漏洞命名归类为“幻影函数”,也就指的是调用了某一合约没有实现的函数所造成的漏洞。这也是面向对象开发的一个通病,虽然能大大提升开发效率但是同时庞杂的类内函数和方法也会由于开发者考虑不周全而带来安全隐患。

此次攻击事件的漏洞问题出在没有对传入的参数的地址做鉴别,默认其为AnySwapERC20合约地址,其合法性校验取决于permit函数,但是攻击者利用了fallback的特性,使得其越过了这一合法校验。

从开发者角度,防范这一漏洞的根本还是在于要做好合法性校验,因为开发者不能约束第三方合约不写fallback函数。所以说凡是通过间接调用合约内验证函数的验证手段都需要考虑用户传入的地址是否是合法地址。针对于此次攻击事件,我本人提出如下补救方案:

  1. 从token处入手,验证token合法性,具体做法可以是写死token地址,如果有多个地址且需要扩展的情况下owner记录一个map(address, bool)的字典,定期更新,每次调用时查询。
  2. 从underlying处入手,由于本例子一个关键点在于没有返回值校验,而多数fallback函数没有返回值,可以为permit函数多设置一个返回值并且校验其返回值合法性,不仅仅以执行过程中的require保证执行正确。

分析

一些重要地址:

分析第一笔交易0xd07c0f40eec44f7674dddf617cbdec4758f258b531e99b18b8ee3b3b95885e7d,由黑客0x4986发向攻击合约0x7e01,input数据如下:

1
0x0539154b000000000000000000000000b4f89d6a8c113b4232485568e542e646d93cfab10000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000320000000000000000000000007f4bae93c21b03836d20933ff55d9f77e5b8d34d000000000000000000000000489c9a3d952fd25541c4d5f2a5f626fb085f347800000000000000000000000038b807a474553d3f5eb3e93b3927383fbe4bb2a7000000000000000000000000e7fb823ae52982d6aa26f95434ad912110c672470000000000000000000000004745e902a6bef9d044c0ff89b0c2ed6877c2a137000000000000000000000000bb150bc7f3ba780553abaefd0421482d0a9cb53a....

根据etherscan反编译0x7e01合约后的结果,找到0x0539154b函数,有三个参数:(addr _param1, array _param2, addr _param3)。再分析input数据中的函数参数:

  • 第一个参数为0xb4f89d6a8c113b4232485568e542e646d93cfab1,是攻击合约Exploiter3的地址;
  • 第二个参数是一个地址数组,长度为50,值为[0x7f4b..., 0x489c..., ......]
  • 第三个参数为0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,即WETH token合约的地址。

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_Zv1FWTji4G.png

0x0539154b函数的逻辑可以结合tx-decoder(如上图)来分析:

  1. 调用_param1.0xad5c04f函数,传入_param3。_param1是Exploiter3合约0xb4f8,_param3是WETH token合约0xc02a。此处就不去分析Exploiter3合约了,作用是大概是把0xc02a保存到合约中。
  2. 进入一个while循环,遍历_param2中的每个地址addr。在第一次循环中,addr为数组第一个元素0x7F4bae93C21b03836D20933ff55D9F77e5B8d34D。这是授权Anyswap使用WETH代币的某个冤种用户(钱就这样被盗取了),下文将以0x7F4b这个地址为例进行分析。

循环主体的逻辑如下:

  1. 首先,调用_param3.allowance函数,获取用户0x7F4b授权AnyswapV4Router合约0x6b7a提取WETH token的限额。
  2. 调用_param3.balanceOf函数,获取用户0x7F4b的WETH余额。
  3. 调用AnyswapV4Router合约0x6b7a的0x8d7d3eea函数,即anySwapOutUnderlyingWithPermit函数。传入该函数的各参数,可参考下图。

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_ePQG7U5jzP.png

继续分析anySwapOutUnderlyingWithPermit函数:

  1. 首先调用token合约的underlying函数,此处token值为0xB4f89D6a8C113b4232485568e542e646D93cFAB1,即Exploiter3合约。调用返回结果为WETH合约的地址0xc02a。

  2. 调用WETH合约的permit函数。

    由于WETH未实现permit函数,则调用了其fallback函数,进而调用deposit函数(如下),返回成功。

    1
    2
    3
    4
    5
    6
    7
    function() public payable {
    deposit();
    }
    function deposit() public payable {
    balanceOf[msg.sender] += msg.value;
    Deposit(msg.sender, msg.value);
    }
  3. 将冤种用户0x7F4b的所有剩余WETH代币,转移到token合约,即Exploiter3合约0xb4f8。

    ./跨链桥合约漏洞汇总分析/SCJKrmimageimage_qbb1xoPSWI.png

  4. 调用Exploiter3合约的depositVaultburn函数。在Exploiter3合约中,这两个函数都直接返回true,没啥作用。

至此,黑客成功盗取用户0x7F4b的所有WETH代币,进入下一个循环,盗取用户0x489C9a3D952Fd25541c4d5f2a5F626fb085F3478的WETH代币,直到循环结束。

总结

就像参考博客中所说,导致这次事件的原因如下:

  1. 参数未严格校验。未校验用户传入的合约地址,就直接进行调用。
  2. 调用关键函数而未检查返回值。

2022年1月QBridge【8000万美元】

参考

官方文档:Protocol Exploit Report

细节决定成败:QBridge被黑事件分析 | 登链社区 | 区块链技术社区 (learnblockchain.cn)

4.账户0xeb645b4c35cf160e47f0a49c03db087c421ab545在攻击者发起deposit交易后,在BSC网络中先后连续发起了多次Vote
Proposal交易(调用voteProposal函数),铸造了大量的xETH Token.

但实际上攻击者并没有存入任何Token,这些xETH完全是凭空铸造出来的。

5.攻击者在BSC网络中以凭空铸造的大量的xETH作为抵押物,从Qubit合约中借出了其中的Token。

qubit 攻击分析_放牛日记的博客-CSDN博客

设置0地址白名单交易:https://etherscan.io/tx/0xe3da555d506638bd7b697c0bdf7920be8defc9a175cd35bf72fb10bc77167b66

在合约QBridge中有函数depositdepositETH代码功能几乎相同,分别调用QBridgeHandler的depositdepositETH,但handler中函数deposit未校验tokenaddress地址是否为合约(同时将地址0x000000000…00设置为默认eth地址),导致在调用tokenAddress.safeTransferFrom(depositer, address(this), amount);时,由于 tokenAddress 地址为 0 地址,而 call 调用无 code size 的地址时其执行结果都会为 true。最后触发了Deposit事件,跨链铸造了大量qXETH。

Rekt - Qubit Finance - 翻车

来自Qubit团队的事后总结:

  1. 攻击者在以太坊网络上调用QBridge存款功能,该功能调用存款函数QBridgeHandler。
  2. QBridgeHandler应该收到WETH代币,也就是原来的tokenAddress,如果执行交易的人没有WETH代币,就不应该发生转账。
  3. tokenAddress.safeTransferFrom(Deposit, address(this), amount)。
  4. 在上面的代码中,tokenAddress是0,所以safeTransferFrom没有失败,无论金额多少,存款函数都正常结束。
  5. 此外,tokenAddress在加入depositETH之前是WETH地址,但随着depositETH的加入,它被替换为零地址,也就是ETH的tokenAddress。
  6. 总而言之,在depositETH新开发后,存款功能是一个不应该使用的功能,但它仍然留在合约中。

根据Certik的分析

漏洞的根源之一是tokenAddress.safeTransferFrom()在tokenAddress为零(空)地址(0x0…000)时没有回退。

尽管在以太坊合约中没有锁定任何ETH,但攻击者的BSC地址现在可以获得77162个qXETH(价值1.85亿美元),作为Qubit上贷款的抵押品。

他们用该抵押品来借WETH,BTC-B,美元稳定币,以及CAKE,BUNNY和MDX,然后把所有币换成20万个BNB(约8000万美元),这些钱仍然在BSC地址中。

分析

黑客ETH地址:0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7

黑客BSC地址:0xd01ae1a708614948b2b5e0b7ab5be6afa01325c7

ETH上的被攻击合约:

黑客发起的攻击交易如下:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_Bd46eklHlp.png

前两笔交易是正常交易,并成功完成跨链。在BSC链的0x8c580x881a这两笔交易中,黑客0xd01a分别获得了0.1个qXETH。这是黑客在进行尝试吗?

后续的交易就是黑客发起的攻击交易了,我们从第三笔交易0xac72开始分析。这笔交易由黑客发向QBridge代理合约0x20e5,它会调用逻辑合约0x9930,这些信息可从交易的vmtrace中得知。

0xac72调用QBridge合约的deposit函数:

1
2
3
4
5
6
7
8
9
10
11
function deposit(uint8 destinationDomainID, bytes32 resourceID, bytes calldata data) external payable notPaused {
require(msg.value == fee, "QBridge: invalid fee");

address handler = resourceIDToHandlerAddress[resourceID];
require(handler != address(0), "QBridge: invalid resourceID");

uint64 depositNonce = ++_depositCounts[destinationDomainID];

IQBridgeHandler(handler).deposit(resourceID, msg.sender, data);
emit Deposit(destinationDomainID, resourceID, depositNonce, msg.sender, data);
}

它最终调用QBridgeHander合约的deposit函数,代码如下。QBridgeHandler合约也采用了代理模式,逻辑合约在0x80d1

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
function deposit(bytes32 resourceID, address depositer, bytes calldata data) external override onlyBridge {
uint option;
uint amount;
(option, amount) = abi.decode(data, (uint, uint));

address tokenAddress = resourceIDToTokenContractAddress[resourceID];
require(contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");

if (burnList[tokenAddress]) {
require(amount >= withdrawalFees[resourceID], "less than withdrawal fee");
QBridgeToken(tokenAddress).burnFrom(depositer, amount);
} else {
require(amount >= minAmounts[resourceID][option], "less than minimum amount");
tokenAddress.safeTransferFrom(depositer, address(this), amount);
}
}

...

function safeTransferFrom(
address token,
address from,
address to,
uint value
) internal {
// bytes4(keccak256(bytes('transferFrom(address,address,uint256)')));
(bool success, bytes memory data) = token.call(abi.encodeWithSelector(0x23b872dd, from, to, value));
require(success && (data.length == 0 || abi.decode(data, (bool))), "!safeTransferFrom");
}

data数据可从交易的input中获取,前两个uint分别为0x690xa4cc799563c380000 = 190000000000000000000 = 190ETH

1
000000000000000000000000000000000000000000000000000000000000006900000000000000000000000000000000000000000000000a4cc799563c380000000000000000000000000000d01ae1a708614948b2b5e0b7ab5be6afa01325c7

tokenAddress为0x0000000000000000000000000000000000000000safeTransferFrom函数是QBridge自己实现的,其中并没有判断token地址是否为一个合约地址。

最终,黑客一分钱没存,deposit函数也成功执行,并抛出Deposit事件,触发跨链交易。而后,在BSC链上,黑客在交易0x61ca中获得了199个qXETH。

总结

  1. 在QBridgeHander合约的deposit函数中,地址0不应该包括在合约白名单中。
  2. call调用之前,应该检查地址是否为合约地址。
  3. 链下应该对抛出的事件进行验证。

2022年2月Wormhole【1000万美元】

https://www.anquanke.com/post/id/275550#h2-1

管理员不仅需要通过调用原实现合约B中的upgradeToAndCall函数通过新实现合约 B’ 中的initialize函数更改A存储中的upgrader变量(第一步),同时也需要额外在外部独立调用一次initialize函数更改 B’ 存储中的upgrader变量(第二步)。

在缺少第2步的情况下,相当于是代理合约A被正确的初始化了,用户无法通过代理合约A进行任何恶意的行为,但是 B’
没有做任何的初始化,用户仍旧可以直接调用B’ 的初始化函数initialize,将 B’ 的存储中的upgrader更新为自己,通过控制 B’
的升级行为去调用自毁操作实现将 B’ 合约销毁的操作,使得A合约所指向的实现合约 B’ 消失了,代理A合约所剩下的数据存储也将没有任何用处。

Wormhole负责更新与鉴权的具体逻辑与上文所描述的思路来说稍复杂。其负责实现UUPS标准upgradeToAndCall函数实际名称为submitContractUpgrade,并且鉴权时使用了parseVM等与自定义虚拟机相关的操作。
在实现合约中,initialize负责对管理员变量进行初始化。

Wormhole在上一次调用submitContractUpgrade()更新在区块高度13818843(2021年12月16日),之后实际合约B’
始终处于未初始化的状态。

攻击者可以未授权调用实际合约B’ 的初始化函数initialize( )获取 B’ 合约管理员权限,随后凭借所获得的管理员权限恶意地调用更新函数submitContractUpgrade(),该更新函数中的delegatecall允许执行攻击者指定的任意代码,其中危害最大的是执行selfdestruct让实际合约 B’ 自毁,使得Wormhole项目中的资产被冻结。

Wormhole项目方在高度14269474(2022年2月24日)调用了初始化函数后修复了该问题。修复交易:https://etherscan.io/tx/0x9acb2b580aba4f5be75366255800df5f62ede576619cb5ce638cedc61273a50f

代理合约:https://etherscan.io/address/0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B

逻辑合约:https://etherscan.io/address/0x736D2A394f7810C17b3c6fEd017d5BC7D60c077d

总结:采用UUPS代理模式,调用代理合约完成初始化后,一定要再调用逻辑合约进行初始化!!!

2022年2月Meter【440万美元】

参考

METER - 翻车

知道创宇区块链安全实验室|Meter.io 攻击事件分析_MarsBit

漏洞关键在于跨链桥合约的 deposit 函数中,deposit 函数会根据 resourceID 取相应的depositHandler合约,并调用合约中的 deposit 函数进行实际的质押逻辑。

而在 depositHandler 合约的 deposit 函数中,存在逻辑缺陷,当 tokenAddress 不为 _wtokenAddress 地址时进行 ERC20 代币的销毁或锁定,若为 _wtokenAddress 则直接跳过该部分处理。

该存在缺陷的逻辑判断可能基于在跨链桥合约中的depositETH函数会将链平台币转为wToken后转至depositHandler地址,所以在depositHandler执行deposit逻辑时,已处理过代币转移,故跳过代币处理逻辑。

但跨链桥合约的deposit函数中并没有处理代币转移及校验,在转由deposiHandler执行deposit时,若data数据构造成满足tokenAddress == _wtokenAddress即可绕过处理,实现空手套白狼。

跨链桥Meter.io被攻击事件分析

虚假存款:

铸币:

步骤一:攻击者调用Bridge.deposit()函数,将0.008BNB存入连接到多个链的合约Bridge,包括币安智能链、以太坊以及Moonriver(两次)。

步骤三:由于输入的resourceID是0x000000000000bb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c01,token地址将为0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c,这与_wtokenAddress相同。

一般来说deposit()用于ERC20代币的存款,depositETH()用于WETH/WBNB代币的存款。Bridge合约提供了两个方法:deposit()和 depositETH()。然而,这两个方法造成了相同事件,并且deposit()函数并没有阻止WETH/WBNB的存款交易,因为deposit()没有烧毁或锁定WETH/WBNB。黑客通过使用deposit()来制作假的存款事件,在没有任何真实存款的情况下,将WETH/WBNB存入。

分析

黑客:

被攻击合约:

此次攻击事件涉及多条链(ETH、BSC、moonriver),而且涉及跨链转账,导致一开始分析有点懵。

先看黑客在ETH上发起的攻击,有如下两笔交易:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_jI7pPcWWJi.png

  1. 分析第一笔交易0x2d39,它调用Bridge合约0xa2A2deposit函数,input数据解析后:

    • destinationChainID为1,根据后文分析,1指代以太坊,这笔跨链交易是以太坊跨以太坊。
    • resourceID为0x0000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc201。
    • data为:
      javascript 000000000000000000000000000000000000000000000016e77c77f5de41f3a4 0000000000000000000000000000000000000000000000000000000000000014 8d3d13cac607b7297ff61a5e1e71072758af4d01
  2. 分析Bridge合约deposit函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function deposit(uint8 destinationChainID, bytes32 resourceID, bytes calldata data) external payable whenNotPaused {
    uint256 fee = _getFee(destinationChainID);

    require(msg.value == fee, "Incorrect fee supplied");

    address handler = _resourceIDToHandlerAddress[resourceID];
    require(handler != address(0), "resourceID not mapped to handler");

    uint64 depositNonce = ++_depositCounts[destinationChainID];
    _depositRecords[depositNonce][destinationChainID] = data;

    IDepositExecute depositHandler = IDepositExecute(handler);
    depositHandler.deposit(resourceID, destinationChainID, depositNonce, msg.sender, data);

    emit Deposit(destinationChainID, resourceID, depositNonce);
    }

    它先根据resourceID获取handler合约地址,此处为ERC20Handler合约地址0xde4f;再调用ERC20Handler合约的deposit函数。

  3. 分析ERC20Handler合约的deposit函数。

    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
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    /**
    @notice A deposit is initiatied by making a deposit in the Bridge contract.
    @param destinationChainID Chain ID of chain tokens are expected to be bridged to.
    @param depositNonce This value is generated as an ID by the Bridge contract.
    @param depositer Address of account making the deposit in the Bridge contract.
    @param data Consists of: {resourceID}, {amount}, {lenRecipientAddress}, and {recipientAddress}
    all padded to 32 bytes.
    @notice Data passed into the function should be constructed as follows:
    amount uint256 bytes 0 - 32
    recipientAddress length uint256 bytes 32 - 64
    recipientAddress bytes bytes 64 - END
    @dev Depending if the corresponding {tokenAddress} for the parsed {resourceID} is
    marked true in {_burnList}, deposited tokens will be burned, if not, they will be locked.
    */
    function deposit(
    bytes32 resourceID,
    uint8 destinationChainID,
    uint64 depositNonce,
    address depositer,
    bytes calldata data
    ) external override onlyBridge {
    bytes memory recipientAddress;
    uint256 amount;
    uint256 lenRecipientAddress;

    assembly {

    amount := calldataload(0xC4)

    recipientAddress := mload(0x40)
    lenRecipientAddress := calldataload(0xE4)
    mstore(0x40, add(0x20, add(recipientAddress, lenRecipientAddress)))

    calldatacopy(
    recipientAddress, // copy to destinationRecipientAddress
    0xE4, // copy from calldata @ 0x104
    sub(calldatasize(), 0xE) // copy size (calldatasize - 0x104)
    )
    }

    address tokenAddress = _resourceIDToTokenContractAddress[resourceID];
    require(_contractWhitelist[tokenAddress], "provided tokenAddress is not whitelisted");

    // ether case, the weth already in handler, do nothing
    if (tokenAddress != _wtokenAddress) {
    if (_burnList[tokenAddress]) {
    burnERC20(tokenAddress, depositer, amount);
    } else {
    lockERC20(tokenAddress, depositer, address(this), amount);
    }
    }

    _depositRecords[destinationChainID][depositNonce] = DepositRecord(
    tokenAddress,
    uint8(lenRecipientAddress),
    destinationChainID,
    resourceID,
    recipientAddress,
    depositer,
    amount
    );
    }

    根据函数注释,可以解析data中数据:

    • amount为0x16e77c77f5de41f3a4 = 422508708639363200424 wei = 422 ETH
    • recipientAddress length为0x14=20
    • recipientAddress为0x8d3d13cac607b7297ff61a5e1e71072758af4d01

    根据resourceID可得到tokenAddress为0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2,其实就是resourceID去掉结尾的01,这个地址为WETH token合约地址

    同时,由于 _wtokenAddress也等于WETH token合约地址,if语句被跳过,直接保存存款记录。存款记录中:

    • tokenAddress为WETH token地址0xc02a。
    • lenRecipientAddress为0x14。
    • destinationChainID为1,以太坊。
    • resourceID为0x0000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc201
    • recipientAddress为0x8d3d13cac607b7297ff61a5e1e71072758af4d01,即黑客地址。
    • depositer为交易发起者,也就是黑客地址。
    • amount为422508708639363200424 wei = 422 ETH

    该函数成功执行,最后返回Bridge合约的deposit函数,Deposit事件被抛出。

  4. 10分钟后,以太坊上的黑客0x8d3d收到一笔422ETH的转账,来自交易0xd619

    ./跨链桥合约漏洞汇总分析/SCJKrmimage1689671775667_4Jm4Wi_8XT.png

同理,分析黑客在以太坊上发起的第二笔交易0xdfea,大体流程与第一笔相同。不同的是,这笔交易是发到destinationChainID为3的目标链上,数量为0x5150ae84a8cdf00000 = 1500000000000000000000 wei = 1500 ETH。目前,我还没搞清楚ID为3的链是哪条,所以分析不到钱最终去哪了。

接着,分析BSC链上黑客发起的攻击交易,如下:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_FZBpONgTDB.png

第一笔交易0xc4d7是发到ID为3的目标链上,token地址为WMTRG token合约地址0xbd29。此处token地址似乎并不等于_wtokenAddress,这笔攻击交易似乎没有利用成功。

第二笔交易0x63f3是发到ID为4的目标链上(根据后续分析,4指BSC链),token地址为WBNB token合约地址0xbb4c,数量为0xc4ef2bf0fd2ac0dda7 = 3632795971816270486660 = 3632 BNB。这次利用成功了,BSC上黑客地址随后收到了3632个BNB的转账:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_oKRAsMB4gF.png

后续两笔交易0xc7eb0x5d7c都是发到ID为5的目标链上(moonriver链),数量都为0x32d26d12e980b600000 = 15000000000000000000000 = 15000 BNB。这两笔交易都攻击成功,黑客在moonriver上的地址0x8d3d随后收到了这两笔跨链转账:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_qln6w0X_dZ.png

总结

meter这次漏洞感觉就是开发人员的疏忽,代码写得也不咋优雅,主要有如下几个问题。

  1. 未校验函数调用前提。Bridge合约中,deposit函数用于储存ERC20代币,depositETH函数用于储存原生代币,它们最终都调用ERC20Handler合约的deposit函数。这两个函数中都没对传入的resourceID做判断,导致黑客能够调用deposit函数储存原生代币。
  2. 逻辑混乱。储存过程中最重要的转账步骤,一个实现在Bridge合约中(原生代币),一个实现在ERC20Handler合约中(ERC20代币)。
  3. 未校验存款事件的正确性。直接就相信了,而没有进行深入的验证。

2022年3月LayerZero

Cobo 安全团队:简析 Stargate 跨链桥底层协议 LayerZero 重大安全漏洞

LayerZero 3月28日在未发表任何公告的情况下更新了跨链使用的验证合约[2]。Cobo安全团队通过对比原始验证合约(MPTValidator)和新验证合约(MPTValidatorV2)代码[3],发现本次更新是对之前重大安全漏洞的修復。

原始漏洞代码在进行 MPT 验证时,通过外部传入的 pointer 来获取下一层计算所用到的 hashRoot。这里使用 solidity 底层 add, mload 等汇编指令从 proofBytes 中获取 hashRoot ,由于没有限制 pointer 在 proofBytes 长度内,因此攻击者可以通过传入越界的 pointer,使合约读取到 proofBytes 以外的数据作为下一层的 hashRoot。这样就存在伪造 hashRoot 的可能,进一步导致伪造的交易 receipt 可以通过 MPT 验证。最终可造成的后果是,在 Oracle 完全可信的前提下,Relayer 仍可以单方面通过伪造 receipt 数据的方式来实现对跨链协议的攻击,打破了 LayerZero 之前的安全假设。

新合约(包含源码):0xe9AE261D3aFf7d3fCCF38Fa2d612DD3897e07B2d

旧合约:https://cdn.jsdelivr.net/npm/@layerzerolabs/proof-evm@1.0.1-beta.0/contracts/

总结:本质是越界问题。

2022年8月Nomad【1.9亿美元】

参考

Nomad 跨链桥被盗1.8亿美元事件分析-安全客 - 安全资讯平台 (anquanke.com)

由于Replica合约初始化时,_committedRoot传入了零值,导致之后合约中的confirmAt[0x000…000]均为true。在process方法中可直接通过判断条件,导致每条消息在默认情况下都被证明有效,任何人都可以发送构造消息转移资金。

Nomad被黑解析|致命漏洞损失1.9亿美元,白帽已返还近900万美元

在该合约的初始化交易中可以看到,输入的所有变量都为0,也就是说在初始化阶段 confirmAt[0x00..0] 的值被设为了1。

而confirmAt[] 只在initialize,update和 setConfirmation三个函数中出现被修改的情况,导致此次攻击事件发生的问题出现在
initialize 函数中。

这也就直接导致了 acceptableRoot 函数中发生了不合理的绕过。

初始化交易哈希:

0x53fd92771d2084a9bf39a6477015ef53b7f116c79d98a21be723d06d79024cad

Nomad被攻击事件分析:黑客点火,多人“趁火打劫”

分析

合约:

黑客:

Nomad这次攻击事件比较混乱,最初的那名黑客在网上公布了攻击方法后,很多人开始效仿,导致出现了很多黑客,攻击方式也各不相同。而且,Replica合约有多个代理合约,它们都指向一个实现合约0xb923,这些代理合约似乎都受到了各种攻击。由于被攻击的合约很多,攻击交易也很多,攻击方法也是各具特色,我就不一一分析了。

直接分析一笔流程相对简单的攻击交易:0x87ba81。发起这笔交易的黑客只盗取了几百美金,还使用ENS地址。

通过分析vmtrace,这笔交易调用的是代理合约0x5d94,逻辑合约在0xb923,对应Replica合约。交易调用的是process函数,传入的_message参数如下(结合代码进行了一点优化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0x000063310000000000000000000000009FAF7F27C46ACDECEE58EB4B0AB6489E603EC251000002CE0065746800000000000000000000000088A69B4E698A4B090DF6CF5BD7B2D47325AD30A3006574680000000000000000000000003D6F0DEA3AC3C607B3998E6CE14B6350721752D9030000000000000000000000000C79132266696787598917B079F43CFFC13BDCDD00000000000000000000000000000000000000000000004BD2B6144303B2F00CBA3D8A5E319F14B64B064EA8FB15093F84A84CFF2B1DF818F409C871315D2112

优化:

0: 00006331
4: 0000000000000000000000009FAF7F27C46ACDECEE58EB4B0AB6489E603EC251
36: 000002CE
40: 00657468
44: 00000000000000000000000088A69B4E698A4B090DF6CF5BD7B2D47325AD30A3
76: 00657468
0000000000000000000000003D6F0DEA3AC3C607B3998E6CE14B6350721752D9
03
0000000000000000000000000C79132266696787598917B079F43CFFC13BDCDD
00000000000000000000000000000000000000000000004BD2B6144303B2F00C
BA3D8A5E319F14B64B064EA8FB15093F84A84CFF2B1DF818F409C871315D2112

当然,在这个漏洞中,参数似乎并不重要。漏洞的关键是process函数中的这行校验代码:

1
2
3
4
5
function process(bytes memory _message) public returns (bool _success)
...
require(acceptableRoot(messages[_messageHash]), "!proven");
...
}

它调用了acceptableRoot函数(如下),该函数在参数_root为0时,confirmAt[_root]不为0,函数返回true。而用户只要传入任意不存在的_messageHashmessages[_messageHash]就为0,就能通过require验证。

1
2
3
4
5
6
7
8
9
10
11
12
function acceptableRoot(bytes32 _root) public view returns (bool) {
// this is backwards-compatibility for messages proven/processed
// under previous versions
if (_root == LEGACY_STATUS_PROVEN) return true;
if (_root == LEGACY_STATUS_PROCESSED) return false;

uint256 _time = confirmAt[_root];
if (_time == 0) {
return false;
}
return block.timestamp >= _time;
}

confirmAt只有在initialize,update和setConfirmation三个函数中被修改。

关于何时将0设置为可信根,即confirmAt[0]=1。有些博客说是在交易0x53fd92中。但这笔交易是发向Replica逻辑合约0xb923的,它只修改了逻辑合约的状态。而实际黑客攻击的是代理合约,代理合约通过delegatecall调用逻辑合约,状态保存在代理合约中。因此,这笔交易并没有影响代理合约的状态

有些博客则称是在创建合约时,就将0设置为可信根,这个说法我是认同的。在创建代理合约0x5d94的交易0x99662d中,调用了代理合约的构造函数,构造函数中又调用了逻辑合约:

./跨链桥合约漏洞汇总分析/SCJKrmimageimage_fO2PpW-PiV.png

其中,input数据优化如下:

1
0xe7e7a7b70000000000000000000000000000000000000000000000000000000061766178000000000000000000000000b93d4dbb87b80f0869a5ce0839fb75acdbeb1b7700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000708

0xe7e7a7b7是Replica合约的initialize函数的ID,该函数代码如下。第三个参数为0,进而导致confirmAt[0] = 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function initialize(
uint32 _remoteDomain,
address _updater,
bytes32 _committedRoot,
uint256 _optimisticSeconds
) public initializer {
__NomadBase_initialize(_updater);
// set storage variables
entered = 1;
remoteDomain = _remoteDomain;
committedRoot = _committedRoot;
// pre-approve the committed root.
confirmAt[_committedRoot] = 1;
_setOptimisticTimeout(_optimisticSeconds);
}

不过,创建这个代理合约时,逻辑合约为0x7f58。而被黑客攻击时,逻辑合约又变为0xb923。当然,逻辑合约变动,并不影响代理合约中存储的状态。因此,我认为,是在代理合约被创建时,就将0设为可信根,进而导致了后续的漏洞。但Nomad开发者为什么要将0设为可信根呢,这就不得而知了。

总结

总之,这次漏洞是因为:

  1. 配置错误。开发者将不知道啥用的值,设置到关键配置中。
  2. 开发疏忽。开发acceptableRoot函数时,没有考虑到参数 _root 为0的情况。

2022年10月TRANSIT SWAP【2000万美元】

Cross-chain DEX Aggregator Transit Swap Hacked Analysis | by SlowMist | Medium

这种攻击的主要原因是Transit Swap协议在令牌交换期间没有严格验证用户传入的数据,从而导致任意外部调用。攻击者利用任意外部调用中的此漏洞窃取用户授权的令牌。

跨链DEX聚合器Transit Swap攻击分析

Transit Swap被黑分析

Transit Swap 被黑事件简析

黑客:

攻击交易:https://etherscan.io/tx/0x743e4ee2c478300ac768fdba415eb4a23ae66981c076f9bff946c0bf530be0c7