지난 한 주간(2026/08/17 - 2026/08/23), 총 손실액 약 $10.26M에 달하는 다음 2건의 주요 보안 사고가 발생했습니다.
| 날짜 | 사고 | 유형 | 추정 손실액 |
|---|---|---|---|
| 2026/08/18 | MAYAChain | 비즈니스 로직 결함 | ~$1.76M |
| 2026/08/23 | Term Finance | 결함 있는 거버넌스 설계 | ~$8.5M |
선정 이유
- MAYAChain: 연쇄적인 회계 및 상태 검증 실패로 인해 하나의 조작된 예치(deposit)가 아웃바운드 정산(outbound reconciliation)을 손상시키고, 실질적인 뒷받침 없이 저유동성 풀의 기록된 잔액을 부풀릴 수 있었던 사례로, 각각은 제한적인 여러 저수준 결함들이 어떻게 결합되어 크로스체인 유동성 네트워크에 대한 자금 유출로 이어질 수 있는지를 보여주기 때문에 선정되었습니다.
- Term Finance: 거버넌스 참여율이 거의 0에 가까워 악의적인 제안을 부결시킬 유권자가 없었고, 실행 지연을 뒷받침하는 가디언이나 취소 경로도 없어서 최소한의 자본을 가진 공격자가 투표를 장악하고 제안을 통과시켜 6개의 프로토콜 볼트에서 약 $8.5M를 유출할 수 있었기 때문에 선정되었습니다.
Best Security Auditor for Web3
Validate design, code, and business logic before launch
이번 주 주요 사건: Term Finance
Term Finance가 이번 주 주요 사례로 선정된 이유는 이 실패가 코드 버그가 아니라 거버넌스 설계 결함이었기 때문입니다. 이는 프로토콜이 각 볼트마다 별도의 온체인 DAO를 배포함에 따라 커지는 위험 범주로, 참여자가 거의 없으면 투표에 대한 통제권을 저렴하게 매수할 수 있으며, 볼트 자체의 거버넌스가 공격 표면이 되어 버립니다.
2026년 8월 23일, 이더리움 기반 고정금리 대출 프로토콜인 Term Finance는 공격자가 볼트들의 온체인 거버넌스를 장악하면서 약 $8.5M의 손실을 입었습니다. 거의 어떤 예치자도 볼트의 거버넌스 토큰을 발행(mint)한 적이 없었기 때문에, 공격자는 약 0.5 ETH로 해당 볼트의 압도적 투표권을 확보하고, 프로토콜의 지지 임계값(support-threshold) 및 최소 참여(minimum-participation) 검사를 통과한 뒤, 볼트의 전략(strategies)을 취소하고 자산을 이전하는 악의적인 제안을 실행할 수 있었습니다. 이러한 방식으로 Term의 볼트 6개가 유출되었습니다 [1].
배경
Term Finance는 이더리움 기반의 고정금리 대출 프로토콜입니다. Term Vaults는 그 위에 별도로 얹혀진 제품으로, 각 볼트는 Yearn V3 코드를 기반으로 구축된 ERC-4626 볼트이며, 메타 볼트는 단일 자산을 받아 여러 전략 볼트에 배분합니다. Term의 프레임워크는 각 볼트와 함께 Aragon OSx DAO와 TokenVoting 컨트랙트를 배포하며, 이 DAO는 함께 출시되는 볼트에 대한 업그레이드 및 권한(role) 권한을 갖습니다. 따라서 볼트는 누가 큐레이션하는지, 실제로 자본이 배분되는지 여부와 무관하게 태어날 때부터 자체적인 거버넌스 표면을 갖게 됩니다.
거버넌스 결정은 TokenVoting에 의해 이루어집니다. 각 볼트는 자체 지분 토큰과 이에 대응하는 Aragon GovernanceWrappedERC20 거버넌스 래퍼를 가지고 있으며, 분석 대상인 ETH Meta Vault에서는 이것이 tmvETH와 gtmvETH입니다. 지분 토큰 보유자는 누구나 depositFor()를 호출하여 1:1로 거버넌스 토큰을 받을 수 있으며, 거버넌스 토큰 보유자는 누구나 결과로 생긴 투표권을 delegate()할 수 있습니다. 제안이 생성되면, TokenVoting.createProposal()은 snapshotBlock, supportThreshold, 그리고 해당 스냅샷 시점의 토큰 공급량에서 파생된 minVotingPower를 기록합니다. 투표권은 이후 해당 블록에서 래퍼로부터 읽어옵니다.
취약점 분석
이번 사건의 핵심에 있는 거버넌스 컨트랙트는 TokenVoting입니다. 제안 실행은 오직 _canExecute()에 의해서만 통과 여부가 결정되며, 이는 일반적인(조기 실행이 아닌) 제안의 경우 투표가 아직 실행되지 않았는지, 제안이 마감되었는지, 그리고 두 가지 검사, 즉 isSupportThresholdReached()와 isMinParticipationReached()를 통과하는지를 요구합니다.

두 검사 모두 다수결 투표(majority voting)의 올바른 구현이지만, 각각은 절대량이 아닌 상대적 비율을 측정합니다. isSupportThresholdReached()는 찬성표가 설정된 비율을 초과하여 반대표를 앞서기만을 요구합니다:

isMinParticipationReached()는 투표된 표수가 minVotingPower에 도달하기만을 요구하며, 이는 스냅샷 시점의 minParticipation * totalSupply로부터 파생됩니다:

근본 원인은 이 거버넌스 설계가 제안을 통과시키는 데 필요한 자본이나 참여 범위에 대한 절대적 하한선(floor)을 두지 않았다는 것입니다. 두 관문 모두 순전히 투표된 표수와 토큰 공급량에 대한 상대적 값이었으며, tmvETH 보유자 중 gtmvETH로 래핑하여 거버넌스에 참여한 사람이 거의 없었기 때문에 그 공급량과 이에 따른 최소 참여 하한선은 거의 0에 가까웠습니다. 따라서 거의 텅 빈 유권자 집단 내에서의 상대적 다수결만으로 두 검사를 모두 통과할 수 있었으며, 투표자 수가 너무 적어 제안을 부결시킬 반대표를 던질 사람이 없었습니다. 최소 투표 기간이 실행을 지연시켰지만, 가디언이나 취소 경로가 없었기 때문에 이 지연은 통과된 제안을 막지 못하고 단지 미룰 뿐이었습니다.
공격 분석
공격자는 특정 볼트의 거버넌스 토큰에 대한 지배적 지분을 확보한 후, 이를 이용해 해당 볼트를 비우는 제안을 통과시키고 실행했으며, 동일한 기법이 Term의 볼트 6개에 걸쳐 적용되었습니다. 다음 분석은 트랜잭션 0xd354a1...d3014129와 0x9f273f...44c2e8a0를 기반으로 하나의 볼트를 추적합니다.
-
1단계: 공격자는 Mayan Finance 포워더를 통해
0.5 ETH를0.485 tmvETH로 스왑했으며, 이 포워더가 스왑을 경로 설정하여 공격자에게 볼트 지분을 전달했습니다. -
2단계: 공격자는
0.485 tmvETH를 1:1로0.485 gtmvETH로 래핑하여 이후 단계에서 사용할 투표권을 확보했습니다. -
3단계: 공격자는
propose()를 호출했으며, 이는 자신의 투표권을 읽고TokenVoting.createProposal()을 호출했습니다. 컨트랙트는 스냅샷을 기록했으며, 해당 블록에서 전체 거버넌스 공급량을 단0.535 gtmvETH로 읽었으므로 공격자의 지분은 전체 유권자의 약 90.66%에 해당했습니다. -
4단계: 공격자는
vote()를 호출하여 자신의 전체 투표권을 제안에 찬성표로 던졌으며, 유일한 참여자가 되었습니다. -
5단계: 투표 기간이 끝난 후, 공격자는
executeProposal()을 호출했습니다.canExecute()관문은isSupportThresholdReached()를 확인했고, 공격자가 유일한 찬성 투표자였기 때문에 지지 비율이 50% 임계값을 크게 초과했습니다. 이어서isMinParticipationReached()를 확인했는데, 공격자 본인의 투표권만으로도minVotingPower를 초과했습니다. 두 검사 모두 통과했습니다. -
6단계: 그런 다음 악의적인 제안이 실행되어
tmvETH볼트가 전략에 배분했던 자금을 회수하고, 이를 볼트가 보유한 유동적인WETH로 전환하여 공격자가 자산을 인출할 수 있게 했습니다. Term의 볼트 6개에 걸쳐 적용된 이 공격으로 총 약 $8.5M의 손실이 발생했습니다.
결론
이번 사건은 코딩 오류가 아닌 결함 있는 거버넌스 설계에 의해 발생했습니다: 지지 및 참여 검사가 순전히 상대적이었기 때문에, 거의 텅 빈 유권자 집단 상황에서 저렴하게 획득한 다수 지분이 반대표에 직면하지 않았고, 실행 지연에는 이를 뒷받침하는 가디언이나 취소 경로가 없었습니다. 자산의 보관을 통제하는 거버넌스는 절대적인 정족수(quorum) 또는 참여 하한선을 강제해야 하며, 실행 지연에는 가디언이나 취소 경로, 그리고 모니터링되는 제안 피드가 뒷받침되어야 합니다. 타임락은 그 기간 중에 누군가가 조치를 취할 수 없다면 통과된 제안을 단지 지연시킬 뿐이기 때문입니다.
이번 주 추가 사건
MAYAChain
2026년 8월 18일, Cosmos-SDK 기반 크로스체인 유동성 네트워크인 MAYAChain은 하나의 조작된 예치(deposit)가 체인의 내부 회계를 손상시키면서 약 $1.76M의 손실을 입었습니다. 정상적인 인출이 실패한 것처럼 보이도록 만들어, 해당 예치는 복구 경로(recovery path)를 발동시켰고, 이는 실질적인 뒷받침 없이 저유동성 풀의 기록된 네이티브 토큰 잔액을 부풀렸습니다. 이후 공격자는 유동성을 추가하고 인출하는 방식으로 이 부풀려진 값을 유출했습니다 [2]. 확인된 온체인 유출액은 약 $1.36M으로, 주로 20.83 BTC였으며, 남아있는 네이티브 토큰 보유량을 포함하면 공식 추정치는 약 $1.76M에 달합니다.
배경
MAYAChain은 네이티브 자산이 CACAO인 Cosmos-SDK 기반 크로스체인 유동성 네트워크입니다. 외부 체인의 이벤트는 검증자(validator)들에 의해 관찰되고, 관찰된 트랜잭션(observed transactions)으로 MAYAChain에 재현됩니다. 인바운드 작업이 자산을 외부로 보내야 할 때, 노드는 하나 이상의 TxOutItem을 스케줄링하고, 이후 관찰된 아웃바운드 트랜잭션을 이 스케줄된 기록과 대조하여 정산(reconcile)합니다. 모든 풀 자산은 공유 Asgard 볼트에 함께 커스터디되며, 각 유동성 풀은 분리된 잔액이 아닌 이 공유 커스터디에 대한 회계상 포지션입니다. 인출은 풀의 기록된 잔액에 따라 Asgard로부터 지급됩니다.
트레이드 계정(Trade accounts)은 ARB~ETH, ARB~LINK와 같은 트레이드 자산에 대한 네이티브 MAYAChain 회계 포지션입니다. 트레이드 계정 인출은 trade-:ARB~LINK와 같은 메모가 포함된 네이티브 MsgDeposit을 통해 시작됩니다. 사용자는 단일 네이티브 트랜잭션에 서명하지만, 예치 핸들러는 내부적으로 관찰된 트랜잭션을 재구성하고 네이티브 트랜잭션 해시를 키로 하는 ObservedTxVoter를 저장합니다.
취약점 분석
근본 원인은 단일한 고립된 버그가 아니라, 서로 결합된 일련의 회계 및 상태 검증 결함들의 연쇄였습니다. 문제가 되는 로직은 MAYANode의 예치 및 아웃바운드 핸들러에 존재합니다.
첫째, handler_deposit.go에서, 네이티브 트랜잭션 내의 각 메시지는 트랜잭션 해시를 키로 하는 새로운 ObservedTxVoter를 생성하고 SetObservedTxInVoter()로 이를 저장합니다:
txIn := ObservedTx{Tx: tx}
txInVoter := NewObservedTxVoter(txIn.Tx.ID, []ObservedTx{txIn})
txInVoter.Height = ctx.BlockHeight()
txInVoter.FinalisedHeight = ctx.BlockHeight()
txInVoter.Tx = txIn
h.mgr.Keeper().SetObservedTxInVoter(ctx, txInVoter)
배치 내의 모든 메시지가 동일한 tx.ID를 공유하기 때문에, 이후의 메시지가 이전 메시지들이 작성한 voter 상태를 덮어쓰게 되어, 그들의 아웃바운드 스케줄링 메타데이터가 폐기됩니다.
둘째, handler_common_outbound.go에서, 아웃바운드 정산은 voter.OutboundHeight(또는 이것이 0일 경우 voter.FinalisedHeight)부터 시작하여 서명 기간(signing period) 단위로 앞으로 스캔합니다:
outHeight := voter.OutboundHeight
if outHeight == 0 {
outHeight = voter.FinalisedHeight
}
for height := outHeight; height <= ctx.BlockHeight(); height += signingTransPeriod {
txOut, err = h.mgr.Keeper().GetTxOut(ctx, height)
...
}
voter가 이런 방식으로 덮어써진 경우, 스캔은 최종 확정(finalised)된 예치 높이에서 시작되며 스케줄된 아웃바운드를 담고 있는 블록을 결코 검사하지 않게 되어, 핸들러는 이를 누락된 것으로 취급하고 슬래시-복구(slash-recovery) 경로를 발동시킵니다.
셋째, helpers.go에서, 복구 경로는 관찰된 원시(raw) 금액을 사용하여 "누락된" 자산을 CACAO 보조금(subsidy)으로 평가하며, 풀의 실제 자산 깊이(depth)에 대한 상한선(cap)이 전혀 없습니다:
f.stolenAsset = f.stolenAsset.Add(coin.Amount)
runeValue := pool.AssetValueInRune(coin.Amount)
f.subsidiseRune = f.subsidiseRune.Add(runeValue)
자산 측 깊이가 약 0.11 LINK뿐인 풀에서, 이 상한 없는 변환은 관찰된 LINK 금액을 엄청난 CACAO 값으로 매핑할 수 있었습니다. 동일한 루틴은 자금 이전(funding transfer)을 시도하기 전에 부풀려진 풀 잔액을 상태(state)에 커밋합니다:
pool.BalanceCacao = pool.BalanceCacao.Add(f.subsidiseRune)
pool.BalanceAsset = common.SafeSub(pool.BalanceAsset, f.stolenAsset)
if err = mgr.Keeper().SetPool(ctx, pool); err != nil {
...
}
runeToAsgard := common.NewCoin(common.BaseAsset(), f.subsidiseRune)
if err = mgr.Keeper().SendFromModuleToModule(ctx, ReserveName, AsgardName, common.NewCoins(runeToAsgard)); err != nil {
ctx.Logger().Error("fail to send subsidy from bond to asgard", "error", err)
return err
}
풀에 대한 쓰기(write)가 이전(transfer)보다 먼저 커밋되기 때문에, 이전이 자금 조달에 실패하더라도 풀의 BalanceCacao는 결코 롤백되지 않습니다. 마지막으로, handler_observed_txout.go에서, 호출자는 반환된 오류를 삼키고(swallow), voter를 완료(done) 상태로 표시한 뒤 계속 진행하므로, 일관성 없는 풀 상태가 그대로 유지될 수 있습니다:
_, err = handler(ctx, m)
if err != nil {
ctx.Logger().Error("handler failed:", "error", err)
slashObservedOutbound("failed_outbound")
voter.SetDone()
h.mgr.Keeper().SetObservedTxOutVoter(ctx, voter)
continue
}
공격 분석
공격자는 이러한 결함들을 단 하나의 네이티브 트랜잭션으로 결합한 후, 부풀려진 풀에서 가치를 추출했습니다. 다음 분석은 MAYAChain 트랜잭션 516BA14D...E9B7를 기반으로 합니다.
-
1단계: 공격자는 23개의 메시지가 포함된 네이티브 트랜잭션
516BA14D...E9B7를 제출했습니다: 20개의trade-:ARB~ETH인출, 2개의trade-:ARB~LINK인출, 그리고 마지막으로 최소 단위 하나의DONATE:ARB.LINK입니다. -
2단계: 트레이드 인출들은 유효한 스케줄된 아웃바운드를 생성했지만, 마지막
DONATE메시지가 동일한 네이티브 트랜잭션 해시에 대한 공유 인바운드 voter를 덮어써서, 이전 인출들의 아웃바운드 스케줄링을 삭제했습니다. -
3단계: 이후
ARB.LINK아웃바운드가 관찰되었을 때, 정산 스캔은 손상된 voter의 높이(height) 필드를 사용했으며, 스케줄된 아웃바운드가 담긴 블록에 결코 도달하지 못해 이를 누락된 것으로 취급했습니다. -
4단계: 슬래시-복구 경로는 누락된 LINK를 얇은
ARB.LINK풀에 대해 평가했고, 풀의CACAO측을 약49.45M CACAO만큼 부풀렸습니다. 이 보조금을 조달해야 했던 Reserve-to-Asgard 이전은 실패했는데, Reserve가 부풀려진 보조금에 훨씩 미치지 못하는 약168K CACAO만을 보유하고 있었기 때문입니다. 하지만 변경된 풀 잔액은 그대로 유지되었고, 실패한 핸들러는 완료(done) 상태로 표시되었습니다. -
5단계: 이후 시점에서, 공격자는 왜곡된 풀에
100 CACAO와 소량의 LINK를 유동성으로 추가했습니다. 풀의 한쪽이 크게 균형을 벗어났기 때문에, 유동성 추가 공식은 기존의 약731M단위 대비 공격자에게 약1TLP 단위를 부여했습니다. -
6단계: 공격자는 즉시 해당 포지션의 거의 전부를 인출하여, 부풀려진 풀 잔액에 대해 공유 Asgard 볼트로부터 지급된 약
48.87M CACAO와98.82 LINK를 받았으며, 추출한CACAO를 MAYAChain 풀을 통해 BTC 및 기타 자산으로 스왑했습니다. 매도 과정에서CACAO가격은 약$0.115에서 최저$0.013근처까지 하락했습니다.
공격자가 인출한 48.87M CACAO는 익스플로잇 이전 가격 기준으로 명목상 $5M이 넘는 가치였지만, 이것이 전부 외부 수익으로 실현된 것은 아니었습니다. 상당 부분이 풀로 다시 스왑되어 CACAO 가격을 붕괴시켰고, 공격자와 무관한 기회주의적 차익거래자(arbitrageur)들도 이 붕괴 과정에서 가치를 함께 빼갔습니다. 직접적으로 확인된 온체인 추출액은 약 $1.36M으로 주로 20.83 BTC였으며, 공격자의 남은 CACAO와 트레이드 계정 잔액을 포함하면 공식 추정치인 약 $1.76M에 이릅니다.
결론
이번 사건은 단일 버그가 아닌 일련의 회계 및 상태 검증 결함들의 연쇄에 의해 발생했습니다: 어느 단계도 그 자체로는 파괴적이지 않았지만, 이들이 결합되면서 조작된 예치가 얇은 풀의 기록된 잔액을 뒷받침 없는 가치로 바꿀 수 있었습니다. 핵심 요구 사항은 원자성(atomicity)과 제한된 신뢰(bounded trust)입니다: 회계상의 변경(mutation)과 이를 조달하기 위한 이전(transfer)은 함께 성공하거나 함께 실패해야 하며, 실패한 복구 경로는 절대 완료(done)로 표시되어서는 안 되며, 저유동성 풀에 대한 가치 평가에는 상한(cap)이 있어야 합니다. 공유된 인바운드 상태 또한 동일한 트랜잭션 내의 이후 메시지에 의해 조용히 덮어써져서는 안 됩니다.
참고 자료
BlockSec 소개
BlockSec는 풀스택 블록체인 보안 및 암호화폐 컴플라이언스 제공업체입니다. 저희는 고객이 코드 감사(스마트 컨트랙트, 블록체인, 지갑 포함)를 수행하고, 실시간으로 공격을 차단하고, 사건을 분석하고, 불법 자금을 추적하며, AML/CFT 의무를 이행할 수 있도록 돕는 제품과 서비스를 프로토콜과 플랫폼의 전체 수명주기에 걸쳐 구축합니다.
BlockSec은 유명 학회에 다수의 블록체인 보안 논문을 발표했으며, DeFi 애플리케이션의 여러 제로데이 공격을 신고했고, 다수의 해킹을 차단하여 2천만 달러 이상을 구제했으며, 수십억 달러 규모의 암호화폐를 보호했습니다.
-
공식 웹사이트: https://blocksec.com/
-
공식 트위터 계정: https://twitter.com/BlockSecTeam



