2022.04.04

暗号資産市場最大といわれるAxie Infinity (Ronin Bridge)のハッキング事件を検証する

こんにちは。F.S.です。
今回はつい先日、2022年3月29日に報告された6億2500万ドル相当(約750億円規模)の暗号資産ハッキング事件がどのように発生したか、Ronin Bridgeコントラクトのソースを交えて検証してみます。

サマリー

  • 事象
    • Axie Infinityが使うサイドチェーンとのブリッジにロックされたアセットのうち 173,600 WETH と 2,550万 USDC が攻撃者のアドレスへ引き出された
  • 要因
    • ブリッジ(スマートコントラクト)への引き出し命令はサイドチェーンのトランザクションを検証するバリデーターノードの多数決が必要だが、一部脆弱なノードへ攻撃者が侵入したことより過半数のノードの署名が生成可能となった
  • 所感
    • スケーリングソリューションが拡大し、多くのアセットをロックするブリッジに命令実行できるノード(ホットウォレット)は常に攻撃の脅威にさらされる
    • 過去の失敗の学びによりスマートコントラクト自体の脆弱性は聞かなくなってきたが、周辺システムの脆弱性は後を立たず、特に秘密鍵流出は依然として脅威である
    • ノード運用の分散性が高くない場合、画一のノード運用をしている可能性があり、ネットワークの多数決が容易に不正取得されるリスクがある

1. Axie InfinityおよびRoninについて

  • Ethereumを基盤としたPlay to Earn(ゲームして稼ぐ)型のブロックチェーンゲーム
  • NFTであるAxieを育成・繁殖して強いAxieを作り、戦いに勝って報酬を得る
  • 人気を博し、スケーラビリティのためRonin Networkサイドチェーンを開発・導入した
Axie Infinity – Wikipedia

2. 事件の顛末

Substackにて報告されています。4月3日時点でアセットは取り戻せておらず、随時状況が追記されています。
Community Alert: Ronin Validators Compromised – Ronin’s Newsletter

2-1. 問題のトランザクション

3月23日に2つのトランザクションで不正引き出しが行われています。
いずれも同一の攻撃者によるトランザクションで、Ronin Bridgeコントラクトの withdrawERC20For を実行して引き出されています。

3. Ronin Bridgeコントラクトの検証

ここから本記事のメインコンテンツです。

3-1. 引き出し処理の流れ

メインネット(Ethereum)への引き出しを行うコントラクトを見てみます。ソースコードはGitHubから取得します。
axieinfinity/ronin-smart-contracts – GitHub

メインネット側のBridgeコントラクトは contracts/chain/mainchain/MainchainGatewayManager.sol にあたります。
まずは withdrawERC20For から
// MainchainGatewayManager.solより抜粋
  function withdrawERC20For(
    uint256 _withdrawalId,
    address _user,
    address _token,
    uint256 _amount,
    bytes memory _signatures
  )
    public
    whenNotPaused
    onlyMappedToken(_token, 20)
  {
    bytes32 _hash = keccak256(
      abi.encodePacked(
        "withdrawERC20",
        _withdrawalId,
        _user,
        _token,
        _amount
      )
    );

    require(verifySignatures(_hash, _signatures));

    // 以降、ERC20のトランスファー処理が行われる
この verifySignatures 要求を通過すると、つつがなくERC20のトランスファーが行われます。この流れはNFT(ERC721)でも同様です。

次に verifySignatures の中身を見てみましょう。
// MainchainGatewayManager.solより抜粋
  /**
   * @dev returns true if there is enough signatures from validators.
   */
  function verifySignatures(
    bytes32 _hash,
    bytes memory _signatures
  )
    public
    view
    returns (bool)
  {
    uint256 _signatureCount = _signatures.length.div(66);

    Validator _validator = Validator(registry.getContract(registry.VALIDATOR()));
    uint256 _validatorCount = 0;
    address _lastSigner = address(0);

    for (uint256 i = 0; i < _signatureCount; i++) {
      address _signer = _hash.recover(_signatures, i.mul(66));
      if (_validator.isValidator(_signer)) {
        _validatorCount++;
      }
      // Prevent duplication of signatures
      require(_signer > _lastSigner);
      _lastSigner = _signer;
    }

    return _validator.checkThreshold(_validatorCount);
  }
検証メッセージのハッシュ(_hash)と連結署名(_signature)から各署名者のアドレスを取得します。それがバリデーター登録されているアドレスであれば有効な署名としてカウントし、カウントが閾値を超えているかチェックしています。
事件当時、バリデーター総数(分母)は 9、閾値(分子)は 5 に設定されていました。つまり、9ノード中5ノードの署名があれば実行可能です。

3-2. 署名の検証

ここで、問題のトランザクションの署名者が本当に適切なバリデーターだったのか検証してみましょう。
Hardhatで新規プロジェクト作成し、下記の通りRonin Bridgeと同じロジックで署名者のアドレスを抽出するコントラクトと実行スクリプトを作成して、問題のトランザクションと同じパラメーターを指定して実行をしてみます。
  • contracts/Recoverer.sol
    //SPDX-License-Identifier: Unlicense
    pragma solidity ^0.8.0;
    
    import "./ECVerify.sol";    // ronin-smart-contractsにある署名検証コントラクト
    import "./SafeMath.sol";  // openzeppelin-contractsのSafeMath
    
    contract Recoverer {
        using SafeMath for uint256;
        using ECVerify for bytes32;
    
        function recoverSignerAddresses(
            uint256 _withdrawalId,
            address _user,
            address _token,
            uint256 _amount,
            bytes memory _signatures
        )
            public
            pure
            returns (address[5] memory)
        {
            bytes32 _hash = keccak256(
                abi.encodePacked(
                    "withdrawERC20",
                    _withdrawalId,
                    _user,
                    _token,
                    _amount
                )
            );
    
            address[5] memory _signers;
            uint256 _signatureCount = _signatures.length.div(66);
            for (uint256 i = 0; i < _signatureCount; i++) {
                address _signer = _hash.recover(_signatures, i.mul(66));
                _signers[i] = _signer;
            }
    
            return _signers;
        }
    }
    
  • scripts/recover-signer-addresses.js
    const hre = require("hardhat");
    
    const fromHexString = hexString =>
      new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
    
    async function main() {
      const Recoverer = await hre.ethers.getContractFactory("Recoverer");
      const recoverer = await Recoverer.deploy();
    
      await recoverer.deployed();
    
      console.log("* Verify singers of tx 0xc28fad5e8d5e0ce6a2eaf67b6687be5d58113e16be590824d6cfa1a94467d0b7");
      const paramWETH = [
        2000000,
        "0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
        "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
        "173600000000000000000000",
        fromHexString("01175db2b62ed80a0973b4ea3581b22629026e3c6767125f14a98dc30194a533744ba284b5855cfbc34c1416e7106bd1d4ce84f13ce816370645aad66c0fcae4771b010984ea09911beeadcd3dab46621bc81071ba91ce24d5b7873bc6a34e34c6aafa563916059051649b3c1930425aa3a79a293cacf24a21bda3b2a46a1e3d39a6551c01f962ee0e333c2f7261b3077bb7b7544001d555df4bc2e6a5cae2b2dac3d1fe3875cd1d12fadbeb4c01f01e196aa36e395a94de074652971c646b4b3b7149b3121b0178bd67c4fa659087c5f7696d912dee9db37802a3393bf4bd799e22eb201e78d90dc3f57e99d8916cd0282face42324f3afa0d96b0a09c4f914f15cac9c11037b1b0102b7a3a587c5be368f324893ed06df7bdcd3817b1880bd6dada86df15bd50d275fc694a8914d1818a2d432f980a97892f303d5a893a3eec176f46957958ecb991c")
      ]
      console.log("** params", paramWETH);
      console.log("** signer addresses", await recoverer.recoverSignerAddresses(...paramWETH));
    
      console.log("* Verify singers of tx 0xed2c72ef1a552ddaec6dd1f5cddf0b59a8f37f82bdda5257d9c7c37db7bb9b08");
      const paramUSDC = [
        2000001,
        "0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
        "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
        25500000000000,
        fromHexString("016734b276131c27fa94464db17b44ca517b0a9134b15ee4b776596725741cc7836beea1681dda98a83406515981e1d315d5eba13a0173a5a9688f9f920d7a3f7a1c01155c24a2d7a2ffb02530cf58da40c528301dfc22b21b16267dbf4eba2cd3d087276142bddd1d82404b2e75bd12993606a0c7c7626aa74c4d90bd7e4558fbe4261c01067c5aaba1b8e5bb686cda9efdae909aff86dc83f5be79f13af3ee677fb1791175e0b03401bdf7aa6e604eb995c7670384e6fadef3d687a00fd6d33cd47a0dde1c01dad673b6630394d15f8cca8975351d8272390a6c8bb1cb07cc2b04e8d7ea7a867e56a99e9d0c17a8e0629cebda86ee5a5f8b42610494ad0ed0245ffe9b5287631c012f1fb5b4c2b3718ea69197a5239316fbb9b805be3cdf8420324765ab53144b006b3148921458e629ea254df2c383175ca250e6442b8904a0f50ffdf465f6aa6f1b")
      ]
      console.log("** params", paramUSDC);
      console.log("** signer addresses", await recoverer.recoverSignerAddresses(...paramUSDC));
    }
    
    main()
      .then(() => process.exit(0))
      .catch((error) => {
        console.error(error);
        process.exit(1);
      });
    
下記が実行結果です。
% npx hardhat run scripts/recover-signer-addresses.js --network localhost
Compiled 1 Solidity file successfully
* Recover singers of tx 0xc28fad5e8d5e0ce6a2eaf67b6687be5d58113e16be590824d6cfa1a94467d0b7
** params [
  2000000,
  '0x098B716B8Aaf21512996dC57EB0615e2383E2f96',
  '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
  '173600000000000000000000',
  Uint8Array(330) [
      1,  23,  93, 178, 182,  46, 216, 10,   9, 115, 180, 234,
     53, 129, 178,  38,  41,   2, 110, 60, 103, 103,  18,  95,
     20, 169, 141, 195,   1, 148, 165, 51, 116,  75, 162, 132,
    181, 133,  92, 251, 195,  76,  20, 22, 231,  16, 107, 209,
    212, 206, 132, 241,  60, 232,  22, 55,   6,  69, 170, 214,
    108,  15, 202, 228, 119,  27,   1,  9, 132, 234,   9, 145,
     27, 238, 173, 205,  61, 171,  70, 98,  27, 200,  16, 113,
    186, 145, 206,  36, 213, 183, 135, 59, 198, 163,  78,  52,
    198, 170, 250,  86,
    ... 230 more items
  ]
]
** signer addresses [
  '0x11360EaCDEDd59bC433aFad4fc8f0417d1fbEbab',
  '0x1A15a5E25811FE1349d636a5053A0e59d53961C9',
  '0x70bB1FB41C8C42F6ddd53a708E2B82209495e455',
  '0xB9e59D56fd1632FC250935182BDd5C59188b2302',
  '0xf224beFf587362A88D859e899D0d80C080E1e812',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000'
]
* Recover singers of tx 0xed2c72ef1a552ddaec6dd1f5cddf0b59a8f37f82bdda5257d9c7c37db7bb9b08
** params [
  2000001,
  '0x098B716B8Aaf21512996dC57EB0615e2383E2f96',
  '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  25500000000000,
  Uint8Array(330) [
      1, 103,  52, 178, 118,  19,  28,  39, 250, 148,  70,  77,
    177, 123,  68, 202,  81, 123,  10, 145,  52, 177,  94, 228,
    183, 118,  89, 103,  37, 116,  28, 199, 131, 107, 238, 161,
    104,  29, 218, 152, 168,  52,   6,  81,  89, 129, 225, 211,
     21, 213, 235, 161,  58,   1, 115, 165, 169, 104, 143, 159,
    146,  13, 122,  63, 122,  28,   1,  21,  92,  36, 162, 215,
    162, 255, 176,  37,  48, 207,  88, 218,  64, 197,  40,  48,
     29, 252,  34, 178,  27,  22,  38, 125, 191,  78, 186,  44,
    211, 208, 135,  39,
    ... 230 more items
  ]
]
** signer addresses [
  '0x11360EaCDEDd59bC433aFad4fc8f0417d1fbEbab',
  '0x1A15a5E25811FE1349d636a5053A0e59d53961C9',
  '0x70bB1FB41C8C42F6ddd53a708E2B82209495e455',
  '0xB9e59D56fd1632FC250935182BDd5C59188b2302',
  '0xf224beFf587362A88D859e899D0d80C080E1e812',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000',
  '0x0000000000000000000000000000000000000000'
]
どちらのトランザクションパラメーターでも同じアドレス群が返却されました。

後述してますが、事件発覚後に4つのバリデーターが入れ替えられています。その外された4つのアドレスが上記5つのうちの4つと一致していました。残り1つはまだ有効なバリデーターとして登録されています。

つまり、攻撃者は 正規のバリデーターが持つウォレットによる署名を5つ手に入れることができていた ということが証明されました。

攻撃者がバリデーターの署名を得ることができた理由は次のように報告されています。
  • Sky Mavis(ネットワーク運営元)のノード(全4ノード)に攻撃者が侵入した
  • 昨年11月に負荷対策で追加したバリデーター1ノード(Axie DAO)はSky Mavisノードによる代理署名を許可していた
  • 結果、4 + 1 で合計5ノード分の署名が入手可能となった

4. Ronin Networkの今(4/3時点)

Block Explorerをみるに、Roninサイドチェーン自体は稼働し続けているようです)

4-1. バリデータノード入れ替え

バリデーター管理コントラクトのトランザクション履歴により、攻撃発覚後の暫定処置としてバリデーター署名の閾値が 5 / 9 から 8 / 9 に引き上げられ、その後 4ノードの入れ替えが実施されていることがわかります。運営の報告通りです。


Ronin Networkのバリデーターエクスプローラーによりノード自体の入れ替えが実施されていることもわかります。

4-2. ブリッジ停止

Ronin Bridgeサイトはメンテナンス状態になっています。


ブリッジコントラクトでは pause が発動されていてアセットの預け入れ、引き出しが失敗する(whenNotPausedが通らない)状態になっています。


失敗トランザクションの例)

4-3. 流出アセットの状況

流出後のアセットの流れをウォッチしている方がTwitterにいらっしゃいました。参考まで。

おわりに

Ethereumデータ分析プラットフォームのDune Analyticsより、多くのアセットが各種ブリッジにロックされている現状が見えます。
(グラフ最上段の紫がRonin Bridgeのアセット総量 ※3/30からRoninのデータがなくなっている)

いかにブロックチェーン、スマートコントラクトが堅牢であっても、ブロックチェーンの外側にあるシステムの脆弱性、特に秘密鍵の管理は永遠の課題です。

今回は以上です。

それでは、また。

次世代システム研究室では、ブロックチェーンのように尖った技術を活用したアプリケーションの設計・開発を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

Pocket

関連記事