✦ BLOCKCHAIN DEV GUIDE SERIES ✦
스마트 컨트랙트 보안·감사
완전 정복 A-Z
재진입 공격 · 오라클 조작 · 접근 제어 · Slither · Echidna · 버그 바운티
🛡️ 이 가이드를 완료하면?
- 재진입 공격 패턴 5가지 완전 이해
- 오라클 조작 공격 방어 구현
- 정수 오버플로우/언더플로우 방어
- 접근 제어 취약점 완전 차단
- Slither 정적 분석 도구 활용
- Echidna 퍼징 테스트 작성
- Foundry 불변량 테스트 구현
- 감사 보고서 작성 실습
- 버그 바운티 참여 전략
- 현업 보안 체크리스트 완성
📊 시리즈 진행 현황
📚 전체 학습 목차
| 챕터 | 주제 | 핵심 기술 | 난이도 |
|---|---|---|---|
| 01 | 재진입 공격 완전 분석 | CEI 패턴, ReentrancyGuard, Pull Payment | ★★★★☆ |
| 02 | 오라클 조작 공격 | TWAP, Chainlink, Spot Price 위험 | ★★★★☆ |
| 03 | 정수 오버플로우·언더플로우 | SafeMath, Solidity 0.8+, unchecked | ★★★☆☆ |
| 04 | 접근 제어 취약점 | Ownable, RBAC, tx.origin 위험 | ★★★☆☆ |
| 05 | 플래시 론 공격 패턴 | 가격 조작, 거버넌스 공격, 방어 패턴 | ★★★★★ |
| 06 | Slither 정적 분석 | 자동 취약점 감지, 커스텀 디텍터 | ★★★☆☆ |
| 07 | Echidna 퍼징 테스트 | 속성 기반 테스트, 불변량 정의 | ★★★★☆ |
| 08 | Foundry 불변량 테스트 | invariant test, handler, ghost variables | ★★★★★ |
| 09 | 감사 보고서 작성 | 심각도 분류, PoC 작성, 리포트 구조 | ★★★★☆ |
| 10 | 면접 Q&A TOP 15 | 보안 면접 완벽 대비 | ★★★★☆ |
📘 Chapter 01
재진입 공격(Reentrancy Attack) — 스마트 컨트랙트 최대 위협
2016년 DAO 해킹 360만 ETH 탈취 사례부터 최신 공격 패턴과 완벽 방어까지
🚨 역대 최대 보안 사고 — 2016 DAO 해킹
2016년 6월, The DAO 컨트랙트에서 재진입 공격으로 360만 ETH(당시 약 6,000만 달러) 탈취. 이 사고로 이더리움이 ETH/ETC로 하드포크됨. 공격자는 withdraw() 함수가 잔액을 업데이트하기 전에 반복 호출하여 잔액보다 훨씬 많은 ETH를 인출.
1.1 재진입 공격 동작 원리
// ❌ 취약한 컨트랙트 — 재진입 공격에 노출
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 취약점: ETH 전송 후에 잔액을 0으로 업데이트 → 재진입 가능
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// ⚠️ 위험: 외부 호출 먼저 (ETH 전송)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// ⚠️ 상태 업데이트가 외부 호출 이후 → 재진입 공격 허용
balances[msg.sender] = 0;
}
}
// ❌ 공격자 컨트랙트
contract Attacker {
VulnerableBank public bank;
uint256 public attackAmount = 1 ether;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
function attack() external payable {
require(msg.value >= attackAmount, "Need ETH");
bank.deposit{value: attackAmount}();
bank.withdraw(); // 첫 withdraw 호출
}
// ETH 수신 시 자동 실행되는 fallback
receive() external payable {
// bank.balances[this] 가 아직 0이 안 됐으므로 재호출 가능!
if (address(bank).balance >= attackAmount) {
bank.withdraw(); // 재진입!!! 반복 탈취
}
}
}
// ✅ 방어 1: CEI (Checks-Effects-Interactions) 패턴
contract SafeBank_CEI {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// ✅ Effects 먼저 — 상태 변경
balances[msg.sender] = 0;
// ✅ Interactions 나중에 — 외부 호출
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
// ✅ 방어 2: ReentrancyGuard (뮤텍스 잠금)
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank_Guard is ReentrancyGuard {
mapping(address => uint256) public balances;
// nonReentrant: 함수 실행 중 재진입 완전 차단
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
// ✅ 방어 3: Pull Payment 패턴 (가장 안전)
import "@openzeppelin/contracts/security/PullPayment.sol";
contract SafeBank_Pull is PullPayment {
function refund(address payee, uint256 amount) internal {
// ETH를 직접 보내지 않고 escrow에 보관
_asyncTransfer(payee, amount);
}
// 수령자가 직접 withdrawPayments() 호출하여 출금
}
1.2 크로스 함수 & 크로스 컨트랙트 재진입
⚠️ 고급 재진입 패턴 — 단순 nonReentrant로 막을 수 없는 경우
- 크로스 함수 재진입: withdraw()와 transfer() 두 함수 모두를 이용 (하나에만 nonReentrant 걸면 뚫림)
- 읽기 전용 재진입: view 함수를 통해 업데이트 전 상태를 읽어 다른 프로토콜을 속이는 공격
- ERC-777 재진입: ERC-20과 달리 전송 시 콜백이 발생 → 토큰 전송 시 재진입 가능
// ⚠️ 크로스 함수 재진입 예시
contract CrossFunctionVulnerable {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
// ETH 전송 (여기서 공격자의 receive()가 실행됨)
payable(msg.sender).transfer(amount);
balances[msg.sender] = 0; // 아직 0이 아님!
}
// 공격자는 receive()에서 transfer()를 호출 → balances 복사 가능
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount); // 아직 잔액이 있음!
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
// ✅ 해결: 모든 관련 함수에 nonReentrant 적용 OR 전역 잠금 사용
contract CrossFunctionSafe is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // CEI 패턴 적용
payable(msg.sender).transfer(amount);
}
function transfer(address to, uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
📘 Chapter 02
오라클 조작 공격 — DeFi 최대 위협 완전 분석
Flash Loan + Spot Price 조작으로 수억 달러 탈취 — 원리와 완벽 방어까지
💡 오라클 조작 공격의 핵심 원리
온체인 DEX의 spot price(현재 가격)를 오라클로 사용하면, 플래시 론으로 일시적으로 가격을 조작할 수 있습니다. 예: 1. 플래시 론으로 대량 USDC 대출 → 2. Uniswap에서 대량 매수로 ETH 가격 인위적 상승 → 3. 부풀려진 ETH를 담보로 프로토콜에서 과대 대출 → 4. 자산 인출 → 5. ETH 매도로 가격 원상복구 → 6. 플래시 론 상환. 단 1개 트랜잭션으로 완료.
2.1 실제 공격 사례와 피해액
| 프로토콜 | 연도 | 피해액 | 공격 방법 |
|---|---|---|---|
| Harvest Finance | 2020.10 | $34M | USDC/USDT Curve spot price 조작 |
| Compound | 2020.11 | $89M | DAI Uniswap V2 가격 조작 → 청산 공격 |
| Cream Finance | 2021.10 | $130M | yUSD 오라클 조작 + 플래시 론 |
| Mango Markets | 2022.10 | $117M | MNGO 토큰 가격 조작 후 과대 대출 |
// ❌ 취약한 오라클 — Uniswap spot price 직접 사용
contract VulnerableOracle {
IUniswapV2Pair public pair; // WETH/USDC 쌍
// 현재 블록의 spot price만 읽음 → 조작 가능
function getPrice() external view returns (uint256) {
(uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
// token0=WETH, token1=USDC
return (uint256(reserve1) * 1e18) / uint256(reserve0); // USDC per WETH
}
}
// ✅ 방어 1: Chainlink Price Feed (가장 권장)
contract ChainlinkOracle {
AggregatorV3Interface immutable priceFeed;
constructor(address _feed) { priceFeed = AggregatorV3Interface(_feed); }
function getPrice() external view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt < 1 hours, "STALE"); // 신선도 검증
require(price > 0, "INVALID");
return uint256(price); // 8 decimals
}
}
// ✅ 방어 2: TWAP (시간 가중 평균 가격) — Uniswap V3
contract UniswapTWAP {
IUniswapV3Pool public pool;
uint32 public twapInterval = 1800; // 30분 평균
function getTWAP() external view returns (uint256) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapInterval; // 30분 전
secondsAgos[1] = 0; // 현재
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 avgTick = int24(tickCumulativesDelta / int56(uint56(twapInterval)));
// tick → price 변환
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(avgTick);
return FullMath.mulDiv(uint256(sqrtPriceX96) ** 2, 1e18, 2**192);
}
}
// ✅ 방어 3: 다중 오라클 + 편차 검증
contract MultiOracle {
AggregatorV3Interface immutable chainlink;
uint256 constant MAX_DEVIATION = 5; // 5% 이상 편차 시 거래 거부
function getVerifiedPrice() external view returns (uint256) {
uint256 chainlinkPrice = _getChainlinkPrice();
uint256 twapPrice = _getTWAP();
// 두 오라클 가격 편차 검증
uint256 deviation = chainlinkPrice > twapPrice
? ((chainlinkPrice - twapPrice) * 100) / chainlinkPrice
: ((twapPrice - chainlinkPrice) * 100) / twapPrice;
require(deviation <= MAX_DEVIATION, "ORACLE_MANIPULATION_DETECTED");
return (chainlinkPrice + twapPrice) / 2; // 평균값 사용
}
}
📘 Chapter 03
정수 오버플로우·언더플로우 & 접근 제어 취약점
수학적 함정과 권한 관리 실수로 인한 취약점 완전 분석
// 정수 오버플로우 — Solidity 0.8.0 이전의 주요 취약점
// Solidity 0.8.0+는 기본적으로 오버플로우 시 revert
// ❌ 취약: Solidity ^0.7.x
contract VulnerableToken {
mapping(address => uint256) balances;
function transfer(address to, uint256 amount) external {
// uint256 underflow: balances[msg.sender]가 amount보다 작으면
// 0 - 1 = 2^256-1 (엄청난 양!)
balances[msg.sender] -= amount; // ❌ 언더플로우 가능
balances[to] += amount;
}
}
// ✅ 방어 1: Solidity 0.8+ 사용 (자동 체크)
// ✅ 방어 2: SafeMath (0.8 이전 버전)
// ✅ 방어 3: unchecked는 오버플로우 불가능한 경우에만 사용
contract SafeArithmetic {
// 가스 최적화: for 루프에서 unchecked 사용 (i < arr.length 보장됨)
function sumArray(uint256[] calldata arr) external pure returns (uint256 total) {
uint256 len = arr.length;
for (uint256 i = 0; i < len; ) {
total += arr[i];
unchecked { ++i; } // overflow 불가 (len < 2^256)
}
}
}
// ===== 접근 제어 취약점 =====
// ❌ tx.origin 사용 (피싱 공격 가능)
contract TxOriginVulnerable {
address public owner;
function transferOwnership(address newOwner) external {
// tx.origin은 트랜잭션 최초 발신자
// 중간 컨트랙트 경유 시 조작 가능
require(tx.origin == owner, "NOT_OWNER"); // ❌ 위험!
owner = newOwner;
}
}
// 공격 시나리오: owner가 악성 컨트랙트를 실행 →
// 악성 컨트랙트가 transferOwnership 호출 → tx.origin = owner 통과!
// ✅ msg.sender 사용
contract SafeAccessControl {
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "NOT_OWNER"); // ✅ 안전
_;
}
// RBAC (역할 기반 접근 제어) — OpenZeppelin AccessControl
// DEFAULT_ADMIN_ROLE, MINTER_ROLE, PAUSER_ROLE 등 세분화
}
// ❌ 초기화 취약점 (Uninitialized Proxy Storage)
contract UninitializedVulnerable {
address public owner;
bool public initialized;
function initialize() external {
// 초기화 여부 체크 없음 → 누구나 initialize() 재호출 가능
owner = msg.sender; // ❌ 공격자가 owner 탈취 가능
}
}
// ✅ initializer 수정자 (OpenZeppelin Initializable)
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract SafeInitialize is Initializable {
address public owner;
function initialize() external initializer {
// initializer 수정자: 단 1회만 실행 가능
owner = msg.sender; // ✅ 안전
}
}
📘 Chapter 04
플래시 론 공격 패턴 심화 — 거버넌스 공격 & 방어
단 1 트랜잭션으로 DAO를 탈취하는 거버넌스 공격과 방어 메커니즘
// 플래시 론 거버넌스 공격 시나리오:
// 1. Aave에서 대량 거버넌스 토큰 플래시 론
// 2. 자기 자신에게 delegate() → 대량 투표권 획득
// 3. 악성 프로포절 즉시 통과 (정족수 초과)
// 4. Treasury 자금 전액 인출 실행
// 5. 거버넌스 토큰 상환 → 모든 것이 1 트랜잭션
// ✅ 방어 1: 스냅샷 기반 투표 (ERC20Votes)
// ERC20Votes는 프로포절 생성 시점의 블록 스냅샷 사용
// 플래시 론으로 토큰을 빌려도 해당 블록 이전 잔액이 0이면 투표권 없음
// ✅ 방어 2: Timelock (2일 이상 대기)
// 거버넌스 공격이 성공해도 실행 전 커뮤니티가 발견 가능
// ✅ 방어 3: Quorum 상향 + Voting Period 연장
// 최소 정족수를 높게 설정, 투표 기간을 길게 (최소 1주일)
// ✅ 방어 4: 고가치 작업에 추가 Timelock
function executeHighValueAction(uint256 amount) external {
require(amount <= dailyLimit, "EXCEEDS_DAILY_LIMIT");
require(block.timestamp >= lastExecution + 1 days, "TOO_FREQUENT");
// ...
}
📘 Chapter 05
Slither — 자동 취약점 정적 분석 도구 완전 활용
Trail of Bits의 Slither로 스마트 컨트랙트 취약점을 자동으로 탐지한다
5.1 Slither 설치 및 기본 사용법
# Slither 설치
pip3 install slither-analyzer
# 기본 분석 실행
slither contracts/MyToken.sol
# 특정 탐지기만 실행
slither contracts/ --detect reentrancy-eth,reentrancy-no-eth
# JSON 리포트 출력
slither contracts/ --json slither-report.json
# 상세 정보 출력
slither contracts/ --print call-graph
slither contracts/ --print cfg
slither contracts/ --print inheritance-graph
# Hardhat 프로젝트에서 실행
slither . --hardhat-ignore-compile
5.2 Slither 주요 탐지기 분류
| 심각도 | 탐지기 | 설명 |
|---|---|---|
| High | reentrancy-eth | ETH 전송 재진입 취약점 |
| High | suicidal | 누구나 selfdestruct 호출 가능 |
| Medium | tx-origin | tx.origin 인증 사용 |
| Medium | unused-return | 함수 반환값 미사용 |
| Low | events-maths | 중요 연산 후 이벤트 미발생 |
| Informational | solc-version | 권장 solc 버전 사용 여부 |
# 커스텀 Slither 탐지기 작성 (Python)
# detectors/custom_reentrancy.py
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.core.cfg.node import NodeType
class CustomReentrancyDetector(AbstractDetector):
ARGUMENT = "custom-reentrancy"
HELP = "Custom reentrancy detector"
IMPACT = DetectorClassification.HIGH
CONFIDENCE = DetectorClassification.MEDIUM
WIKI = "https://example.com/wiki"
WIKI_TITLE = "Custom Reentrancy"
WIKI_DESCRIPTION = "Detects potential reentrancy"
WIKI_EXPLOIT_SCENARIO = "..."
WIKI_RECOMMENDATION = "Use CEI pattern or ReentrancyGuard"
def _detect(self):
results = []
for contract in self.contracts:
for function in contract.functions:
# ETH 전송 후 storage 쓰기 패턴 탐지
if self._has_eth_transfer_before_storage(function):
result = self.generate_result([
"Potential reentrancy in ", function, "
"
])
results.append(result)
return results
📘 Chapter 06
Echidna — 퍼징 테스트로 숨겨진 버그 발견
Trail of Bits의 Echidna 스마트 컨트랙트 퍼저로 불변량(invariant)을 자동 검증한다
🔍 Echidna란?
Echidna는 속성 기반 퍼징(Property-Based Fuzzing) 도구입니다. 개발자가 "항상 참이어야 하는 조건(불변량)"을 Solidity로 작성하면, Echidna가 수천~수백만 개의 무작위 트랜잭션을 생성하여 그 불변량을 깨뜨리는 입력값을 찾아냅니다.
// ERC-20 토큰 Echidna 퍼징 테스트
// echidna_test.sol
pragma solidity ^0.8.19;
import "./MyToken.sol";
contract EchidnaTest is MyToken {
address internal constant USER1 = address(0x10000);
address internal constant USER2 = address(0x20000);
constructor() {
// 초기 상태 설정
_mint(USER1, 1000 * 10**18);
_mint(USER2, 500 * 10**18);
}
// ===== 불변량 1: 총 공급량은 항상 민팅 합계와 같아야 한다 =====
function echidna_total_supply_constant() public view returns (bool) {
return totalSupply() == 1500 * 10**18;
}
// ===== 불변량 2: 잔액 합 <= 총 공급량 =====
function echidna_balance_sum() public view returns (bool) {
return balanceOf(USER1) + balanceOf(USER2) <= totalSupply();
}
// ===== 불변량 3: 잔액은 절대 음수가 될 수 없다 (uint이므로 당연) =====
function echidna_no_negative_balance() public view returns (bool) {
return balanceOf(msg.sender) >= 0; // uint256은 항상 true
}
// ===== 불변량 4: approve 후 allowance는 정확해야 한다 =====
function echidna_allowance_after_approve(address spender, uint256 amount)
public returns (bool)
{
approve(spender, amount);
return allowance(msg.sender, spender) == amount;
}
// ===== 불변량 5: transfer 후 잔액 보존 =====
function echidna_transfer_balance_preservation(address to, uint256 amount)
public returns (bool)
{
uint256 senderBefore = balanceOf(msg.sender);
uint256 receiverBefore = balanceOf(to);
if (senderBefore >= amount && to != msg.sender) {
transfer(to, amount);
return balanceOf(msg.sender) == senderBefore - amount
&& balanceOf(to) == receiverBefore + amount;
}
return true;
}
}
# Echidna 실행
# echidna-test echidna_test.sol --contract EchidnaTest
# echidna-test echidna_test.sol --contract EchidnaTest --config echidna.yaml
# echidna.yaml 설정 파일
testLimit: 50000 # 최대 테스트 횟수
seqLen: 100 # 시퀀스당 최대 트랜잭션 수
timeout: 300 # 제한 시간 (초)
workers: 4 # 병렬 워커 수
# 테스트 모드
testMode: "property" # property | assertion | exploration
# 커버리지 출력
coverage: true
# 가스 최적화
gasEnabled: false # 가스 계산 비활성화로 속도 향상
# corpusDir: "./corpus" # 이전 실행 결과 재사용
📘 Chapter 07
Foundry 불변량 테스트 — 고급 보안 테스트의 정수
Handler 패턴, Ghost Variables, 불변량 정의로 완벽한 보안 테스트 구현
// Foundry 불변량(Invariant) 테스트 — 현업 수준
// test/invariant/InvariantBank.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/InvariantTest.sol";
import "../../src/Bank.sol";
// Handler: Foundry가 호출할 수 있는 함수들을 정의
// Ghost Variables: 테스트에서 추적하는 가상의 상태 변수
contract BankHandler is Test {
Bank public bank;
// Ghost variables — 우리가 직접 추적하는 상태
uint256 public ghost_depositSum; // 모든 예금 합계
uint256 public ghost_withdrawSum; // 모든 출금 합계
address[] public actors;
address internal currentActor;
constructor(Bank _bank) {
bank = _bank;
// 테스트 액터 설정
actors.push(makeAddr("user1"));
actors.push(makeAddr("user2"));
actors.push(makeAddr("user3"));
}
modifier useActor(uint256 actorSeed) {
currentActor = actors[actorSeed % actors.length];
vm.startPrank(currentActor);
_;
vm.stopPrank();
}
// Foundry가 무작위로 호출할 함수들
function deposit(uint256 actorSeed, uint256 amount) external useActor(actorSeed) {
amount = bound(amount, 0.01 ether, 100 ether); // 범위 제한
deal(currentActor, amount);
bank.deposit{value: amount}();
ghost_depositSum += amount;
}
function withdraw(uint256 actorSeed, uint256 amount) external useActor(actorSeed) {
uint256 balance = bank.balances(currentActor);
if (balance == 0) return;
amount = bound(amount, 0, balance);
bank.withdraw(amount);
ghost_withdrawSum += amount;
}
}
// 불변량 테스트 컨트랙트
contract InvariantBank is Test, InvariantTest {
Bank bank;
BankHandler handler;
function setUp() external {
bank = new Bank();
handler = new BankHandler(bank);
// Foundry에게 handler의 함수만 호출하도록 지정
targetContract(address(handler));
}
// ===== 불변량 1: ETH 잔액 = 총 예금 - 총 출금 =====
function invariant_bankBalance() external {
assertEq(
address(bank).balance,
handler.ghost_depositSum() - handler.ghost_withdrawSum(),
"Bank balance mismatch"
);
}
// ===== 불변량 2: 개별 잔액 합 = 컨트랙트 ETH 잔액 =====
function invariant_solvency() external {
uint256 sumOfBalances;
address[] memory actors = handler.getActors();
for (uint256 i = 0; i < actors.length; i++) {
sumOfBalances += bank.balances(actors[i]);
}
assertEq(address(bank).balance, sumOfBalances, "Solvency broken");
}
// 실행: forge test --match-contract InvariantBank -v
// 출력: runs: 256, μ: 127341, ~: 129043
}
📘 Chapter 08
스마트 컨트랙트 감사 보고서 작성 실습
Code4rena, Sherlock, ImmuneFi 스타일의 전문 감사 보고서 작성법
8.1 감사 보고서 구조 & 심각도 분류
| 심각도 | 정의 | 예시 | 버그 바운티 보상 |
|---|---|---|---|
| Critical | 자금 직접 탈취 가능 | 재진입, 접근 제어 완전 우회 | $50,000~$10M+ |
| High | 큰 자금 손실 가능 | 오라클 조작, 청산 실패 | $10,000~$50,000 |
| Medium | 조건부 자금 손실 | 슬리피지 미보호, 잘못된 수식 | $1,000~$10,000 |
| Low | 소액 또는 간접 영향 | 이벤트 누락, 부정확한 계산 | $100~$1,000 |
| Gas/Info | 개선 권장사항 | 가스 최적화, 코드 품질 | 인정 or 소액 |
# 감사 보고서 템플릿 (Code4rena / Sherlock 스타일)
## [H-01] ReentrancyGuard 미적용으로 인한 ETH 탈취 가능
**심각도:** High
**컨트랙트:** Bank.sol
**함수:** withdraw()
### 취약점 설명
withdraw() 함수에서 ETH 전송 후 잔액 업데이트 순서가 잘못되어 재진입 공격에 취약합니다.
### 공격 경로 (PoC)
1. 공격자가 1 ETH를 Bank에 예금
2. withdraw() 호출
3. ETH 수신 시 receive() 함수에서 withdraw() 재호출
4. bank.balance가 소진될 때까지 반복
5. 공격자는 1 ETH 예금으로 전체 ETH 탈취
### PoC 코드
```solidity
contract AttackTest is Test {
Bank bank;
Attacker attacker;
function setUp() public {
bank = new Bank();
// 다른 사용자 예금 설정
vm.deal(address(bank), 10 ether);
attacker = new Attacker(address(bank));
}
function test_reentrancyAttack() public {
vm.deal(address(attacker), 1 ether);
uint256 bankBalanceBefore = address(bank).balance;
attacker.attack{value: 1 ether}();
// 공격자가 bank 전체 자금 탈취
assertEq(address(bank).balance, 0);
assertGt(address(attacker).balance, bankBalanceBefore);
}
}
```
### 권장 수정사항
CEI 패턴 적용 및 ReentrancyGuard 추가:
```solidity
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // Effects 먼저
(bool ok,) = msg.sender.call{value: amount}(""); // Interaction 나중
require(ok);
}
```
8.2 버그 바운티 참여 전략
🥉 ImmuneFi
- DeFi 최대 버그 바운티
- 최고 $10M 보상
- Uniswap, Aave 등 참여
- immunefi.com
🏆 Code4rena
- 경쟁형 감사 플랫폼
- 4일~2주 감사 콘테스트
- 순위별 보상 지급
- code4rena.com
🛡️ Sherlock
- 감사자+보험 결합
- USDC 보상 지급
- 시니어 감사자 우대
- sherlock.xyz
🎤 Chapter 09
블록체인 보안 전문가 면접 Q&A TOP 15
보안 감사 회사 & DeFi 프로토콜 면접에서 자주 출제되는 핵심 질문
Q1. 재진입 공격의 3가지 방어 방법과 각각의 장단점은?
A: ① CEI 패턴: 구현 간단, 개발자 실수 가능. ② ReentrancyGuard: 확실한 보호, 가스 약간 증가(~20 gas). ③ Pull Payment: 가장 안전하나 UX 복잡. 실무에서는 CEI + nonReentrant 조합 권장. 크로스 함수 재진입은 모든 관련 함수에 nonReentrant 필요.
Q2. Slither와 Mythril의 차이점은?
A: Slither는 정적 분석(AST 분석)으로 빠르고 오탐이 적으며 커스텀 탐지기 작성 가능. Mythril은 심볼릭 실행(모든 실행 경로 탐색)으로 더 정확하지만 느리고 복잡한 컨트랙트에서 타임아웃 발생. 실무: Slither를 CI/CD에 통합하고 Mythril은 심층 분석 시 사용.
Q3. 퍼징 테스트(Fuzzing)와 단위 테스트의 차이점은?
A: 단위 테스트는 개발자가 예상하는 시나리오를 직접 작성. 퍼징은 불변량(항상 참이어야 할 조건)만 정의하면 도구가 수십만 개의 무작위 입력으로 자동 검증. 개발자가 예상하지 못한 엣지 케이스를 발견하는 데 탁월. 현업에서는 둘 다 필수.
Q4. TWAP 오라클은 모든 오라클 조작 공격을 방어할 수 있나요?
A: TWAP은 장기간 가격 조작을 어렵게 하지만 완벽하지 않습니다. 짧은 TWAP 기간(예: 5분)은 여전히 조작 가능. 저유동성 쌍은 긴 TWAP도 조작 가능. 권장: ① Chainlink Price Feed 우선 사용 ② TWAP은 30분 이상으로 설정 ③ 두 오라클 가격 편차 5% 이상 시 거래 거부.
Q5. 프록시 패턴의 storage collision 문제를 설명하고 해결 방법은?
A: Proxy와 Implementation이 같은 storage slot을 사용하면 덮어쓰기 발생. 예: Proxy의 _implementation이 slot 0이고 Implementation도 변수가 slot 0이면 충돌. 해결: ① EIP-1967 표준 — 랜덤 해시로 슬롯 지정 (예: keccak256("eip1967.proxy.implementation") - 1) ② 최신 OZ TransparentUpgradeableProxy는 자동 처리.
Q6. selfdestruct의 보안 위험과 현재 상태는?
A: selfdestruct는 컨트랙트를 파괴하고 ETH를 강제 전송(receive() 없이도 전송). 위험: ① 컨트랙트 ETH 잔액을 receive()를 우회해 강제 전송 → 잔액 기반 로직 오작동 ② contract existence 체크 우회. 현재: EIP-6780(Dencun 업그레이드)으로 selfdestruct는 동일 트랜잭션 내 생성된 컨트랙트에서만 동작하도록 제한됨.
Q7. Foundry 불변량 테스트에서 Handler 패턴을 사용하는 이유는?
A: Handler 없이 Foundry가 컨트랙트를 직접 퍼징하면 대부분의 호출이 revert되어 의미 있는 상태에 도달하지 못합니다. Handler는 유효한 입력 범위를 bound()로 제한하고, 올바른 순서(예: 출금 전 예금)를 보장하며, Ghost Variables로 외부 상태를 추적합니다. 이로 인해 퍼징 효율이 수십~수백 배 향상됩니다.
Q8. ERC-4626 Vault의 주요 보안 취약점은?
A: ① 인플레이션 공격(Inflation Attack): 첫 번째 예금자가 1 wei를 예금하고 대량의 자산을 직접 전송하여 shares 가치를 인위적으로 부풀려 후속 예금자가 0 shares를 받게 함. 방어: virtual offset 사용(OZ ERC4626 기본 적용). ② 가격 조작: totalAssets()가 조작 가능한 값을 참조할 때.
Q9. block.timestamp 사용 시 주의사항은?
A: block.timestamp는 검증자가 약 ±12초(1 slot) 범위에서 조작 가능합니다. 짧은 시간 기반 로직(예: 10초 잠금)은 취약. 반면 1분 이상의 시간 비교는 실용적으로 안전. 로또나 난수 시드로 절대 사용 금지. block.number 사용도 블록타임 가정 시 주의 필요.
Q10. delegatecall의 보안 위험을 설명하세요.
A: delegatecall은 호출된 코드가 호출자의 storage, msg.sender, msg.value 컨텍스트에서 실행됩니다. 위험: ① 악성 Implementation 컨트랙트가 Proxy의 storage를 마음대로 변경 ② storage collision으로 _owner 덮어쓰기 ③ selfdestruct 호출 시 Proxy 파괴. 방어: 신뢰할 수 있는 Implementation만 사용, EIP-1967 표준 슬롯 사용.
Q11. Code4rena에서 좋은 점수를 받기 위한 전략은?
A: ① 중복 제출 최소화 — 다른 감사자가 찾을 명백한 버그보다 독창적인 취약점 발굴. ② PoC 코드 필수 — 실제 익스플로잇 가능성 증명. ③ 심각도 근거 명확히 — 왜 High인지 자금 손실 경로 서술. ④ 코드 커버리지 높이기 — 비즈니스 로직의 엣지 케이스 집중 분석. ⑤ 상태 전환 다이어그램 그리기 — 예상치 못한 상태 발견.
Q12. 프론트런닝 방지를 위한 Commit-Reveal 패턴의 한계는?
A: ① 2단계 트랜잭션 필요 → UX 저하 ② Commit 이후 Reveal 기간 동안 시장 변화로 불리해져도 커밋 이행 강제됨 ③ Reveal 단계에서 다른 참여자 값을 보고 참여 취소 가능(Last-Revealer 문제). 대안: Flashbots private mempool, SUAVE(MEV-Share), EIP-7702 등 최신 솔루션 활용.
Q13. ERC-20 approve/transferFrom의 프런트런닝 문제와 해결책은?
A: approve(100) → 지출자가 100 사용 → approve(200)으로 변경 시, 지출자가 프런트런닝으로 100을 먼저 전송 후 변경된 200도 사용 → 총 300 탈취 가능. 해결: ① increaseAllowance/decreaseAllowance 사용 ② 먼저 0으로 재설정 후 새 값으로 설정 ③ ERC-2612 permit() 사용(서명 기반, 별도 approve 트랜잭션 불필요).
Q14. 스마트 컨트랙트 업그레이드 시 발생할 수 있는 보안 문제는?
A: ① Storage 레이아웃 변경 — 기존 storage 슬롯 순서 변경 시 데이터 손상. 해결: 항상 새 변수를 끝에 추가. ② 초기화 재공격 — 새 Implementation의 initialize()를 공격자가 먼저 호출. 해결: _disableInitializers() 생성자에 추가. ③ 신뢰 가정 붕괴 — 업그레이드 권한을 가진 주소가 악의적 코드 배포. 해결: Timelock + 다중서명으로 업그레이드 제어.
Q15. 현업 보안 감사 프로세스(풀 감사 기준)를 설명하세요.
A: ① 스코핑(1일): 감사 범위, 시간, 비용 협의. ② 자동화 분석(1일): Slither, Mythril, Semgrep 실행. ③ 수동 코드 리뷰(3~5일): 비즈니스 로직 분석, 공격 경로 탐색. ④ PoC 작성(1~2일): 발견된 취약점 익스플로잇 증명. ⑤ 리포트 작성(1일): 심각도 분류, 수정 권고. ⑥ 재감사(1~2일): 수정사항 검증. 총 2~4주 소요.
✅ 스마트 컨트랙트 보안 학습 체크리스트
🔥 공격 패턴 이해
- 재진입 공격 PoC 작성
- 크로스 함수 재진입 이해
- 플래시 론 오라클 조작
- 거버넌스 공격 시나리오
- ERC-20 approve 프런트런
- 정수 오버/언더플로우
🛡️ 방어 패턴 구현
- CEI 패턴 + nonReentrant
- Chainlink 오라클 연동
- TWAP 오라클 구현
- AccessControl RBAC 구현
- Commit-Reveal 구현
- Timelock 거버넌스 배포
🔧 도구 활용
- Slither CI/CD 통합
- Echidna 퍼징 테스트 작성
- Foundry 불변량 테스트
- Handler 패턴 구현
- Mythril 심층 분석
- Semgrep 규칙 작성
📋 커리어
- 감사 보고서 1개 작성
- Code4rena 콘테스트 참여
- ImmuneFi 등록
- GitHub 보안 포트폴리오
- 면접 Q&A TOP 15 암기
- 역대 해킹 사례 분석 블로그
🎉 BlockchainDevGuide0006 완료!
재진입 공격, 오라클 조작, 플래시 론 공격, Slither, Echidna, Foundry 불변량 테스트,
감사 보고서 작성, 버그 바운티까지 — 보안 전문가로 가는 완벽한 로드맵을 완주했습니다!
📖 마지막 편 예고
BlockchainDevGuide0007
실전 프로젝트 완전 정복 A-Z
DeFi·NFT·DAO 풀스택 완성 + 포트폴리오 + 취업 전략