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
약 557만 USD 손실: X100 Freezer·NEM 공격 | BlockSec 주간 보고서
Security Insights

약 557만 USD 손실: X100 Freezer·NEM 공격 | BlockSec 주간 보고서

이 주간 보고서는 2026년 10월 19일–2026년 10월 25일에 Ethereum과 BNB Chain에서 발생한 보안 사고 두 건을 다룹니다. 플래시론으로 자금을 조달해 X100 Freezer의 거버넌스를 장악한 사건과 BNB Chain의 NEM 토큰 유동성 풀에 영향을 미친 세금 정산 결함을 살펴봅니다. 보고서 범위에 포함된 추정 손실액은 X100의 대표 거래에서 발생한 약 475만 USD와 NEM 풀 사고에서 발생한 약 82.0만 USD를 합쳐 총 약 557만 USD입니다. 이 총액에는 별도로 보고된 후속 X100 자금 유출액은 포함되지 않습니다.

Autheo Network·UNCX 해킹으로 약 387만 USD 손실 | BlockSec 주간 소식
Security Insights

Autheo Network·UNCX 해킹으로 약 387만 USD 손실 | BlockSec 주간 소식

이 주간 보고서는 2026년 10월 12일–2026년 10월 18일 기간에 발생한 보안 사고 3건을 다루며, 총 추정 손실액은 약 387만 USD입니다. Autheo Network에서는 개인 키 침해로 약 176만 USD의 손실이 발생했고, Qubic Wallet에서는 취약한 시드 생성으로 약 130만 USD의 손실이 발생했습니다. UNCX Network의 Smart Token Vesting V2 계약에서는 Ethereum, BNB Chain, Base, Robinhood Chain에 걸쳐 약 80.7만 USD의 손실을 초래한 회계 오류가 발생했습니다. 중점적으로 다룬 UNCX 분석에서는 잘못된 지분 합산 계산이 서로 다른 토큰 대 지분 비율로 생성된 베스팅 트랜치를 혼합해 공유 풀에서 초과 인출을 가능하게 한 과정을 설명합니다.

약 54만 2천 달러 손실: MakerDaoBot 공격 | BlockSec 주간 소식
Security Insights

약 54만 2천 달러 손실: MakerDaoBot 공격 | BlockSec 주간 소식

2026/10/05~10/11 이더리움 보안 사고 1건의 추정 손실은 약 54.2만 달러입니다. 공격 컨트랙트가 악성 어댑터를 접근 제어가 없는 MakerDaoBot 함수에 전달하자 봇이 내부 MakerDAO 잔액 권한을 부여했습니다. 어댑터는 기존 ETH-A 경매 4건을 정산하고 담보 200 ETH-A를 WETH로 전환했습니다. 공격 컨트랙트는 이를 언래핑해 공격자에게 200 ETH를 보냈습니다.

Best Security Auditor for Web3

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

BlockSec Audit
심층 분석: 트루빗 사건