본문으로 건너뛰기
0%

Solidity 스마트 컨트랙트 보안 모범 사례 12가지

Usman Asim headshot

작성자 Usman Asim

2025년 11월 13일에 게시됨11분 읽기

코드 보안을 나타내는 추상적인 방패

스마트 컨트랙트는 블록체인 마법 뒤에 숨은 요술사다. 온체인 로직을 자동화하는 코드 블록으로, 탈중앙화 대출 프로토콜의 청산 임계값부터 실물 자산의 토큰화까지 온갖 것을 명시한다.

하지만 큰 힘에는 큰 책임이 따른다. 특히 보안 측면에서 그렇다. 버그 하나가 대규모 익스플로잇으로 이어질 수 있으며, 자신의 프로젝트가 수백만 달러 규모 해킹의 다음 헤드라인이 되길 바라는 사람은 없다.

2025년 상반기에만 23억 달러 규모의 암호화폐가 익스플로잇과 침해로 유출됐으며, 그 중 접근 제어 문제로 인한 손실만 16억 달러가 넘는다. Ethereum이나 유사 체인에서 Solidity로 개발하고 있다면, 보안을 제대로 갖추는 것은 선택 사항이 아니다. 사용자 자금을 지키고 평판을 온전히 유지하는 데 필수적이다.

이 글에서는 스마트 컨트랙트 보안이 실제로 무엇을 의미하는지 짚어보고, 가장 큰 컨트랙트 취약점들을 살펴본 뒤, 코드를 견고하게 만들고 mainnet에 안전한 컨트랙트를 배포하는 데 도움이 되는 실전 모범 사례를 코드 예제와 함께 소개한다.

그리고 항상 기억할 것: mainnet 배포 전에는 반드시 testnet에서 먼저 테스트하라. 사용자가 컨트랙트로 자금을 보내기 시작하기 전에 취약점을 찾아내는 편이 훨씬 낫다.

스마트 컨트랙트 보안이란?

스마트 컨트랙트는 특정 조건이 충족되면 실행되는 자체 실행 코드다. 중개자 없이 전송, 투표, 심지어 복잡한 DeFi 작업까지 처리하는 자동화된 계약이라 생각하면 된다. 블록체인에 바이트코드로 배포되며, 한 번 실행되면 불변이기 때문에 컨트랙트에 있는 결함은 누구나 볼 수 있게 공개된다.

여기서 보안이란 결국 코드가 견고한지 확인하는 것으로 귀결되는데, 컨트랙트 자체가 온체인 자산을 보관하는 경우가 많기 때문이다. 해커가 특정 컨트랙트에서 자금을 빼낼 수 있는가? 이상한 엣지 케이스에서도 컨트랙트 로직이 제대로 작동하는가? 자산이 무단 접근으로부터 안전하게 잠겨 있는가?

이런 질문들에 대한 답에 취약점이 있다면, 악의적인 행위자가 컨트랙트에서 사용자 자금을 빼내 가져갈 수 있다는 뜻이다. 이런 취약점으로부터 보호하려면 입력값 검증, 설계 패턴, 코드 감사를 도입해 mainnet 배포 전에 코드를 강화해야 한다.

더 깊이 있는 소개는 Ethereum 문서의 스마트 컨트랙트 보안 페이지를 참고하라.

왜 보안이 당신에게 중요한가

안전하지 않은 컨트랙트를 만드는 것은 사용자에게 위험할 뿐 아니라, 프로젝트를 하룻밤 사이에 망가뜨릴 수도 있다. 프로젝트에서 자금이 빠져나가면 사용자는 신뢰를 잃고, 대부분 돌아오지 않는다. 블록체인의 비가역성은 자금이 사라지면 그걸로 끝이라는 뜻이다. 여기엔 지불 취소 같은 게 없다. 자금이 순식간에 사라질 수 있는 것처럼, 평판도 마찬가지다.

수십억 달러가 온체인에서 오가는 만큼, 해커들은 항상 약점을 찾고 있다. 최근 수치는 그 위험성을 보여준다. 2025년 상반기에만 23억 달러가 손실됐다. 그 손실의 대부분은 부실한 접근 제어와 재진입 같은 예방 가능한 버그에서 비롯됐다. 보안을 우선시함으로써 사용자를 보호하고, 신뢰를 쌓고, 단 한 번의 트랜잭션으로 프로젝트가 0이 되는 악몽을 피할 수 있다.

시중에 나와 있는 도구와 모범 사례를 활용하면, 기능적일 뿐 아니라 견고한 코드를 작성할 수 있는 방법이 많다.

스마트 컨트랙트 취약점 이해하기

스마트 컨트랙트 취약점을 이해하는 데 유용한 자료 중 하나가 OWASP(Open Web Application Security Project의 약자)다. 전 세계 소프트웨어 보안 개선을 위한 무료 자료를 만드는 비영리 단체다. 이들의 Smart Contract Top 10은 실제 익스플로잇 사례와 수십억 달러 손실을 초래한 사고 데이터를 기반으로 한, 블록체인 앱에서 가장 위험한 취약점 목록이라 할 수 있다.

이 목록은 여러 생태계의 해킹 패턴을 바탕으로, 감사와 컨트랙트 설계에서 우선적으로 신경 써야 할 취약점 순위를 개발자에게 제공한다. 2025년판은 작년 한 해에만 9억 5,300만 달러의 손실을 초래한 접근 결함 같은 위험을 강조한다. 코드를 검토할 때 이런 흔한 취약점들을 의식하는 것은 코드의 견고성을 쌓아가는 좋은 출발점이 된다.

  1. 접근 제어 취약점: 권한 확인 누락 등으로 인해 인가되지 않은 사람이 데이터나 함수를 조작할 수 있게 되는 결함. 이 문제가 목록 최상위에 있는 데는 이유가 있다. 탈취된 관리자 키가 컨트랙트를 비우는 걸 생각해보라. 단순하지만 효과적이고 흔하다.
  2. 가격 오라클 조작: 해커가 외부 데이터 피드(오라클)를 조작해 가격을 왜곡할 수 있으며, 이는 잘못된 대출이나 거래로 이어진다. 정확한 가격이 중요한 DeFi에서 흔하다.
  3. 로직 오류: 컨트랙트가 의도치 않은 동작을 하는 버그. 토큰을 추가로 발행하거나 보상을 잘못 계산하는 것 등이 있다. 모든 엣지 케이스를 다뤄야 하는 취약점으로, 컨트랙트 로직에 대한 깊은 감사가 필요하다.
  4. 입력 검증 부재: 사용자 입력을 확인하지 않으면 잘못된 데이터가 로직을 깨뜨리거나 오버플로우를 유발할 수 있다.
  5. 재진입 공격: 외부 호출로 인해 해커가 실행 도중 함수에 다시 진입해 여러 번 출금하는 등의 일이 발생할 수 있다.
  6. 검증되지 않은 외부 호출: 실패한 호출을 제대로 처리하지 않으면 컨트랙트가 잘못된 가정 하에 계속 진행될 수 있다.
  7. 플래시 론 공격: 즉시 대출을 악용해 단일 트랜잭션 안에서 시장이나 프로토콜을 조작할 수 있다.
  8. 정수 오버플로우와 언더플로우: 숫자가 한계를 넘어 순환할 때 발생하는 계산 오류로, 잘못된 계산이나 자금 탈취로 이어질 수 있다.
  9. 안전하지 않은 난수 생성: 예측 가능한 부실한 RNG는 게임이나 추첨에서 악용될 수 있다.
  10. 서비스 거부(DoS) 공격: 가스를 많이 소모하는 연산으로 컨트랙트를 과부하시켜 사용 불가능하게 만든다.

OWASP의 전체 세부 내용은 Smart Contract Top 10 페이지를 참고하라. Ethereum 특화 팁은 보안 가이드라인을 참고하라.

스마트 컨트랙트 보안 모범 사례 12가지

이제 위협 환경을 이해했으니, 실전 방어책을 살펴보자. OWASP 목록의 각 취약점에는 수천 개 컨트랙트에서 검증된 대응 모범 사례가 존재한다.

아래 섹션들은 이런 흔한 결함들을 하나씩 짚어보며, 취약한 패턴과 안전한 구현을 모두 보여주는 구체적인 코드 예제를 제시한다. 이를 방어 플레이북으로 생각하라. 일관되게 적용하면 공격 표면을 줄일 수 있는 간단한 기법들이다.

1. delegatecall를 신중하게 사용하라

Delegatecall은 한 컨트랙트가 자신의 스토리지를 사용하면서 다른 컨트랙트의 코드를 실행하게 해준다. 라이브러리나 업그레이드에 매우 유용하지만, 호출된 코드가 변수를 건드리면 예상치 못한 상태 변화로 이어질 수 있어 위험하다. 해커가 소유자 주소 같은 핵심 상태를 덮어쓰거나 자금을 빼내는 악성 로직을 주입하는 대형 익스플로잇의 배후에 있다.

위험한 점은 delegatecall가 호출하는 컨트랙트의 컨텍스트(msg.sender, msg.value, 스토리지)를 그대로 유지한다는 것이다. 따라서 외부 코드가 완전한 권한으로 실행된다. 반드시 필요할 때만 사용하고, 컨트랙트 간 스토리지 레이아웃이 완벽히 일치하는지 확인해야 한다. 슬롯이 어긋나면 데이터가 손상될 수 있다.

취약한 예제:

solidity
Copied
contract LibraryContract { address public owner; // Slot 0 function updateOwner() public { owner = msg.sender; // Changes caller's storage slot 0! } } contract VulnerableProxy { uint256 public value; // Slot 0 - MISMATCH! address public owner; // Slot 1 function delegateCall(address lib) public { // DANGER: updateOwner() will overwrite 'value', not 'owner'! lib.delegatecall(abi.encodeWithSignature("updateOwner()")); } }

더 안전한 접근:

solidity
Copied
contract SecureProxy { address public implementation; address public owner; modifier onlyOwner() { require(msg.sender == owner, "Not owner"); _; } function execute(bytes memory data) public onlyOwner { (bool success,) = implementation.delegatecall(data); require(success, "Execution failed"); } }

모범 사례: OpenZeppelin의 업그레이드 가능 컨트랙트 같은 검증된 프록시 패턴을 사용하고, 버전 간 스토리지 레이아웃을 동일하게 유지하며(변수 순서나 타입을 절대 바꾸지 말 것), delegatecall는 신뢰할 수 있는 감사된 컨트랙트에만 제한하고, 내부적으로 delegatecall을 안전하게 사용하는 library 키워드가 있는 라이브러리 사용을 고려하며, delegatecall에 사용자가 제어하는 주소를 절대 허용하지 마라. 그것은 즉각적인 탈취 벡터다.

2. 재진입 가드를 사용하라

재진입은 외부 호출이 함수의 상태 업데이트가 완료되기 전에 제어권을 넘길 때 발생하며, 호출자가 다시 들어와 잔액 업데이트 전에 출금 같은 동작을 반복할 수 있게 한다. OWASP의 Web3 위험 순위에서 5위를 차지하며 악명 높은 DAO 해킹(6천만 달러 손실) 같은 역대 최대 규모 해킹의 일부를 일으켰다.

공격자는 자금 전송과 상태 업데이트 사이의 이 재진입 창을 악용해, 출금 함수를 재귀적으로 호출함으로써 컨트랙트를 비울 수 있다. 외부 컨트랙트는 항상 적대적이라고 가정해야 한다.

취약한 예제:

solidity
Copied
contract VulnerableBank { mapping(address => uint256) public balances; function withdraw() public { uint256 bal = balances[msg.sender]; require(bal > 0); // DANGER: Sends Ether before updating balance! (bool sent,) = msg.sender.call{value: bal}(""); require(sent); balances[msg.sender] = 0; // Too late - already reentered! } } contract Attacker { VulnerableBank bank; fallback() external payable { if (address(bank).balance >= 1 ether) { bank.withdraw(); // Calls withdraw again! } } function attack() external payable { bank.withdraw(); // Starts the loop } }

안전한 구현:

solidity

`import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureBank is ReentrancyGuard { mapping(address => uint256) public balances;

solidity
Copied
import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract SecureBank is ReentrancyGuard { mapping(address => uint256) public balances; function withdraw() public nonReentrant { uint256 bal = balances[msg.sender]; require(bal > 0); // Update state BEFORE external call balances[msg.sender] = 0; (bool sent,) = msg.sender.call{value: bal}(""); require(sent); } }

모범 사례: OpenZeppelin의 ReentrancyGuard modifier를 사용하고, Checks-Effects-Interactions 패턴(검증 → 상태 업데이트 → 외부 호출)을 따르며, 사용자가 직접 자금을 인출하는 pull-over-push 패턴을 고려하라.

3. 인증에는 tx.origin 대신 msg.sender을 사용하라

tx.origin 호출은 원래 트랜잭션을 시작한 주체까지 거슬러 올라가는 반면, msg.sender은 직접적인 호출자를 가리킨다. 이 구분은 보안에서 매우 중요하다. 다중 컨트랙트 호출 체인에서 tx.origin는 그대로 유지되지만, 악의적인 중개 컨트랙트가 코드를 속여 원래 사용자가 인가받은 것처럼 착각하게 만들 수 있다.

공격자는 자신의 함수를 호출하는 피싱 컨트랙트를 만들 수 있는데, tx.origin를 확인하면 트랜잭션을 시작한 피해자를 가리켜 인증을 완전히 우회하게 된다. msg.sender은 항상 직접적인 호출자이므로 접근 제어에 신뢰할 수 있다. tx.origin은 트랜잭션 원 시작자가 특별히 필요한 드문 경우에만 사용하고, 인증 용도로는 절대 사용하지 마라. 이 취약점은 OWASP 1위 위험인 접근 제어 결함과 직결된다.

취약한 예제:

solidity
Copied
contract VulnerableWallet { address public owner; constructor() { owner = msg.sender; } function transfer(address payable to, uint256 amount) public { require(tx.origin == owner, "Not owner"); // BAD! to.transfer(amount); } } // Attacker tricks owner into calling this contract MaliciousContract { function attack(address wallet) public { // When owner calls this, tx.origin == owner// So the wallet thinks the call is authorized! VulnerableWallet(wallet).transfer(payable(msg.sender), 1 ether); } }

안전한 구현:

solidity
Copied
contract SecureWallet { address public owner; constructor() { owner = msg.sender; } function transfer(address payable to, uint256 amount) public { require(msg.sender == owner, "Not owner"); // GOOD! to.transfer(amount); } }

중요한 이유: 소유자가 실수로 악성 컨트랙트와 상호작용하면(피싱 링크 클릭, 침해된 앱 사용 등), tx.origin이 여전히 소유자를 가리키기 때문에 그 컨트랙트가 취약한 지갑을 비울 수 있다. msg.sender를 사용하면 소유자 주소 자체만이 전송을 인가할 수 있다. 인증 및 접근 제어 확인에는 항상 msg.sender을 사용하라.

4. Solidity 가시성 modifier를 제대로 사용하라

가시성 modifier는 접근 경계를 정의한다: public(내부·외부 어디서든 호출 가능), external(컨트랙트 외부에서만), internal(이 컨트랙트와 상속받는 컨트랙트), private(엄격히 이 컨트랙트만). 이전 Solidity 버전에서는 modifier를 생략하면 기본값이 public로 설정되어, 민감한 함수가 실수로 외부 공격자에게 노출되곤 했다.

잘못된 가시성 설정으로 인해 해커가 관리자 함수를 호출하거나 제한되어야 할 핵심 상태 변수를 조작하는 익스플로잇이 발생해왔다. 항상 가시성을 명시적으로 선언하라. 이는 보안 관행이면서 동시에 가독성 향상이기도 하다.

solidity

`contract VulnerableBank { mapping(address => uint) balances;

solidity
Copied
contract VulnerableBank { mapping(address => uint) balances; // BAD: accidentally public in old Solidity function resetBalance(address user) { balances[user] = 0; // anyone can call this! } // GOOD: explicit visibility function deposit() external payable { balances[msg.sender] += msg.value; } function _updateInternal() internal { // only this contract + children } }

배포 전에 누락되거나 잘못된 가시성 modifier를 잡아내기 위해 Slither 같은 정적 분석 도구를 사용하라.

5. 블록 타임스탬프 조작을 피하라

검증자는 block.timestamp을 작은 범위(Ethereum에서 약 ±15초) 내에서 조작할 수 있어, 정밀한 타이밍이나 난수 소스로 사용하기엔 신뢰할 수 없다. 채굴자나 검증자는 자신에게 유리하게 타임스탬프를 조정해 지급을 유발하거나, 추첨에서 이기거나, 시간 기반 검증을 우회할 수 있다.

따라서 초 단위가 중요한 핵심 로직에는 block.timestamp을 절대 사용하지 말고, 난수 생성에는 절대 사용하지 마라. 대략적인 시간 추정("24시간이 지났나?" 같은)에는 괜찮지만, 정확한 조건에는 위험하다.

취약한 예제:

solidity
Copied
contract TimestampLottery { uint public lastPlay; fallback() external payable { require(msg.value == 10 ether); require(block.timestamp != lastPlay); lastPlay = block.timestamp; // BAD: validator can manipulate timestamp to win if (block.timestamp % 15 == 0) { payable(msg.sender).transfer(address(this).balance); } } }

타이밍에 더 나은 접근:

solidity
Copied
contract BlockBasedTiming { uint public startBlock = block.number; // Use block numbers instead (~12s per block on Ethereum) function isTimePassed(uint blocks) public view returns (bool) { return block.number >= startBlock + blocks; } }

난수의 경우 직접 만들지 마라. 대신 Chainlink VRF 같은, 암호학적으로 안전하고 조작 방지된 난수를 제공하는 검증 가능한 난수 함수 오라클을 사용하라.

6. 산술 오버플로우와 언더플로우를 피하라

0.8 이전 버전의 Solidity에서는 정수가 최댓값이나 최솟값을 초과하면 조용히 순환한다. 즉, uint8이 255에서 증가하면 0이 되거나, 0에서 감소하면 255가 된다.

이로 인해 공격자가 무한히 토큰을 발행하거나, 순환으로 거대한 값이 된 음수 잔액을 만들거나, 핵심 검증을 우회하는 등의 치명적인 익스플로잇이 발생해왔다.

취약한 예제(0.8 이전):

solidity
Copied
pragma solidity 0.7.0; contract UnsafeToken { mapping(address => uint8) public balances; function transfer(address to, uint8 amount) public { balances[msg.sender] -= amount; // Underflow: 0 - 1 = 255 balances[to] += amount; // Overflow: 255 + 1 = 0 } }

자동 오버플로우 방지를 위해 Solidity >=0.8로 업그레이드하라:

solidity
Copied
pragma solidity ^0.8.0; contract SafeToken { mapping(address => uint8) public balances; function transfer(address to, uint8 amount) public { balances[msg.sender] -= amount; // Reverts on underflow balances[to] += amount; // Reverts on overflow } }

오래된 Solidity에 묶여 있다면 OpenZeppelin의 SafeMath 라이브러리를 사용하라. 0.8 이상에서는 검증이 내장되어 있어 자동으로 revert되지만, 가스 최적화를 위해 의도적으로 순환 동작을 원하는 경우 unchecked \{\} 블록을 사용할 수 있다.

7. 견고한 접근 제어를 구현하라

접근 제어가 깨지면 인가되지 않은 사용자가 특권 함수를 실행할 수 있다. 이는 OWASP Web3 top 10에서 1위 취약점이며, 2025년 상반기에만 16억 달러 이상의 손실을 초래했다. 적절한 가드가 없으면 공격자는 자금을 빼내거나, 토큰을 발행하거나, 컨트랙트를 일시 중지하거나, 소유권을 변경할 수 있다.

흔한 실수로는 modifier 누락, 인증을 tx.origin에 의존하는 것, 업그레이드 과정에서 간과되는 단순한 require\(msg.sender == owner\) 확인 등이 있다. 해결책은 최소 권한 원칙을 적용한 역할 기반 접근 제어다. 각 주소에게 반드시 필요한 권한만 부여하라.

OpenZeppelin을 활용한 안전한 구현:

solidity
Copied
pragma solidity ^0.8.20; import "@openzeppelin/contracts/access/AccessControl.sol"; contract SecureVault is AccessControl { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(ADMIN_ROLE, msg.sender); } function withdrawFunds() public onlyRole(ADMIN_ROLE) { // Only admins can withdraw } function updateConfig() public onlyRole(OPERATOR_ROLE) { // Operators handle config, not full admin } }

역할 할당을 정기적으로 감사하고, 이해관계가 큰 변경에는 시간 지연 관리자 작업을 구현하고, 핵심 역할에는 다중 서명 지갑을 사용하라. 인증에는 tx.origin를 절대 사용하지 마라. 직접 만드는 대신 OpenZeppelin AccessControl 같은 검증된 라이브러리를 활용하라.

8. 오라클 통합을 조작으로부터 안전하게 지켜라

오라클은 블록체인과 실세계 데이터를 결합하지만, 중앙화되어 있거나 쉽게 조작될 수 있다면 공격자가 거짓 정보를 주입해 컨트랙트를 악용할 수 있다. 이 유형의 익스플로잇은 OWASP Web3 위험 순위 2위이며, DeFi 프로토콜에서 수억 달러를 빼내갔다.

플래시 론 공격은 종종 온체인 가격 오라클을 조작한다(단일 DEX를 가격 소스로 사용하는 경우 등). 이를 통해 해커는 가격을 인위적으로 부풀리거나 폭락시켜 포지션을 청산하거나, 유동성 풀을 비우거나, 담보가 부족한 대출을 발행할 수 있다. 단일 가격 소스나 한 트랜잭션 안에서 조작 가능한 스팟 가격에 절대 의존하지 마라.

Chainlink를 이용한 안전한 오라클 사용:

solidity
Copied
pragma solidity ^0.8.20; import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; contract SecurePriceFeed { AggregatorV3Interface internal priceFeed; uint256 private constant STALENESS_THRESHOLD = 3600; // 1 hour constructor(address _aggregator) { priceFeed = AggregatorV3Interface(_aggregator); } function getLatestPrice() public view returns (int) { ( uint80 roundId, int price, , uint256 updatedAt, uint80 answeredInRound ) = priceFeed.latestRoundData(); require(price > 0, "Invalid price"); require(answeredInRound >= roundId, "Stale price"); require(block.timestamp - updatedAt < STALENESS_THRESHOLD, "Price too old"); return price; } }

모범 사례: 여러 데이터 소스를 갖춘 Chainlink 같은 탈중앙화 오라클 네트워크를 사용하고, 핵심 작업에는 시간 가중 평균 가격(TWAP)을 구현하고, 최신성을 검증하고 가격 범위의 타당성을 확인하고, 가능하면 여러 오라클을 집계하라. 네트워크별 피드와 보안 고려사항은 Chainlink 문서를 확인하라.

9. 모든 입력을 철저히 검증하라

사용자 입력을 검증하지 않으면 공격자가 악성 데이터를 주입하거나, 예상치 못한 동작을 유발하거나, 코드의 엣지 케이스를 악용할 수 있다. OWASP Web3 취약점에서 4위이며, 자금 유출이나 컨트랙트 로직 파괴의 흔한 벡터다.

적절한 검증이 없으면 사용자는 수수료를 우회하기 위해 0 값을 전달하거나, 산술을 악용하기 위해 음수를 전달하거나, 0 주소나 악성 컨트랙트를 가리키는 주소를 전달하거나, 범위를 벗어난 배열 인덱스로 out-of-bounds 접근을 유발할 수 있다. 모든 외부 입력은 안전이 증명되기 전까지는 신뢰할 수 없다. 심층 방어란 모든 경계에서 검증하는 것을 의미한다. 사용자가 제공한 데이터를 처리하기 전에 범위, null 값, 배열 길이, 비즈니스 로직 제약을 확인하라.

적절한 입력 검증:

solidity
Copied
pragma solidity ^0.8.20; contract SecureDeposit { mapping(address => uint256) public balances; uint256 public constant MAX_DEPOSIT = 1000 ether; function deposit(uint256 amount) public payable { require(amount > 0, "Amount must be positive"); require(amount == msg.value, "Amount mismatch"); require(amount <= MAX_DEPOSIT, "Exceeds max deposit"); require( amount <= type(uint256).max - balances[msg.sender], "Balance overflow" ); balances[msg.sender] += amount; } function transfer(address to, uint256 amount) public { require(to != address(0), "Invalid recipient"); require(to != address(this), "Cannot transfer to contract"); require(amount <= balances[msg.sender], "Insufficient balance"); balances[msg.sender] -= amount; balances[to] += amount; } }

}`

금액이 0이 아니고 범위 내에 있는지, 주소가 null이거나 유효하지 않은 것이 아닌지, 배열 인덱스가 범위 내에 있는지, 비즈니스 로직 제약이 충족되는지 항상 검증하라.

Solidity 0.8.4 이상에서는 상세한 메시지와 함께 가스 효율적인 revert를 위해 커스텀 오류를 사용하라.

10. 외부 호출을 안전하게 처리하라

다른 컨트랙트로의 외부 호출은 반환값을 확인하지 않으면 조용히 실패할 수 있다. 이 문제는 OWASP Web3 위험 순위 6위이며, 자금이 묶이거나 실제로는 작업이 실패했는데 컨트랙트가 성공한 것으로 가정하는 사태로 이어져 왔다.

call\(\), delegatecall\(\), send\(\) 같은 저수준 호출은 자동으로 revert되지 않고 성공 여부를 나타내는 boolean 값을 반환한다. 이런 반환값을 무시하면 컨트랙트가 잘못된 가정 하에 계속 실행되어, 실제로는 전송되지 않은 자금이 전송됐다고 생각하거나 실패한 핵심 작업이 완료됐다고 생각할 수 있다. ERC-20의 transfer\(\)도 일부 구현에서 revert 대신 false를 반환하는 이 문제를 갖고 있다.

취약한 패턴:

solidity
Copied
function unsafeTransfer(address payable recipient) public { recipient.send(1 ether); // Returns false on failure, but continues!// Contract thinks transfer succeeded }

안전한 구현:

solidity
Copied
contract SecureCaller { event CallExecuted(address target, bool success); function safeCall(address target, bytes memory data) public returns (bytes memory) { (bool success, bytes memory result) = target.call(data); require(success, "External call failed"); emit CallExecuted(target, success); return result; } function safeTransfer(address payable recipient, uint256 amount) public { (bool success, ) = recipient.call{value: amount}(""); require(success, "Transfer failed"); } // For ERC-20 tokens, use SafeERC20 from OpenZeppelin }

}`

외부 호출의 반환값은 항상 캡처하고 확인하라. Ether 전송에는 더 이상 사용되지 않는 send\(\)이나 transfer\(\)보다 call\{value: x\}\(""\)을 선호하라. 토큰 전송에는 실패 시 revert하지 않는 비표준 ERC-20 구현을 처리해주는 OpenZeppelin의 SafeERC20 래퍼를 사용하라.

11. 플래시 론 공격을 완화하라

플래시 론은 같은 트랜잭션 내에서 상환하기만 하면 담보 없이 막대한 금액을 빌릴 수 있게 해준다. 공격자는 이 로직을 악용해 가격을 조작하거나, 풀을 비우거나, 프로토콜 로직을 악용할 수 있어, DeFi 전반에서 수십억 달러가 손실된 OWASP Web3 위험 순위 7위 문제다.

해커는 플래시 론으로 얻은 자금을 이용해 오라클 가격을 인위적으로 왜곡하거나, 대규모로 반올림 오류를 악용하거나, 취약한 컨트랙트 로직을 유발하는 일시적인 시장 조건을 만든다. 여러 해킹에서 반복해서 나타난 전형적인 패턴: 수백만 달러를 빌리고, 가격 오라클이나 유동성 풀을 조작하고, 자신의 프로토콜에서 잘못된 가격 책정을 악용하고, 대출을 상환하고, 차익을 챙긴다. 이 모든 것이 하나의 트랜잭션 안에서 원자적으로 일어난다.

취약한 패턴:

solidity
Copied
contract VulnerablePool { function getPrice() public view returns (uint) { // BAD: using spot price that can be manipulated return tokenReserve / ethReserve; } function borrow(uint amount) public { uint collateral = getPrice() * userCollateral[msg.sender]; require(collateral >= amount, "Insufficient collateral"); // Attacker manipulates price in same transaction! } }

더 나은 접근:

solidity
Copied
contract SecureLending { mapping(address => uint256) public lastBorrow; function borrow(uint256 amount) public { // Add cooldown between borrows require(block.number > lastBorrow[msg.sender] + 2, "Too soon"); lastBorrow[msg.sender] = block.number; // Use TWAP or Chainlink instead of spot price uint256 price = chainlinkOracle.getPrice(); uint256 collateral = price * userCollateral[msg.sender]; require(collateral * 150 / 100 >= amount, "Need 150% collateral"); } }

방어 전략: 스팟 가격 대신 시간 가중 평균 가격(TWAP)이나 Chainlink를 사용하라. 핵심 작업에 다중 블록 쿨다운을 추가하고, 과담보를 요구하고, 상태 변경 후 프로토콜의 건전성을 검증하라. 플래시 론이 필요 없다면 아예 차단하는 것도 고려하라.

12. 핵심 함수에서 로직 오류를 피하라

핵심 함수의 로직 오류는 컨트랙트의 핵심 보안 가정을 무너뜨리며 치명적일 수 있다. 이 오류 유형은 기술적으로 "올바른" 코드처럼 보이지만 전체 시스템을 무너뜨리기 때문에 OWASP Web3 위험 순위 3위에 올라 있다.

컴파일러가 잡아내는 문법 버그와 달리, 로직 오류는 모든 검사를 통과하지만 잘못된 결과를 낳는다. 루프의 off-by-one 오류, 잘못된 연산 순서, 엣지 케이스 처리 누락, 잘못된 조건 등이다. 전형적인 예로는 전송 후 잔액 업데이트를 잊거나, &gt; 대신 &gt;=을 사용하거나, 잘못된 순서로 수수료를 계산해 정밀도 손실이 발생하는 경우가 있다.

흔한 로직 오류:

solidity
Copied
contract LogicErrors { mapping(address => uint256) public balances; // ERROR 1: Balance updated AFTER transfer (reentrancy risk!) function withdraw(uint256 amount) public { payable(msg.sender).transfer(amount); balances[msg.sender] -= amount; // Too late! } // ERROR 2: Off-by-one lets loop access invalid index function distribute(address[] memory users) public { for(uint i = 0; i <= users.length; i++) { // Should be // Crashes on last iteration! } } }

수정된 버전:

solidity
Copied
contract SecureLogic { mapping(address => uint256) public balances; function withdraw(uint256 amount) public { require(balances[msg.sender] >= amount, "Insufficient balance"); // Update state BEFORE external call balances[msg.sender] -= amount; payable(msg.sender).transfer(amount); } function distribute(address[] memory users) public { for(uint i = 0; i < users.length; i++) { // Correct boundary// Safe iteration } } }

방어 전략: Checks-Effects-Interactions 패턴(검증 → 상태 업데이트 → 외부 호출)을 따르고, 엣지 케이스(0, 최댓값, 빈 배열)에 대한 단위 테스트를 작성하고, Foundry로 무작위 입력을 테스트하는 퍼징을 사용하고, "분배된 총량이 풀 잔액을 절대 초과하지 않아야 한다" 같은 불변식을 정의하라. 핵심 함수에는 항상 동료 검토를 거쳐라.

널리 쓰이는 스마트 컨트랙트 보안 도구 6가지

위의 모범 사례들은 견고성을 높이는 데 도움이 되지만, 눈으로는 놓칠 수 있는 오류를 잡아낼 도구도 필요하다. 코드를 감사하는 데 쓸 수 있는 고전적인 도구와 최신 도구를 모아봤다:

  1. Slither: 결함을 찾는 40개 이상의 탐지기를 갖춘 정적 분석기. 빠른 스캔에 적합하며 컨트랙트 세부 정보를 출력한다. GitHub.
  2. Mythril: 여러 체인을 위한 EVM 바이트코드 분석기로 심볼릭 문제를 발견할 수 있다. MythX의 일부다. GitHub.
  3. Securify: 37개 이상의 결함을 정밀한 정적 분석으로 찾아내는, Ethereum Foundation이 지원하는 스캐너. Website (no longer available).
  4. Foundry: 로직 버그에 강한 불변식 테스트를 제공하는, Solidity를 위한 현대적인 테스팅/퍼징 프레임워크. Book.
  5. Certora: 코드가 명세와 수학적으로 일치함을 증명하는 형식 검증 도구. Website.
  6. Echidna: 취약점을 위한 속성 기반 퍼저. GitHub.

이 6가지 도구와 더불어, 바운티 기반 감사를 위해 Code4rena 같은 플랫폼과 협력할 수도 있다.

스마트 컨트랙트를 안전하게 지키기: 마무리 생각

개발자로서 목표는 단순하다. 해킹당하지 않는 컨트랙트를 만들어 사용자 자금을 안전하게 지키고 앱을 매끄럽게 운영하는 것이다. 이 과정에서 위의 모범 사례를 채택하고 설계 단계부터 보안을 고려하는 사고방식을 받아들여야 한다.

업그레이드 가능한 프록시(OpenZeppelin Upgrades 경유), 모듈식 코드, 계정 추상화(더 나은 UX/보안을 위한 ERC-4337 등. 자세한 내용은 ERC-4337 가이드 참고)를 사용하라. 단위 테스트, 형식 검증, 전문 감사, 런타임 모니터링(예: Fortress 경유), 그리고 Immunefi에서의 버그 바운티를 진행하라. 사용자와 프로젝트의 성공이 여기에 달려 있다.

컨트랙트 보안에 관한 더 많은 자료는 다음을 참고하라:

자주 묻는 질문

Solidity 스마트 컨트랙트를 작성할 때 따라야 할 가장 중요한 보안 패턴은 무엇인가?

가장 중요한 패턴으로는 checks-effects-interactions 사용, 재진입 가드 구현, 모든 입력 검증, 명시적 가시성 modifier 설정, 역할 기반 권한을 통한 적절한 접근 제어 준수가 있다.

컨트랙트에서 재진입 공격을 어떻게 방지할 수 있나?

OpenZeppelin의 ReentrancyGuard modifier를 사용하고, 외부 호출 전에 컨트랙트 상태를 업데이트하는 checks-effects-interactions 패턴을 따르고, 사용자가 직접 자금을 인출하는 pull-over-push 패턴을 고려하라.

인증에 tx.origin 대신 msg.sender를 사용해야 하는 이유는?

tx.origin은 원래 사용자가 인가받은 것처럼 코드를 속이는 악의적인 중개 컨트랙트에 의해 악용될 수 있는 반면, msg.sender는 항상 직접적인 호출자를 가리키므로 접근 제어에 신뢰할 수 있다.

Solidity에서 산술 오버플로우와 언더플로우로부터 어떻게 보호할 수 있나?

Solidity 0.8.x 이상으로 업그레이드하라. 오류 발생 시 자동으로 revert하는 내장 오버플로우/언더플로우 검사가 있다. 이전 버전에는 OpenZeppelin의 SafeMath 같은 검증된 라이브러리를 사용하라.

테스트와 보안 감사는 스마트 컨트랙트 보안에서 어떤 역할을 하는가?

Foundry 같은 도구를 이용한 종합적인 단위 테스트와 퍼징, Slither를 이용한 정적 분석은 엣지 케이스와 취약점을 발견하는 데 도움이 되며, 독립적인 보안 감사는 mainnet 배포 전에 로직 결함을 찾아낸다.

Solidity에서 delegatecall을 안전하게 처리하려면 어떻게 해야 하나?

신뢰할 수 있는 감사된 구현에만 delegatecall을 사용하고, 사용자가 제어하는 주소에는 절대 사용하지 말고, 컨트랙트 간 스토리지 레이아웃을 동일하게 유지하고, OpenZeppelin의 업그레이드 가능 컨트랙트 같은 검증된 프록시 패턴을 선호하라.

플래시 론 공격으로부터 스마트 컨트랙트를 어떻게 지킬 수 있나?

스팟 가격 대신 시간 가중 평균 가격(TWAP)이나 Chainlink 오라클을 사용하고, 핵심 작업에 다중 블록 쿨다운을 구현하고, 과담보를 요구하고, 상태 변경 후 프로토콜 건전성을 검증하라.

스마트 컨트랙트 보안 테스트에는 어떤 도구를 사용해야 하나?

배포 전 정적 분석에는 Slither, 테스팅과 퍼징에는 Foundry, 바이트코드 분석에는 Mythril, 종합적인 취약점 스캔에는 Securify 같은 도구가 널리 쓰인다.

Background gradient

블록체인 매직을 만드세요

Alchemy는 가장 강력한 Web3 개발자 제품 및 도구를 리소스, 커뮤니티, 그리고 전설적인 지원과 결합합니다.