솔리디티 개발 시 가장 자주 발생하는 보안 취약점 7가지와 해결책

어두운 배경 위로 푸른색 디지털 데이터와 보안 사슬이 얽혀 있는 입체적인 컴퓨터 코드 그래픽 이미지.
안녕하세요, 10년 차 생활 블로거 김창수입니다. 요즘 블록체인 기술이 우리 삶에 깊숙이 들어오면서 솔리디티(Solidity)를 공부하시는 분들이 정말 많아진 것 같아요. 저도 처음 스마트 컨트랙트를 접했을 때 코딩 한 줄에 수천만 원, 수억 원의 자산이 왔다 갔다 한다는 사실에 무척 긴장했던 기억이 납니다.
하지만 의욕만 앞서서 보안을 소홀히 했다가는 정말 큰일 날 수 있거든요. 스마트 컨트랙트는 한 번 배포하면 수정하기가 극도로 어렵기 때문에 처음부터 취약점을 막는 것이 무엇보다 중요하더라고요. 오늘은 제가 직접 겪은 시행착오와 수많은 개발자의 눈물을 쏙 뺀 주요 보안 취약점들을 하나씩 풀어보려고 합니다.
이 글을 끝까지 읽으시면 초보 개발자가 흔히 저지르는 실수부터 베테랑도 놓치기 쉬운 복잡한 로직 오류까지 확실히 잡으실 수 있을 거예요. 자산의 안전을 지키는 코딩, 지금 바로 시작해 볼까요?
1. 재진입성(Reentrancy) 공격의 공포
2. 오버플로우와 언더플로우의 변천사
3. 권한 관리와 가시성 설정의 중요성
4. 서비스 거부(DoS) 공격 방어하기
5. 프론트 러닝과 트랜잭션 순서 의존성
6. 예측 가능한 난수 생성의 함정
7. tx.origin 사용의 위험성
8. 자주 묻는 질문(FAQ)
재진입성(Reentrancy) 공격의 공포
솔리디티 보안을 이야기할 때 가장 먼저 등장하는 녀석이 바로 재진입성 공격입니다. 2016년 그 유명한 The DAO 해킹 사건의 주범이기도 하죠. 외부 컨트랙트로 이더를 보낼 때 상대방의 fallback 함수가 실행되면서 우리 컨트랙트의 함수를 다시 호출해 자금을 중복으로 인출해가는 방식이에요.
제가 예전에 테스트넷에서 간단한 은행 컨트랙트를 만들었을 때 일입니다. 잔액을 확인하고 돈을 보낸 뒤에 잔액을 0으로 업데이트하는 순서로 코드를 짰거든요. 그랬더니 테스트용 공격 스크립트가 잔액이 0이 되기도 전에 계속해서 인출 함수를 때리더라고요. 순식간에 제 테스트용 이더가 바닥나는 걸 보고 정말 소름이 돋았습니다.
해결책은 의외로 간단합니다. Checks-Effects-Interactions 패턴을 지키는 것이죠. 조건을 먼저 확인(Checks)하고, 내부 상태(잔액 등)를 먼저 변경(Effects)한 뒤에, 마지막으로 외부와 상호작용(Interactions)하는 순서로 짜야 합니다. 혹은 OpenZeppelin에서 제공하는 ReentrancyGuard 라이너를 사용하는 것도 아주 좋은 방법이에요.
오버플로우와 언더플로우의 변천사
숫자 계산에서도 보안 사고는 자주 발생합니다. 0에서 1을 뺐는데 엄청나게 큰 숫자가 되어버리거나(언더플로우), 최대치에서 1을 더했더니 0이 되어버리는(오버플로우) 현상 때문이죠. 과거에는 이를 막기 위해 SafeMath 라이브러리를 쓰는 게 필수였어요.
최근에는 솔리디티 버전이 0.8.0 이상으로 올라오면서 언어 자체에서 이러한 산술 오류를 체크해 줍니다. 참 다행이죠? 하지만 여전히 unchecked 블록을 사용해 가스비를 아끼려다 실수를 범하는 경우를 종종 봅니다. 최적화도 좋지만 안전이 담보되지 않으면 아무 소용이 없다는 걸 명심해야 할 것 같아요.
| 취약점 유형 | 발생 원인 | 권장 해결책 |
|---|---|---|
| 재진입성 | 외부 호출 후 상태 업데이트 | Checks-Effects-Interactions 패턴 |
| 오버플로우 | 데이터 타입 범위를 벗어난 연산 | Solidity 0.8.0 이상 사용 |
| 권한 미흡 | 함수 제어자(Modifier) 누락 | Ownable 패턴 적용 |
| 난수 예측 | 블록 정보(Timestamp 등) 사용 | Chainlink VRF 사용 |
권한 관리와 가시성 설정의 중요성
함수를 누가 실행할 수 있는지를 정하는 것도 매우 기초적이면서 치명적인 부분입니다. 관리자만 실행해야 하는 함수에 public을 붙여놓고 아무런 제어 장치를 걸지 않으면, 지나가던 누구나 컨트랙트의 주인이 되거나 자금을 빼갈 수 있거든요. 생각보다 이런 실수가 현업에서도 빈번하게 일어납니다.
특히 가시성(Visibility) 설정을 명확히 해야 합니다. external, public, internal, private 중 용도에 맞는 가장 좁은 범위를 선택하는 습관을 들여야 해요. 저는 개인적으로 모든 함수를 일단 private으로 두고, 꼭 필요한 경우에만 하나씩 열어주는 방식을 선호합니다.
중요한 함수에는 반드시 onlyOwner나 hasRole 같은 모디파이어를 붙이세요. 그리고 배포 전에는 반드시 '누가 이 함수를 호출할 수 있는가?'를 리스트업해서 검증하는 과정을 거쳐야 합니다.
서비스 거부(DoS) 공격 방어하기
스마트 컨트랙트에서 DoS 공격은 주로 가스 제한(Gas Limit)을 이용해 발생합니다. 예를 들어, 수천 명의 사용자에게 한꺼번에 보상을 나눠주는 루프(Loop)를 돌린다고 가정해 볼게요. 만약 사용자 수가 너무 많아져서 한 트랜잭션의 가스비가 블록 가스 한도를 넘어서면 어떻게 될까요? 그 함수는 영원히 실행되지 못하고 컨트랙트는 멈춰버립니다.
또 다른 사례는 외부 컨트랙트 호출의 실패를 이용하는 거예요. 여러 명에게 돈을 보내는 과정에서 단 한 명이라도 fallback 함수에서 revert를 발생시키면 전체 트랜잭션이 취소될 수 있습니다. 악의적인 사용자가 이를 이용해 시스템 전체를 마비시킬 수 있다는 뜻이죠.
이를 방지하기 위해서는 Pull over Push 패턴을 사용해야 합니다. 컨트랙트가 사용자에게 돈을 밀어 넣어주는(Push) 대신, 사용자가 직접 자신의 몫을 찾아가도록(Pull) 설계하는 것이죠. 이렇게 하면 한 명의 실패가 다른 사람에게 영향을 주지 않게 됩니다.
프론트 러닝과 트랜잭션 순서 의존성
블록체인의 모든 트랜잭션은 멤풀(Mempool)이라는 대기 공간을 거칩니다. 이곳에서 채굴자나 봇들은 아직 처리되지 않은 트랜잭션의 내용을 훤히 들여다볼 수 있어요. 만약 어떤 이익이 발생하는 트랜잭션을 발견하면, 가스비를 더 높게 책정해서 자신의 트랜잭션을 먼저 처리되게 만드는 것이 바로 프론트 러닝입니다.
탈중앙화 거래소(DEX)에서 큰 규모의 거래를 할 때 슬리피지(Slippage) 설정을 너무 넉넉하게 하면 이런 공격의 대상이 되기 쉽더라고요. 컨트랙트 설계 시에는 트랜잭션의 순서가 결과에 큰 영향을 미치지 않도록 설계하거나, commit-reveal 스킴을 사용해 실제 내용을 나중에 공개하는 방식을 고민해봐야 합니다.
예측 가능한 난수 생성의 함정
온체인에서 난수를 생성하는 건 정말 어려운 숙제입니다. 많은 초보 개발자가 block.timestamp나 block.difficulty를 섞어서 난수를 만들곤 하는데요. 이건 정말 위험한 생각입니다. 채굴자는 자신이 블록을 생성할 때 이 값들을 어느 정도 조작하거나 미리 계산해볼 수 있거든요.
예전에 제가 참여했던 프로젝트에서도 경품 추첨 로직을 블록 타임스탬프로 짰다가 호되게 당할 뻔한 적이 있었습니다. 테스트 과정에서 특정 시간에 트랜잭션을 넣으면 당첨 확률이 비정상적으로 높아지는 걸 발견했거든요. 만약 메인넷에 그대로 올렸다면 프로젝트 신뢰도가 바닥을 쳤을 거예요.
진정으로 안전한 난수가 필요하다면 오라클 서비스를 이용하는 게 정답입니다. 대표적으로 Chainlink VRF(Verifiable Random Function)가 있죠. 외부에서 검증 가능한 난수를 가져오기 때문에 조작의 위험에서 훨씬 자유로워질 수 있습니다.
tx.origin 사용의 위험성
마지막으로 tx.origin에 대해 이야기해 보겠습니다. 사용자 인증을 할 때 msg.sender 대신 tx.origin을 쓰는 경우가 있는데, 이는 피싱 공격에 매우 취약합니다. tx.origin은 트랜잭션을 처음 시작한 지갑 주소를 나타내기 때문이에요.
만약 제가 악성 컨트랙트를 만들어서 여러분이 그 컨트랙트의 함수를 실행하게 유도한다면, 그 트랜잭션의 tx.origin은 여러분의 주소가 됩니다. 이때 제 악성 컨트랙트가 여러분의 소중한 자산이 들어있는 다른 컨트랙트에 접근해 '주인이 맞느냐'고 물어봤을 때 tx.origin으로 체크한다면, 보안망이 그대로 뚫리게 되는 셈이죠.
따라서 권한 확인에는 반드시 msg.sender를 사용해야 합니다. tx.origin은 특별한 경우가 아니면 아예 잊어버리는 게 정신 건강에 이롭더라고요. 작은 차이 같지만 자산을 지키는 데 있어서는 하늘과 땅 차이입니다.
스마트 컨트랙트 보안은 '설마' 하는 곳에서 터집니다. 코드 한 줄을 짤 때마다 이 코드가 악용될 시나리오가 없는지 스스로 해커가 되어 질문을 던져보세요.
자주 묻는 질문(FAQ)
Q. 솔리디티 0.8.0 이상을 쓰면 SafeMath는 아예 필요 없나요?
A. 네, 기본적으로 언어 레벨에서 오버/언더플로우를 체크하기 때문에 별도의 라이브러리 없이도 안전하게 연산할 수 있습니다.
Q. 재진입 방지용 가드(Guard)를 쓰면 가스비가 많이 나오나요?
A. 약간의 추가 가스비가 발생하지만, 해킹으로 자산을 잃는 위험에 비하면 아주 미미한 수준이므로 필수적으로 사용하는 것을 추천합니다.
Q. 가시성을 internal로 설정하면 외부에서 절대 못 보나요?
A. 블록체인상의 모든 데이터는 공개되어 있습니다. internal이나 private은 다른 컨트랙트의 '접근'을 막는 것이지 '조회'를 막는 것이 아님을 유의해야 합니다.
Q. 프론트 러닝을 완전히 막는 방법이 있나요?
A. 완전히 막기는 어렵지만, 슬리피지 제한이나 Commit-Reveal 패턴, 혹은 Flashbots 같은 MEV 보호 서비스를 활용해 피해를 최소화할 수 있습니다.
Q. 컨트랙트 배포 후에 보안 취약점이 발견되면 어떡하죠?
A. 이미 배포된 코드는 수정할 수 없습니다. 따라서 프록시 패턴(Proxy Pattern)을 사용해 업그레이드 가능하게 설계하거나, 긴급 정지(Pausable) 기능을 미리 넣어두는 것이 좋습니다.
Q. msg.value와 msg.sender의 차이는 무엇인가요?
A. msg.sender는 함수를 호출한 주소이고, msg.value는 해당 트랜잭션과 함께 전송된 이더(Wei 단위)의 양을 나타냅니다.
Q. 보안 감사는 꼭 받아야 하나요?
A. 메인넷에 큰 자금을 운용하는 프로젝트라면 전문 보안 업체의 감사는 선택이 아닌 필수입니다. 스스로 발견하지 못한 로직 오류를 찾아내는 데 큰 도움이 됩니다.
Q. 이더리움 외의 체인에서도 동일한 보안 수칙이 적용되나요?
A. EVM(Ethereum Virtual Machine) 호환 체인(BSC, Polygon, Avalanche 등)이라면 대부분 동일한 보안 원칙이 적용됩니다.
지금까지 솔리디티 개발 시 주의해야 할 핵심 보안 취약점 7가지를 살펴보았습니다. 블록체인 세상에서 보안은 아무리 강조해도 지나치지 않더라고요. 처음에는 복잡하고 어렵게 느껴질 수 있지만, 하나씩 원리를 이해하고 코딩 습관을 들이다 보면 어느새 안전한 컨트랙트를 짜는 자신을 발견하실 수 있을 거예요.
개발 과정에서 궁금한 점이 생기면 언제든 댓글 남겨주세요. 제가 아는 선에서 최대한 답변해 드릴게요. 여러분의 멋진 프로젝트가 안전하게 날아오르기를 진심으로 응원합니다. 오늘도 열혈 코딩하시고, 자산 보안도 꽉 잡으시는 하루 되시길 바랄게요!
작성자: 10년 차 생활 블로거 김창수
IT 기술과 일상의 접점을 탐구하며, 초보자도 이해하기 쉬운 지식을 전달하기 위해 노력하고 있습니다. 블록체인과 스마트 컨트랙트 보안에 깊은 관심을 가지고 다양한 실무 경험을 공유합니다.
면책조항: 본 포스팅은 정보 전달을 목적으로 하며, 실제 투자나 개발에 따른 결과에 대해 어떠한 법적 책임도 지지 않습니다. 모든 코드는 배포 전 충분한 테스트와 전문적인 보안 감사를 거칠 것을 권장합니다.
댓글
댓글 쓰기