지난주(2026년 10월 19일–2026년 10월 25일) 이 보고서는 추정 손실액이 약 557만 USD인 보안 사고 두 건을 추적했습니다. 상세 분석은 이더리움에서 발생한 X100 Freezer의 거버넌스 장악에 초점을 맞춥니다.
| 날짜 | 사고 | 유형 | 추정 손실액* |
|---|---|---|---|
| 2026년 10월 21일 | NEM Token | 결함이 있는 비즈니스 로직 | 약 82.0만 USD |
| 2026년 10월 24일 | X100 Freezer | 결함이 있는 거버넌스 설계 | 약 475만 USD |
*손실액은 근사치입니다. X100 Freezer의 수치는 아래에서 분석한 대표 거래에 해당하며 Phalcon의 초기 추정치와 일치합니다. 총액에는 별도로 보고된 후속 자금 유출이 포함되지 않습니다. NEM의 날짜는 온체인 거래 날짜이며, 공개 경고는 10월 22일에 게시되었습니다 [1].
- X100 Freezer: 이 사고는 즉시 효력이 발생하는 거버넌스 참여 자격과 실행 타임락 부재가 결합된 위험을 보여줍니다. 공격자는 플래시론으로 빌린 자금으로 토큰을 매수하고 동결해 곧바로 Senate 의석 과반을 확보한 뒤, 동일한 거래 내에서 악의적인 재무 자금 운용 제안을 통과시키고 실행했습니다.
Web3를 위한 최고의 보안 감사 기관
출시 전에 설계, 코드 및 비즈니스 로직을 검증하세요
이번 주 주요 사고: X100 Freezer
2026년 10월 24일에 이더리움 기반 온체인 스테이킹 및 유동성 관리 시스템인 X100 Freezer가 익스플로잇을 당했으며, 여기서 살펴보는 거래의 추정 손실액은 약 475만 USD입니다. 결함이 있는 거버넌스 설계로 인해 새로 확보한 동결 잔액이 즉시 Senate 의석을 부여했고, 제안 승인과 실행 사이에 타임락도 없었습니다. 공격자는 세 의석 중 두 의석을 확보하고 재무 자금의 스왑을 공격자가 통제하는 풀로 유도한 뒤, 회수한 토큰의 일부를 매도해 플래시론을 상환하고 이익을 실현했습니다 [2].
배경
X100 사용자는 보상을 얻기 위해 X 토큰을 티어별 스테이킹 포지션에 동결합니다. 0x79f3...2a46의 Freezer 컨트랙트는 Uniswap V3/V4 및 PancakeSwap V3에서 프로젝트의 유동성도 관리합니다. 이 컨트랙트는 DexTreasury를 통해 Senate 거버넌스 컨트랙트를 상속하며, Senate는 제안과 투표를 통해 재무 자금 운용을 결정합니다.
공개된 freeze() 진입점은 사용자의 토큰을 컨트랙트로 전송한 다음 비공개 _freeze() 함수를 호출해 스테이킹 포지션을 생성하고 관련 회계 기록을 갱신합니다. _tierTotal[owner][tier]는 각 계정이 각 티어에 동결한 수량을 기록합니다. _freeze() 함수는 이 잔액을 갱신하고 _onStakeChanged(owner)를 통해 거버넌스 시스템에 변경 사항을 알립니다.
function _freeze(address owner, uint8 tier, uint256 amount) private returns (uint256 id) {
_settle(owner);
Account storage account = _accounts[owner];
if (account.firstFreezeAt == 0) {
account.firstFreezeAt = block.timestamp;
}
id = _create(owner, tier, amount, block.timestamp, block.timestamp);
_tierTotal[owner][tier] += amount;
totalFrozen += amount;
totalFrozenIn[tier] += amount;
_onStakeChanged(owner);
emit Frozen(id, owner, tier, amount);
}
Senate 후보 명단에 들어가려면 계정에서 applyForSenate() 함수를 호출합니다. 이 함수는 _register() 함수를 호출하며, 후자는 등록을 기록하고 후보 등록 수수료를 징수한 다음 동일한 스테이크 갱신 훅을 호출합니다.
function _register(address account) internal {
require(!_registered[account], AlreadyRegistered(account));
_registered[account] = true;
_collectCandidacyFee(account);
emit Registered(account);
_onStakeChanged(account);
}
이 훅은 등록되지 않은 계정을 건너뜁니다. 등록된 계정에 대해서는 동결 잔액에 따라 순위를 매긴 명단을 유지하고, _link() 함수를 통해 계정을 삽입하거나 이동합니다. isSenator() 함수는 이 명단의 앞에서부터 senateSize개의 계정을 상원의원으로 식별합니다. 시스템은 3석에서 21석까지 지원하며, 의석당 투표권은 한 표입니다. 동결된 토큰에는 출금 대기 기간이 적용되며, 이 기간은 _unfreezeAt() 함수가 포지션의 frozenAt 타임스탬프와 스테이킹 티어를 바탕으로 계산합니다.
상원의원은 propose() 함수를 통해 허용된 재무 자금 운용을 제출합니다. 이 함수는 제안을 기록하고 제안자의 찬성표를 자동으로 행사합니다. 다른 상원의원들은 vote() 함수를 통해 투표하며, _tally() 함수는 현재 의석의 찬성표를 집계합니다. 제안에는 생성 시각과 votingPeriod에 따라 정해지는 마감 시각이 기록되며, 후자의 기본값은 3일입니다.
v3Swap() 함수와 같은 재무 자금 운용 함수는 DexTreasury._isProposable()에서 허용되는 제안 대상입니다. 이러한 함수의 onlySenate 수정자는 컨트랙트 자체에서 호출할 것을 요구합니다. 실행 경로는 승인된 제안 데이터를 사용해 이 자체 호출을 수행합니다. 이후 Freezer._afterExecute() 함수는 남은 X 잔액이 totalFrozen 값을 충당하는지 확인해 동결된 원금 총액을 보존합니다.
취약점 분석
Freezer의 거버넌스 설계에는 새로 확보한 스테이크가 Senate 권한을 부여하기까지의 지연 기간과 승인된 제안이 실행되기 전의 타임락이 없었습니다.
첫째, 순위 결정에 사용되는 스테이크는 현재 동결 잔액에서 직접 가져옵니다. _stakeOf() 함수는 frozenOf() 함수를 호출하며, 후자는 스테이크의 보유 기간을 확인하거나 과거 잔액을 읽지 않고 _tierTotal 값을 합산합니다.
function _stakeOf(address account) internal view override returns (uint256) {
return frozenOf(account);
}
function frozenOf(address owner) public view returns (uint256 total) {
total = 0;
for (uint8 tier = 0; tier < TIERS; ++tier) {
total += _tierTotal[owner][tier];
}
}
위에 제시된 예치 및 등록 경로는 동일한 호출 순서 내에서 참여 자격을 갱신합니다. _freeze() 함수가 포지션의 타임스탬프를 기록하지만, 순위 및 상원의원 자격 확인에는 최소 보유 기간을 적용하는 데 이 타임스탬프를 사용하지 않습니다. 따라서 자금이 충분한 신규 후보는 동일한 거래 내에서 의석을 얻고 제안권과 투표권을 행사할 수 있습니다. 토큰 출금 대기 기간은 이러한 거버넌스 권한의 행사를 지연시키지 않습니다.
둘째, 3일의 투표 기간은 제안의 만료 시점만 결정합니다. _state() 함수는 제안이 존재하는 즉시 Open을 반환하며, 제안은 실행, 취소 또는 만료될 때까지 열려 있습니다. 과반 여부 확인은 경과 시간과 무관합니다.
function _state(Proposal storage proposal) private view returns (ProposalState) {
if (proposal.executed) {
return ProposalState.Executed;
}
if (proposal.cancelled) {
return ProposalState.Cancelled;
}
if (block.timestamp > proposal.deadline) {
return ProposalState.Expired;
}
return ProposalState.Open;
}
function _isPassed(uint256 support) private view returns (bool) {
return support * 2 > senateSize;
}
execute() 함수는 찬성표가 senateSize의 절반을 초과하는 즉시 열려 있는 제안을 받아들입니다. 재무 자금 운용 함수를 호출하기 전에 createdAt 이후의 최소 경과 시간도, 승인 후의 지연 기간도 확인하지 않습니다.
function execute(uint256 id) external {
Proposal storage proposal = _requireOpen(id);
uint256 size = senateSize;
(uint256 support, address[] memory seats) = _tally(id);
require(_isPassed(support), NotPassed(id, support, size));
proposal.executed = true;
(bool ok, bytes memory result) = address(this).call(proposal.data);
if (!ok) {
assembly ("memory-safe") {
revert(add(result, 32), mload(result))
}
}
_afterExecute(id);
emit ProposalExecuted(id, _msgSender(), support, size, seats);
}
자체 호출은 onlySenate 조건을 충족하므로 재무 자금 접근 권한 확인으로 인한 실행 지연은 없습니다. 마지막 동결 원금 확인은 제안의 실행 시점을 제한하거나 재무 자금 스왑의 최소 반환액을 강제하지 않습니다. 이러한 조건으로 인해 새로 확보한 과반 의석으로 하나의 거래 내에서 재무 자금 운용을 승인하고 실행할 수 있습니다.
공격 분석
다음 분석은 거래 0x38c608...4d81d0을 바탕으로 합니다.
-
단계 1: 공격자는 플래시론으로 빌린 자금으로
X토큰을 매수하고 자신이 통제하는 두 컨트랙트에 분배했습니다. 빌린 자금은 두 스테이킹 포지션을 만드는 데 사용할 토큰 매수 자금이 되었습니다. -
단계 2: 공격자가 통제하는 각 컨트랙트는 보유한
X토큰을 Freezer에 동결하고applyForSenate()함수를 호출했습니다. 거래 추적 기록에는 두 컨트랙트 모두 등록 후Ranked이벤트를 받은 것으로 나타납니다. 두 컨트랙트는 동결 잔액을 바탕으로 해당 거래 내에서 Senate 의석 두 개를 확보했습니다. 공격 당시 Senate에는 의석이 세 개였으므로, 두 의석을 통제하면 재무 자금 운용 제안을 통과시키는 데 필요한 과반을 확보할 수 있었습니다.

- 단계 3: 공격자는 PancakeSwap V3에
X/USDT풀을 만들고 유동성을 공급했습니다. 공격자가 통제하는 컨트랙트 하나가 재무 자금 스왑을 제안하면서 자동으로 찬성표를 행사했고, 다른 컨트랙트가 두 번째 찬성표를 행사했습니다. 과반 요건이 충족되자 Freezer는 제안 7을 실행했습니다. 해당v3Swap()호출은 입력액을 400,000개의X토큰으로 지정하고amountOutMinimum = 0을 설정했습니다. 재무 자금 계정은 약 35.68개의USDT토큰만 받았습니다. 거래 추적 기록에는votesFor = 2와senateSize = 3이 기록되어 있어, 공격자가 장악한 과반 의석이 해당 운용을 승인했음을 보여줍니다.

- 단계 4: 공격자는 새로 만든 PancakeSwap V3 풀에서 유동성을 인출해 재무 자금 계정이 스왑에 투입한
X토큰을 회수했습니다. 그런 다음 해당 토큰의 일부를 기존 Uniswap V3X/USDT풀에서 매도하고 플래시론을 상환했습니다. 상환 자금은 동결된 스테이킹 포지션에서 즉시 출금한 자금이 아니라 이후의 매도 대금이었습니다.
결론
이 사고는 결함이 있는 거버넌스 설계에서 비롯되었습니다. 현재 동결 잔액이 즉시 권한을 부여했고, 승인된 제안은 타임락 없이 실행될 수 있었습니다. 공격자는 이 두 조건을 이용해 과반 의석을 확보하고 플래시론 상환 기한이 오기 전에 그 권한을 행사했습니다. 그 결과 재무 자금 계정은 공격자가 통제하는 PancakeSwap V3 풀을 통해 400,000개의 X 토큰을 약 35.68개의 USDT 토큰에 매도했습니다. 공격자는 해당 풀에서 유동성을 인출해 재무 자금 계정이 투입한 토큰을 회수하고, 그중 일부를 별도로 존재하던 Uniswap V3 X/USDT 풀에서 매도한 뒤, 매도 대금으로 플래시론을 상환하고 이익을 남겼습니다.
거버넌스에는 최소 스테이킹 기간을 요구하거나 현재 거래 이전 블록에서 찍은 과거 잔액 스냅샷을 기준으로 의석 자격과 투표권을 결정해야 합니다. 이렇게 하면 해당 거래 내에서 자금을 투입한 포지션이 즉시 재무 자금 이동을 승인하는 것을 방지할 수 있습니다. 승인과 실행 사이의 타임락은 장악부터 자금 유출까지의 전체 과정이 하나의 거래 내에서 완료되는 것을 막고, 악의적인 제안을 검토하거나 취소할 시간을 제공해야 합니다. 이러한 통제 조치는 거버넌스 경로의 서로 다른 단계에 대응하므로 스마트 컨트랙트 감사에서 함께 평가해야 합니다.
참고 자료
[1] BlockSec Phalcon: NEM Token 사고 분석
[2] BlockSec Phalcon: X100 Freezer 거버넌스 사고 분석
BlockSec 소개
BlockSec은 블록체인 보안과 암호화폐 컴플라이언스 전반을 아우르는 제공업체입니다. 당사는 프로토콜과 플랫폼의 전체 수명주기에 걸쳐 고객이 코드 감사(스마트 컨트랙트, 블록체인 및 지갑 포함)를 수행하고, 공격을 실시간으로 차단하고, 사고를 분석하고, 불법 자금을 추적하고, AML/CFT 의무를 이행하도록 돕는 제품과 서비스를 구축합니다.
BlockSec은 권위 있는 학회에 블록체인 보안 논문 여러 편을 발표하고, DeFi 애플리케이션에 대한 여러 건의 제로데이 공격을 보고했으며, 여러 해킹을 차단해 2,000만 USD이 넘는 자금을 구하고 수십억 개의 암호화폐 자산을 보호했습니다.
-
공식 웹사이트: https://blocksec.com/
-
공식 Twitter 계정: https://twitter.com/BlockSecTeam



