Solidity 보안 기초와 학습맵
도입
Solidity 보안은 취약점 이름을 외우는 일이 아니다. 돈의 상태가 여러 actor와 여러 함수 호출 순서에서도 깨지지 않는지 검증하는 일이다. 스테이블코인과 checkout contract는 잔고, allowance, mint/burn 권한, freeze/pause, 서명 nonce, refund/settlement 상태를 동시에 다루기 때문에 작은 누락이 곧 정산 사고로 이어질 수 있다.
이 강의는 보안 트랙의 시작점이다. 목표는 완벽한 auditor가 되는 것이 아니라, 코드를 처음 열었을 때 어디부터 표시해야 하는지 배우는 것이다. 어떤 자산이 위험한지, 누가 움직일 수 있는지, 어떤 상태가 깨지면 안 되는지, 외부 호출과 시간이 개입하는지부터 본다.
학습 목표
- Solidity 보안의 기본 위험 범주를 설명한다.
- 스테이블코인 코드 리뷰의 첫 번째 pass를 수행한다.
- asset, authority, state, interaction, time, upgrade, operations 관점으로 리뷰 메모를 작성한다.
개념 설명
핵심 가정 오류
권한 경계가 테스트되는가
운영 상태 누락
불변조건이 실패 상태를 잡는가
학습 산출물 미흡
Security first-pass 리뷰표 만들기
1. 보안 트랙은 리뷰 관점에서 읽는다
Security Testing 코스는 아래 순서로 이어진다. 각 강의는 하나의 질문에 답한다.
| 순서 | 강의 | 핵심 질문 |
|---|---|---|
| 1 | Solidity 보안 기초 | 이 코드에서 무엇이 위험한가? |
| 2 | AccessControl/Pausable | 누가 mint, burn, freeze, pause, upgrade할 수 있는가? |
| 3 | Foundry fuzz/invariant | 어떤 호출 순서에서도 깨지면 안 되는 성질은 무엇인가? |
| 4 | Slither/Echidna workflow | 도구 결과를 어떻게 triage할 것인가? |
| 5 | 보안 리뷰 체크리스트 | 리뷰 메모를 release gate로 어떻게 바꿀 것인가? |
| 6 | ECDSA/EIP-712 | 서명이 다른 chain, contract, nonce에서 재사용되지 않는가? |
| 7 | Upgradeable proxy | code와 storage가 바뀌어도 돈의 상태가 유지되는가? |
| 8 | Monitoring runbook | 배포 후 mint/freeze/upgrade 이상을 어떻게 감지하는가? |
2. 먼저 잡을 mental model
코드를 읽기 전에 아래 일곱 가지 관점을 표로 만든다.
| 관점 | 질문 | 스테이블코인/checkout 예시 |
|---|---|---|
| Asset | 무엇이 위험한가? | user balance, merchant settlement, escrowed funds |
| Authority | 누가 움직일 수 있는가? | minter, burner, freezer, pauser, upgrader, relayer |
| State | 어떤 상태가 깨지면 안 되는가? | totalSupply, payment status, refund idempotency |
| Interaction | 외부 호출과 callback이 있는가? | token transfer, receiver hook, oracle, bridge |
| Time | deadline, finality, challenge window가 있는가? | permit deadline, ERC-3009 validBefore, CCTP finality |
| Upgrade | code나 storage가 바뀔 수 있는가? | proxy implementation, storage layout, admin key |
| Operations | 배포 후 누가 감시하고 멈출 수 있는가? | monitoring, pause, manual review, timelock |
이 표는 리뷰를 빠르게 만든다. 예를 들어 refund() 함수를 보면 Asset은 escrowed funds, Authority는 payer/merchant/admin, State는 Refunded, Interaction은 token transfer, Time은 refund deadline, Operations는 failed refund queue가 된다.
3. Solidity 공식 문서가 주는 기준
Solidity 공식 문서는 스마트컨트랙트가 토큰이나 더 가치 있는 것을 다룰 수 있고, 실행과 소스가 공개되는 경우가 많으므로 예상하지 못한 사용을 막는 것이 특히 중요하다고 설명한다. 여기서 중요한 태도는 "정상 입력에서 잘 된다"보다 "예상하지 못한 actor와 호출 순서에서도 깨지지 않는다"를 확인하는 것이다.
4. 주요 위험군은 stablecoin 예시로 연결한다
| 위험 | 설명 | 스테이블코인 예시 |
|---|---|---|
| Reentrancy | 외부 호출이 다시 contract를 호출 | refund/withdraw 중 중복 출금 |
| Access control | 권한 체크 누락 | 아무나 mint/freeze 가능 |
| Approval race | allowance 변경 경쟁 | spender가 이전 allowance 사용 |
| Signature replay | 같은 서명 재사용 | permit/ERC-3009 nonce 누락 |
| Domain replay | 다른 chain/contract에서 재사용 | 잘못된 EIP-712 domain |
| Gas/loop DoS | loop가 커져 실행 불가 | holders 전체 순회 |
| Upgrade risk | storage/logic 변경 | token accounting 깨짐 |
| Oracle/price risk | 외부 가격 잘못 사용 | RWA NAV, collateral valuation |
위험군을 외우는 것만으로는 부족하다. 각 위험군이 어떤 함수와 상태에 연결되는지 표시해야 한다. 예를 들어 signature replay는 permit, transferWithAuthorization, x402 settlement에서 nonce와 domain 검증으로 내려간다.
5. Checks-Effects-Interactions는 상태 전이 언어로 읽는다
공식 Solidity 문서는 reentrancy 방어의 기본으로 checks-effects-interactions 패턴을 제시한다. 결제 코드에서는 아래처럼 읽는다.
| 단계 | 리뷰 질문 | Checkout 예시 |
|---|---|---|
| Checks | 권한, 입력값, 상태가 맞는가? | payment가 Paid이고 refund window 안인가? |
| Effects | 내부 상태를 먼저 바꾸는가? | RefundRequested 또는 Refunded로 전이하는가? |
| Interactions | 외부 호출 실패와 callback을 다루는가? | token transfer 실패 시 RefundFailed로 남기는가? |
주의할 점은 항상 상태를 먼저 바꾸라는 기계적 규칙이 아니라는 것이다. 외부 token transfer 실패 시 전체 transaction을 revert할지, 별도 failed state로 남길지 제품 요구사항과 회계 정책을 함께 봐야 한다.
6. Fail-safe mode는 운영 기능이다
Solidity 공식 문서는 fail-safe mode도 권장한다. 스테이블코인/결제 시스템에서 fail-safe는 단순한 pause() 함수가 아니라 사용자의 돈을 더 큰 손실로부터 보호하는 운영 기능이다.
| Fail-safe | 막으려는 사고 | 리뷰 포인트 |
|---|---|---|
| Pause | 전역 결제/정산 오류 확산 | pause 권한, paused 중 refund 정책 |
| Freeze | 특정 주소 제재/도난 자금 이동 | sender/receiver/spender/authorizer 모두 막는가 |
| Emergency withdrawal | escrow 자금 장기 고착 | 누가, 언제, 어떤 증거로 실행하는가 |
| Manual review queue | 자동 처리 위험 | 어떤 상태가 queue로 들어가는가 |
| Rate limit | 대량 mint/withdrawal 사고 | 한도 변경 권한과 event monitoring |
| Admin timelock | 악성/오류 upgrade 지연 | delay, proposer/executor, emergency path |
| Monitoring alert | 사고 조기 감지 | role, mint, freeze, pause, upgrade event |
7. 작은 contract가 좋은 이유
복잡한 contract는 테스트와 리뷰가 어렵다. 결제 시스템도 한 contract에 모든 기능을 넣지 말고 역할을 나눈다. 역할이 나뉘면 권한과 invariant도 더 선명해진다.
| Contract | 맡는 역할 | 먼저 볼 위험 |
|---|---|---|
| Stablecoin | balance, mint/burn, freeze, permit, authorization | supply, role, freeze bypass, nonce |
| Checkout | invoice와 payment state | double payment, stale invoice, relayer failure |
| Escrow | 보관, claim, refund | reentrancy, stuck funds, refund idempotency |
| Router | chain/token route 선택 | wrong chain, fake token, slippage |
| Policy | spend limit, allowlist | admin abuse, stale config, missing event |
8. 첫 리뷰표 예시
| 함수 | Asset | Authority | State change | External interaction | 첫 질문 |
|---|---|---|---|---|---|
mint(to, amount) | totalSupply, user balance | MINTER_ROLE | supply 증가 | 없음 | reserve/issuer approval 없이 호출 가능한가? |
freeze(account) | account mobility | FREEZER_ROLE | frozen flag | 없음 | permit/ERC-3009 경로도 막히는가? |
permit(owner, spender, value, deadline, sig) | allowance | signer | nonce/allowance | 없음 | domain, nonce, deadline이 맞는가? |
payWithAuthorization(...) | user balance, merchant receivable | signer/relayer | payment status | token transfer | relayer 지연과 nonce 사용 상태를 분리하는가? |
refund(paymentId) | escrow/merchant ledger | payer/admin/system | refund status | token transfer | double refund와 transfer failure를 어떻게 처리하는가? |
이 표가 첫 pass의 제출물이다. 아직 exploit을 찾지 못해도 된다. 먼저 리뷰 대상 함수와 위험을 분류해야 다음 강의에서 role matrix, invariant, fuzz target을 만들 수 있다.
9. 첫 invariant 후보
| 영역 | invariant 후보 |
|---|---|
| Supply | totalSupply는 mint와 burn으로만 변하고, balances 합과 설명 가능한 차이만 가진다. |
| Freeze/Pause | frozen account는 transfer, transferFrom, permit, ERC-3009 경로로 자산을 움직일 수 없다. |
| Nonce | permit/ERC-3009 nonce는 재사용되지 않고 chain/contract domain 밖에서 유효하지 않다. |
| Payment | 하나의 payment intent는 한 번만 Paid가 되고, refund는 원 결제 금액을 초과할 수 없다. |
| Settlement | merchant settlement는 Paid 또는 Delivered 상태 없이 실행되지 않는다. |
강의 포인트
| 관점 | 강의 중 확인할 질문 | 학습 후 남길 증거 |
|---|---|---|
| Asset | 어떤 돈이나 권리가 위험한가? | asset list |
| Authority | 누가 상태를 바꾸는가? | role/function matrix |
| State | 어떤 상태가 절대 깨지면 안 되는가? | invariant 후보 |
| Interaction | 외부 호출과 실패 상태가 있는가? | CEI/reentrancy review note |
| Operations | 멈추고 복구하는 방법이 있는가? | fail-safe 표 |
실무 예시
컨트랙트상황: MockStablecoinCheckout 코드 리뷰를 맡았다. contract에는 mint, freeze, permit, pay, refund, settleMerchant가 있다. 첫날 해야 할 일은 모든 취약점을 찾는 것이 아니라, 각 함수가 어떤 위험 영역에 속하는지 표시하는 것이다.
| 함수 | 첫 pass에서 남길 메모 |
|---|---|
mint | role check, mint event, supply invariant, reserve approval assumption |
freeze | bypass path, approval/permit side effect, support flow |
permit | EIP-712 domain, deadline, nonce, signer recovery |
pay | payment state, token transfer failure, relayer replay |
refund | idempotency, CEI, failed transfer state, merchant ledger |
settleMerchant | authorization, settlement amount, slippage/fee, double settlement |
이 메모가 있어야 Foundry invariant와 Slither/Echidna triage가 의미를 가진다. 도구를 먼저 돌리면 warning은 나오지만, 무엇이 진짜 출시 차단 이슈인지 판단하기 어렵다.
코드로 확인하기
앞의 보안 기준을 코드와 테스트로 확인한다. 함수가 어떤 전제를 세우고, 테스트가 어떤 실패 조건을 고정하는지 함께 읽는다.
컨트랙트Checks-Effects-Interactions를 결제 환불에 적용
환불 함수 한 줄이 CEI 패턴의 핵심 차이를 만든다. 외부 token transfer 전에 status를 먼저 바꿔야 reentry 경로가 막힌다.
// 잘못된 예 — interaction이 effect 전에 일어난다function refund(bytes32 paymentId) external { Payment storage p = payments[paymentId]; if (p.status != Status.Paid) revert(); IERC20(p.token).transfer(p.payer, p.amount); // 외부 호출 먼저 p.status = Status.Refunded; // 상태 변경이 나중}// CEI에 맞춘 예 — checks → effects → interactionsfunction refund(bytes32 paymentId) external nonReentrant { Payment storage p = payments[paymentId]; if (p.status != Status.Paid) revert PaymentNotPaid(paymentId); if (msg.sender != p.merchant) revert OnlyMerchant(msg.sender); p.status = Status.Refunded; // 효과 먼저 확정 emit PaymentRefunded(paymentId); IERC20(p.token).safeTransfer(p.payer, p.amount); // 외부 호출은 마지막}컨트랙트함수 단위 리뷰표를 코드 주석으로 박기
리뷰표를 별도 문서로 만드는 것보다 NatSpec에 박아두면 PR diff에 같이 보이고 staleness가 줄어든다.
/// @notice 결제 환불을 실행한다./// @dev Asset: escrowed funds in this contract/// Authority: payment.merchant 만 호출 가능/// State: Status.Paid -> Status.Refunded (단방향)/// Interaction: token.safeTransfer 1회 (CEI 마지막 단계)/// Time: refund 기한은 invoice 만료 시점까지/// Invariant: 같은 paymentId 에 대해 PaymentRefunded 는 최대 1회 emitfunction refund(bytes32 paymentId) external nonReentrant { ... }운영Fail-safe 모드 진입/이탈 절차
pause는 단순 토글이 아니다. 진입 시점에 어떤 상태를 동결할지, 이탈 후 백로그를 어떻게 처리할지가 같이 결정되어야 한다.
# 1) 이상 신호 확인 (monitoring 대시보드)forge cast call $MOCK_STABLE "paused()" --rpc-url $RPC# 2) PAUSER_ROLE 보유 주소에서 pause 실행forge cast send $MOCK_STABLE "pause()" --private-key $PAUSER_KEY --rpc-url $RPC# 3) 후속 조치 — refund 큐 동결 여부 / 운영자 통보 / 외부 공지# 4) 원인 해소 후 unpause + 백로그 재처리 절차 문서화forge cast send $MOCK_STABLE "unpause()" --private-key $PAUSER_KEY --rpc-url $RPC흔한 오해와 실패 시나리오
| 오해 | 실제로 확인할 것 |
|---|---|
| 취약점 이름을 많이 알면 리뷰가 된다고 생각한다. | asset, authority, state, interaction 표가 먼저다. |
| unit test가 통과하면 보안 테스트가 끝났다고 본다. | 호출 순서, actor, time, upgrade, operations를 따로 흔들어야 한다. |
pause가 있으면 fail-safe가 완성됐다고 본다. | paused 중 refund, emergency withdrawal, manual review 정책이 필요하다. |
| 도구가 찾아주는 항목만 고치면 된다고 본다. | 도구는 triage 입력이고 business logic invariant는 사람이 정의해야 한다. |
실습 과제
- 컨트랙트Security first-pass 리뷰표 만들기: Mock stablecoin 또는 checkout contract의 함수 목록을 asset, authority, state change, external interaction, time condition, fail-safe 여부로 분류한다.
- 컨트랙트첫 invariant 후보 작성하기: supply/balance, freeze/pause, permit/ERC-3009 nonce, refund/settlement 중 최소 3개 invariant 후보를 문장으로 작성한다.
- 운영Fail-safe 상태 설계하기: pause, freeze, emergency withdrawal, manual review, rate limit, monitoring alert가 각각 어떤 사고를 막는지 checkout 예시로 정리한다.
완료 기준
- 기본 취약점 범주를 설명했다.
- 리뷰 대상 함수 목록을 작성했다.
- 첫 불변식 후보를 3개 적었다.
- 외부 호출, 권한 함수, fail-safe 상태를 표시한 리뷰 표를 만들었다.
근거 자료
- 보안 테스트 학습맵: 02-보안-테스트/00-보안-테스트-학습맵.md
- Solidity 보안 기초: 02-보안-테스트/01-Solidity-보안-기초.md
- Solidity Security Considerations: https://docs.soliditylang.org/en/latest/security-considerations.html