Solidity 계약의 산술 오류 방지 방법

작성자

Solidity 스마트 계약에서 산술 오류는 토큰, 지분, 가격 계산 시 잘못된 공식, 소수점 단위, 반올림 방향 또는 수치 가정을 사용하여 사용자가 과도한 청구권을 발행하거나, 예치한 금액보다 더 많은 가치를 인출하거나, 회계 불변식이 깨지게 되는 경우 발생합니다. 이를 방지하려면 단위를 명확히 정의하고, 고정 소수점 산술을 사용하며, 공식이 반드시 유지해야 할 경제적 불변식들을 테스트해야 합니다.

그 영향은 매우 큽니다. Cetus CLMM은 2025-05-22에 Sui 체인에서 $2억 2,300만 손실을 입었으며, 이는 토큰 및 지분 회계 관련 산술 오류로 분류되었습니다. 산술적 안정성은 단순히 Solidity 오버플로우 리버트 방지를 의미하지 않습니다. 모든 발행된 지분이 자산에 대한 방어 가능한 청구권을 나타내도록 보장하는 문제입니다.

Solidity에서 산술 오류란 무엇이며 공격은 어떻게 작동하는가?

Solidity 산술 오류는 예치, 인출, 잔고, 가격, 수수료, 유동성을 경제적 청구로 변환하는 계산에 결함이 있는 경우입니다. 공격자는 의도된 회계 모델과 구현된 공식 간 불일치를 이용하며, 종종 거래 순서, 자산 양, 풀 상태를 조작해 잘못된 변환이 자신에게 유리하게 작용하도록 만듭니다.

현대 Solidity에서는 오버플로우 및 언더플로우가 기본적으로 리버트됩니다. 이런 보호 기능은 도움이 되지만, 공식이 경제적으로 올바르다는 것을 증명하지는 못합니다. 계산 결과가 uint256 범위 내에 있어도, 지나치게 많은 지분이 발행되거나 담보가 부족하게 청구되거나 인출 금액이 사용자에게 유리하게 반올림될 수 있습니다.

과도한 지분 발행 공격은 일반적으로 다음 절차를 따릅니다:

  1. 금고에 기존 자산과 지분이 있어 각 지분의 가격이 1이 아님.
  2. 금고가 수익, 수수료, 기부, 리베이스 잔고 증가를 받음.
  3. 총 자산 증가로 지분 가격이 상승하지만 지분 총액은 변하지 않음.
  4. 공격자가 “1 지분 = 1 자산”이라고 가정하는 공식으로 자산을 예치함.
  5. 금고가 현재 가격 대비 예치 금액보다 더 많은 지분을 발행함.
  6. 공격자가 과도한 지분을 인출하여 풀 내 자산 중 불균형적인 몫을 회수함.

핵심 불변식은 간단합니다. 예치 후 사용자의 소유권 비율은 그가 기여한 가치에 부합해야 합니다. 예치 금액이 풀 자산의 1%임에도 불구하고 1% 이상의 지분을 받으면 기존 보유자들의 지분 희석이 발생합니다.

Solidity에서 어떤 패턴이 토큰 및 지분 회계 오류를 발생시키는가?

토큰과 지분 회계 오류는 일반적으로 단위 혼합, 중간 값의 절삭, 변하는 지분 가격을 고정 값으로 취급하는 데서 발생합니다. 위험도가 높은 함수는 deposit, mint, withdraw, redeem, 차입 한도, 수수료 정산, 유동성 계산, 보상 분배 등입니다.

위험한 패턴 중 하나는 예치한 자산 단위로부터 직접 지분을 발행하는 경우입니다:

shares = assets * 1e18 / assetScale;

이 변환은 금고가 처음 만들어질 때 기준 단위를 “1 지분 = 1 정규화된 자산 단위”로 정의한 경우에만 적절할 수 있습니다. 금고가 수익을 올리거나 손실이 발생하면 예치 시 현재 환율을 반드시 반영해야 합니다:

shares = assets * totalShares / totalAssets;

심지어 이 공식도 신중한 처리가 필요합니다. 곱셈을 먼저 하면 unchecked 내에서 오버플로우가 발생할 수 있고, 나눗셈을 먼저 하면 정밀도 손실이 발생할 수 있습니다. 소수점 정규화도 오류 요소입니다. USDC형 자산은 일반적으로 18소수점인 지분 토큰보다 소수점 자릿수가 적으며, 프로토콜은 원시 토큰 단위와 정규화된 회계 단위를 일관되게 구분해야 합니다.

주요 원인은 다음과 같습니다:

  • 자산의 소수점 자릿수와 다른 1e18을 하드코딩함
  • 지분 가격이 바뀐 후에도 고정 1:1 비율로 지분을 발행함
  • 곱하기 전에 나누기를 하여 의미 있는 정밀도를 무음으로 잃음
  • 예치 또는 인출 시 무조건 반올림하여 누적 가치 누수 발생
  • 사용자 제어 값이나 스케일링 인자에 unchecked를 사용함
  • 토큰 잔고는 일반적인 입출금 흐름 외에는 변하지 않는다고 가정함
  • 수수료 전송, 리베이스, 수익 발생 토큰이 캐시된 회계를 무효화할 수 있음을 간과함

Solidity 지분 계산의 산술 오류를 어떻게 고치는가?

산술 오류를 고치려면 자산 단위를 정규화하고, 현재 풀 비율에 따라 지분을 계산하며, 고정 소수점 곱셈과 나눗셈을 사용하고, 기존 보유자에게서 새로운 예치자로 가치 이전이 발생하지 않는 반올림을 선택해야 합니다. 또한 계약은 자산 잔액과 지분 공급량을 연계된 회계 상태로 취급해야 합니다.

아래 취약한 금고는 조정 가능한 소수점 자릿수를 가진 토큰을 받습니다. reportYield가 자산을 늘렸음에도 불구하고 고정 비율로 정규화된 지분을 발행합니다. 예치자는 수익이 쌓인 후 과도한 지분을 받고 높은 금액의 풀에서 인출할 수 있습니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}

contract VulnerableShareVault {
    IERC20 public immutable asset;
    uint8 public immutable assetDecimals;
    uint256 public totalAssets;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    constructor(address asset_, uint8 decimals_) {
        asset = IERC20(asset_);
        assetDecimals = decimals_;
    }

    function deposit(uint256 assets) external {
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");

        uint256 scale = 10 ** assetDecimals;
        // 취약점: 기존 지분의 현재 가치를 무시함.
        uint256 minted = (assets * 1e18) / scale;

        totalAssets += assets;
        totalShares += minted;
        shares[msg.sender] += minted;
    }

    function reportYield(uint256 assets) external {
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
        totalAssets += assets;
    }

    function withdraw(uint256 shareAmount) external {
        uint256 assets = (shareAmount * totalAssets) / totalShares;
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalAssets -= assets;
        require(asset.transfer(msg.sender, assets), "transfer failed");
    }
}

수정된 버전은 원시 자산 단위를 18소수점 회계 단위로 변환하고 현재 환율에 따라 지분을 발행합니다. OpenZeppelin의 고정 소수점 Math.mulDiv를 사용하며, 입출금 모두 바닥값(floor)으로 반올림하고, 지원하는 소수점 범위를 제한합니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}

contract FixedShareVault {
    IERC20 public immutable asset;
    uint256 public immutable assetScale;
    uint256 public totalAssets;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    constructor(address asset_, uint8 decimals_) {
        require(decimals_ <= 18, "unsupported decimals");
        asset = IERC20(asset_);
        assetScale = 10 ** decimals_;
    }

    function deposit(uint256 assets) external {
        require(assets > 0, "zero assets");
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");

        uint256 normalizedAssets = Math.mulDiv(assets, 1e18, assetScale);
        uint256 normalizedPool = Math.mulDiv(totalAssets, 1e18, assetScale);

        // 수정: 예치 이전 풀 비율에 따라 지분 가격 책정.
        uint256 minted = totalShares == 0
            ? normalizedAssets
            : Math.mulDiv(normalizedAssets, totalShares, normalizedPool);

        require(minted > 0, "deposit too small");
        totalAssets += assets;
        totalShares += minted;
        shares[msg.sender] += minted;
    }

    function withdraw(uint256 shareAmount) external {
        uint256 assets = Math.mulDiv(shareAmount, totalAssets, totalShares);
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalAssets -= assets;
        require(asset.transfer(msg.sender, assets), "transfer failed");
    }
}

프로토콜이 자체 산술로 예치, 인출, 담보, 유동성을 가격 책정한다면, 스마트 계약 감사 및 보안 평가를 통해 전체 거래 수명주기 동안 단위 변환과 경제적 불변식을 추적할 수 있습니다.

개발자는 어떻게 산술 오류를 방지할 수 있나?

개발자는 공식을 구현하기 전 회계 단위를 명확히 지정하고, 보수적 반올림을 적용하며, 모든 변환을 보안 경계로 간주하여 산술 오류를 줄일 수 있습니다. 공식은 각 입력이 무엇을 의미하는지, 소수점 정밀도, 허용 범위와 반올림의 이득이 누구에게 돌아가는지를 명확히 해야 합니다.

기법 방지 효과 가스 또는 복잡성 비용 제한 사항
명시적 단위 정규화 원시 토큰 단위와 18소수점 지분 단위 혼합 곱셈 또는 나눗셈 추가 문서화된 소수점 정책 필요
Math.mulDiv 사용 a * b / c 계산에서 오버플로우 및 정밀도 손실 방지 단순 연산자보다 가스 더 소모 경제 공식 유효성 검증 아님
입출금 시 바닥값(floor) 반올림 지분 과발행 및 과다 인출 방지 미세 잔액 발생 가능 미세 잔액 회수 정책 필요
최소 지분 발행 임계치 0 지분으로 반올림되는 예치 방지 한 번의 비교 연산 소규모 정당 사용자 차단 주의
입력값 및 소수점 범위 제한 불안전한 지수 연산과 극단적 입력 차단 검증 검사 추가 제한 범위는 지원 자산과 일치해야 함
불변식 테스트 풀 회계 위반 상태 전이 탐지 테스트 작성 노력 필요 불변식이 공격 시퀀스를 포괄해야 함

가능한 경우 풀에 부담이 가지 않는 방향으로 바닥값 반올림을 선택하세요. 예를 들어, 금고는 예치된 지분과 인출된 자산 모두를 일반적으로 내림합니다. 사용자에 유리한 반올림이 필요하다면, 이를 명시적으로 보조금 형태로 지원해야 하며, 무임승차를 막아야 합니다.

보안 인사이트: Soken 방법론에 따르면, 산술 검토는 연산자가 아니라 단위와 보존 법칙에서 시작합니다. 확인된 곱셈도 결과가 잘못된 자산, 지분, 가격, 소유권 청구권이라면 취약할 수 있습니다.

산술 오류가 실재 사건에서 어떻게 영향을 미쳤나?

산술 오류 사건은 토큰과 지분 회계가 단순 코드 품질 문제가 아닌 프로토콜 전반의 보안 속성임을 보여줍니다. 문제는 AMM 준비금, 대출 보상, 금고 지분, 유동성 계산, 자산 변환 루틴 등 광범위한 로직에 있을 수 있습니다.

프로토콜 날짜 손실 금액 체인
Cetus CLMM 2025-05-22 $223.0M Sui
Compound V2 2021-09-29 $147.0M Ethereum
Uranium Finance 2021-04-28 $57.2M BSC
ValueDefi 2021-05-07 $11.0M BSC
KiiChain 2026-08-22 $9.7M KiiChain
Truebit 2026-01-08 $26.4M Ethereum
Notional V2 2026-09-04 $1.7M Ethereum
USM 2026-08-09 $136K Ethereum

이 사건들에서 공통점은 온체인 가치 배분이나 평가를 담당하는 계산이었으며, 무단 uint256 덧셈이 아니었습니다. 토큰과 지분 회계에서 취약점은 종종 준비금, 공급, 가격, 사용자 청구권 간 잘못된 관계입니다.

DefiLlama는 2020년부터 2026년까지 35건의 스마트 계약 산술 오류 사건을 기록하며, 손실 총액은 $5억 6,100만에 이릅니다. 프로토콜 팀은 접근 제어나 외부 호출 처리와 동일한 우선순위로 회계 경로를 검토해야 합니다.

배포 전 산술 오류를 어떻게 탐지하나?

산술 오류는 수동으로 공식만 검토하는 것보다 상태 전이 검토 및 다양한 시퀀스에서 경제적 속성 테스트를 통해 더 잘 탐지됩니다. 정적 분석은 위험한 연산자와 unchecked 블록을 찾아내고, 퍼징 및 불변식 테스트는 예치, 인출, 수익, 손실, 비정상 소수점 값을 반영하는 회계 모델의 타당성을 평가합니다.

중점 검토 체크리스트:

  1. 모든 자산, 지분, 가격, 비율, 수수료 변수에 단위와 소수점 표기를 기록한다.
  2. 모든 예치가 예치 전 환율을 사용하는지 확인한다.
  3. 모든 인출이 소각되는 지분과 일치하는 환율을 사용하는지 확인한다.
  4. 회계 경로 내 원시 *, /, unchecked, 하드코딩된 스케일 상수를 검색한다.
  5. 서로 다른 소수점 자릿수 자산과 최소 예치 금액 근처를 테스트한다.
  6. 반올림이 과도한 지분 발행이나 자산 방출을 초래하지 않는지 확인한다.
  7. 사용자 동작 전 리베이스, 기부, 수수료, 수익에 따른 잔고 변화를 모델링한다.
  8. 전체 사용자 청구액이 인출 가능 자산을 초과하지 않는지 검사한다.

Slither는 위험한 산술 패턴, unchecked 블록, 의심스러운 정밀도 연산을 탐지하지만, 지분 가격 공식이 경제적으로 타당한지 판단할 수 없습니다. Foundry 퍼징은 totalAssets가 모든 청구를 덮으며, 예치 직후 인출 시 합법적 수익 없이 이익을 내지 못하는지 단언문과 함께 사용할 때 더욱 효과적입니다.

불변식 테스트는 다중 사용자 시퀀스(예치 → 수익 보고 → 인출 → 일부 인출 → 기부 → 반복 소액 예치)를 생성해야 합니다. Soken 검토 관행은 각 상태 업데이트 전후에 의도된 수학 모델을 비교하는데, 올바른 공식이라도 상태 변경 후가 아닌 전 단계에 적용하면 지분 가격이 잘못 책정될 수 있기 때문입니다.

산술 안전성은 모든 변환 과정에서 경제적 진실을 유지하는 훈련입니다. Solidity 0.8의 체크 산술을 사용해도 잘못된 가격으로 지분을 발행하거나 잘못된 소수점을 가정하거나 가치를 외부로 반올림하면 취약해질 수 있습니다.

단위와 불변식을 모든 회계 변수에 먼저 작성하고, 추가 자산 유형이나 가격 경로를 넣기 전에 Foundry 테스트로 부호화하세요. Solidity 보안 패턴에 대한 더 깊은 연구는 Soken Hub에서 확인할 수 있습니다.

작성자

자주 묻는 질문

Solidity 스마트 계약의 산술 오류란 무엇인가요?

Solidity 산술 오류는 입금, 출금, 잔액, 가격, 수수료, 유동성, 토큰, 지분 계산에서 발생하는 결함입니다. 잘못된 공식, 소수점 스케일, 반올림 방향, 숫자 가정으로 과다 청구, 과도한 출금, 회계 불변성 실패가 발생할 수 있습니다.

산술 오류가 사용자에게 과도한 출금을 허용하는 이유는?

잘못된 공식, 소수점 단위, 반올림 방향, 숫자 가정이 적용된 계산은 사용자에게 실제 자산 이상을 청구하거나 출금하도록 합니다. 이로 인해 토큰, 지분, 가격 계산이 필요 이상의 경제적 청구권을 부여합니다.

Solidity에서 산술 오류를 어떻게 예방할 수 있나요?

각 계산의 단위를 명확히 정의하고, 정밀한 제한 산술을 사용하며, 경제 불변성을 테스트하여 산술 오류를 방지합니다. 이는 공식, 소수점 단위, 반올림, 숫자 가정 오류로 인한 잘못된 청구를 막습니다.

오버플로우 리버트만으로 산술 오류를 막을 수 있나요?

오버플로우 리버트만으로는 충분하지 않습니다. 올바른 공식, 명확한 단위, 적절한 소수점 단위, 올바른 반올림 방향, 건전한 숫자 가정도 필요합니다. 리버트를 피해도 부당한 청구권이 생길 수 있습니다.

Cetus CLMM 손실 사건이 산술 오류에 대해 알려주는 점은?

Cetus CLMM은 Sui에서 $223M 손실을 입었으며, 이는 토큰과 지분 회계와 관련된 산술 오류로 분류됩니다. 이 사건은 산술 안전이 단순 오버플로우 방지뿐 아니라 청구권의 정당성 확보임을 보여줍니다.

채팅