Back to Blog

심층 분석: 트루빗 사건

Code Auditing
2026년 1월 14일
9 min read

2026년 1월 8일, 이더리움의 Truebit 프로토콜이 익스플로잇되어 약 2,600만 달러의 손실이 발생했습니다 [1]. 근본 원인은 TRU 토큰 구매 가격 산정 로직의 정수 오버플로우였습니다. 해당 컨트랙트가 기본적으로 오버플로우 검사를 강제하지 않는 Solidity v0.6.10으로 컴파일되었기 때문에, 구매 비용 계산 과정의 큰 중간 값이 훨씬 작은 값으로 래핑되었습니다. 그 결과, 공격자는 적은 ETH 또는 심지어 0 ETH로 매우 많은 양의 TRU를 구매한 뒤, 획득한 TRU를 즉시 컨트랙트에 유리한 가격으로 다시 매도하여 프로토콜 준비금을 고갈시킬 수 있었습니다.

0x0 배경

Truebit은 오프체인 연산과 인터랙티브 검증을 통해 이더리움에 연산 서비스를 제공합니다 [2]. 프로토콜 내에서 TRU 토큰은 스테이킹 및 작업 관련 결제를 포함한 인센티브 조율을 위한 핵심 경제적 수단으로 기능합니다.

프로토콜은 TRU 구매 및 환매를 위한 두 가지 퍼블릭 함수를 제공합니다:

  • buyTRU()는 TRU 구매를 실행합니다. 필요한 ETH 비용은 getPurchasePrice()에서도 사용되는 내부 가격 산정 함수로 계산되므로, getPurchasePrice()는 구매 실행 시 적용되는 정확한 온체인 가격 산정 로직을 반영합니다.

  • sellTRU()는 TRU 매도(환매)를 실행합니다. 예상 ETH 지급액은 getRetirePrice()를 통해 조회할 수 있습니다.

주요 설계 측면은 가격 책정의 비대칭성입니다:

  • 구매는 볼록 본딩 커브를 사용합니다 (공급이 증가할수록 한계 가격이 상승).
  • 매도는 선형 환매 규칙을 사용합니다 (준비금에 비례).

구현 컨트랙트의 소스 코드는 공개되어 있지 않으므로, 이하 분석은 디컴파일된 바이트코드를 기반으로 합니다.

구매 로직

buyTRU() 함수(및 getPurchasePrice() 함수)는 가격 산정을 프라이빗 함수 _getPurchasePrice()에 위임하며, 이 함수는 amount만큼의 TRU를 구매하는 데 필요한 ETH를 계산합니다.

function buyTRU(uint256 amount) public payable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // 구매 가격 조회
    require(msg.value == v0, Error('ETH payment does not match TRU order'));
    v1 = 0x18ef(100 - _setParameters, msg.value);
    v2 = _SafeDiv(100, v1);
    v3 = _SafeAdd(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4 = stor_97_0_19.mint(msg.sender, amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    return msg.value;
}

function getPurchasePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getPurchasePrice(amount); // 구매 가격 조회
    return v0;
}

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// 분모 = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// 분자_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// 분자_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // 구매가격 = (분자_1 + 분자_2) / 분모
    return v13;
}

디컴파일된 로직으로부터, 구매 가격은 다음과 같은 본딩 커브 형태의 함수로 표현할 수 있습니다:

여기서,

  • amount: 구매할 TRU 수량
  • reserve (_reserve): 컨트랙트의 이더 준비금
  • totalSupply: TRU의 총 공급량
  • θ (_setParameters): 계수, 75로 고정

이 커브는 대규모 구매를 점점 더 비싸게 만들어(볼록 비용 증가) 투기를 억제하고 즉각적인 매수 측 조작을 줄이기 위해 설계되었습니다.

매도 로직

sellTRU() 함수(및 getRetirePrice() 함수)는 프라이빗 함수 _getRetirePrice()를 사용하여 TRU 환매 시 지급되는 ETH를 계산합니다.

function sellTRU(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.allowance(msg.sender, address(this)).gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    require(RETURNDATASIZE() >= 32);
    require(v1 >= amount, Error('Insufficient TRU allowance'));
    v2 = _getRetirePrice(amount); // 환매 가격 조회
    v3 = _SafeSub(v2, _reserve);
    _reserve = v3;
    require(bool(stor_97_0_19.code.size));
    v4, /* uint256 */ v5 = stor_97_0_19.transferFrom(msg.sender, address(this), amount).gas(msg.gas);
    require(bool(v4), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    require(RETURNDATASIZE() >= 32);
    require(bool(stor_97_0_19.code.size));
    v6 = stor_97_0_19.burn(amount).gas(msg.gas);
    require(bool(v6), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    v7 = msg.sender.call().value(v2).gas(!v2 * 2300);
    require(bool(v7), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    return v2;
}

function getRetirePrice(uint256 amount) public nonPayable {
    require(msg.data.length - 4 >= 32);
    v0 = _getRetirePrice(amount); // 환매 가격 조회
    return v0;
}

function _getRetirePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    require(RETURNDATASIZE() >= 32);
    v1 = v2.length;
    v3 = v2.data;
    v4 = 0x18ef(_reserve, amount);// 분자 = _reserve * amount
    if (v1 > 0) {
        assert(v1);
        return v4 / v1;// 환매가격 = 분자 / totalSupply
    } else {
    // ...
}

환매 규칙은 선형입니다:

환매 가격은 총 공급량에서 환매되는 비율(즉, amount / totalSupply)에 reserve를 곱한 값에 비례합니다.

이 의도적인 비대칭성은 큰 스프레드를 만들어냅니다: 구매는 볼록(대규모에서 고가)이고, 매도는 선형(준비금의 비례적 지분만 환매)입니다. 정상적인 조건에서 이 스프레드는 즉각적인 구매→매도 차익거래를 매력적이지 않게 만듭니다.

0x1 취약점 분석

대규모 구매는 비싸다는 설계 의도에도 불구하고, _getPurchasePrice()는 산술 연산에서 정수 오버플로우가 발생합니다. 해당 컨트랙트가 Solidity 0.6.10으로 컴파일되었기 때문에, uint256의 산술 연산은 명시적으로 보호되지 않으면(예: SafeMath 사용) 2^256 모듈로로 조용히 오버플로우되어 래핑될 수 있습니다.

function _getPurchasePrice(uint256 amount) private { 
    require(bool(stor_97_0_19.code.size));
    v0, /* uint256 */ v1 = stor_97_0_19.totalSupply().gas(msg.gas);
    require(bool(v0), 0, RETURNDATASIZE()); // 호출 상태 확인, 오류 시 오류 데이터 전파
    require(RETURNDATASIZE() >= 32);
    v2 = 0x18ef(v1, v1)
    v3 = 0x18ef(_setParameters, v2);
    v4 = 0x18ef(v1, v1);
    v5 = 0x18ef(100, v4);
    v6 = _SafeSub(v3, v5);// 분모 = 100 * totalSupply**2 - _setParameters * totalSupply**2 
    v7 = 0x18ef(amount, _reserve);
    v8 = 0x18ef(v1, v7);
    v9 = 0x18ef(200, v8);// 분자_2 = 200 * totalSupply * amount * _reserve
    v10 = 0x18ef(amount, _reserve);
    v11 = 0x18ef(amount, v10);
    v12 = 0x18ef(100, v11);// 분자_1 = 100 * amount**2 * _reserve
    v13 = _SafeDiv(v6, v12 + v9); // 구매가격 = (분자_1 + 분자_2) / 분모
    return v13;
}

_getPurchasePrice()에서, 충분히 큰 amount는 두 개의 큰 분자 항의 덧셈 시(v12 + v9) 오버플로우를 유발합니다. 이 오버플로우가 발생하면 분자가 작은 값으로 래핑되어, 최종 나눗셈이 인위적으로 낮은 구매 가격(잠재적으로 0)을 반환합니다.

결정적으로, 오버플로우는 구매 측 가격 책정에만 영향을 미칩니다. 매도 측 함수는 선형으로 유지되어 의도한 대로 동작하므로, 공격자는 다음을 수행할 수 있습니다:

  • 저평가(또는 0)된 비용으로 많은 양의 TRU를 구매한 뒤,
  • sellTRU()를 통해 훨씬 높은 실효 비율로 ETH로 환매합니다.

0x2 공격 분석

공격자는 단일 트랜잭션 내에서 여러 라운드의 차익거래를 수행했으며 [3], getPurchasePrice() -> buyTRU() -> sellTRU()를 반복했습니다.

첫 번째 라운드: 무비용 구매 후 수익 매도

신중하게 선택된 구매 수량(240,442,509.453,545,333,947,284,131)을 입력하여 공격자는 _getPurchasePrice()에서 오버플로우를 유발하고, 계산된 구매 가격을 0 ETH로 낮춰 약 2억 4천만 TRU를 무비용으로 획득했습니다.

아래 파이썬 코드 검증은 분자가 2^256을 초과하며, 래핑 후 계산된 구매 가격이 정수로 캐스팅될 때 0으로 잘리는 매우 작은 소수 값이 됨을 보여줍니다.

>>> _reserve = 0x1ceec1aef842e54d9ee
>>> totalSupply = 161753242367424992669183203
>>> amount = 240442509453545333947284131
>>> numerator = int(100 * amount * _reserve * (amount + 2 * totalSupply))
>>> numerator > 2**256
True
>>> denominator = (100 - 75) * totalSupply**2
>>> purchasePrice = (numerator - 2**256) / denominator
>>> purchasePrice
0.00025775798757211426
>>> int(purchasePrice)
0

공격자는 즉시 sellTRU()를 호출하여 프로토콜 준비금에서 TRU를 5,105 ETH로 환매했습니다.

이후 라운드: 저비용 구매 후 수익 매도

공격자는 여러 차례 사이클을 반복했습니다. 이후 구매들은 항상 완전히 무비용은 아니었지만, 오버플로우가 지속적으로 구매 가격을 해당 매도 수익보다 훨씬 낮게 유지했습니다.

이 라운드들에 걸쳐 공격자는 상당한 ETH를 추출했으며, 당사 조사에 따르면 첫 번째 라운드 이후에도 추가적인 무비용 구매가 가능했을 수 있으나, 공격자가 일부 비영구매 라운드를 선택한 이유는 불분명합니다.

전체적으로 공격자는 Truebit의 준비금에서 8,535 ETH를 탈취했습니다.

0x3 요약

이번 사고는 궁극적으로 Truebit의 구매 측 가격 산정 로직에 있는 검사되지 않은 정수 오버플로우로 인해 발생했습니다. 프로토콜의 비대칭 구매/매도 가격 책정 모델이 투기에 저항하도록 설계되었음에도 불구하고, 체계적인 오버플로우 보호 없이 구버전 Solidity(0.8 이전)로 컴파일한 것이 설계를 무력화하고 준비금 고갈을 가능하게 했습니다.

아직 Solidity 0.8 미만 버전을 사용하는 프로덕션 컨트랙트의 개발자는 다음을 수행해야 합니다:

  • 모든 관련 연산에 오버플로우 안전 산술(예: SafeMath 또는 동등한 검사)을 적용하거나,
  • 기본 오버플로우 검사의 혜택을 받기 위해 Solidity 0.8+로 마이그레이션하는 것이 바람직합니다.

참조

[1] https://x.com/Truebitprotocol/status/2009328032813850839

[2] https://docs.truebit.io/v1docs

[3] 공격 트랜잭션

BlockSec 소개

BlockSec은 풀스택 블록체인 보안 및 암호화폐 컴플라이언스 제공업체입니다. 당사는 고객이 프로토콜 및 플랫폼의 전체 수명 주기에 걸쳐 코드 감사(스마트 컨트랙트, 블록체인 및 지갑 포함), 실시간 공격 차단, 사고 분석, 불법 자금 추적, AML/CFT 의무 준수를 수행할 수 있도록 돕는 제품 및 서비스를 구축합니다.

BlockSec은 권위 있는 학회에서 다수의 블록체인 보안 논문을 발표했으며, DeFi 애플리케이션의 제로데이 공격을 여러 건 보고하고, 다수의 해킹을 차단하여 2천만 달러 이상을 구제했으며, 수십억 달러의 암호화폐를 보호했습니다.

Sign up for the latest updates
약 4억 1,800만 달러 손실: Bitget, NEAR Intents 해킹 사건 | BlockSec Weekly
Security Insights

약 4억 1,800만 달러 손실: Bitget, NEAR Intents 해킹 사건 | BlockSec Weekly

이번 격주 보안 리포트는 2026년 9월 21일부터 10월 4일까지 발생한 8건의 사고를 다루며, 이더리움, BNB 체인, 솔라나, 비트코인, 트론, XRP 레저, 지캐시, 아발란체, 니어 등 네트워크에서 약 4억 1,840만 달러의 손실이 발생했습니다. Bitget의 3억 8,750만 달러 규모 제3자 보안 제품 취약점과 NEAR Intents의 387만 달러 환불 검증 결함 및 상태 롤백 누락 문제를 상세히 분석합니다.

Bitget의 3억 8,750만 달러 오프체인 침해 사고: 키와 컨트랙트를 넘어서
Security Insights

Bitget의 3억 8,750만 달러 오프체인 침해 사고: 키와 컨트랙트를 넘어서

2026.9.24, 공격자가 제3자 보안 제품 취약점을 악용해 내부 자격증명을 탈취하고 출금 명령을 위조했습니다. 이더리움·EVM·XRP Ledger·Zcash·TRON상 Bitget 지갑에서 약 3.875억 달러가 인출됐으나 개인키·콜드월렛은 무사했습니다. 이 글은 사건 경위·자금 흐름과 자산 전환·복구 대응을 분석하고, 기관용 심층 방어 체계를 제안하며 승인된 블록체인 침투 테스트의 검증 역할을 설명합니다.

약 1,130만 달러 손실: Multicall Router, Nostra | BlockSec Weekly
Security Insights

약 1,130만 달러 손실: Multicall Router, Nostra | BlockSec Weekly

2026/09/14~09/20, 이더리움·Starknet에서 약 1,130만 달러 손실 발생. 멀티콜 라우터가 자신 주소를 대상으로 받아들여 Safe 지갑 권한이 도용되어 약 2,900 aEthrsETH가 공격자 Uniswap v4 풀로 유출. Starknet Nostra는 오라클 최소 소스 1개 요구로 조작된 가격이 반영돼 NSTR이 약 $49.52로 평가, 약 350만 달러 대출 발생.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit