こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。
https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58
今回はスマートコントラクトでの「DoS攻撃」について解説していきます!
「DoS攻撃」という用語はWeb2でもよく起きている攻撃手法なので聞いたことがある人も多いはずです。
スマートコントラクトでも気をつけないと「DoS攻撃」をされてしまうので、スマートコントラクトでの「DoS攻撃」をしっかり理解する必要があります。
Solidityスキルを1段階あげたい方やバグ・バウンティに参加したいバグハンターを目指している方は必須の知識となるので、この記事で学んでいってください。
バグ・バウンティが何かわからない人は以下の記事を読んでください。
DoS攻撃とは?
まずはDoSが何かを確認していきましょう。
用語の整理
DoSとは、「Denial Of Service」の略で「ドス」と読みます。
DoSはハッキングなどの攻撃手法の1つであるため、Dos ≒ DoS攻撃と思ってください。
DoS攻撃(ドスこうげき、英: denial-of-service attack)は、情報セキュリティにおける可用性を侵害する攻撃手法のひとつ。
ウェブサービスを稼働しているサーバやネットワークなどのリソース(資源)に意図的に過剰な負荷をかけたり脆弱性をついたりすることでサービスを妨害する。
https://ja.wikipedia.org/wiki/DoS攻撃
Wikipediaを見ると上記のように書かれています。
DoS攻撃を簡単にまとめると、最後の行にあるように「過剰に負荷をかけたり脆弱性をつくことで、正当なユーザーからのリクエストを受け付けないようにする」ことです。
スマートコントラクトでこのDoS攻撃を行うと、悪意のないユーザーが一定期間、もしくは永久にアクセスできなくなってしまいます。
スマートコントラクトに限らず、DoS攻撃やDDoS攻撃はよくあることなので、聞いたことある人もいるはずです。
国際的ハッカー集団であるアノニマスが得意としている攻撃手法がDDoS攻撃です。
フラッド型のDoS攻撃には、大量のマシンから1つのサービスに、一斉にDoS攻撃を仕掛けるDDoS攻撃 (ディードスこうげき、分散型サービス妨害攻撃 、英: Distributed Denial of Service attack)という類型がある。
https://ja.wikipedia.org/wiki/DoS攻撃#DDoS.E6.94.BB.E6.92.83
DDoS攻撃についてWikipediaでは上記のように書かれています。
DDoS攻撃は単純にDoS攻撃を複数のマシンから行うことを指していますね。
ブロックチェーンにおけるDoS攻撃
では次にブロックチェーンにおけるDoS攻撃はどんな種類があるのか確認していきましょう!
DoS攻撃の種類
- Unexpected Revert
- Block Gas Limit
- Block Stuffing
ブロックチェーンにおけるDoS攻撃として、主に上記の3つの攻撃があります。
1つずつ確認していきましょう。
Unexpected Revert
直訳すると「予期しないRevert」となります。
スマートコントラクトでのRevertとは、「エラーが起きた際に、コントラクトの処理を停止し、コントラクトの状態を実行前位に戻したのち未使用のガスを呼び出し元に返す」処理をします。
詳細は次の章でコードを見ながら解説しますが、予期しない部分で上記の処理が行われることで、コントラクトが正常に実行されなくなってしまう恐れがあります。
Block Gas Limit
Ethereumにはガスリミットと呼ばれる、ガス代の上限が設定されています。
デフォルトでは21,000Gas
となっていて、何度もコントラクトの処理が実行されるのを防ぐ役割があります。
しかし、DoS攻撃ではあえてこのガスリミットに引っ掛かるようにすると、処理が通らなくなり全ての資金がロッグされる可能性があります。
Block Stuffing
Ethereumでは、他のトランザクションが通る前にガス代を多く払うことで処理を先に通すことができます。
これを利用して、他のユーザーのトランザクションが通る前に、複数の自分のトランザクションを通してブロックに他のユーザーがのトランザクションが含まれないようにできてしまいます。
Dos攻撃
この章では実際にコードを用いてスマートコントラクトでのDoS攻撃について確認していきましょう!
Unexpected Revert
まずは「予期しないRevert」から確認していきましょう。
コードの確認
前章で簡単に解説しましたが、「予期しないRevert」が起こるとどんな不都合が生じるのかを見ていきましょう。
「現在一番資金を預けているユーザーよりも多くの資金を預けることを繰り返し、一定期間や一定回数後に一番資金を預けているユーザーが報酬として1 etherもらえる」ようなコントラクトがあるとします。
contract Dos {
address public winner;
uint public balance;
/// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
/// winnerにmsg.senderが設定される関数。
function deposit() public payable {
require(msg.value > balance, "Deposit more funds than you currently have on deposit.");
(bool sent, ) = winner.call{value: balance}("");
require(sent, "Failed to send Ether");
balance = msg.value;
winner = msg.sender;
}
}
winner
現在一番多くの資金を預けているユーザーのアドレスが格納される変数。
balance
現在一番多くの資金を預けているユーザーが預けている資金が格納される変数。
deposit
現在一番多くの資金を預けているユーザーの資金よりも多くの資金を送金しているか確認。
もし、上記がtrue
である場合現在一番多くの資金を預けていたユーザーに資金を戻し、 balance
とwinner
を更新。
上記のコントラクトはユーザーからの預け入れを想定していますが、これから説明するように別コントラクトから資金を預けることも可能です。
コントラクトから預けることで「予期しないRevert」を発生することができるようになります。
ではこのコントラクトに攻撃を仕掛けるコントラクトを見ていきましょう。
contract Attack {
Dos dos;
constructor (Dos _dos) {
dos = Dos(_dos);
}
/// @notice Dosコントラクトのdeposit関数を呼び出す関数。
function attack() public payable {
dos.deposit{value: msg.value}();
}
}
dos
Dosコントラクトが格納されます。
attack
Dosコントラクトのdeposit
関数を呼び出して資金を送っています。
実行
ではコントラクトを実行して動作を確認していきましょう。
いかがでしょうか?
attack
関数を実行するまではdeposit
関数が正常に動作していましたが、attack
関数を実行した後はdeposit
関数が正常に通らなくなっています。
なぜでしょう?
理由はfallback
関数がないためです。
コントラクトは資金を受け取った際にfallback
関数が呼び出されます。
しかし、Attackコントラクトにはfallback
関数がないためエラーになってしまっているのです。
deposit
関数の以下の部分の実行が毎回エラーになってしまい、一定期間経過後にAttackコントラクトが勝者になってしまいます。
(bool sent, ) = winner.call{value: balance}("");
コード全体
コード全体は以下になります。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract Dos {
address public winner;
uint public balance;
/// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
/// winnerにmsg.senderが設定される関数。
function deposit() public payable {
require(msg.value > balance, "Deposit more funds than you currently have on deposit.");
(bool sent, ) = winner.call{value: balance}("");
require(sent, "Failed to send Ether");
balance = msg.value;
winner = msg.sender;
}
}
contract Attack {
Dos dos;
constructor (Dos _dos) {
dos = Dos(_dos);
}
/// @notice Dosコントラクトのdeposit関数を呼び出す関数。
function attack() public payable {
dos.deposit{value: msg.value}();
}
}
対処法
上記のような脆弱性の対処法としては、「資金を引き出す関数を用意しユーザー実行してもらう」という方法があります。
contract Dos {
address public winner;
uint public balance;
mapping(address=>uint) public balances;
/// @notice 現在のwinnerよりも多くの資金を預けた場合、現在のwinnerに預けた資金を戻し
/// winnerにmsg.senderが設定される関数。
function deposit() public payable {
require(msg.value > balance, "Deposit more funds than you currently have on deposit.");
// (bool sent, ) = winner.call{value: balance}("");
// require(sent, "Failed to send Ether");
balances[winner] += balance;
balance = msg.value;
winner = msg.sender;
}
function widthdraw() public {
require(msg.sender != winner, "Current king cannot withdraw");
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
balance
どのユーザーがどのくらいの資金を預けたか保存するマッピング。
deposit
以前のwinner
には資金を送金せず、変数に記録だけするように変更。
widthdraw
balances
配列に格納されているユーザーに資金を送金する関数。
もし先ほど同様attack
関数でwidthdraw
関数を実行しても、fallback
関数がないため資金を受け取ることができなくなる。
Block Gas Limit
1回のトランザクショで使用できるガスの量には制限があります。
これは、実行するユーザーやコントラクトが予想しているガス代を請求されないようにするためです。
しかし、あえてガス量の制限に引っかからせて攻撃されたり、予期せぬ動作により処理が正常に動作しなくなってしまうことがあります。
これから細かく中身を確認していきます。
コード解説
contract Dos {
address[] public users;
uint[] public balances;
/// @notice 資金を預ける関数。
function deposit() public payable {
users.push(msg.sender);
balances.push(msg.value);
}
function shar() public {
for (uint i; i < users.length; i++) {
(bool sent, ) = users[i].call{value: balances[i]}("");
require(sent, "Failed to send Ether");
}
}
}
シンプルですが上記のようなコントラクトがあるとします。
users
資金を預けたユーザーのアドレスが格納される配列。
balances
預けた資金が記録される配列。
deposit
資金を送金し、users
配列とbalances
配列に格納する関数。
shar
users
配列とbalances
配列をfor文で1つずつ確認し、資金を送金する関数。
実行
では早速実行していきましょう。
deposit
関数を実行するたびに、users
配列とbalances
配列に値が格納されているのが確認できます。
shar
関数を実行すると、コントラクト内に保持していた資金をそれぞれ預けていたユーザーに返金しています。
ではこれの何が問題なのでしょうか?
それはusers
配列とbalances
配列がめちゃくちゃ長くなってしまった時、shar
関数の実行時にガスリミットに引っかかってしまうことです。
shar
関数ではfor文を使用しているため、users
配列とbalances
配列が長くなれば長くなるほど実行時間とガス代がかかってきます。
そのため、攻撃者が大量のアドレスを用意してdeposit
関数を実行した場合、shar
関数実行時にガス制限に引っかかりshar
関数が実行できなくなってしまうのです。
また、攻撃者がわざわざ大量のアドレスを用意しなくても、大量のユーザーがdeposit
関数を実行することでも同じことが起こってしまいます。
コード全体
コード全体は以下になります。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract Dos {
address[] public users;
uint[] public balances;
/// @notice 資金を預ける関数。
function deposit() public payable {
users.push(msg.sender);
balances.push(msg.value);
}
/// @notice 資金を分配する関数。
function shar() public {
for (uint i; i < users.length; i++) {
(bool sent, ) = users[i].call{value: balances[i]}("");
require(sent, "Failed to send Ether");
}
}
}
対処法
対処法としては以下のように残りガスを確認し、送金結果を途中まで記録する方法があります。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract Dos {
address[] public users;
uint[] public balances;
uint nextIndex;
/// @notice 資金を預ける関数。
function deposit() public payable {
users.push(msg.sender);
balances.push(msg.value);
}
/// @notice 資金を分配する関数。
function shar() public {
// for (uint i; i < users.length; i++) {
// (bool sent, ) = users[i].call{value: balances[i]}("");
// require(sent, "Failed to send Ether");
// }
uint i = nextIndex;
while (i < users.length && gasleft() < 10000) {
(bool sent, ) = users[i].call{value: balances[i]}("");
require(sent, "Failed to send Ether");
i++;
}
nextIndex = i;
}
}
shar
関数を実行するとwhileで、users
配列を超えていず、gas
代が10000を下回らない限り実行されるようにしています。
途中で処理が止まってもnextIndex
にどこまで処理をしたのか記録することで、次回は途中から実行することができます。
だいぶ無理矢理ですが、どうしてもこのような処理をしないといけない時は参考にしてください。
Block Stuffing
トランザクションは通るまで順番待ちのように各トランザクションが並んでいます。
通常は実行された順に処理をされていくのですが、ガス代を多く払うことで優先的に処理をしてもらえるようにできます。
これを利用して、複数のトランザクションのガス代を多く払い優先して処理してもらうことで、一定時間後に資金をもらえるようなコントラクトの結果を攻撃者が操作できてしまいます。
例
1 ether
以上を預け入れ、一定期間後に一番多くのether
を預けたユーザーが全ての資金を受け取れるとします。
手順
- 各ユーザーが資金を預けていく。
- 攻撃者が資金を預ける。
- ガス代を多く払い、一定時間後まで他のトランザクションが通らないように複数のトランザクションを優先して通す。
- 他のユーザーはトランザクションが通らず資金を預け入れることができなくなる。
- 一定時間経過し、攻撃者がコントラクト内の資金を全て受け取ることができる。
攻撃者はガス代を自分で負担しないといけないが、それでも得られる資金の方が多ければ実行する価値は出てくる。
対処法
ガス代の負担と利益を比較して、利益の方が多いと攻撃者はガス代を払ってまで他のトランザクションを通さないモチベーションが出るので、負担の方が大きいようなモデルにする方法があります。
また、時間経過でも攻撃者によって妨害されないモデルかどうかも確認することが重要です。
最後に
今回はスマートコントラクトにおける「DoS攻撃」について解説してきました。
いかがだったでしょうか?
ポイント
- DoS攻撃について理解できた!
- DoS攻撃についての脆弱性を理解できた!
- DoS攻撃をされないように対処する方法を理解できた!
上記のポイントを抑えることができていたら嬉しいです!
もし何か質問などがあれば以下のTwitterなどから連絡ください!
普段はSolidityやブロックチェーン、Web3についての情報発信をしています。
Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!
参考
https://www.shadan-kun.com/waf/dos_ddos_attack/
https://github.com/kadenzipfel/smart-contract-attack-vectors/blob/master/attacks/dos-gas-limit.md
https://consensys.github.io/smart-contract-best-practices/attacks/denial-of-service/
https://coinsbench.com/denial-of-service-hack-solidity-6-2ce2243f41d1