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로 환매했습니다.
이후 라운드: 저비용 구매 후 수익 매도
공격자는 여러 차례 사이클을 반복했습니다. 이후 구매들은 항상 완전히 무비용은 아니었지만, 오버플로우가 지속적으로 구매 가격을 해당 매도 수익보다 훨씬 낮게 유지했습니다.
전체적으로 공격자는 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천만 달러 이상을 구제했으며, 수십억 달러의 암호화폐를 보호했습니다.
-
공식 웹사이트: https://blocksec.com/
-
공식 트위터 계정: https://twitter.com/BlockSecTeam
-
🔗 BlockSec 감사 서비스 : 요청 제출



