[EVM] Smart Contract 작성시 유의사항

birdgang
21 min readApr 2, 2024

--

Reentrancy 공격방지

스마트 컨트랙트 가 외부 컨트랙트 에 호출을 할 때 발생할 수 있는 보안 취약점 입니다. 이 공격은 외부 컨트랙트가 컨트랙트 의 함수를 재귀적으로 다시 호출함으로써 발생 합니다, 특히 금융 관련 컨트랙트(예: 지갑, 거래소)에서 자금을 도난당하는 결과를 초래할 수 있습니다.

공격 매커니즘

1. 스마트 컨트랙트 A가 특정 조건하에 외부 컨트랙트 B 에게 이더 를 전송하는 함수를 포함하고 있다.
2. 컨트랙트 B는 이더를 받는 순간(예: fallback 함수나 receive 함수 내) 컨트랙트 A의 함수를 다시 호출할 수 있다.
3. 만약 컨트랙트 A가 호출이 완료되기 전에 자신의 상태를 업데이트하지 않는다면, B는 A의 취약한 함수를 반복해서 호출하여 A에 보관된 이더 전체를 빼낼 수 있다.


Reentrancy 공격을 방지하기 위한 방법은 여러 가지가 있습니다.
가장 널리 알려진 방법은 '체크-효과-상호작용' 패턴을 사용하는 것이고, ReentrancyGuard 모디파이어 를 사용하는 것도 효과적 입니다.

체크-효과-상호작용 패턴
이 패턴은 모든 조건 체크(체크), 상태 업데이트(효과)를 외부 호출(상호작용)보다 먼저 실행하는 것을 말합니다.
이 방법은 외부 호출로 인한 재진입을 방지하여 안전한 실행 순서를 보장해.

예제

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

// 취약한 컨트랙트 예시
contract VulnerableBank {
mapping(address => uint) public balances;

// 입금
function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 출금 - Reentrancy 공격에 취약
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");

(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");

balances[msg.sender] = 0;
}
}



// ReentrancyGuard를 사용하여 공격 방지
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
mapping(address => uint) public balances;

function deposit() public payable {
balances[msg.sender] += msg.value;
}

// nonReentrant 모디파이어 사용
function withdraw() public nonReentrant {
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");

balances[msg.sender] = 0;

(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
}

취약한 예시 에서는 withdraw 함수가 외부 호출(msg.sender.call) 이후 상태를 업데이트하는데, 이는 재진입 공격으로 인해 자금이 여러 번 인출될 수 있음 을 의미합니다.
반면, SafeBank 컨트랙트는 ReentrancyGuard를 상속받고, withdraw 함수에 nonReentrant 모디파이어를 적용 하여 이러한 공격을 방지합니다. 상태 업데이트가 외부 호출보다 먼저 일어나므로 안전한 출금이 보장 됩니다.

오버플로 와 언더플로 방지

오버플로(overflow)와 언더플로(underflow)는 컴퓨터 프로그래밍에서 변수가 허용하는 최대값을 초과하거나 최소값보다 작아질 때 발생하는 현상 입니다. 스마트 컨트랙트에서 는 주로 정수형 변수에 대한 산술 연산 시 이러한 문제가 나타날 수 있습니다.


오버플로(Overflow)

오버플로는 변수에 할당된 값이 해당 변수가 저장할 수 있는 최대값을 초과할 때 발생 합니다.
예를 들어, uint8 타입의 변수는 0에서 255까지의 값을 저장할 수 있습니다.
만약 이 변수에 255를 초과하는 값을 더하려고 시도한다면, 오버플로가 발생하며, 예상치 못한 결과가 나타날 수 있습니다.



언더플로(Underflow)

언더플로는 변수에 할당된 값이 해당 변수가 저장할 수 있는 최소값보다 작아질 때 발생합니다.
uint 타입의 변수를 사용하는 경우, 이 변수의 값은 0 이상이어야 하는데, 0에서 1을 빼면 언더플로가 발생합니다.
값은 uint 타입에서 가능한 최대값으로 설정됩니다.

예제

pragma solidity ^0.7.0;

contract OverflowExample {
uint8 public myUint8;

// 오버플로 발생 함수
function add(uint8 _value) public {
myUint8 += _value; // myUint8 값이 255를 초과하면 오버플로 발생
}

// 언더플로 발생 함수
function subtract(uint8 _value) public {
myUint8 -= _value; // myUint8가 0보다 작아지면 언더플로 발생
}
}




pragma solidity ^0.8.0;

contract SafeMathExample {
uint8 public myUint8;

// 오버플로 시도 시 revert 발생
function add(uint8 _value) public {
myUint8 += _value;
}

// 언더플로 시도 시 revert 발생
function subtract(uint8 _value) public {
myUint8 -= _value;
}
}

Solidity 0.8.0 이후 버전에서는 내장된 오버플로 와 언더플로 체크 덕분에, 이러한 문제가 자동으로 감지되고, 오류(revert)를 발생시켜. 이는 추가적인 보안 라이브러리 없이도 스마트 컨트랙트가 더 안전하게 작동하게 합니다.

가스 한도와 루프

이더리움 스마트 컨트랙트에서 가스는 계산 리소스의 양을 측정하는 단위 입니다. 모든 트랜잭션과 스마트 컨트랙트 실행에는 가스가 소모되고, 이는 이더리움 네트워크를 이용하여 계산을 수행하는 데 필요한 비용을 나타 냅니다. 가스 한도(Gas Limit)는 트랜잭션이나 컨트랙트 함수 호출 시 사용할 수 있는 최대 가스 양을 지정하는데, 이 한도를 초과하면 “out of gas” 오류가 발생하고 실행이 중단 됩니다.

루프(Loops)를 사용할 때 가스 소모는 특히 주의해야 할 부분 입니다. 복잡한 루프나 대량의 데이터를 처리하는 루프는 많은 양의 가스를 소모할 수 있고, 가스 한도를 쉽게 초과할 수 있습니다. 따라서, 스마트 컨트랙트 를 작성할 때는 루프를 효율적으로 사용하고 가스 소모를 최적화 해야 합니다.

예제


// 가스 소모를 고려하지 않고 루프를 사용한 스마트 컨트랙트 예
pragma solidity ^0.8.0;

contract GasGuzzler {
// 사용자의 예치금을 추적하는 매핑
mapping(address => uint) public balances;

// 예치금을 받는 함수
function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 모든 사용자의 예치금을 0으로 초기화 (가스 소모가 매우 큼)
// resetBalances 함수는 입력으로 받은 모든 사용자의 예치금을 0으로 초기화하는데,
// 사용자 수가 매우 많을 경우 가스 한도를 초과하여 트랜잭션이 실패할 수 있습니다.
function resetBalances(address[] calldata users) public {
for (uint i = 0; i < users.length; i++) {
balances[users[i]] = 0;
}
}
}




pragma solidity ^0.8.0;

contract GasSaver {
mapping(address => uint) public balances;

function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 사용자가 자신의 잔액을 리셋
function resetMyBalance() public {
balances[msg.sender] = 0;
}
}

가시성 지정자

스마트 컨트랙트 에서 함수와 상태 변수의 가시성 지정자는 해당 함수나 변수가 어디서 접근 될 수 있는지를 정의 합니다. 이는 스마트 컨트랙트 의 보안과 구조를 결정하는 중요한 부분 입니다. Solidity 에서는 주로 public, external, internal, private 네 가지 종류의 가시성 지정자를 사용 합니다.

  1. public : 함수나 변수가 어디에서든지 접근 가능함을 의미 합니다. 상태 변수에 public 지정자를 사용하면 Solidity 는 자동으로 getter 함수를 생성 합니다.
  2. external : 함수가 오직 외부에서만 호출 될 수 있음을 의미 합니다.(예: 다른 컨트랙트나 트랜잭션을 통해). 이 지정자는 함수 에만 사용할 수 있습니다.
  3. internal : 함수나 변수가 해당 컨트랙트와 상속받은 자식 컨트랙트 내에서만 접근 가능함을 의미 합니다.
  4. private : 함수나 변수가 오직 그 함수나 변수가 정의된 컨트랙트 내에서만 접근 가능함을 의미 합니다. 상속받은 자식 컨트랙트에서도 접근할 수 없습니다.

예제

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

contract VisibilityExample {
// private 상태 변수는 해당 컨트랙트 내부에서만 접근 가능
uint private privateCounter = 0;

// internal 상태 변수는 이 컨트랙트와 상속받은 컨트랙트에서 접근 가능
uint internal internalCounter = 0;

// public 상태 변수는 어디서든 접근 가능, getter 함수 자동 생성
uint public publicCounter = 0;

// private 함수는 해당 컨트랙트 내부에서만 호출 가능
function incrementPrivateCounter() private {
privateCounter += 1;
}

// internal 함수는 이 컨트랙트와 상속받은 컨트랙트 내부에서 호출 가능
function incrementInternalCounter() internal {
internalCounter += 1;
}

// external 함수는 오직 외부에서만 호출 가능
function incrementPublicCounter() external {
publicCounter += 1;
}

// public 함수는 어디서든 호출 가능
function incrementCounters() public {
incrementPrivateCounter();
incrementInternalCounter();
// 외부 함수는 이렇게 직접 호출할 수 없음: incrementPublicCounter();
publicCounter += 1; // 대신 상태 변수를 직접 변경
}
}

// 상속을 통해 internal 변수와 함수에 접근
contract ChildContract is VisibilityExample {
function incrementInternalFromChild() public {
incrementInternalCounter(); // internal 함수 호출 가능
internalCounter += 1; // internal 변수 직접 접근 가능
}
}

이 예시에서 privateCounterVisibilityExample 컨트랙트 내에서만 접근하고 수정할 수 있습니다. internalCounterVisibilityExample과 이를 상속 받은 ChildContract에서 접근하고 수정할 수 있습니다. publicCounter는 어디서든 접근할 수 있으며, incrementPublicCounter 함수는 외부에서만 호출할 수 있습니다.

체크-효과-상호작용

체크-효과-상호작용(Checks-Effects-Interactions) 패턴은 스마트 컨트랙트 개발에서 권장되는 설계 원칙 중 하나 입니다. 이 패턴의 목적은 재진입(reentrancy) 공격과 같은 보안 취약점을 방지하는 것 입니다. 이를 구현 함으로써 함수 호출이 예상대로 안전하게 실행되도록 할 수 있습니다. 패턴의 이름처럼 세 부분으로 나눌 수 있습니다.

  1. 체크(Checks) : 모든 조건 검사를 먼저 수행 합니다. 이는 함수가 실행되기 전에 필요한 모든 요구 사항이 충족 되었는지 확인 합니다.
  2. 효과(Effects) : 상태 변수의 변경과 같은 모든 효과를 적용 합니다. 이 단계에서는 스마트 컨트랙트의 상태를 변경 합니다.
  3. 상호작용(Interactions) : 외부 컨트랙트와의 상호작용을 수행 합니다. 이 단계는 가장 마지막에 이루어져야 합니다. 왜냐하면 외부 호출을 통해 컨트랙트의 함수가 재진입 될 수 있는 가능성을 줄 이기 위해서 입니다.

예제

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

contract SimpleBank {
mapping(address => uint) public balances;

// 예치금을 받는 함수
function deposit() public payable {
balances[msg.sender] += msg.value;
}

// 체크-효과-상호작용 패턴을 사용하여 예치금 인출
function withdraw(uint _amount) public {
// 체크: 사용자가 충분한 잔액을 가지고 있는지 확인
require(balances[msg.sender] >= _amount, "Insufficient balance");

// 효과: 잔액을 먼저 감소시킴
balances[msg.sender] -= _amount;

// 상호작용: 사용자에게 이더 전송
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}

이 코드에서는 먼저 사용자의 잔액이 인출 하려는 금액보다 큰지 확인하는 체크 단계가 이루어 지고 다음으로, 사용자의 잔액을 감소시키는 효과 단계가 실행 됩니다. 마지막으로, 실제로 이더 를 사용자에게 전송하는 상호작용 단계가 진행 됩니다. 이러한 순서로 실행 함으로써, 재 진입 공격의 위험을 줄일 수 있습니다.

시크릿과 개인 키 관리

스마트 컨트랙트 를 작성 할 때 시크릿(비밀 정보)이나 개인 키의 안전한 관리는 매우 중요 합니다. 스마트 컨트랙트 코드는 블록체인 에 영구적으로 기록되며, 누구나 볼 수 있어서, 코드 내에 시크릿 이나 개인 키를 직접 저장하는 것은 매우 위험 합니다. 이러한 정보가 노출되면, 악의적인 사용자가 이를 이용하여 자금을 도난당하거나, 데이터를 조작할 수 있습니다.

개인 키 관리

  • 절대 불가 : 스마트 컨트랙트 내에 개인 키를 직접 저장하는 것은 절대로 해서는 안 됩니다. 개인 키는 사용자만이 알고 있어야 하며, 안전한 외부 저장소에 저장되어야 합니다.
  • 외부 서비스 활용 : 블록체인 외부의 안전한 서버나 서비스를 이용하여 시크릿을 관리할 수 있습니다. 예를 들어, 서명된 메시지를 스마트 컨트랙트 로 전송하여 인증을 수행할 수 있습니다.

예제

pragma solidity ^0.8.0;

contract VerifySignature {
// 메시지를 서명한 주소를 복구하는 함수
function recoverSigner(bytes32 message, bytes memory sig)
public
pure
returns (address)
{
uint8 v;
bytes32 r;
bytes32 s;

(v, r, s) = splitSignature(sig);

// ecrecover 함수를 사용하여 서명자 주소를 복구
return ecrecover(message, v, r, s);
}

// 서명을 분리하는 함수
function splitSignature(bytes memory sig)
internal
pure
returns (uint8, bytes32, bytes32)
{
require(sig.length == 65, "Invalid signature length");

bytes32 r;
bytes32 s;
uint8 v;

assembly {
// 첫 32바이트를 r로, 다음 32바이트를 s로, 마지막 바이트를 v로 저장
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}

return (v, r, s);
}
}

이 예시 에서는 사용자가 메시지에 서명하고, 이 서명을 스마트 컨트랙트 로 전송하면, 스마트 컨트랙트가 ecrecover 함수를 사용하여 서명자의 주소를 복구하고, 이를 통해 서명이 유효한지 를 검증 합니다. 이 방법은 사용자의 개인 키를 직접 요구하지 않으면서 사용자의 인증을 안전하게 수행할 수 있는 방법 입니다.

Delegatecall 사용 주의

delegatecall은 스마트 컨트랙트 에서 다른 컨트랙트의 코드 를 현재 컨트랙트의 컨텍스트(상태 변수, 이더 밸런스 등)와 함께 실행 할 수 있게 해주는 낮은 수준의 함수 호출 방식 입니다. 이 기능은 라이브러리 코드와 같은 재사용 가능한 코드를 실행 하거나 업그레이더블 컨트랙트 를 구현 할 때 유용하게 사용 되지만, 잘못 사용될 경우 심각한 보안 취약점을 초래 할 수 있습니다.

  • 상태 변수 레이아웃 일치 : 호출 되는 컨트랙트 와 호출하는 컨트랙트 간에 상태 변수의 레이아웃이 일치 해야 합니다. 그렇지 않으면 예기치 않은 방식으로 상태 변수가 변경 될 수 있습니다.
  • 위임된 컨트랙트의 신뢰성 : delegatecall을 사용하면 호출된 컨트랙트가 호출 하는 컨트랙트의 상태를 변경 할 수 있기 때문에, 신뢰할 수 있는 소스로부터 코드를 실행해야 합니다.
  • 데이터 노출 : delegatecall을 통해 실행 되는 코드는 호출하는 컨트랙트 의 전체 상태에 접근할 수 있으므로, 민감한 데이터가 노출될 위험이 있습니다.

예제

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

// 라이브러리 컨트랙트
contract LibraryContract {
// 이 함수는 CallerContract의 상태를 업데이트할 수 있음
function updateData(uint newData) public {
// 상태 업데이트 로직...
}
}

// 호출자 컨트랙트
contract CallerContract {
uint public data;
address public libraryAddress; // 라이브러리 컨트랙트의 주소

constructor(address _libraryAddress) {
libraryAddress = _libraryAddress;
}

function updateData(uint newData) public {
// LibraryContract의 updateData 함수를 delegatecall을 통해 호출
(bool success, ) = libraryAddress.delegatecall(
abi.encodeWithSignature("updateData(uint256)", newData)
);
require(success, "Delegatecall failed");
}
}

이 예시에서 CallerContractupdateData 함수는 delegatecall을 사용하여 LibraryContractupdateData 함수를 호출 합니다. 이 때, LibraryContract의 함수는 CallerContractdata 상태 변수를 업데이트할 수 있습니다. delegatecall은 호출하는 컨트랙트의 상태를 유지하면서 코드를 실행하기 때문에, LibraryContract의 상태는 변경되지 않습니다.

블록 타임스탬프 조작 가능성

채굴자는 블록을 마이닝 할 때 현재 시간의 정확한 타임 스탬프 를 포함시키는 것이 일반적이지만, 몇 초 정도의 차이는 네트워크에서 허용 됩니다. 이는 대부분의 경우 문제가 되지 않지만, 타임스탬프 에 기반한 랜덤넘버 생성과 같이, 타임 스탬프의 정확도가 중요한 로직 에서는 채굴자가 이를 악용할 가능성이 있습니다.

예제

pragma solidity ^0.8.0;

contract RandomNumberGenerator {
uint private seed;

constructor() {
seed = (block.timestamp + block.difficulty) % 100;
}

// 블록 타임스탬프를 사용한 랜덤 숫자 생성
function generateRandomNumber() public view returns (uint) {
return uint(keccak256(abi.encodePacked(block.timestamp, seed))) % 100;
}
}

이 컨트랙트는 generateRandomNumber 함수를 호출 할 때마다, 현재 블록 타임스탬프와 초기 seed 값을 기반으로 새로운 랜덤 숫자를 생성 합니다. 하지만, 타임 스탬프를 조작할 수 있는 채굴자가 결과에 영향을 줄 수 있기 때문에, 이러한 방식의 랜덤 넘버 생성은 높은 보안이 요구되는 애플리케이션(예: 로또, 게임)에서 사용 하기에는 적합 하지 않습니다. 보다 안전한 대안으로는 온체인과 오프체인 데이터의 조합, 또는 오프체인에서 생성된 랜덤넘버를 스마트 컨트랙트 로 가져오는 방법을 고려해볼 수 있습니다.

코드 감사와 테스트

스마트 컨트랙트 의 코드 감사와 테스트는 보안 취약점을 식별하고, 예상치 못한 동작을 방지하기 위해 필수적인 과정 입니다. 감사는 코드의 검토와 분석을 통해 진행되며, 테스트는 컨트랙트 가 예상대로 작동 하는지 확인 하기 위해 자동화된 테스트 스크립트를 사용 합니다.

예제

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

contract SimpleStorage {
uint256 private _value;

function setValue(uint256 value) public {
_value = value;
}

function getValue() public view returns (uint256) {
return _value;
}
}
  1. 새 프로젝트 디렉토리 를 만들고, 해당 디렉토리 에서 npm init -y를 실행 하여 package.json 파일을 생성 합니다.
  2. Hardhat을 설치 하려면, npm install --save-dev hardhat를 실행 합니다.
  3. 프로젝트를 설정 하려면, npx hardhat를 실행하고 지시에 따라 기본 프로젝트를 생성 합니다.
  4. contracts 폴더에 위의 SimpleStorage.sol 컨트랙트를 저장 합니다.
const { expect } = require("chai");

describe("SimpleStorage", function () {
it("Should return the new value once it's changed", async function () {
const SimpleStorage = await ethers.getContractFactory("SimpleStorage");
const simpleStorage = await SimpleStorage.deploy();
await simpleStorage.deployed();

await simpleStorage.setValue(42);

expect(await simpleStorage.getValue()).to.equal(42);
});
});

이 테스트 스크립트는 Hardhat의 ethers 플러그인을 사용하여 스마트 컨트랙트 와 상호작용 합니다.

  1. SimpleStorage 컨트랙트를 배포 합니다.
  2. setValue 함수를 호출 하여 값 42를 저장 합니다.
  3. getValue 함수를 호출 하여 저장된 값을 검색하고, 기대한 값(42)이 맞는지 확인 합니다.

테스트를 실행 하기 위해선 터미널에서 프로젝트의 루트 디렉토리 로 이동한 후 npx hardhat test 명령을 실행 합니다. 이 명령은 test 폴더 내의 모든 테스트 케이스를 실행하고, 결과를 출력 합니다.

--

--