Now and Nawoo

NFTの制作記録、技術メモ → C#, Solidity, Blockchain, Bitcoin, Ethereum, NFT

イーサの送金とリエントランシー攻撃

コントラクトからイーサを送金する方法について調べていたら、リエントランシー攻撃という、すごくややこしい話に踏み込んでしまって、なかなか理解するのが大変でした。 単にイーサを送るだけでこんなに大変だとは。。。 今後のためにわかったことをメモしておきます。

イーサを送金するには

  • address.transfer(amount)
    • 失敗で例外(revert)
    • ガスリミットが2300固定
  • address.send(amount)
    • 失敗でfalse
    • ガスリミットが2300固定
  • address.call{value: amount}("")
    • 失敗でfalse
    • ガスリミットを指定しなければ制限なし

なんで3つもあるねん!と言いたくなりますが、さらにリエントランシー攻撃について理解する必要があります。

リエントランシー攻撃とは

callを使った送金例

// 注意: ダメな例です
contract Fund {
    mapping(address => uint) shares;

    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}(""); // (※1)
        if (success)
            shares[msg.sender] = 0; // (※2)
    }
}

※1のmsg.sender.call{value:...}("") で msg.senderにイーサを送金します。

このコードでは msg.senderが、悪意のあるコントラクトだった場合、 fallback関数(receive関数)を使ってwithdrawをコールバックされてしまいます。

※2のshares[msg.sender] = 0 にたどり着く前に、再びwithdrawが呼ばれてしまい、またmsg.senderにイーサを送金します。 何度もwithdrawのコールバックが繰り返されて、Fundコントラクトに保有しているイーサはすべて引き出されてしまいます。

sendを使った送金例

// 注意: ダメな例です
contract Fund {
    mapping(address => uint) shares;

    function withdraw() public { 
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

こちらはcallではなくsendを使ってイーサを送金しています。

callと違って、sendやtransferでは2300ガスと決められています。 これはログ発行ぐらいにしか使えない量なので、withdrawのコールバックなどはできません。

とはいえ、将来ガスコストが変更された場合に問題が発生するかもしれません。 (追記:これは2300ガスでもコールバックが可能になるかもしれないから危険という意味かと思っていましたが、そうではなく今まで2300で動いていた機能が動かなくなる、という意味での問題のようです。)

注意

ここではイーサの送金のみをとりあげていますが、リエントランシーは、イーサの送金だけでなく、他のコントラクトの関数呼び出しでも起こり得ます。

対策

1. Checks-Effects-Interactions パターンを使う

contract Fund {
    mapping(address => uint) shares;

    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;  // 送金より前に残高を0にしておく
        payable(msg.sender).transfer(share); // 送金する
    }
}

これならtransferで送金したときにwithdrawをコールバックされても、すでにshares[msg.sender]は0になっているので イーサを引き出すことはできません。 Checks Effects Interactions | solidity-patterns

2. mutexを使う

contract Fund {
    mapping(address => uint) shares;
    bool locked = false;

    function withdraw() public {
        require(!locked); // コールバックで呼ばれたときは locked = true
        locked = true;
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
        locked = false;
    }
}

二度目に呼ばれたときには lockedがtrueになっているため、withdrawは実行されずに失敗します。

3. OpenZeppelinのReentrancyGuardを使う

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Fund {
    mapping(address => uint) shares;

    function withdraw() public nonReentrant {
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

withdrawにnonReentrantというmodifierを指定するだけです。

ソースコード をみると、mutexを使っていることがわかります。

参考リンク

愚痴

イーサの送金方法を調べていただけなのに、

  • callを使えばいいよ
  • callは使うなよ(リエントランシー対策のため)
  • send, transferを使えばいいよ
  • send, transferは使うなよ(ガスコストは将来変わるかもしれんぞ)

と、みんなバラバラのことを言うので、どないせーっちゅうねん、という気分でした。 (仕様変更などで、ベストな方法が変わってしまうからですが、初学者にとっては混乱の元です。)

ちなみに callの書き方は、以前は call.value(amount)() だったようですが、現在は call{value: amount}("") のようです。

追記 2021/9/26

Cross-functionタイプ

リエントランシー攻撃には、Single FunctionタイプとCross-functionタイプがある。 (上で書いた内容は Single Functionタイプのみ。)

Cross-functionタイプとは、複数の関数や複数のコントラクトをまたぐリエントランシー攻撃のこと。 mutex での対策が役に立たない場合があるので、Checks-Effects-Interactions パターンを推奨。

Known Attacks - Ethereum Smart Contract Best Practices

ReentrancyGuardのデメリットはあるのか?

  1. ガスコストがわずかに増える。(気にするほどではない)
  2. nonReentractの付いた関数から他のnonReentrantの付いた関数を呼び出せない。(mutexがコントラクト全体でグローバルだから)

Various question on ReentrancyGuard usage · Issue #1495 · OpenZeppelin/openzeppelin-contracts · GitHub