‘마스터링 이더리움’ 세미나 6, 7 – 9장. 스마트 컨트랙트 보안

이번 세미나에서는 ‘9장. 스마트 컨트랙트 보안’을 다룹니다. 개인적으로 이 책에서 가장 중요하게 생각하는 장입니다. 실무에서 스마트 컨트랙트를 작성하는 개발자라면 스마트 컨트랙트를 작성하기 전에 의식적으로 이 장을 반복해서 읽기를 권합니다.

이번 장은 내용의 난이도와 중요도가 모두 높기 때문에 2주에 걸쳐 진행합니다. 솔리디티 버전이 올라가면서 달라진 부분도 있지만 본문 내용을 충실히 반영해 세미나를 진행합니다.

 

아래 인용한 본문 내용을 강조해서 읽고 마음에 새겨둡니다.

  • 스마트 컨트랙트는 실수를 용납하지 않는다. 모든 버그는 금전적 손실을 발생시킬 수 있다. 스마트 컨트랙트 프로그래밍은 범용 프로그래밍과 같은 방식으로 다루어서는 안 된다. 솔리디티로 스마트 컨트랙트를 작성하는 것은 자바스크립트로 웹 위젯을 만드는 것과 다르며, 오히려 항공 우주 공학을 비롯해 실수를 용납하지 않는 분야에서와 마찬가지로 엄격한 엔지니어링 및 소프트웨어 개발 방법론을 적용해야 한다. 일단 코드가 배포되고 나면 문제를 해결할 수 있는 방법이 거의 없다.
  • 광범위하게 사용되고 테스트된 코드는 새로 작성한 코드보다 더 안전하다. NIH 증후군을 조심하라. 처음부터 새로 구성하여 기능이나 구성요소를 개선하려는 유혹에 빠지기 쉽다. The security risk is often greater than the improvement value.
  • 할 수 있는 모든 것을 테스트하라. Smart contracts run in a public execution environment, where anyone can execute them with whatever input they want. You should never assume that input, such as function arguments, is well formed, properly bounded, or has a benign purpose. Test all arguments to make sure they are within expected ranges and properly formatted before allowing execution of your code to continue.
  • 스마트 컨트랙트 프로그래머라면 컨트랙트를 위험에 노출시키는 프로그래밍 패턴을 감지하고 피할 수 있도록 가장 공통적인 보안 위험에 익숙해야 한다.

재진입성

아래 인용한 본문 내용을 다시 한 번 주의를 기울여 읽습니다.

  • One of the features of Ethereum smart contracts is their ability to call and utilize code from other external contracts. Contracts also typically handle ether, and as such often send ether to various external user addresses. These operations require the contracts to submit external calls.

 

외부 컨트랙트 함수를 호출한다는 제어권을 넘겨준다는 것을 의미합니다. 다시 한 번 강조하는데 제어권을 넘겨준 것입니다.

  • 제어권을 넘겨주었으니 사고가 난다면 큰 사고가 나겠지요.
  • 스마트 컨트랙트에서 이더를 전송할 때 주의해야 할 점은 해당 주소가 컨트랙트일 수도 있다는 것입니다.
    • 해당 주소가 컨트랙트라면 제어권을 넘긴게 됩니다.
  • 외부 컨트랙트 함수를 호출하거나 이더를 전송하는 부분이 있다면, 별도로 표시해 두고 보안 위험 가능성에 대해서 철저하게 살펴야 합니다. 
  • 재진입은 컨트랙트 주소로 이더를 전송해서 발생한 대표적인 악용 사례입니다.

 

예방 기법

아래 요약한 재진입성 예방 기법들을 기억해 둡니다.

  • 이더를 외부의 컨트랙트에 보낼 때 내장된 transfer 함수를 사용하라.
    • transfer는 외부 함수 호출에 대해 2300 가스만을 보낸다. 이 정도 가스양으로 목적지 주소/컨트랙트가 다른 컨트랙트를 호출하기에는 충분하지 않다.
      • 이더리움 하드포크 등에서 특정 연산에 대한 가스 단위가 바뀜에 따라 transfer가 동작하지 않는 경우가 발생했습니다. 지금은 transfer 를 사용하지 않는 쪽으로 권장하기 때문에 이 해결 방법은 무시합니다.
  • 이더를 전송하기 전에 상태 변수를 변경하도록 한다.
    •  checks-effects-interactions pattern
      • ‘함수 실행에 따른 결과(상태 변경)을 먼저 다루고 외부 컨트랙트 함수를 호출한다’는 정도의 의미를 담고 있습니다.
  • 뮤텍스를 도입해서 코드 실행 중에 컨트랙트를 잠그는 상태 변수를 추가하여 재진입 호출을 방지한다.

산술 오버플로/언더플로

산술 오버플로/언더플로를 예방하기 위해 오픈제플린의 SafeMath 라이브러리를 사용합니다.

예기치 않은 이더

아래 인용한 본문 내용을 주의 깊게 읽고 기억해 둡니다.

  • There are two ways in which ether can (forcibly) be sent to a contract without using a payable function or executing any code on the contract:
    • Self-destruct
      • This means any attacker can create a contract with a selfdestruct function, send ether to it, call selfdestruct(target) and force ether to be sent to a target contract.
        • self-destruct는 소멸 시에 지정한 주소로 컨트랙트 생성 시에 사용한 가스에 해당하는 이더를 환불해 주거나 컨트랙트에 저장된 모든 이더를 전송합니다. 이때 컨트랙트 코드가 실행되지 않고 직접 지정한 주소에 해당하는 컨트랙트의 balance를 변경합니다.
    • Pre-sent ether

 

위와 같은 이유로 this.balance를 사용해서 이더 수량을 기준으로 제약사항을 체크하는 것은 위험합니다.

 

예방 기법

this.balance 값을 사용하지 말고, 입금된 이더의 정확한 값이 필요하다면 입금된 이더를 안전하게 추적할 수 있는 상태 변수를 정의해서 사용합니다.

DELEGATECALL

아래 인용한 본문 내용을 주의를 기울여 읽습니다.

  • The CALL and DELEGATECALL opcodes are useful in allowing Ethereum developers to modularize their code. Standard external message calls to contracts are handled by the CALL opcode, whereby code is run in the context of the external contract/function. The DELEGATECALL opcode is almost identical, except that the code executed at the targeted address is run in the context of the calling contract, and msg.sender and msg.value remain unchanged. This feature enables the implementation of libraries, allowing developers to deploy reusable code once and call it from future contracts.

 

라이브러리(library)는 stateless여야 합니다. delegatecall은 라이브러리 호출에만 사용해야 합니다.

디폴트 가시성

상태 변수와 함수의 가시성을 명시적으로 작성하도록 합니다.

컨트랙트 작성이 완료되면 가시성을 모두 작성했는지 확인합니다.

엔트로피 환상

아래 인용한 본문 내용을 주의를 기울여 다시 읽어 봅니다.

  • All transactions on the Ethereum blockchain are deterministic state transition operations. This means that every transaction modifies the global state of the Ethereum ecosystem in a calculable way, with no uncertainty. This has the fundamental implication that there is no source of entropy or randomness in Ethereum.
  • A common pitfall is to use future block variables—that is, variables containing information about the transaction block whose values are not yet known, such as hashes, timestamps, block numbers, or gas limits. The issue with these are that they are controlled by the miner who mines the block, and as such are not truly random.
  • The source of entropy (randomness) must be external to the blockchain. This can be done among peers with systems such as commit–reveal, or via changing the trust model to a group of participants (as in RandDAO). This can also be done via a centralized entity that acts as a randomness oracle. Block variables (in general, there are some exceptions) should not be used to source entropy, as they can be manipulated by miners.

외부 컨트랙트 참조

솔리디티에서는 컨트랙트 주소를 컨트랙트로 캐스팅할 수 있기 때문에 외부에서 주소로 컨트랙트를 주입할 수 있습니다. 이러한 점이 악용될 수 있기 때문에 컨트랙트 사용자나 컨트랙트 개발자는 이러한 경우에 주의를 기울여야 합니다.

컨트랙트 사용자는 자신이 사용하는 컨트랙트에 주소 설정으로 컨트랙트 주입 코드가 있는지 잘 살펴보고 악용 가능성에 대해서 평가해야 합니다. 컨트랙트 개발자는 외부 컨트랙트 참조가 악용되지 않은 것을 보장해야 합니다.

컨트랙트 개발자는 new 키워드를 사용해서 참조 컨트랙트를 생성하거나, 컨트랙트 주소를 하드 코딩하고 사용자가 쉽게 검사할 수 있도록 컨트랙트 주소를 public으로 설정하도록 합니다. 참조하는 컨트랙트를 변경해야 하는 경우에는 시간 잠금이나 투표 메커니즘을 구현해서 사용자들이 변경사항을 알고 동의할지 거부할지 결정할 수 있도록 해야 합니다.

짧은 주소/파라미터 공격

스마트 컨트랙트에 전달되는 파라미터는 ABI에 따라 인코딩되는데, 예상되는 길이보다 짧아도 보낼 수 있다는 점이 악용될 수 있습니다. EVM은 예상보다 짧은 길이의 파라미터가 전달되면 끝에 0을 추가하여 정해진 길이로 맞춥니다.

이러한 공격을 막기 위해서는 외부에서 호출되는 스마트 컨트랙트 함수의 매개변수에 대해 유효성 검사를 해야 합니다. 패딩은 끝부분에서만 발생하기 때문에 파라미터 순서를 신중하게 정하면 공격을 어느 정도 완화 시킬 수 있습니다.

확인되지 않은 CALL 반환 값

call 및 send와 같이 실패 했을 때 실행을 중단하고 원상태로 돌리지 않고, 성공 실패 여부를 리턴하는 함수가 있습니다. 이런 함수를 사용할 때는 결과 값을 확인하고 결과 값에 따라 다음 코드를 실행해야 합니다.

좀 더 강력한 권장사항은 출금 패턴을 채택하는 것이다.

  • In this solution, each user must call an isolated withdraw function that handles the sending of ether out of the contract and deals with the consequences of failed send transactions. The idea is to logically isolate the external send functionality from the rest of the codebase, and place the burden of a potentially failed transaction on the end user calling the withdraw function.

레이스 컨디션 / 프런트 러닝

트랜잭션 내용은 누구나 볼 수 있으며, 트랜잭션은 블록에 포함되기 전까지는 풀에 저장됩니다. 풀에 저장된 트랜잭션은 채굴자에 의해 선택되어 블록에 포함됩니다. 채굴자는 보통 수수료가 높게 설정된 트랜잭션을 먼저 선택합니다.

블록에 포함할 트랜잭션 선택 권한이 채굴자에게 있기 때문에 순서에 의존한 로직이 있는 컨트랙트는(예를 들어 답을 제일 먼저 제출한 사람에게 보상하는 컨트랙트와 같이) 채굴자에게 악용될 가능성이 있습니다. 채굴자가 악용하지 않더라도 수수료가 높게 설정된 트랜잭션이 먼저 선택되기 때문에 다른 사용자에 의해서 악용될 수도 있습니다.

이러한 공격을 막기 위해 가스 가격에 상한을 둘 수 있습니다. 이 방법으로는 채굴자가 공격자가 되었을 때는 막지 못합니다.

commit-reveal 방식을 사용하면 좀 더 견고하게 방어할 수 있습니다. 트랜잭션에 해결책을 포함할 때 해시와 같이 봐도 알 수 없는 정보로 보냅니다. 공격자는 이 해결책이 맞는 것인지를 알 수 없기 때문에 공격하기로 결정하기 쉽지 않습니다. 사용자는 트랜잭션이 블록에 포함되면 해결책에 대한 데이터를 포함한 트랜잭션을 보냅니다. 첫 번째 트랜잭션에서 해시 형태로 보냈다면 두 번째 트랜잭션 데이터에 대한 해시를 구해 비교합니다.

A further suggestion by Lorenz Breidenbach, Phil Daian, Ari Juels, and Florian Tramèr is to use “submarine sends”.

서비스 거부(DoS)

컨트랙트가 동작하지 않게 할 수 있는 방법은 다양합니다. 블록 가스 한도를 이용하거나 트랜잭션 가스 한도를 이용할 수 있습니다.

트랜잭션 가스 한도를 이용한 서비스 거부는 주로 반복 대상이 되는 배열이나 매핑 항목을 외부에서 추가할 수 있을 때 발생합니다.

반복 문에 외부 컨트랙트 호출을 포함할 경우 공격자는 자신의 컨트랙트가 호출되었을 때 실패하게 함으로 함수 실행이 영원히 성공하지 못하도록 할 수 있습니다.

  • 반복문을 사용하는 경우 외부에서 조작할 수 있는 매핑 또는 배열이 없도록 합니다.
  • 반복문을 사용하지 않는 방법을 찾습니다.

 

설정된 권한에 의해 특정 행위 수행에 영향을 주는 상태 변경이 제어될 경우, 권한에 해당하는 키를 분실할 경우 특정 상태를 기반으로 실행되는 함수가 영원히 실행되지 못할 수 있습니다.

외부 호출에 기반한 상태 진행의 경우 외부 호출이 실패하거나 외부적 용인으로 인해 차단된 경우, 특정 상태를 기반으로 실행되는 함수가 영원히 실행되지 못할 수 있습니다.

  • 상태 기반으로 함수 실행이 제한되는 경우에 민감하게 대응합니다.
    • 권한과 관련된 경우 키 분실 등에 대비하도록 합니다.
      • 다중 서명 방식을 사용하거나 시간-잠금을 사용합니다.
    • 외부 호출이 실패할 경우나 호출이 결코 일어나지 않을 경우에 대한 대비책을 준비합니다.

블록 타임스탬프 조작

블록 타임스탬프는 채굴자에 의해 설정되는데 탈중앙화의 특성 상 어느 정도의 오차 범위가 허용됩니다. 채굴자에 의해 값이 조정될 수 있습니다.

  • 따라서 오차 범위를 인정할 수 있는 수준이 아닌 경우라면 블록 타임스탬프를 로직에 사용해서는 안 됩니다.
  • 시간에 민감한 논리가 요구되는 경우 블록 타임스탬프보다 블록 번호나 평균 블록 시간을 사용해서 시간을 추정하는 것이 좋습니다.
  • 블록 타임스탬프를 엔트로피 또는 랜덤 값을 생성하는데 사용하지 않도록 해야 합니다.

 

생성자 관리

솔리디티 0.4.22 이전에는 컨트랙트 이름과 같은 이름을 갖는 함수로 생성자를 작성했습니다. 여기서 문제는 생성자라고 생각하고 작성했는데, 컨트랙트 이름을 바꾸거나 오타로 인해 컨트랙트 이름과 달라져 생성자가 아니라 일반 함수가 되는 것입니다. 이렇게 되는 경우 생성자에서 소유자 설정과 같은 중요한 일을 처리하도록 했는데, 컨트랙트 생성시 실행되지 않고 외부에서 공격자에 의해 호출되는 것입니다.

솔리디티 0.4.22에서는 생성자로 constructor라는 키워드를 사용해서 이 문제를 원천적으로 막고 있습니다. 가능한 최신 컴파일러를 사용하도록 합니다.

 

초기화되지 않은 스토리지 포인터

솔리디티는 기본적으로 struct 같은 복잡한 데이터 타입을 지역 변수로 초기화할 때 스토리지에 저장합니다. 문제는 초기화하지 않은 로컬 스토리지 변수가 slot[0]에 매핑되는 것입니다. slot[0]에는 상태 변수 값이 할당되어 있을 수 있습니다.

  • 솔리디티 컴파일러는 초기화되지 않은 스토리지 변수에 대해 경고합니다.
    • 복잡한 유형을 처리할 때 memory 또는 storage 지정자를 명시적으로 사용하도록 합니다.
    • 0.5.0 이후 버전에서는 경고가 아니라 에러로 처리합니다. 특별한 이유가 없다면 최신 버전 컴파일러를 사용합니다.

 

부동소수점 및 정밀도

솔리디티에는 고정소수점 유형이 없기 때문에 개발자는 표준 정수 데이터 타입을 사용해 자체적으로 구현해야 합니다.

아래 인용한 본문 내용을 주의 깊게 읽어 봅니다.

스마트 컨트랙트에서 올바른 정확성을 유지하는 것은 매우 중요한데, 특히 경제적인 결정을 반영하는 비율을 다룰 때 매우 중요하다.

비율을 사용할 때는 분수에서 큰 분자를 사용할 수 있는지 확인해야 한다.

작업 순서를 염두에 두어야 한다. 어떤 순서로 하면 더 높은 정밀도를 얻을 수 있는지 따져봐야 한다.

더 높은 정밀도로 변환하고 모든 수학 연산을 수행한 다음 최종적으로 출력에 필요한 정밀도로 다시 변환하는 것이 좋다. 더 높은 정밀도로 변환하기 위해서 일반적으로 uint256이 사용된다.

 

Tx.Origin 인증

글로벌 변수 tx.origin은 전체 호출 스택을 가로지르고 원래 호출을 보낸 계정의 주소를 포함합니다. 문제는 tx.origin을 사용해 제약사항으로 사용할 때 발생합니다. 공격자는  사용자로 하여금 자신의 컨트랙트를 호출하도록 하고, tx.origin을 획득한 후 사용자의 컨트랙트를 호출합니다. tx.origin이 같으니 제약사항을 통과하게 됩니다.

tx.origin은 제약사항을 체크하는데 사용하지 않아야 합니다.

컨트랙트 라이브러리

광범위한 테스트를 거치고 사실상 표준 구현으로 기능을 수행하는 라이브러리를 활용하도록 합니다. 대표적인 오픈소스인 오픈 제플린을 사용합니다.

Dappsys도 살펴볼만한 가치가 충분합니다.

About the Author
(주)뉴테크프라임 대표 김현남입니다. 저에 대해 좀 더 알기를 원하시는 분은 아래 링크를 참조하세요. http://www.umlcert.com/kimhn/

Leave a Reply

*