2025년 2월 12일, StarkNet 기반의 대출 프로토콜인 zkLend[1]가 누산기(accumulator) 메커니즘의 정교한 조작을 통해 약 1,000만 달러의 피해를 입었습니다. 공격자는 플래시 론과 반올림 취약점을 활용하여 담보 가치를 인위적으로 부풀리고, 프로토콜에서 다른 자산을 빌려 수익을 취했습니다.
그러나 보안 관점에서의 상세하고 정확한 기술적 분석은 아직 부족한 상황입니다. 다른 보안 연구자들의 분석이 있었고 유용한 통찰을 제공했음에도 불구하고, 특히 공격 분석과 관련하여 일부 오해가 지속되고 있습니다. zkLend가 이후 공개한 공식 사후 분석 보고서[2]는 간략한 설명을 제공하지만 상세한 기술적 분석이 부족합니다. 이 블로그에서는 사건을 명확히 규명하기 위한 종합적인 검토를 제공하고자 합니다.
핵심 요약 (TL;DR)
-
이번 사건의 근본 원인은 다음 세 가지 문제의 복합적인 조합에서 비롯됩니다:
- 빈 마켓 초기화로 인해 임의의 자산 예치가 가능합니다.
- zkLend 플래시 론의 특정 기부(donation) 메커니즘은 사용자의 담보 잔액을 동적으로 조정하는 스케일링 인수인 전역 변수 누산기(accumulator)의 조작을 가능하게 합니다.
- 절사(truncation)로 인한 정밀도 손실이 발생합니다. 고전적인 나눗셈에서의 정밀도 손실과 달리, 분모가 1에서 시작하지만 매우 큰 값으로 부풀려져 공유 토큰 소각 시 과소평가가 발생합니다.
-
공격자는 다른 사용자가 예치한 wstETH로부터 수익을 취하지 않았습니다. 대신, 공격자는 취약점을 활용하여 담보 잔액을 조작하고, 소량의 wstETH를 초기 자본으로 사용하여 담보 잔액을 7,000 wstETH 이상으로 늘림으로써 마켓에서 다른 자산을 빌릴 수 있었습니다.
이후 섹션에서는 먼저 zkLend에 관한 중요한 배경 정보를 제공한 후, 문제점과 관련 공격에 대한 심층 분석을 수행할 것입니다.
0x1 배경: zkLend의 핵심 프로토콜 이해
zkLend는 StarkNet 기반의 대출 프로젝트로, 담보 대출 및 플래시 론과 같은 일반적인 대출 프로토콜을 지원합니다. 이 두 프로토콜의 구현 세부 사항을 살펴보겠습니다.
0x1.1 담보 대출
담보 대출이란 사용자가 특정 자산을 프로토콜에 담보로 예치하고 다른 자산을 빌리는 과정을 말합니다. 담보의 가치는 대출 가능 금액을 결정하는 데 사용됩니다. 대출 프로토콜은 일반적으로 담보의 자산 가치를 직접 저장하지 않고, 다음 공식을 사용하여 계산한다는 점이 중요합니다:
collateral_balance = lending_accumulator * raw_balance
구체적으로, lending_accumulator는 각 사용자의 담보 가치를 동적으로 조정하는 스케일링 인수이며, raw_balance는 사용자가 마켓에서 보유한 실제 지분을 나타냅니다. raw_balance는 lending_accumulator를 사용하여 collateral_balance로부터 도출됩니다.
이 설계의 목적은 무엇인가요? 프로토콜이 담보 가치를 효율적으로 관리하는 동시에 사용자의 자산 예치를 장려할 수 있게 합니다. 프로토콜 수익의 일부를 담보 제공자에게 배분함으로써 lending_accumulator가 증가하고, 이를 통해 모든 사용자의 담보 가치가 비례적으로 동시에 증폭됩니다.
0x1.2 zkLend의 플래시 론
플래시 론은 무담보 대출의 일종으로, 사용자가 프로토콜에서 자산을 매우 짧은 기간, 일반적으로 단일 트랜잭션 내에서 빌릴 수 있습니다. 차입자가 대출금을 상환하거나 지정된 조건을 충족하지 못하면 전체 트랜잭션이 되돌려지고 대출이 실행되지 않습니다.
zkLend의 플래시 론 구현에는 독특한 기부(donation) 메커니즘이 있습니다. 구체적으로, 사용자가 자산을 상환할 때 필요한 최소 금액만 반환하는 것이 아니라 추가 자금을 기부로 제공할 수 있습니다. 프로토콜은 이 기부된 자금을 추적하고 그에 따라 lending_accumulator를 업데이트합니다. 이 과정은 settle_extra_reserve_balance() 함수에서 구현됩니다. lending_accumulator 업데이트 공식은 다음과 같습니다:
new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply
reserve_balance: 사용자가 기부한 토큰 금액을 포함하여 컨트랙트에 보유된 기초 토큰(예: wstETH)의 총량입니다.totaldebt: 모든 차입 사용자의 총 부채입니다.amount_to_treasury: 프로토콜 수익 금액입니다.ztoken_supply: 공유 토큰(예: zwstETH)의 총 공급량입니다. 사용자가 wstETH를 예치하면 zkLend ztoken 컨트랙트가 동일한 양의 zwstETH를 발행합니다.
zkLend의 핵심 프로토콜을 이해했으니, 이제 공격자가 lending_accumulator와 raw_balance 변수를 조작하여 담보 자산을 어떻게 조작했는지 공식적으로 설명하겠습니다.
0x2 공격 분석
공격자는 zkLend 컨트랙트의 다음 메커니즘과 취약점을 이용하여 담보 가치를 조작했습니다:
lending_accumulator조작- 빈 마켓: 공격 전, wstETH 토큰에 대한 zkLend 마켓은 비어 있었으며, 이는 조작에 완벽한 조건을 제공했습니다. 또한 zkLend 마켓 컨트랙트는 누구든지 빈 마켓에 임의의 양의 자산을 예치할 수 있게 허용합니다. 공격자는 소량의 자산을 예치하여
lending_accumulator값을 크게 부풀렸습니다. - 기부 메커니즘: zkLend 마켓 컨트랙트의
flash_loan()함수에는 독특한 기부 메커니즘이 있습니다. 구체적으로, 사용자가 플래시 론을 상환할 때 마켓 컨트랙트는 반환된 초과 자금을 계산하고 전역lending_accumulator변수를 증가시켜 컨트랙트 내 모든 사용자의 담보 가치를 증폭시킵니다.
- 빈 마켓: 공격 전, wstETH 토큰에 대한 zkLend 마켓은 비어 있었으며, 이는 조작에 완벽한 조건을 제공했습니다. 또한 zkLend 마켓 컨트랙트는 누구든지 빈 마켓에 임의의 양의 자산을 예치할 수 있게 허용합니다. 공격자는 소량의 자산을 예치하여
raw_balance조작- 반올림 동작: 공유 토큰 소각 과정의 나눗셈 연산에서 절사(truncation)를 사용하여, 출금 시 사용자의
raw_balance변화가 과소평가됩니다.
- 반올림 동작: 공유 토큰 소각 과정의 나눗셈 연산에서 절사(truncation)를 사용하여, 출금 시 사용자의
이 두 변수를 모두 조작함으로써 공격자는 담보 잔액을 7,000 wstETH 이상으로 늘리고 수익을 위해 마켓에서 다른 자산을 빌릴 수 있었습니다.
0x2.1 lending_accumulator 변수 조작
0x2.1.1 빈 마켓 초기화
공격 이전 마켓 컨트랙트의 트랜잭션 기록을 확인하면, 공격자가 처음에 wstETH 마켓 컨트랙트에 1 wei의 wstETH를 예치했음을 알 수 있습니다. 이 트랜잭션의 내부 호출을 검토하면, wstETH 마켓 컨트랙트가 0 wstETH를 보유하고 있었으며 zwstETH의 총 공급량도 0이었음이 명확합니다.
따라서 zkLend wstETH 마켓에 이전 예치 또는 차입이 없었음을 확인할 수 있습니다. reserve_balance와 ztoken_supply 모두 초기값인 0이었으며, lending_accumulator의 초기값은 1이었습니다. 이 빈 마켓 시나리오는 이후 공격을 위한 조건을 만들었으며, 공격자가 최소한의 wstETH로 lending_accumulator를 크게 증폭시킬 수 있게 했습니다.
0x2.1.2 플래시 론을 통한 lending_accumulator 조작
다음으로, 이 트랜잭션에서 공격자는 flash_loan() 함수를 호출하여 1 wei wstETH를 빌리고 1000 wei wstETH를 상환합니다. 초과분인 999 wei는 기부로 처리되어 컨트랙트의 reserve_balance에 기록됩니다.
lending_accumulator 계산 공식에 따르면, 이 트랜잭션으로 인해 lending_accumulator가 1에서 851.0으로 증가합니다.
0x2.1.3 flash_loan()의 반복 실행
공격자는 총 10번의 flash_loan() 호출을 실행하며, 매번 1 wei의 wstETH만 빌리지만 더 큰 금액을 상환합니다. 결과적으로 lending_accumulator는 4,069,297,906,051,644,020 (4.069 × 10^18)이라는 천문학적인 값으로 상승하며, 이는 우연히 wstETH의 소수점 정밀도와 일치합니다.
0x2.2 raw_balance 변수 조작
lending_accumulator를 약 4.069 × 10^18로 조작한 후, 공격자는 마켓 컨트랙트의 deposit() 함수를 4.069297906051644020 wstETH로 호출했습니다. 최신 lending_accumulator 값을 기반으로 공격 컨트랙트의 raw_balance는 2가 되었습니다.
0x2.2.1 raw_balance를 조작하는 첫 번째 트랜잭션
이 트랜잭션에서 공격자는 공격 컨트랙트의 callflashloandraaan() 함수를 호출했습니다. 이 컨트랙트는 오픈 소스가 아니지만, 내부 호출 추적을 기반으로 이 함수의 로직에는 다음 동작을 수행하는 루프가 포함된 것으로 추측할 수 있습니다:
- 예치(Deposit): 공격자가 마켓 컨트랙트에 일정량의 wstETH를 예치합니다.
- 출금(Withdraw): 공격자가 특정 양의 wstETH를 출금합니다.
토큰 전송 기록 분석
공격자가 예치하는 wstETH의 양은 항상 lending_accumulator의 정수 배수이며, 예를 들어 lending_accumulator 값의 2배(예: 8.13859)임을 알 수 있습니다.
그러나 출금되는 wstETH의 양은 lending_accumulator 값의 1.5배(예: 6.10394)입니다.
계산을 통해 출금된 wstETH의 양이 예치된 양을 초과함을 알 수 있습니다. 왜 이런 일이 발생할까요?
반올림 동작
deposit() 및 withdraw() 메서드의 구현을 검토하면, 이 두 메서드가 각각 zwstETH의 발행(mint)과 소각(burn)을 포함한다는 것을 알 수 있습니다. 작동 방식은 다음과 같습니다:
마켓 컨트랙트의 `mint()` 함수
마켓 컨트랙트의 `burn()` 함수
mint()와 burn() 과정 모두 스케일 다운(scale down) 로직을 포함합니다. 스케일 다운 로직은 내림 반올림(floor rounding)(가장 가까운 정수로 내림)을 사용한 정수 나눗셈을 포함하며, 이것이 익스플로잇에서 핵심적인 역할을 합니다.
공격자가 일정량의 zwstETH를 소각할 때 스케일 다운 로직이 적용됩니다. 조작된 lending_accumulator 값이 매우 높기 때문에(약 4,069,297,906,051,644,020), 이 나눗셈으로 인해 공격자의 raw_balance는 6 zwstETH 이상을 소각했음에도 불구하고 1단위만 감소합니다.
공격자의 raw_balance 변화는 다음 표에 요약되어 있습니다:
이 트랜잭션에서 공격자가 예치-출금 로직을 반복적으로 실행하여 withdraw() 함수 중 정밀도 손실을 악용하고, 이로 인해 raw_balance 차이가 과소평가됨을 알 수 있습니다. 결국 사용자의 raw_balance가 2에서 3으로 증가하여 추가 단위를 획득했습니다.
0x2.2.2 이후 공격 과정
이후 공격 트랜잭션들은 첫 번째 공격과 동일한 패턴을 따랐습니다: 공격자는 예치-출금 트랜잭션을 반복적으로 순환하여 wstETH를 획득합니다.
획득한 wstETH는 다시 마켓에 재예치되어 raw_balance를 더욱 증가시키고, 공격자의 담보 가치가 계속 상승하게 됩니다.
예시 설명
다음 트랜잭션을 예시로 사용합니다.
- 총 30번의 예치가 이루어졌으며, 매번 4.069 wstETH가 예치되었습니다.
- 총 30번의 출금이 이루어졌으며, 매번 6.104 wstETH가 출금되었습니다.
- 이 사이클 후 계산에 따르면 공격자는 61.39 wstETH를 성공적으로 추출했습니다.
또한, 이러한 공격 트랜잭션 사이에 여러 increase() 메서드가 호출되었음을 주목할 필요가 있습니다. 이 메서드들은 공격자의 계정에서 공격 컨트랙트로 특정 양의 wstETH를 전송하는 데 사용되었으며, 이후 마켓 컨트랙트에 예치할 자금을 제공했습니다.
이러한 행동들은 raw_balance 값을 높여 공격자가 담보 가치를 계속 증가시킬 수 있게 합니다. 결국 공격자의 raw_balance는 1,724에 도달했으며, 7,015.4 wstETH의 가치로 마켓에서 다른 자산을 빌리기에 충분했습니다.
0x3 수익 분석
0x3.1 다른 유형의 자금 차입
담보 가치를 조작한 후, 공격자는 마켓에서 다른 유형의 자금을 차입하고 다음 트랜잭션을 진행했습니다(일부 발췌):
0x3.2 차입한 자금을 레이어1로 브리지
공격자의 컨트랙트의 브리지 트랜잭션을 검사하면, 공격자가 차입한 자금의 일부를 레이어1로 브리지했음을 알 수 있습니다.
0x4 결론
요약하자면, zkLend 프로토콜에 대한 이번 공격은 탈중앙화 대출 프로토콜의 설계 및 보안에 대한 여러 중요한 시사점을 강조합니다:
- 마켓 초기화 및 자산 예치 조건:
초기의 빈 마켓은 공격자가 소량의 wstETH를 예치하고
lending_accumulator를 조작하여 익스플로잇의 레버리지를 확보할 수 있게 했습니다. 충분한 유동성 기반을 확보하거나 초기 마켓 단계에서 자산 기부를 제한하는 것이 유사한 공격을 방지하는 데 도움이 될 수 있습니다. - 적절한 누산기 메커니즘의 중요성:
공격자는
flash_loan()함수의 기부 메커니즘을 악용하여lending_accumulator를 조작하고 모든 사용자의 담보 가치를 부풀렸습니다. 누산기 기반 메커니즘을 가진 프로토콜은 스케일링 인수의 손쉬운 조작에 대한 안전장치를 마련해야 합니다. - 반올림 동작 및 정밀도 손실:
zwstETH 토큰 소각 중 반올림 문제가 정밀도 손실과
raw_balance의 과소평가를 초래하여 공격자가raw_balance를 조작할 수 있게 했습니다. 프로토콜은 이러한 익스플로잇을 방지하기 위해 더 높은 정밀도나 검증 체크를 사용해야 합니다.
다시 한번, 이 사건은 초기화 및 운영 상태에 관한 적시 알림의 중요성과 잠재적 손실을 완화하기 위한 선제적 위협 방지의 중요성을 강조합니다.
참고 문헌
[1] https://zklend.com/
[2] zkLend 보안 사고 사후 분석 보고서: https://drive.google.com/file/d/10i1dh_J89tPPw7KRcmFIVM6iNrJZAyfi/view



