Hacking Smart Contract Solidity ブロックチェーン

[Solidity-Bug Bounty] Smart ContractのDos攻撃を1からわかりやすく解説

かるでね

こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。

今回はスマートコントラクトでの「DoS攻撃」について解説していきます!

DoS攻撃」という用語はWeb2でもよく起きている攻撃手法なので聞いたことがある人も多いはずです。

スマートコントラクトでも気をつけないと「DoS攻撃」をされてしまうので、スマートコントラクトでの「DoS攻撃」をしっかり理解する必要があります。

Solidityスキルを1段階あげたい方やバグ・バウンティに参加したいバグハンターを目指している方は必須の知識となるので、この記事で学んでいってください。

バグ・バウンティが何かわからない人は以下の記事を読んでください。

DoS攻撃とは?

まずはDoSが何かを確認していきましょう。

用語の整理

DoSとは、「Denial Of Service」の略で「ドス」と読みます。

DoSはハッキングなどの攻撃手法の1つであるため、Dos ≒ DoS攻撃と思ってください。

DoS攻撃(ドスこうげき、英: denial-of-service attack)は、情報セキュリティにおける可用性を侵害する攻撃手法のひとつ。

ウェブサービスを稼働しているサーバやネットワークなどのリソース(資源)に意図的に過剰な負荷をかけたり脆弱性をついたりすることでサービスを妨害する。

https://ja.wikipedia.org/wiki/DoS攻撃

Wikipediaを見ると上記のように書かれています。

DoS攻撃を簡単にまとめると、最後の行にあるように「過剰に負荷をかけたり脆弱性をつくことで、正当なユーザーからのリクエストを受け付けないようにする」ことです。

スマートコントラクトでこのDoS攻撃を行うと、悪意のないユーザーが一定期間、もしくは永久にアクセスできなくなってしまいます。

スマートコントラクトに限らず、DoS攻撃やDDoS攻撃はよくあることなので、聞いたことある人もいるはずです。

国際的ハッカー集団であるアノニマスが得意としている攻撃手法がDDoS攻撃です。

フラッド型のDoS攻撃には、大量のマシンから1つのサービスに、一斉にDoS攻撃を仕掛けるDDoS攻撃 (ディードスこうげき、分散型サービス妨害攻撃 、英: Distributed Denial of Service attack)という類型がある。

https://ja.wikipedia.org/wiki/DoS攻撃#DDoS.E6.94.BB.E6.92.83

DDoS攻撃についてWikipediaでは上記のように書かれています。

DDoS攻撃は単純にDoS攻撃を複数のマシンから行うことを指していますね。

ブロックチェーンにおけるDoS攻撃

では次にブロックチェーンにおけるDoS攻撃はどんな種類があるのか確認していきましょう!

DoS攻撃の種類

  • Unexpected Revert
  • Block Gas Limit
  • Block Stuffing

ブロックチェーンにおけるDoS攻撃として、主に上記の3つの攻撃があります。

1つずつ確認していきましょう。

Unexpected Revert

直訳すると「予期しないRevert」となります。

スマートコントラクトでのRevertとは、「エラーが起きた際に、コントラクトの処理を停止し、コントラクトの状態を実行前位に戻したのち未使用のガスを呼び出し元に返す」処理をします。

詳細は次の章でコードを見ながら解説しますが、予期しない部分で上記の処理が行われることで、コントラクトが正常に実行されなくなってしまう恐れがあります。

Block Gas Limit

Ethereumにはガスリミットと呼ばれる、ガス代の上限が設定されています。

デフォルトでは21,000Gasとなっていて、何度もコントラクトの処理が実行されるのを防ぐ役割があります。

しかし、DoS攻撃ではあえてこのガスリミットに引っ掛かるようにすると、処理が通らなくなり全ての資金がロッグされる可能性があります。

Block Stuffing

Ethereumでは、他のトランザクションが通る前にガス代を多く払うことで処理を先に通すことができます。

これを利用して、他のユーザーのトランザクションが通る前に、複数の自分のトランザクションを通してブロックに他のユーザーがのトランザクションが含まれないようにできてしまいます。

Dos攻撃

この章では実際にコードを用いてスマートコントラクトでのDoS攻撃について確認していきましょう!

Unexpected Revert

まずは「予期しないRevert」から確認していきましょう。

コードの確認

前章で簡単に解説しましたが、「予期しないRevert」が起こるとどんな不都合が生じるのかを見ていきましょう。

現在一番資金を預けているユーザーよりも多くの資金を預けることを繰り返し、一定期間や一定回数後に一番資金を預けているユーザーが報酬として1 etherもらえる」ようなコントラクトがあるとします。

contract Dos {
    address public winner;
    uint public balance;

    /// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
    ///         winnerにmsg.senderが設定される関数。
    function deposit() public payable {
        require(msg.value > balance, "Deposit more funds than you currently have on deposit.");

        (bool sent, ) = winner.call{value: balance}("");
        require(sent, "Failed to send Ether");

        balance = msg.value;
        winner = msg.sender;
    }
}
winner

現在一番多くの資金を預けているユーザーのアドレスが格納される変数。

balance

現在一番多くの資金を預けているユーザーが預けている資金が格納される変数。

deposit

現在一番多くの資金を預けているユーザーの資金よりも多くの資金を送金しているか確認。

もし、上記がtrueである場合現在一番多くの資金を預けていたユーザーに資金を戻し、 balancewinnerを更新。

上記のコントラクトはユーザーからの預け入れを想定していますが、これから説明するように別コントラクトから資金を預けることも可能です。

コントラクトから預けることで「予期しないRevert」を発生することができるようになります。

ではこのコントラクトに攻撃を仕掛けるコントラクトを見ていきましょう。

contract Attack {
    Dos dos;

    constructor (Dos _dos) {
        dos = Dos(_dos);
    }

    /// @notice Dosコントラクトのdeposit関数を呼び出す関数。
    function attack() public payable {
        dos.deposit{value: msg.value}();
    }
}
dos

Dosコントラクトが格納されます。

attack

Dosコントラクトのdeposit関数を呼び出して資金を送っています。

実行

ではコントラクトを実行して動作を確認していきましょう。

いかがでしょうか?

attack関数を実行するまではdeposit関数が正常に動作していましたが、attack関数を実行した後はdeposit関数が正常に通らなくなっています。

なぜでしょう?

理由はfallback関数がないためです。

コントラクトは資金を受け取った際にfallback関数が呼び出されます。

しかし、Attackコントラクトにはfallback関数がないためエラーになってしまっているのです。

deposit関数の以下の部分の実行が毎回エラーになってしまい、一定期間経過後にAttackコントラクトが勝者になってしまいます。

(bool sent, ) = winner.call{value: balance}("");

コード全体

コード全体は以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Dos {
    address public winner;
    uint public balance;

    /// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
    ///         winnerにmsg.senderが設定される関数。
    function deposit() public payable {
        require(msg.value > balance, "Deposit more funds than you currently have on deposit.");

        (bool sent, ) = winner.call{value: balance}("");
        require(sent, "Failed to send Ether");

        balance = msg.value;
        winner = msg.sender;
    }
}

contract Attack {
    Dos dos;

    constructor (Dos _dos) {
        dos = Dos(_dos);
    }

    /// @notice Dosコントラクトのdeposit関数を呼び出す関数。
    function attack() public payable {
        dos.deposit{value: msg.value}();
    }
}

対処法

上記のような脆弱性の対処法としては、「資金を引き出す関数を用意しユーザー実行してもらう」という方法があります。

contract Dos {
    address public winner;
    uint public balance;
    mapping(address=>uint) public balances;

    /// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
    ///         winnerにmsg.senderが設定される関数。
    function deposit() public payable {
        require(msg.value > balance, "Deposit more funds than you currently have on deposit.");

        // (bool sent, ) = winner.call{value: balance}("");
        // require(sent, "Failed to send Ether");

        balances[winner] += balance;

        balance = msg.value;
        winner = msg.sender;
    }

    function widthdraw() public {
        require(msg.sender != winner, "Current king cannot withdraw");

        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
}
balance

どのユーザーがどのくらいの資金を預けたか保存するマッピング。

deposit

以前のwinnerには資金を送金せず、変数に記録だけするように変更。

widthdraw

balances配列に格納されているユーザーに資金を送金する関数。

もし先ほど同様attack関数でwidthdraw関数を実行しても、fallback関数がないため資金を受け取ることができなくなる。

Block Gas Limit

1回のトランザクショで使用できるガスの量には制限があります。

これは、実行するユーザーやコントラクトが予想しているガス代を請求されないようにするためです。

しかし、あえてガス量の制限に引っかからせて攻撃されたり、予期せぬ動作により処理が正常に動作しなくなってしまうことがあります。

これから細かく中身を確認していきます。

コード解説

contract Dos {
    address[] public users;
    uint[] public balances;

    /// @notice 資金を預ける関数。
    function deposit() public payable {
        users.push(msg.sender);
        balances.push(msg.value);
    }

    function shar() public {
        for (uint i; i < users.length; i++) {
            (bool sent, ) = users[i].call{value: balances[i]}("");
            require(sent, "Failed to send Ether");
        }
    }
}

シンプルですが上記のようなコントラクトがあるとします。

users

資金を預けたユーザーのアドレスが格納される配列。

balances

預けた資金が記録される配列。

deposit

資金を送金し、users配列とbalances配列に格納する関数。

shar

users配列とbalances配列をfor文で1つずつ確認し、資金を送金する関数。

実行

では早速実行していきましょう。

deposit関数を実行するたびに、users配列とbalances配列に値が格納されているのが確認できます。

shar関数を実行すると、コントラクト内に保持していた資金をそれぞれ預けていたユーザーに返金しています。

ではこれの何が問題なのでしょうか?

それはusers配列とbalances配列がめちゃくちゃ長くなってしまった時、shar関数の実行時にガスリミットに引っかかってしまうことです。

shar関数ではfor文を使用しているため、users配列とbalances配列が長くなれば長くなるほど実行時間とガス代がかかってきます。

そのため、攻撃者が大量のアドレスを用意してdeposit関数を実行した場合、shar関数実行時にガス制限に引っかかりshar関数が実行できなくなってしまうのです。

また、攻撃者がわざわざ大量のアドレスを用意しなくても、大量のユーザーがdeposit関数を実行することでも同じことが起こってしまいます。

コード全体

コード全体は以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Dos {
    address[] public users;
    uint[] public balances;

    /// @notice 資金を預ける関数。
    function deposit() public payable {
        users.push(msg.sender);
        balances.push(msg.value);
    }

    /// @notice 資金を分配する関数。
    function shar() public {
        for (uint i; i < users.length; i++) {
            (bool sent, ) = users[i].call{value: balances[i]}("");
            require(sent, "Failed to send Ether");
        }
    }
}

対処法

対処法としては以下のように残りガスを確認し、送金結果を途中まで記録する方法があります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Dos {
    address[] public users;
    uint[] public balances;
    uint nextIndex;

    /// @notice 資金を預ける関数。
    function deposit() public payable {
        users.push(msg.sender);
        balances.push(msg.value);
    }

    /// @notice 資金を分配する関数。
    function shar() public {
        // for (uint i; i < users.length; i++) {
        //     (bool sent, ) = users[i].call{value: balances[i]}("");
        //     require(sent, "Failed to send Ether");
        // }
        uint i = nextIndex;
        while (i < users.length && gasleft() < 10000) {
            (bool sent, ) = users[i].call{value: balances[i]}("");
            require(sent, "Failed to send Ether");
            i++;
        }
        nextIndex = i;
    }
}

shar関数を実行するとwhileで、users配列を超えていず、gas代が10000を下回らない限り実行されるようにしています。

途中で処理が止まってもnextIndexにどこまで処理をしたのか記録することで、次回は途中から実行することができます。

だいぶ無理矢理ですが、どうしてもこのような処理をしないといけない時は参考にしてください。

Block Stuffing

トランザクションは通るまで順番待ちのように各トランザクションが並んでいます。

通常は実行された順に処理をされていくのですが、ガス代を多く払うことで優先的に処理をしてもらえるようにできます。

これを利用して、複数のトランザクションのガス代を多く払い優先して処理してもらうことで、一定時間後に資金をもらえるようなコントラクトの結果を攻撃者が操作できてしまいます。

1 ether以上を預け入れ、一定期間後に一番多くのetherを預けたユーザーが全ての資金を受け取れるとします。

手順

  • 各ユーザーが資金を預けていく。
  • 攻撃者が資金を預ける。
  • ガス代を多く払い、一定時間後まで他のトランザクションが通らないように複数のトランザクションを優先して通す。
  • 他のユーザーはトランザクションが通らず資金を預け入れることができなくなる。
  • 一定時間経過し、攻撃者がコントラクト内の資金を全て受け取ることができる。

攻撃者はガス代を自分で負担しないといけないが、それでも得られる資金の方が多ければ実行する価値は出てくる。

対処法

ガス代の負担と利益を比較して、利益の方が多いと攻撃者はガス代を払ってまで他のトランザクションを通さないモチベーションが出るので、負担の方が大きいようなモデルにする方法があります。

また、時間経過でも攻撃者によって妨害されないモデルかどうかも確認することが重要です。

最後に

今回はスマートコントラクトにおける「DoS攻撃」について解説してきました。

いかがだったでしょうか?

ポイント

  • DoS攻撃について理解できた!
  • DoS攻撃についての脆弱性を理解できた!
  • DoS攻撃をされないように対処する方法を理解できた!

上記のポイントを抑えることができていたら嬉しいです!

もし何か質問などがあれば以下のTwitterなどから連絡ください!

普段はSolidityやブロックチェーン、Web3についての情報発信をしています。

Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!

参考

-Hacking, Smart Contract, Solidity, ブロックチェーン
-, ,