보안 고려사항

보안 고려사항 (Security Considerations)

예상대로 동작하는 소프트웨어를 만드는 것은 보통 꽤 쉬운 반면, 아무도 예상하지 못한 방식으로 사용할 수 없는지 확인하는 것은 훨씬 어려워요. Solidity에서는 스마트 컨트랙트로 토큰이나 더 가치 있는 것을 다룰 수 있기 때문에 이것이 더 중요해요. 게다가 스마트 컨트랙트의 모든 실행이 공개적으로 일어나고, 소스 코드도 흔히 공개돼요. 이 절에서는 몇 가지 함정과 일반적인 보안 권장 사항을 나열할게요.

출처: 문서

본문

예상대로 동작하는 소프트웨어를 만드는 것은 보통 꽤 쉬운 반면, 아무도 예상하지 못한 방식으로 그것을 사용할 수 없는지 확인하는 것은 훨씬 어려워요. Solidity에서는 스마트 컨트랙트로 토큰이나, 어쩌면 훨씬 더 가치 있는 것들을 다룰 수 있기 때문에 이것이 훨씬 더 중요해요. 게다가 스마트 컨트랙트의 모든 실행이 공개적으로 일어나고, 그 외에도 소스 코드가 흔히 공개돼요.

물론 항상 얼마나 큰 것이 걸려 있는지 고려해야 해요. 스마트 컨트랙트를 공개된(그래서 악의적인 행위자에게도 열린) 그리고 어쩌면 심지어 오픈소스인 웹 서비스와 비교할 수 있어요. 그 웹 서비스에 장보기 목록만 저장한다면 너무 신경 쓸 필요가 없을 수 있지만, 그 웹 서비스로 은행 계좌를 관리한다면 더 조심해야 해요.

이 절은 몇 가지 함정과 일반적인 보안 권장 사항을 나열하지만, 당연히 결코 완전할 수 없어요. 또한 스마트 컨트랙트 코드가 버그가 없다고 해도, 컴파일러나 플랫폼 자체에 버그가 있을 수 있다는 점을 명심하세요. 컴파일러의 공개적으로 알려진 보안 관련 버그 중 일부 목록은 알려진 버그 목록에서 찾을 수 있으며, 여기도 기계가 읽을 수 있어요. Solidity 컴파일러의 코드 생성기를 다루는 버그 바운티 프로그램이 있다는 점을 알아두세요. 오픈소스 문서에서 항상 그렇듯, 이 절을 확장하는 데 도움을 주세요(특히 몇 가지 예시가 도움이 될 거예요)!

참고 (Note)

아래 목록에 더해, Guy Lando의 지식 목록과 Consensys GitHub 저장소에서 더 많은 보안 권장 사항과 모범 사례를 찾을 수 있어요.

함정 (Pitfalls)

사적인 정보와 무작위성 (Private Information and Randomness)

스마트 컨트랙트에서 사용하는 모든 것은 공개적으로 보이며, private로 표시된 지역 변수와 상태 변수까지도 그래요. 스마트 컨트랙트에서 난수를 사용하는 것은 블록 빌더가 속일 수 없게 하려면 꽤 까다로워요.

재진입 (Reentrancy)

컨트랙트(A)가 다른 컨트랙트(B)와 상호작용하는 것과 Ether를 전송하는 것은 그 컨트랙트(B)에 제어권을 넘겨줘요. 이는 B가 이 상호작용이 완료되기 전에 A로 다시 호출하는 것을 가능하게 해요. 예를 들어 다음 코드는 버그를 포함해요(완전한 컨트랙트가 아니라 조각일 뿐이에요):

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
    // This will report a warning (deprecation)
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

문제는 send의 일부인 제한된 가스 때문에 그렇게 심각하지는 않지만, 여전히 약점을 드러내요. Ether 전송은 항상 코드 실행을 포함할 수 있으므로, 수신자가 withdraw로 다시 호출하는 컨트랙트일 수 있어요. 그러면 여러 번 환불받고, 기본적으로 컨트랙트의 모든 Ether를 가져갈 수 있어요.

특히 다음 컨트랙트는 기본적으로 전달되는 가스 양을 제한하지 않는 call을 사용하기 때문에 공격자가 여러 번 환불받을 수 있게 해요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}("");
        if (success)
            shares[msg.sender] = 0;
    }
}

재진입을 피하려면 아래에서 보여 주는 것처럼 Checks-Effects-Interactions 패턴을 사용할 수 있어요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;
        (bool success, ) = payable(msg.sender).call{value: share}("");
        require(success);
    }
}

Checks-Effects-Interactions 패턴은 컨트랙트를 통한 모든 코드 경로가 상태를 수정하기 전에 제공된 매개변수에 대한 모든 필요한 검사를 완료하도록(Checks) 보장해요. 그런 다음에만 상태에 대한 어떤 변경을 만들어요(Effects). 모든 계획된 상태 변경이 스토리지에 쓰여진 후에 다른 컨트랙트의 함수를 호출할 수 있어요(Interactions). 이것은 재진입 공격을 막는 일반적인 만능(푸루프) 방법이에요. 외부에서 호출된 악성 컨트랙트가 트랜잭션을 확정하기 전에 원래 컨트랙트로 다시 호출하는 로직을 사용해 허용량을 이중 지불하거나 잔액을 이중 출금하는 등의 행위를 하는 것을 방지해요.

재진입은 Ether 전송의 영향일 뿐 아니라 다른 컨트랙트에 대한 어떤 함수 호출의 영향이라는 점을 주의해요. 게다가 다중 컨트랙트 상황도 고려해야 해요. 호출된 컨트랙트가 여러분이 의존하는 다른 컨트랙트의 상태를 수정할 수 있어요.

가스 한도와 루프 (Gas Limit and Loops)

고정된 반복 횟수가 없는 루프, 예를 들어 스토리지 값에 의존하는 루프는 주의해서 사용해야 해요. 블록 가스 한도 때문에 트랜잭션은 특정 양의 가스만 소비할 수 있어요. 명시적으로든 정상 동작으로든, 루프의 반복 횟수가 블록 가스 한도를 넘어 커지면 완전한 컨트랙트가 특정 지점에서 멈출 수 있어요. 이는 블록체인에서 데이터를 읽기 위해서만 실행되는 view 함수에는 적용되지 않을 수 있어요. 그래도 그런 함수는 온체인 연산의 일부로 다른 컨트랙트가 호출해 그 연산들을 멈추게 할 수 있어요. 컨트랙트 문서에 이런 경우를 명시적으로 적어주세요.

Ether 보내기와 받기 (Sending and Receiving Ether)

  • 컨트랙트도 "외부 소유 계정"도 현재 누군가가 자신에게 Ether를 보내는 것을 막을 수 없어요. 컨트랙트는 일반 전송에 반응하고 거부할 수 있지만, 메시지 호출을 만들지 않고 Ether를 옮기는 방법이 있어요. 한 가지 방법은 단순히 컨트랙트 주소로 "채굴(mine to)"하는 것이고, 두 번째 방법은 selfdestruct(x)를 사용하는 것이에요.
  • 컨트랙트가(함수가 호출되지 않고) Ether를 받으면, receive(Ether 수신) 또는 fallback 함수가 실행돼요. receive도 fallback도 없으면 Ether는(예외를 던져) 거부돼요. 이 함수 중 하나의 실행 동안 컨트랙트는 그때 사용할 수 있는 전달된 "가스 stipend(2300 가스)"만 신뢰할 수 있어요. 이 stipend는 스토리지를 수정하기에 충분하지 않아요(당연하다고 받아들이지 마세요, stipend는 미래 하드포크로 바뀔 수 있어요). 컨트랙트가 그 방식으로 Ether를 받을 수 있는지 확실히 하려면 receive와 fallback 함수의 가스 요구를 확인해요(예: Remix의 "details" 섹션).
  • addr.call{value: x}("")를 사용해 수신 컨트랙트에 더 많은 가스를 전달하는 방법이 있어요. 이것은 본질적으로 addr.transfer(x)와 같지만, 남은 모든 가스를 전달하고(일부 EVM 버전이 부과하는 추가 제한, 예를 들어 tangerineWhistle이 도입한 63/64 규칙 제한 적용), 수신자가 더 비싼 동작을 수행할 수 있게 열어줘요(그리고 에러를 자동으로 전파하는 대신 실패 코드를 반환해요). 이는 보내는 컨트랙트로 다시 호출하거나 생각지 못한 다른 상태 변경을 포함할 수 있어요. 그래서 정직한 사용자에게는 큰 유연성을, 악의적인 행위자에게도 큰 유연성을 제공해요.
  • 정밀도 부족으로 반올림되어 손실되는 것은 없도록, 가능한 가장 정밀한 단위로 Wei 금액을 나타내세요.
  • address.transfer로 Ether를 보내려면 알아야 할 특정 세부 사항이 있어요. 수신자가 컨트랙트라면 그것의 receive나 fallback 함수가 실행되고, 그것이 다시 보내는 컨트랙트로 호출할 수 있어요. 호출 깊이가 1024를 넘으면 Ether 전송이 실패할 수 있어요. 호출자가 호출 깊이를 완전히 통제하므로 transfer를 실패하게 강제할 수 있어요. 이 가능성을 고려하거나 send를 사용하고 항상 그 반환 값을 확인해요. 더 좋게는 수신자가 Ether를 출금할 수 있는 패턴으로 컨트랙트를 작성해요. 수신자 컨트랙트의 실행이 할당된 가스 양보다 더 많은 가스를 요구하면(명시적으로 require, assert, revert를 사용하거나 연산이 너무 비싸서) Ether 전송도 실패할 수 있어요. 즉 "가스가 부족해져요"(OOG). transfer나 send를 반환 값 확인과 함께 사용하면, 이것이 수신자가 보내는 컨트랙트의 진행을 막는 수단을 제공할 수 있어요. 여기서도 모범 사례는 "send" 패턴 대신 "withdraw"(출금) 패턴을 사용하는 것이에요.

호출 스택 깊이 (Call Stack Depth)

외부 함수 호출은 최대 호출 스택 크기 제한인 1024를 초과하기 때문에 언제든 실패할 수 있어요. 그런 상황에서 Solidity는 예외를 던져요. 악의적인 행위자는 여러분의 컨트랙트와 상호작용하기 전에 호출 스택을 높은 값으로 강제할 수 있어요. Tangerine Whistle 하드포크부터 63/64 규칙이 호출 스택 깊이 공격을 비현실적으로 만들었다는 점을 주의해요.

또한 호출 스택과 표현식 스택은 둘 다 1024 스택 슬롯의 크기 제한이 있지만 서로 관련이 없다는 점을 주의해요.

.send()는 호출 스택이 소진되면 예외를 던지는 대신 그 경우 false를 반환한다는 점을 주의해요. 저수준 함수 .call(), .delegatecall(), .staticcall()도 같은 방식으로 동작해요.

인가된 프록시 (Authorized Proxies)

컨트랙트가 프록시로 동작할 수 있다면, 즉 사용자가 제공한 데이터로 임의 컨트랙트를 호출할 수 있다면, 사용자는 본질적으로 프록시 컨트랙트의 정체성을 가정할 수 있어요. 다른 보호 조치가 있어도, 프록시가 어떤 권한도 가지지(자기 자신에 대한 권한도) 않도록 컨트랙트 시스템을 구축하는 것이 가장 좋아요. 필요하면 두 번째 프록시를 사용해 그렇게 할 수 있어요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract ProxyWithMoreFunctionality {
    PermissionlessProxy proxy;

    function callOther(address addr, bytes memory payload) public
            returns (bool, bytes memory) {
        return proxy.callOther(addr, payload);
    }
    // Other functions and other functionality
}

// This is the full contract, it has no other functionality and
// requires no privileges to work.
contract PermissionlessProxy {
    function callOther(address addr, bytes memory payload) public
            returns (bool, bytes memory) {
        return addr.call(payload);
    }
}

tx.origin

인가(Authorization)에 tx.origin을 절대 사용하지 마세요. 다음과 같은 지갑 컨트랙트가 있다고 가정해 볼게요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
    address owner;

    constructor() {
        owner = msg.sender;
    }

    function transferTo(address payable dest, uint amount) public {
        // THE BUG IS RIGHT HERE, you must use msg.sender instead of tx.origin
        require(tx.origin == owner);
        // This will report a warning (deprecation)
        dest.transfer(amount);
    }
}

이제 누군가 여러분을 속여 이 공격 지갑의 주소로 Ether를 보내게 한다고 해요:

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface TxUserWallet {
    function transferTo(address payable dest, uint amount) external;
}

contract TxAttackWallet {
    address payable owner;

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

    receive() external payable {
        TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
    }
}

지갑이 인가에 msg.sender를 확인했다면, 받는 것은 소유자 주소가 아니라 공격 지갑의 주소였을 거예요. 그러나 tx.origin을 확인함으로써, 트랜잭션을 시작한 원래 주소를 받게 되는데, 그것은 여전히 소유자 주소예요. 공격 지갑은 즉시 여러분의 모든 자금을 빼내요.

2의 보수 / 언더플로우 / 오버플로우 (Two's Complement / Underflows / Overflows)

많은 프로그래밍 언어에서처럼, Solidity의 정수 타입은 실제로 정수가 아니에요. 값이 작을 때는 정수처럼 보이지만, 임의로 큰 숫자를 나타낼 수는 없어요. 다음 코드는 덧셈의 결과가 타입 uint8에 저장하기에 너무 커서 오버플로우를 일으켜요:

open in Remix

uint8 x = 255;
uint8 y = 1;
return x + y;

Solidity는 이런 오버플로우를 다루는 두 가지 모드가 있어요. Checked와 Unchecked 또는 "래핑" 모드예요. 기본 checked 모드는 오버플로우를 감지해 실패 어서션을 일으켜요. unchecked { ... }를 사용해 이 검사를 비활성화할 수 있고, 그러면 오버플로우가 조용히 무시돼요. 위 코드는 unchecked { ... }로 감싸면 0을 반환할 거예요.

checked 모드에서도 오버플로우 버그로부터 보호된다고 가정하지 마세요. 이 모드에서는 오버플로우가 항상 revert해요. 오버플로우를 피하는 것이 불가능하면, 이는 스마트 컨트랙트가 특정 상태에 갇히게 만들 수 있어요. 일반적으로 2의 보수 표현의 한계에 대해 읽어보세요. 부호 있는 숫자에는 더 특별한 엣지 케이스가 있어요. require로 입력 크기를 합리적인 범위로 제한하고 SMT checker를 사용해 잠재적 오버플로우를 찾으세요.

매핑 지우기 (Clearing Mappings)

Solidity 타입 mapping(매핑 타입 참고)은 스토리지 전용 키-값 데이터 구조이며, 0이 아닌 값이 할당된 키를 추적하지 않아요. 그 때문에 써진 키에 대한 추가 정보 없이 매핑을 정리하는 것은 불가능해요. 매핑이 동적 스토리지 배열의 기본 타입으로 사용되면, 배열을 삭제하거나 pop해도 매핑 요소에 아무 효과가 없어요. 예를 들어 매핑이 동적 스토리지 배열의 기본 타입인 구조체의 멤버 필드 타입으로 사용돼도 같은 일이 일어나요. 매핑은 매핑을 포함하는 구조체나 배열의 할당에서도 무시돼요.

open in Remix

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Map {
    mapping(uint => uint)[] array;

    function allocate(uint newMaps) public {
        for (uint i = 0; i < newMaps; i++)
            array.push();
    }

    function writeMap(uint map, uint key, uint value) public {
        array[map][key] = value;
    }

    function readMap(uint map, uint key) public view returns (uint) {
        return array[map][key];
    }

    function eraseMaps() public {
        delete array;
    }
}

위 예시와 다음 호출 시퀀스(allocate(10), writeMap(4, 128, 256))를 고려해 보세요. 이 시점에 readMap(4, 128)을 호출하면 256을 반환해요. eraseMaps를 호출하면 상태 변수 array의 길이가 0이 되지만, 그 매핑 요소는 0으로 만들 수 없으므로 그 정보는 컨트랙트 스토리지에 살아 있어요. array를 삭제한 후 allocate(5)를 호출하면 array[4]에 다시 접근할 수 있고, writeMap을 다시 호출하지 않아도 readMap(4, 128)이 256을 반환해요.

매핑 정보를 반드시 삭제해야 한다면, 키를 순회하고 적절한 매핑에서 값을 삭제할 수 있게 하는 iterable mapping 같은 라이브러리 사용을 고려해요.

업그레이드 가능한 컨트랙트의 내부 함수 포인터 (Internal Function Pointers in Upgradeable Contracts)

컨트랙트의 코드를 업데이트하면 내부 함수 타입 변수의 값이 무효가 될 수 있어요. 그런 값을 일시적인 것으로 간주하고 상태 변수에 저장하는 것을 피해요. 저장한다면, 코드 업데이트를 가로질러 결코 지속되지 않고, delegatecall이나 계정 추상화의 결과로 같은 스토리지 공간에 접근할 수 있는 다른 컨트랙트가 그것을 사용하지 않도록 보장해야 해요.

사소한 세부 사항들 (Minor Details)

  • 전체 32바이트를 차지하지 않는 타입은 "더티한 상위 비트(dirty higher order bits)"를 포함할 수 있어요. msg.data에 접근할 때 특히 중요해요. 이는 가변성(malleability) 위험을 제기해요. 원시 바이트 인자 0xff000001과 0x00000001로 함수 f(uint8 x)를 호출하는 트랜잭션을 만들 수 있어요. 둘 다 컨트랙트에 공급되고 x의 관점에서는 둘 다 숫자 1처럼 보이지만, msg.data는 달라요. 그래서 어떤 용도로든 keccak256(msg.data)를 사용하면 다른 결과를 얻게 돼요.

권장 사항 (Recommendations)

경고를 진지하게 받아들이기 (Take Warnings Seriously)

컴파일러가 어떤 것에 대해 경고하면 바꾸어야 해요. 그 특정 경고가 보안 영향이 있다고 생각하지 않아도, 그 밑에 다른 문제가 있을 수 있어요. 우리가 내는 컴파일러 경고는 코드의 약간의 변경으로도 잠잠하게 만들 수 있어요. 최근에 도입된 모든 경고를 알려면 항상 최신 버전의 컴파일러를 사용해요. 컴파일러가 내는 info 유형의 메시지는 위험하지 않고, 컴파일러가 사용자에게 유용할 것이라고 생각하는 추가 제안과 선택적 정보를 나타낼 뿐이에요.

Ether의 양 제한 (Restrict the Amount of Ether)

스마트 컨트랙트에 저장할 수 있는 Ether(또는 다른 토큰)의 양을 제한해요. 소스 코드, 컴파일러 또는 플랫폼에 버그가 있으면 이 자금이 손실될 수 있어요. 손실을 제한하고 싶다면 Ether의 양을 제한해요.

작고 모듈화되게 유지하기 (Keep it Small and Modular)

컨트랙트를 작고 쉽게 이해할 수 있게 유지해요. 관련 없는 기능은 다른 컨트랙트나 라이브러리로 분리해요. 소스 코드 품질에 대한 일반적인 권장 사항은 당연히 적용돼요. 지역 변수 개수, 함수 길이 등을 제한해요. 의도가 무엇이고 코드가 하는 것과 다른지 다른 사람이 볼 수 있도록 함수를 문서화해요.

Checks-Effects-Interactions 패턴 사용 (Use the Checks-Effects-Interactions Pattern)

대부분의 함수는 먼저 몇 가지 검사를 수행하고, 그것들이 먼저 되어야 해요(누가 함수를 호출했는지, 인자가 범위 안인지, 충분한 Ether를 보냈는지, 그 사람이 토큰을 가지고 있는지 등). 두 번째 단계로, 모든 검사가 통과했다면 현재 컨트랙트의 상태 변수에 대한 효과를 만들어야 해요. 다른 컨트랙트와의 상호작용은 어떤 함수에서도 가장 마지막 단계여야 해요.

초창기 컨트랙트는 일부 효과를 지연시키고 외부 함수 호출이 오류 없는 상태로 돌아오기를 기다렸어요. 위에서 설명한 재진입 문제 때문에 종종 심각한 실수예요. 알려진 컨트랙트에 대한 호출도 결과적으로 알려지지 않은 컨트랙트에 대한 호출을 일으킬 수 있으므로, 그냥 항상 이 패턴을 적용하는 것이 아마 더 좋아요.

페일세이프 모드 포함 (Include a Fail-Safe Mode)

시스템을 완전히 분산화하면 중개자를 제거하지만, 특히 새 코드에서는 일종의 페일세이프 메커니즘을 포함하는 것이 좋은 생각일 수 있어요. 스마트 컨트랙트에 "Ether이 새지 않았는가?", "토큰의 합이 컨트랙트의 잔액과 같은가?" 같은 자체 검사를 수행하는 함수를 추가할 수 있어요. 거기에 너무 많은 가스를 사용할 수 없으므로, 체인 밖 계산의 도움이 필요할 수 있다는 점을 명심하세요. 자체 검사가 실패하면 컨트랙트는 자동으로 어떤 종류의 "failsafe" 모드로 전환되는데, 예를 들어 대부분의 기능을 비활성화하고, 고정되고 신뢰할 수 있는 제3자에게 제어권을 넘기거나, 컨트랙트를 간단한 "나에게 Ether를 돌려줘" 컨트랙트로 변환해요.

동료 검토 요청 (Ask for Peer Review)

코드 조각을 더 많은 사람이 검토할수록 더 많은 문제가 발견돼요. 코드를 검토해 달라고 요청하는 것은 코드가 이해하기 쉬운지 알아내는 교차 검사로도 도움이 되는데, 이것은 좋은 스마트 컨트랙트의 매우 중요한 기준이에요.

더 알아보기 (Learn more)