솔리디티 코드 분석으로 알아보는 스마트 컨트랙트 취약점 5가지

금간 유리판들이 겹쳐진 위로 돋보기가 놓여 있고, 틈새마다 황금빛 액체 금속이 흐르는 사실적인 모습.
안녕하세요, 10년 차 블로거 김창수입니다. 요즘 블록체인 기술이 우리 삶에 깊숙이 들어오면서 스마트 컨트랙트 개발에 관심을 가지는 분들이 정말 많아졌더라고요. 하지만 화려한 기술의 이면에는 늘 보안이라는 숙제가 따르기 마련입니다.
솔리디티 언어는 자바스크립트와 비슷해 보이지만, 실제로는 돈이 오가는 계약서를 코드로 쓰는 일이라 한 줄의 실수가 수십억 원의 피해로 이어지는 경우를 자주 봤거든요. 저도 예전에 테스트 넷에서 코드를 잘못 짰다가 가스비만 날리고 자산이 묶였던 아찔한 기억이 납니다.
오늘은 제가 그동안 공부하고 직접 겪으며 정리한 솔리디티의 대표적인 취약점 5가지를 코드 레벨에서 꼼꼼하게 공유해 보려고 해요. 개발자분들이나 보안에 관심 있는 분들께 실질적인 도움이 되었으면 좋겠습니다.
목차
재진입 공격(Reentrancy)의 메커니즘
가장 유명하면서도 치명적인 취약점은 단연 재진입 공격입니다. 컨트랙트가 외부로 자금을 전송할 때, 그 수신자가 다시 컨트랙트의 함수를 호출하여 잔액 업데이트가 되기 전에 돈을 계속 빼가는 방식이거든요.
이 문제는 주로 withdraw 함수에서 발생하는데요. 사용자의 잔액을 차감하기 전에 msg.sender.call을 통해 이더를 먼저 보내버리면, 공격자는 자신의 fallback 함수를 이용해 다시 출금 함수를 호출하게 됩니다. 결국 잔액은 그대로인데 돈만 계속 빠져나가는 구조가 되는 거죠.
정수 오버플로우와 언더플로우
솔리디티 0.8 버전 이전에는 숫자가 최대치를 넘으면 다시 0으로 돌아가는 오버플로우 현상이 빈번했습니다. 예를 들어 uint8 타입은 255가 최대인데 여기에 1을 더하면 0이 되어버리는 식이죠. 반대로 0에서 1을 빼면 255가 되는 언더플로우도 심각한 보안 구멍이었습니다.
최근 버전에서는 언어 자체에서 이를 체크해주지만, 여전히 unchecked 블록을 사용하거나 구버전 코드를 유지보수할 때는 주의가 필요해요. 특히 토큰 발행량이나 사용자 예치금을 계산할 때 이런 논리적 오류가 생기면 경제적 시스템이 완전히 무너질 수 있거든요.
주요 취약점 비교 분석표
각 취약점이 가진 특징과 위험도를 한눈에 볼 수 있도록 표로 정리해 보았습니다. 제가 현업에서 느낀 체감 위험도를 기준으로 작성했어요.
| 취약점 명칭 | 주요 원인 | 위험도 | 방어 기법 |
|---|---|---|---|
| 재진입(Reentrancy) | 순서 보장 미흡 | 최상 | 상태 선변경 |
| 오버플로우 | 산술 연산 오류 | 상 | 0.8 버전 사용 |
| 권한 누락 | 제어자 미설정 | 중상 | Ownable 패턴 |
| 예외 처리 미흡 | 반환값 미체크 | 중 | require 필수 |
권한 제어 및 가시성 문제
함수의 가시성(Visibility)을 public으로 설정해두고, 정작 중요한 관리자 권한 체크를 빠뜨리는 실수도 정말 잦더라고요. 누구나 호출해서는 안 되는 transferOwnership이나 selfdestruct 같은 함수가 보호되지 않으면 계약 자체가 파괴될 수 있습니다.
가끔 코드를 짜다 보면 internal로 선언해야 할 내부 로직을 테스트 편의상 external로 열어두었다가 깜빡하고 배포하는 경우도 생기거든요. 이런 작은 틈이 해커들에게는 아주 맛있는 먹잇감이 된다는 사실을 잊지 말아야 해요.
김창수의 뼈아픈 개발 실패담
제가 초보 개발자 시절에 겪었던 일인데요. 간단한 투표 시스템을 만들면서 투표권을 전송하는 함수에 가스 한도 문제를 고려하지 않았던 적이 있습니다. 루프를 돌면서 여러 명에게 보상을 지급하는 구조였는데, 사용자가 늘어나자 가스비가 블록 한도를 초과해버린 것이죠.
결국 함수 실행은 계속 실패하고, 컨트랙트에 쌓인 예치금은 뺄 방법이 없는 이른바 DoS(서비스 거부) 상태에 빠지고 말았습니다. 사용자들의 항의 메일을 보며 식은땀을 흘렸던 그때를 생각하면 지금도 아찔해요. 그때 이후로 저는 무조건 루프보다는 개별 인출(Pull) 방식을 선호하게 되었답니다.
자주 묻는 질문
Q. 솔리디티 최신 버전을 쓰면 보안 사고를 다 막을 수 있나요?
A. 아니요, 언어 차원의 보완은 되지만 로직상의 결함(Logical Error)이나 재진입 공격 같은 구조적 문제는 여전히 개발자의 몫입니다.
Q. transfer() 대신 call()을 권장하는 이유는 무엇인가요?
A. transfer는 가스 한도가 고정되어 있어 수신 측 컨트랙트의 복잡한 로직을 실행하지 못할 수 있기 때문입니다. 현재는 call을 더 권장하는 추세예요.
Q. 스마트 컨트랙트 감사를 꼭 받아야 할까요?
A. 메인넷에 큰 자금을 다루는 프로젝트를 배포한다면 전문 보안 업체의 감사는 선택이 아닌 필수라고 생각합니다.
Q. 가스비 절약을 위해 보안 로직을 생략해도 될까요?
A. 가스비 몇 원 아끼려다 자산 전체를 잃을 수 있습니다. 보안은 타협의 대상이 아니라는 점을 명심해야 해요.
Q. OpenZeppelin 라이브러리는 안전한가요?
A. 업계 표준으로 통할 만큼 검증된 라이브러리입니다. 직접 구현하기보다는 검증된 코드를 상속받아 쓰는 게 훨씬 안전하더라고요.
Q. 랜덤 숫자를 생성할 때 취약점이 있다고 들었습니다.
A. block.timestamp나 hash 등을 사용하면 채굴자가 조작할 수 있습니다. Chainlink VRF 같은 외부 오라클을 사용하는 것이 안전해요.
Q. selfdestruct 함수는 왜 위험한가요?
A. 강제로 이더를 특정 컨트랙트에 밀어넣을 수 있어, 잔액 체크 로직(this.balance)을 우회하거나 망가뜨릴 수 있기 때문입니다.
Q. 프론트엔드에서 검증하면 컨트랙트 보안은 괜찮나요?
A. 해커는 프론트엔드를 거치지 않고 직접 컨트랙트에 트랜잭션을 보냅니다. 모든 보안 검증은 온체인 코드 내에서 이루어져야 해요.
스마트 컨트랙트 보안은 한 번의 실수가 돌이킬 수 없는 결과를 낳는 만큼, 늘 보수적으로 접근해야 한다는 것을 다시 한번 느낍니다. 제가 공유해 드린 내용들이 여러분의 안전한 디앱 개발에 조금이나마 보탬이 되었으면 좋겠네요.
코드를 짤 때는 항상 "누군가 이 코드를 악용한다면?"이라는 질문을 스스로에게 던져보시는 건 어떨까요? 작은 습관 하나가 거대한 자산을 지키는 방패가 될 수 있습니다.
작성자: 김창수 (10년 차 생활 블로거)
기술과 일상을 연결하는 정보를 전달합니다. 블록체인 보안 및 솔리디티 개발 트렌드를 연구하며 얻은 인사이트를 공유하고 있습니다.
본 포스팅은 정보 제공을 목적으로 하며, 실제 배포 시에는 반드시 전문 보안 감사를 받으시길 권장합니다. 코드 결함으로 인한 손실에 대해서는 책임을 지지 않습니다.
댓글
댓글 쓰기