Smart contract 보안 전략

2023. 6. 14. 11:30Blockchain/Security

728x90
반응형

※ 원글 작성 : 22년 5월 31일

 

Manuel Araoz의 Onward with Ethereum Smart Contract Security에서 smart contract 보안을 위한 개발 시 여러 전략들에 대해 정리해 놓은 자료를 확인해 본다. 해당 글에서는 contract 보안 개선을 위한 전략과 샘플을 함께 보여주어 연상하기 쉽도록 설명이 되어있다.

Fail as early and loudly as possible

간단하지만 강력한 프로그래밍 전략은 "Fail early, Fail loudly"이다.

// UNSAFE , DO NOT USE!

contract BadFailEarly {
  uint constant DEFAULT_SALARY = 50000;
  mapping(string => uint) nameToSalary;

  function getSalary(string name) constant returns (uint) {
    if (bytes(name).length != 0 && nameToSalary[name] != 0) {
      return nameToSalary[name];
    } else {
      return DEFAULT_SALARY;
    }
  }
}

Solidity 내 함수 getSalary는 저장된 급여를 반환하기 전 조건을 확인한다. 이때 조건을 충족하지 않으면 초기값인 DEFAULT_SALARY를 반환하게 되는데, 이로인해 caller로 부터 오류를 숨길 수 있다. 이러한 프로그래밍은 굉장히 자주 사용되고 있지만, 앱을 중단시키는 오류에 대한 두려움 때문에 발생한다.

하지만 결과가 더 빨리 실패할수록 문제를 더 쉽게 찾을 수 있다. 오류를 숨기면 다른 부분으로 전달되어 추적하기 더 어려워지기 때문이다.

contract GoodFailEarly {

  mapping(string => uint256) nameToSalary;

  function getSalary(string name) constant returns (uint256) {
    if(bytes(name).length == 0) throw;
    if(nameToSalary[name] == 0) throw;

    return nameToSalary[name];
  }
}

위와 같이 수정한 버전에서는 조건을 분리하고 각각을 개별적으로 실패하게 만드는 바람직한 프로그래밍 패턴이다. 이러한 검사(특히 내부 state 검사)는 function modifier를 통해 구현 가능하다.

Favor pull over push payments

모든 ETH의 transfer는 잠재적으로 "execution"이다. 수신 주소는 오류를 발생시킬 수 있는 Fallback 기능이 구현되어 있을 수 있기 때문에 send의 호출에서 오류가 발생할 수 있다. 따라서 smart contract는 payment를 위한 pull over push를 사용해야 한다.

  • Pull over push : 함수 실행 후 push하는 방식에서 트랜잭션 수신자가 요청하여 받는 pull 방식으로 변환
  • // UNSAFE , DO NOT USE! contract BadPushPayments { address highestBidder; uint256 highestBid; function bid() { if(msg.value < highestBid) throw; if (highestBidder != 0) { // return bid to previous winner if (!highestBidder.send(highestBid)) { throw; } } highestBidder = msg.sender; highestBid = msg.value; } }

위 contract는 send 함수를 호출하고 return value를 확인한다. send는 다른 contract 실행의 트리거를 할 수 있기 때문에 함수의 중간에 호출하는 것은 적절하지 않다.

누군가가 돈을 전송할 때마다 오류가 발생하는 address에서 입찰을 시도한다고 가정했을 시, 다른 사람이 더 높은 입찰가를 제시하려 하면 send 호출은 항상 실패하고, 계속 쌓이게 되어 bid 예외가 발생하게 된다. 결국 contract가 파기 된다.

contract GoodPullPayments {
  address highestBidder;
  uint highestBid;

  mapping(address => uint) refunds;

  function bid() external {
    if (msg.value < highestBid) throw;

    if (highestBidder != 0) {
      refunds[highestBidder] += highestBid;
    }

    highestBidder = msg.sender;
    highestBid = msg.value;
  }

 function withdrawBid() external {
    uint refund = refunds[msg.sender];
    refunds[msg.sender] = 0;
    if (!msg.sender.send(refund)) {
      refunds[msg.sender] = refund;
    }
  }
}

가장 쉬운 해결책은 payment를 다른 기능으로 분리하고 사용자가 나머지 contract logic과 독립적으로 자금을 요청(pull)하도록 하는 것이다. 위 코드는 mapping을 통해 낙찰자 별 refund 금액을 저장하고 출금하는 기능이 구현되어 있다. 오류가 있는 send의 경우 해당 입찰자만 영향을 받고, 이는 reentrancy와 같은 문제를 해결할 수 있는 패턴이다.

Order your function: conditions, actions, interactions

Fail-early의 원칙의 확장으로 모든 기능을 아래와 같이 구현하는 것을 권장한다.

function auctionEnd() {

  // 1. Conditions
  if (now <= auctionStart + biddingTime) throw;

  // auction did not yet end
  if (ended) throw; // this function has already been called

  // 2. Effects
  ended = true;
  AuctionEnded(highestBidder, highestBid);

  // 3. Interaction
  if (!beneficiary.send(highestBid)) throw;
}

첫번째로, 모든 사전 조건을 확인한다.
다음으로, contract state를 변경한다.
마지막으로, 다른 contract와 상호작용한다.

이는 초기에 조건을 확인하기 때문에 fail fast 원칙과 일치하고, 잠재적 위험성을 가지고 있는 다른 contract와의 interaction을 마지막까지 남겨둘 수 있다.

Be aware of platform limits

EVM은 contract가 할 수 있는 것에 대해 엄격한 제한이 있지만 이를 모르는 경우 특정 contract의 보안을 위협한다.

// UNSAFE , DO NOT USE!

contract BadArrayUse {
  address[] employees;

  function payBonus() {
    for (var i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = calculateBonus(employee);
      employee.send(bonus);
    }     
  }

  function calculateBonus(address employee) returns (uint) {
    // some expensive computation ...
  }
}

위 코드는 플랫폼 limit에 따라 3가지 잠재적 문제를 가지고 있다.

  1. for문 내의 i의 타입이 uint8인 것이다. Array에 255개 이상의 parameter가 있는 경우 loop가 종료되지 않아 가스가 고갈된다. 더 엄격한 limit을 위해 var을 사용하는 것 보다 명시적으로 type을 선언하는 것이 좋다.
  2. Gas limit에 대한 문제이다. computeBonus 함수가 복잡한 계산을 기반으로 각 직원의 보너스를 계산한다 가정할 시 transaction or block의 gas limit에 쉽게 도달한다. 거래가 gas limit에 도달하면 모든 변경사항이 취소되지만 수수료가 지불된다. for loop에서 복잡한 계산 과정을 분리하여 contract를 최적화 해야한다. (하지만 아직 직원 array가 증가함에 따른 gas 비 소모 문제가 여전히 존재한다.)
  3. Call stack 제한이다. EVM call stack은 1024의 크기이기 때문에 중첩 호출 수가 1024에 도달하면 contract는 실패하게 된다. 위에서의 PullPayment를 상속하고 asyncSend를 쓰면 보호 가능하다. (EIP150은 재귀호출이 받는 gas 양을 줄여 call stack 공격 가능성을 제거하였다.)

아래 코드는 이러한 문제들을 해결하는 수정 버전이다.

import './PullPayment.sol';

contract GoodArrayUse is PullPayment {
  address[] employees;
  mapping(address => uint) bonuses;

  function payBonus() {
    for (uint i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = bonuses[employee];
      asyncSend(employee, bonus);
    }
  }

  function calculateBonus(address employee) returns (uint) {
    uint bonus = 0;
    // some expensive computation...
    bonuses[employee] = bonus;
  }
}

Write test

테스트 작성은 귀찮고 많은 에너지를 써야 하지만 regression problems를 해결할 수 있다. 이전에는 잘 작동하는 component가 업데이트 됨에따라 손상되면 regression 버그가 나타난다.

테스트에 대해, Truffle의 테스트 가이드OpenZeppelin의 가이드를 확인하면 좋다.

Automatic bug bounties

Review와 security audit 만으로는 완벽하게 안전하지 않다. 최악의 상황을 대비하여 smart contract에 취약점이 있는 경우 안전하게 복구 할 수 있는 방법이 필요하다. 또한 가능한 빨리 이러한 취약점을 찾으려고 노력해야 한다. Contract에 내장된 자동 Bug county가 도움을 줄 수 있다.

import './PullPayment.sol';
import './Token.sol';

contract Bounty is PullPayment {

  bool public claimed;
  mapping(address => address) public researchers;

  function() { if (claimed) throw; }

  function createTarget() returns (Token) {
    Token target = new Token(0);
    researchers[target] = msg.sender;
    return target;
  }

  function claim(Token target) {
    address researcher = researchers[target];
    if (researcher == 0) throw;

    // check Token contract invariants
    if (target.totalSupply() == target.balance) throw;

    asyncSend(researcher, this.balance);
    claimed = true;
  }
}

앞서 "Limit"과 마찬가지로 PullPayment 송금을 사용한다. 이 contract를 통해 researcher는 감사를 원하는 토큰 Bounty contract의 사본을 만들 수 있다. 누구나 contract address로 트랜잭션을 전송하여 bug county에 기여 가능하다. 어떤 researcher가 contract 사본을 손상시키면 보상을 받게 된다. County가 청구 되면 contract는 더 이상 자금을 받지 않는다.

별도의 contract로 원래 contract의 내용을 수정할 필요가 없는 장점이 존재한다.

Fault tolerance

추가적인 safe 메커니즘으로 fault tolrance를 해결할 수 있다. Contract의 큐레이터가 emergency 메커니즘으로 contract를 동결 시킬 수 있도록 하는것이다.

contract Stoppable {

  address public curator;
  bool public stopped;

  modifier stopInEmergency { if (!stopped) _; }
  modifier onlyInEmergency { if (stopped) _; }

  function Stoppable(address _curator) {
    if (_curator == 0) throw;
    curator = _curator;
  }

  function emergencyStop() external {
    if (msg.sender != curator) throw;
    stopped = true;
  }
}

Stoppable contract는 동결 시킬 수 있는 큐레이터 주소를 지정 할 수 있다. 여기서 "stopping the contract"란 function modifier인 stopInEmergencyonlyInEmergency 를 사용하여 Stoppable로 부터 상속된 자식 contract를 정의하는 것이다. 아래 코드로 살펴보자.

import './PullPayment.sol';
import './Stoppable.sol';

contract StoppableBid is Stoppable, PullPayment {
  address public highestBidder;
  uint public highestBid;

  function StoppableBid(address _curator) Stoppable(_curator) PullPayment() {}

  function bid() external stopInEmergency {
    if (msg.value <= highestBid) throw;

    if (highestBidder != 0) {
      asyncSend(highestBidder, highestBid);
    }

    highestBidder = msg.sender;
    highestBid = msg.value;
  }

  function withdraw() onlyInEmergency { suicide(curator); }
}

Contract가 생성될 때 정의된 큐레이터가 입찰을 중단할 수 있다. StoppableBid가 일반 모드 일 때 bid 함수만 호출할 수 있지만 이상한 일이 발생하여 contract에 일관성이 없는 경우 bid함수를 호출할 수 없게 되고 withdraw 함수가 실행될 수 있다.

이 경우 비상 모드에서는 큐레이터가 contract를 파기하고 자금을 회수 가능하다. 하지만 실제인 경우 소유자에게 자금을 반환해야 하는 등 복구 logic이 더 복잡할 수 있다.

Limit the amount of funds deposited

공격으로 부터 contract를 보호하는 다른 방법은 예치된 자금의 양을 제한시키는 것이다. 공격자는 수백만 달러의 contract를 목표로 삼을 것이기 때문에 contract에서 허용하는 금액을 제한하는 것이 유용할 수 있다.

contract LimitFunds {

 uint LIMIT = 5000;

 function() { throw; }

 function deposit() {
    if (this.balance > LIMIT) throw;
    // ...
  }
}

이 short fallback 함수는 contract에 대한 직접적인 지불을 거부한다. Deposit 기능은 먼저 contract의 잔액이 limit을 초과하는지 확인하거나 예외를 발생시킨다.

Write simple and modular

코드가 거대하고 지저분한 경우 오류를 확인하기가 어렵기 때문에 단순하고 모듈식으로 작성하는 것이 중요하다. 즉, 함수는 가능한 짧아야 하고 dependency는 최소로 줄여야 하며 파일은 가능한 작아야 하며 독립적인 logic을 각각 단일 책임을 갖는 모듈로 분리해야 한다.

Naming 또한 중요하므로 변수, 함수명에 대해 고민을 많이 해야한다.

Don't write all your from scratch

Don't roll your own crypto. Smart contract를 통해 자산을 운영하고, 데이터는 블록에 모두 공개되어 있기 때문에 판돈은 높지만 체인이 망할 가능성은 충분히 열려 있다. 플랫폼 고유의 보안 알고리즘 등을 만들수 있지만 이는 공인된 안전성이 아니기 때문에 실제로 사용되고 있는 crypto를 사용하는 것이 좋다.

Wrapping up

  1. Fail as early and loudly as possible
  2. Favor pull over push payments
  3. Order your function : conditions, actions, interactions
  4. Be aware of platform limits
  5. Write tests
  6. Fault tolerance and Automatic bug bounties
  7. Limit the amount of funds deposited
  8. Write simple and modular
  9. Don' write all your from scratch

참고
https://blog.openzeppelin.com/onward-with-ethereum-smart-contract-security-97a827e47702/

728x90
반응형