深度解析 Optimism窃取事件:Layer2 网络合约部署重放攻击

niuaniua 水手 发布在 Web 3.0
 13356  0
Cobo对 Optimism窃取事件进行分析与复现,围绕时间线、攻击路线、以太坊合约地址生成、技术细节等多重角度进行全面技术解读。

事件概述

今年五月底 Optimism 基金会聘请 做市商 Wintermute 为 op 代币提供流动性,Optimism 基金会为 Wintermute团队 提供 2000万 op 代币用于做市。此过程中出现了沟通失误,Wintermute团队 向 Optimism 基金会提供了 Layer1(eth) 上的收款账户,而此账户尚未在 Layer2(Optimism) 上部署,Optimism 基金会向 Layer2 账户打款后,Wintermute团队 发现了该问题,但在账户修复之前,攻击者提前取得了该账户权限,开始抛售账户中的op代币。

时间线

●05.26 & 05.27 -  Optimism 基金会向 Wintermute团队 提供的地址 0x4f3a120e72c76c22ae802d129f599bfdbc31cb81 分别打款 1 op 和 100万 op 作为账户测试。

●05.27 - Optimism 基金会向该地址打款剩余 1900万 op。

●05.30 - Wintermute团队 发现账户错误,联系 Optimism 基金会,并联系 Gnosis Safe 团队请求协助取回资金。Wintermute团队 在与 Optimism基金会 和 Gnosis Safe 团队协商后,评估该账户目前仍是安全的,不会被 Wintermute团队 之外的人控制,在 Gnosis Safe 团队的帮助下可以取回资金。并计划在 06.07 日修复账户权限。

●06.01 - 攻击者部署攻击合约,合约内硬编码了 factory 地址,说明此时攻击者已确定了完整的攻击流程。

●06.05 - 攻击者发起攻击,取得目标账户权限。并转出 100万 op 到 tornado 进行兑换。

●06.09 - Wintermute团队 发表声明对此过失负全责,并会回购所有攻击者抛售的代币,同时要求攻击者归还剩余代币。声明发表4小时后,攻击者再次转出 100万 op 到某私人账户。

攻击路线

1、所有 Gnosis Safe 保险箱合约均由 Gnosis Safe proxy factory 合约部署,要获得目标地址控制权需要调用 proxy factory 在此地址上部署 proxy 合约。

2、攻击发生前,Layer2(Optimism) 上的 proxy factory 合约尚未部署,攻击者通过重放 Layer1 上 factory 合约的部署交易,在 Layer2 上部署新的 factory 合约。

3、在 Layer2 上通过多次调用 factory 合约的 createProxy 方法部署 proxy 合约,不断累加 factory 合约 的 nonce,直到将 proxy 部署到目标地址。

4、在调用 createProxy 部署 proxy 时,将 masterCopy 参数设置为攻击者控制的合约地址,masterCopy 将作为 proxy 的 implementation。至此攻击者得到目标地址的控制权。

以太坊合约地址生成

为了解释上述攻击路线为何可以在特定地址部署合约,我们需要了解一下以太坊的合约地址生成机制。

合约地址不存在对应的私钥,地址是在合约部署时确定的,部署合约的方法有 CREATE 和 CREATE2 两种。CREATE 生成地址具体算法为,发起部署的地址和该地址关联的 nonce 通过 RLP 进行编码,编码后的数据再通过 sha3 得到 hash,取后20个byte作为新部署合约的地址。

new_address = hash(sender, nonce)

具体代码可用 js 表示为:

const Web3 = require("web3");
const RLP = require("rlp");

const nonce = 0;
const account = "0xa990077c3205cbdf861e17fa532eeb069ce9ff96";

var e = RLP.encode(
    [
      account,
      nonce,
    ],
  );
const nonceHash = Web3.utils.sha3(Buffer.from(e));
console.log(nonceHash.substring(26));

对于 EOA 账户,每发起一笔交易 nonce 就会 +1,对于合约账户,每次创建一个合约则 nonce +1。

CREATE2 一般由智能合约调用,生成地址算法如下,编码方式同CREATE:

new_address = hash(0xFF, sender, salt, bytecode)

●0xff 是固定的常数

●sender为发起部署的地址

●salt为 sender指定的任意值

●bytecode 为要部署的合约代码

●CREATE2 避免了引入递增的 nonce 。将其替换为sender可控的 salt,这样可以更好地控制合约部署地址。

技术细节

Layer1 proxy 创建过程

由上一节的合约地址生成方式可知,如果要在Layer2特定地址部署合约,我们需要首先确定他在Layer1 上的部署方式。Layer1 上的Wintermute多签账户 proxy 由交易 https://etherscan.io/tx/0xd705178d68551a6a6f65ca74363264b32150857a26dd62c27f3f96b8ec69ca01  创建,使用的 factory 为 Proxy Factory 1.1.1。创建方法为 createProxy。ProxyFactory.createProxy 方法实现如下:

function createProxy(address masterCopy, bytes memory data)
    public
    returns (Proxy proxy)
{
    proxy = new Proxy(masterCopy);
    if (data.length > 0)
        // solium-disable-next-line security/no-inline-assembly
        assembly {
            if eq(call(gas, proxy, 0, add(data, 0x20), mload(data), 0, 0), 0) { revert(0, 0) }
        }
    emit ProxyCreation(proxy);
}

可以看到此处使用了 new 关键字去部署Proxy合约,new关键字底层调用CREATE opcode 执行合约部署,而非 CREATE2 。

因此我们在 Layer2 上调用相同地址上的 Proxy Factory 1.1.1 合约的createProxy方法,只要能在部署时达到相同的 nonce ,就能创建出与 Layer1 上相同地址的 proxy 合约。

Layer2重放部署 Gnosis Safe proxy factory 1.1.1

在攻击发生之前,Optimism 上尚未部署 Gnosis Safe proxy factory 1.1.1。因此我们需要先将 proxy factory合约部署到与 Layer1 相同的地址上。

Layer1  上 proxy factory 1.1.1 地址为 0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B ,由 交易 https://etherscan.io/tx/0x75a42f240d229518979199f56cd7c82e4fc1f1a20ad9a4864c635354b4a34261  在 2019 年创建。

解码该交易 https://www.ethereumdecoder.com/?search=0x75a42f240d229518979199f56cd7c82e4fc1f1a20ad9a4864c635354b4a34261 ,原始数据为:

{
  "nonce": 2,
  "gasPrice": {
    "_hex": "0x02540be400"
  },
  "gasLimit": {
    "_hex": "0x114343"
  },
  "to": "0x00",
  "value": {
    "_hex": "0x00"
  },
  "data": "xxxx...xxxx",
  "v": 28,
  "r": "0xc7841dea9284aeb34c2fb783843910adfdc057a37e92011676fddcc33c712926",
  "s": "0x4e59ce12b6a06da8f7ec7c2d734787bd413c284fc3d1be3a70903ebc23945e8c"
}

发现 v = 28, 根据 EIP-155 ,v = 28 或者 v = 27 的交易未使用 EIP-155 签名,不会将 ChainID 引入到交易签名中。

因此该 Layer1 上的合约部署交易可以直接在 Layer2(Optimism) 上进行重放,重放交易为 https://Optimism.etherscan.io/tx/0x75a42f240d229518979199f56cd7c82e4fc1f1a20ad9a4864c635354b4a34261 ,该交易部署了一个新的 Gnosis Safe proxy factory,地址与 Layer1 上相同 : https://Optimism.etherscan.io/address/0x76e2cfc1f5fa8f6a5b3fc4c8f4788f0116861f9b   。

图中可以看到,该合约4天前攻击开始时才创建,但是在创建前却有多次合约调用记录。

此时该factory合约账户的 nonce 为 0 ,对于合约账户,每次部署新的合约就会使其 nonce + 1 。因此只要一直调用 createProxy 方法部署 proxy 合约来累加 nonce,最终就能够得到部署在目标地址的 proxy 合约。

Layer2目标地址部署 proxy

攻击合约 0xe7145dd6287ae53326347f3a6694fcf2954bcd8a[1]

攻击者通过该攻击合约批量调用 ProxyFactory[2] 的 createProxy 方法,并将 masterCopy 地址参数指定为自身的合约地址,从而大量创建由攻击合约自身地址作为 implementation 的 proxy 合约。由此实现对目标合约的控制。

具体过程:

攻击者从EOA地址1[3]多次调用攻击合约,每个交易会创建 162 个 Proxy 合约,最终在交易 https://Optimism.etherscan.io/tx/0x00a3da68f0f6a69cb067f09c3f7e741a01636cbc27a84c603b468f65271d415b 中创建出了地址为 0x4f3a120e72c76c22ae802d129f599bfdbc31cb81 的目标合约。

攻击者以EOA地址2[4]作为 proxy owner,控制目标合约操作。

> eth_call 0x4f3a120E72C76c22ae802D129F599BFDbc31cb81 owner()
0x0000000000000000000000008bcfe4f1358e50a1db10025d731c8b3b17f04dbb

经验教训

随着更多以太坊第二层解决方案(如 Optimism、Arbitrum)和侧链(如xDai, Polygon)的出现,资产和dapp分散在不同的网络中。然而 EOA 账户 和 Gnosis Safe  这样的智能合约账户在跨链的行为上差异巨大。

EOA账户本身基于私钥,可以跨网络使用,但是合约账户本身没有私钥而且需要复杂的部署和初始化逻辑才能确定控制权和账户功能,无法直接跨链使用。

同样的,在不同的网络上,作为基础设施的合约也并不是凭空出现、对等拷贝的。

因此在处理不同层级的网络时,对涉及合约的操作需要谨慎,首先需要检查目标网络和 eth 主网的合约地址、合约内容、合约状态是否相同,在涉及复杂逻辑的情况下,甚至需要检查目标网络特性和合约功能是否兼容。不能想当然地认为Layer1的地址会无差别的映射到 Layer2或侧链中,避免像 Wintermute团队 失窃这样的乌龙事件再次发生。

参考资料

[1]攻击合约: https://Optimism.etherscan.io/address/0xe7145dd6287ae53326347f3a6694fcf2954bcd8a

[2]ProxyFactory: https://Optimism.etherscan.io/address/0x76e2cfc1f5fa8f6a5b3fc4c8f4788f0116861f9b#code

[3]攻击者EOA地址1: https://Optimism.etherscan.io/address/0x60B28637879B5a09D21B68040020FFbf7dbA5107

[4]攻击者EOA地址2: https://Optimism.etherscan.io/address/0x8bcfe4f1358e50a1db10025d731c8b3b17f04dbb