はじめに
今回は、第1回、第2回に引き続きSolidityでセキュアなコードを書くために、よく知られている攻撃の手法について説明します。
本記事は、過去にコンセンサス・ベイスが主宰していたオンラインサロンの記事です。記事は2017年~2018年にかけて執筆されたため、一部は、既に古くなっている可能性があります。あらかじめご了承ください。
今回の内容
今回は典型的な攻撃について説明します。
ゴール
Solidityでセキュアなコードを書くための基礎をマスターできます。
ターゲット
Solidityの基本的な書き方を習得している方
前提知識
Solidityの基本的な書き方をマスターしている必要がある。
当シリーズの第1回、第2回を読んでおくことを推奨します。
*本記事は、こちらの記事の翻訳となります。
https://consensys.github.io/smart-contract-best-practices/known_attacks/
典型的な攻撃
競合状態
外部のコントラクトコードを呼び出す際の主な危険の1つとしては、制御フローを引き継ぎ、呼び出し関数が期待していなかったデータを変更することができることです。このようなバグはいろいろな形を取ることができ、DAOの崩壊につながったバグもこの種のバグでした。
リエントラント
このバグの最初のバージョンには、関数の最初の呼び出しが完了する前に、繰り返し呼び出される可能性のある関数が含まれていました。これにより、関数のさまざまな呼び出しが破壊的な方法で相互作用する可能性がありました。
// セキュアでないコード
mapping (address => uint) private userBalances;
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
userBalances[msg.sender] = 0;
}
関数の最後までユーザーの残高が0に設定されていないため、2回目以降の呼び出しは引き続き成功し、繰り返し残高を回収します。 これに非常によく似たバグが、DAO攻撃の脆弱性の1つでした。
call.value()の代わりにsend()を使用することでこの問題を解決することができます。
こうすることで外部のコントラクトコードが実行されなくなります。
外部呼び出しを削除できない場合、この攻撃を防ぐ最も簡単な方法は、必要な内部作業をすべて完了するまで外部のコントラクトコードの関数を呼び出さないようにすることです。
mapping (address => uint) private userBalances;
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0;
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
}
もしwithdrawBalance()を呼び出す別の関数があると、それは潜在的に同じ攻撃の対象となるので信頼できないコントラクトを呼び出す関数を信頼できないものとして扱わなければならないことに注意してください。
Cross-function競合状態
攻撃者は、同じ状態を共有する2つの異なる関数を使うことで同様の攻撃をすること ができます。
// セキュアでないコード
mapping (address => uint) private userBalances;
function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}
function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
userBalances[msg.sender] = 0;
}
攻撃者は、同じ状態を共有する2つの異なる関数を使うことで同様の攻撃をすること ができます。
上記の場合、攻撃者はwithdrawBalanceの外部呼び出しでコントラクトコードが実行される時にtransfer()を呼び出します。残高はまだ0に設定されていないため、すでに引き出しを受けていてもトークンと送信することができます。この脆弱性はDAO攻撃にも使用され ていました。
競合状態解決の落とし穴
競合状態は複数の関数で、また複数のコントラクトコードでも発生する可能性があるため、リエントラントを防止するための解決策は十分ではありません。はじめに全ての内部作業を終了してから、外部関数を呼び出すことを推奨します。このルールに厳密に従うと競合状態を避けることができます。さらに外部関数をあまりにも早く呼び出さないようにするだけでなく、外部関数を呼び出す関数を呼び出さないようにする必要があります。以下のコードは安全ではありません。
// セキュアでないコード
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;
function withdraw(address recipient) public {
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}
function getFirstWithdrawalBonus(address recipient) public {
if (claimedBonus[recipient]) { throw; }
rewardsForA[recipient] += 100;
withdraw(recipient);
claimedBonus[recipient] = true;
}
getFirstWithdrawalBonus()は外部のコントラクトコードを直接呼び出すことはありませんが、withdraw()の呼び出しは競合状態になりえます。したがって、withdraw()を信頼できないものとして扱う必要があります。
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;
function untrustedWithdraw(address recipient) public {
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}
function untrustedGetFirstWithdrawalBonus(address recipient) public {
if (claimedBonus[recipient]) { throw; }
claimedBonus[recipient] = true;
rewardsForA[recipient] += 100;
untrustedWithdraw(recipient);
}
untrustedGetFirstWithdrawalBonus()は外部のコントラクトコードを呼び出す
untrustedWithdraw()を呼び出すため、untrustedGetFirstWithdrawalBonus()も安全ではありません。別の解決策はミューテックスです。これにより”lock”した所有者のみが変更できるように状態を”lock”することができます。
簡単な例を以下に表します。
// 注: これは基本的な例です。ミューテックスは実質的な競合状態や共有状態があるときに特に有効です。
mapping (address => uint) private balances;
bool private lockBalances;
function deposit() payable public returns (bool) {
if (!lockBalances) {
lockBalances = true;
balances[msg.sender] += msg.value;
lockBalances = false;
return true;
}
throw;
}
function withdraw(uint amount) payable public returns (bool) {
if (!lockBalances && amount > 0 && balances[msg.sender] >= amount) {
lockBalances = true;
if (msg.sender.call(amount)()) { // call(amount)は通常セキュアでありませんが、ミューテックスで危険性を取り除いています
balances[msg.sender] -= amount;
}
lockBalances = false;
return true;
}
throw;
}
ユーザーが最初の呼び出しが終了する前にwithdraw()を再度呼び出そうとすると、”lock”によって何も効果が得られなくなります。これは効果的なパターンかもしれませんが協力が必要な複数の契約がある場合は難しくなります。
// セキュアでないコード
contract StateHolder {
uint private n;
address private lockHolder;
function getLock() {
if (lockHolder != 0) { throw; }
lockHolder = msg.sender;
}
function releaseLock() {
lockHolder = 0;
}
function set(uint newState) {
if (msg.sender != lockHolder) { throw; }
n = newState;
}
}
攻撃者はgetLock()を呼び出してからreleaseLock()を呼び出すことはできません。攻撃者がこのようなことをすると、契約は永遠に”lock”されそれ以上の変更はできません。競合状態を防ぐためにミューテックスを使用する場合は、”lock”を要求して解放する方法がないことを慎重に確認する必要があります。
TOD/Front Running
今までの競合状態は攻撃者が単一のトランザクション内で悪質なコードを実行する例です。ブロック内のトランザクションの順序が操作対象になることがあります。しばらくの間、トランザクションはメモリプール内にあるのでどんなアクションが起こるのかをブロックに格納される前に知ることができます。
タイムスタンプ依存
ブロックのタイムスタンプはマイナーによって操作でき、タイムスタンプの直接的及び間接的な使用を全て考慮する必要があります。ブロック数と平均ブロック時間を使用して時間を見積もることができますが、ブロック時間が変更されることがあります。
uint someVariable = now + 1;
if (now % 2 == 0) { // now はマイナーによって操作されることがあります。
}
if ((someVariable - 100) % 2 == 0) { // someValue はマイナーによって操作されることがあります。
}
整数のオーバーフローとアンダーフロー
オーバーフローとアンダーフローになる20個のケース
https://github.com/ethereum/solidity/issues/796#issuecomment-253578925
簡単な例を以下に示す。
mapping (address => uint256) public balanceOf;
// セキュアでないコード
function transfer(address _to, uint256 _value) {
/* 送信者の残高があるかをチェック */
if (balanceOf[msg.sender] < _value)
throw;
/* 残高を移動 */
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
}
// セキュアなコード
function transfer(address _to, uint256 _value) {
/* 送信者の残高があるかを確認し、受信者の残高がオーバーフローしないかをチェック*/
if (balanceOf[msg.sender] < _value || balanceOf[_to] + _value < balanceOf[_to])
throw;
/* 残高を移動 */
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
}
予期しない throwでのDoS攻撃
// セキュアでないコード
contract Auction {
address currentLeader;
uint highestBid;
function bid() payable {
if (msg.value <= highestBid) { throw; }
if (!currentLeader.send(highestBid)) { throw; } // old leader に返金を試み、失敗したらthrowする
currentLeader = msg.sender;
highestBid = msg.value;
}
}
old leaderが払い戻そうとする時払い戻しに失敗した場合にthrowされます。これは悪意のある入札者がリーダーになれることを意味するが、そのアドレスへの払い戻しが常に失敗することを保証します。このようにして他の人がbid()関数を呼び出すことを防ぐことができます。
もう一つの例はコントラクトを使用してユーザーに支払いを行うコントラクトを繰り返す場合です。それぞれの支払いが成功することを確認することが一般的です。
もしそうしなければthrowする必要があります。問題は1つのコールが失敗した場合、支払いシステム全体を元に戻すことです。ループはしません。1つのアドレスでエラーが発生すると誰も支払いを受けることができません。
address[] private refundAddresses;
mapping (address => uint) public refunds;
// だめな例
function refundAll() public {
for(uint x; x < refundAddresses.length; x++) { // 参加しているアドレス数に基づく任意の長さのイテレーション
if(refundAddresses[x].send(refunds[refundAddresses[x]])) {
throw; // 一つのアドレスへの送金が失敗するとすべての送金処理がロールバックされます
}
}
}
GasLimitでのDoS攻撃
前の例ではもう1つ問題があります。一度に全員に支払うことによって、ブロックGasの制限に到達する可能性があります。それぞれのEthereumブロックは最大量の計算を処理しますが、計算量の限界を越えようとするとトランザクションは失敗します。
このことは意図的な攻撃がなくても問題につながる可能性があります。さらには攻撃者が必要なガスの量を操作できるなら最悪です。前の例の場合では攻撃者は一連のアドレスを追加することができ、それぞれが非常に小さな払い戻しを必要とします。そうすると各攻撃者のアドレスを払い戻すためのGas費用はGasリミットを超えてしまい、払い戻しが全く起こらないようになります。
未知のサイズの配列をループする必要がある場合は、実行が複数のブロックにまたがる可能性があります。そういった場合はトランザクションを分ける必要が出てきます。以下の例のように配列のどのインデックスまで処理したかをを保存しておき、その時点から再開できるようにする必要があります。
struct Payee {
address addr;
uint256 value;
}
Payee payees[];
uint256 nextPayeeIndex;
function payOut() {
uint256 i = nextPayeeIndex;
while (i < payees.length && msg.gas > 200000) {
payees[i].addr.send(payees[i].value);
i++;
}
nextPayeeIndex = i;
}
payOut()関数が次に呼ばれるまでに他のトランザクションが処理されても問題が起こらないようにする必要があります。このパターンは絶対に必要な場合にのみ使用してください。
コールスタックの深さを利用した攻撃
EIP 150 のハードフォークによってこの攻撃は気にしなくて良くなりました。深さ制限である1024に達する前にすべてのgasが消費されます。
引用URL:
https://consensys.github.io/smart-contract-best-practices/known_attacks/
Copyright 2016 Smart Contract Best Practices Authors
Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
免責事項
本記事に掲載されている記事の内容につきましては、正しい情報を提供することに務めてはおりますが、提供している記事の内容及び参考資料からいかなる損失や損害などの被害が発生したとしても、弊社では責任を負いかねます。実施される際には、法律事務所にご相談ください。
技術・サービス・実装方法等のレビュー、その他解説・分析・意見につきましてはblock-chani.jp運営者の個人的見解です。正確性・正当性を保証するものではありません。本記事掲載の記事内容のご利用は読者様個人の判断により自己責任でお願いいたします。
ブロックチェーン学習に最適の書籍の紹介
図解即戦力 ブロックチェーンのしくみと開発がこれ1冊でしっかりわかる教科書
本書は、ブロックチェーン技術に興味を持ったエンジニアや、その仕組みを学び、自分の仕事に活かしたいビジネスパーソンを対象にして、ブロックチェーンのコア技術とネットワーク維持の仕組みを平易な言葉で解説しています。この本を読んだうえで、実際にコードを書くような専門書、ブロックチェーンビジネスの解説書を読むことで、理解度が飛躍的に高まるでしょう。(はじめにより)
会社紹介
弊社(コンセンサス・ベイス株式会社)は、2015年設立の国内で最も古いブロックチェーン専門企業です。これまでに、大手企業の顧客を中心に、日本トップクラスのブロックチェーンの開発・コンサルティング実績があります。ブロックチェーンに関わるビジネスコンサル・システム開発・教育・講演などご希望でしたら、お気軽にお問い合わせください。
会社ホームページ
https://www.consensus-base.com/