CertiK:HopeLend 대출 공격 사건 분석
저자:CertiK
2023년 10월 18일 19:48:59(베이징 시간), Hope.money의 대출 풀은 플래시 론을 기반으로 한 공격을 받았습니다.
Hope.money는 대출 플랫폼 HopeLend, 탈중앙화 거래소 HopeSwap, 스테이블코인 HOPE 및 거버넌스 토큰 LT를 포함하여 사용자에게 탈중앙화 금융의 전체 스택 서비스를 제공합니다.
이번 공격에 관련된 프로토콜은 HopeLend로, 이는 사용자들이 프로토콜에 유동성을 제공하거나 초과 담보 대출을 통해 수익을 얻을 수 있는 탈중앙화 대출 플랫폼입니다.
사건 경과
HopeLend의 코드 구현에서 대출 풀에는 악용될 수 있는 취약점이 존재했습니다. 이는 예치증명서를 소각할 때 잘못된 정수 나누기 문제로 인해 소수점 부분이 잘리게 되어, 예상보다 적은 증명서 수량이 소각되고 예상과 일치하는 가치 토큰을 얻는 결과를 초래했습니다.
공격자는 이 결함을 이용해 Hope.money에 존재하는 여러 대출 풀의 자금을 모두 빼냈습니다.
그 중 hEthWbtc 대출 풀은 73일 전에 배포되었으나 자금이 없었기 때문에 해커는 해당 대출 풀에 대량의 자금을 주입하여 할인율이 극적으로 폭등하게 만들었고, 이를 통해 하나의 블록 거래 내에서 다른 모든 대출 풀의 자금을 빠르게 빼낼 수 있었습니다.
더욱 극적인 것은, 이 결함을 이용한 해커는 취약점 이용으로 얻은 자금을 받지 못했습니다. 그의 공격 거래는 프론트 러너에 의해 발견되었고, 프론트 러너는 그의 공격 행동을 모방하여 모든 공격 수익 자금(527 ETH)을 성공적으로 빼앗았습니다. 결국 50%의 공격 수익 자금(263 ETH)이 프론트 러너에 의해 블록을 패킹한 채굴자에게 뇌물로 사용되었습니다(페이로드).
취약점을 발견한 초기 해커는 블록 18377039에서 공격 계약을 생성하고, 블록 18377042에서 공격 계약을 호출했습니다. 이때 프론트 러너는 메모리 풀의 거래를 모니터링하고 그의 공격 계약을 시뮬레이션하여 프론트 러닝 계약의 입력으로 사용했습니다. 같은 18377042 블록에서 이를 이용했지만, 초기 해커의 18377042 블록 거래는 프론트 러너 뒤에 정렬되어 실행에 실패했습니다.
자금의 흐름
프론트 러너는 수익을 얻은 후 한 시간 이내에 자금을 다음 주소로 전송했습니다: 0x9a9122Ef3C4B33cAe7902EDFCD5F5a486792Bc3A
10월 20일 13:30:23에 의심되는 공식 팀이 해당 주소에 연락하여 프론트 러너가 26 ETH(10%의 수익)를 보상으로 남기도록 허용했으며, 프론트 러너의 답변을 받았습니다.
최종 자금은 소통 한 시간 후 Gnosis Safe의 다중 서명 금고로 이전되었습니다.
이제 우리는 실제 취약점과 해커의 이용 세부 사항을 보여드리겠습니다.
전제 정보
HopeLend의 대출 프로토콜은 Aave에서 포크되어 구현되었으므로, 취약점 관련 핵심 비즈니스 로직은 Aave의 백서에서 참고하였습니다.
0x00 예치 및 대출
Aave는 순수한 DeFi로, 대출 사업은 유동성 풀을 통해 이루어집니다. 사용자가 Aave에 예치하여 유동성을 제공할 때, 대출에서 얻는 수익을 기대합니다.
대출 수익은 사용자에게 완전히 분배되지 않으며, 일부 이자 수익은 위험 준비금에 포함됩니다. 이 비율은 적고, 대부분의 대출 수익은 유동성을 제공하는 사용자에게 분배됩니다.
Aave에서 예치 및 대출을 진행할 때, Aave는 할인 방식으로 서로 다른 시점의 예치 수량을 유동성 풀 초기 시점의 예치 수량 지분으로 변환합니다. 따라서 각 수량 지분의 기초 자산에 대한 본이자 합계는 amount(지분) * index(할인율)로 직접 계산할 수 있어 계산과 이해가 크게 용이해집니다.
이는 마치 펀드를 구매하는 과정과 유사하게 이해할 수 있습니다. 펀드의 초기 순자산 가치는 1이며, 사용자가 100원을 투자하여 100의 지분을 얻습니다. 가정하건대, 일정 시간이 지나 수익을 얻어 순자산 가치가 1.03으로 변할 경우, 사용자가 다시 100원을 투자하면 얻는 지분은 97이 됩니다. 사용자의 총 지분은 197이 됩니다.
이는 사실 해당 자산을 index(순자산 가치)에 따라 할인 처리하는 것입니다. 이렇게 처리하는 이유는 사용자의 실제 본이자 합계가 balance에 현재의 index를 곱한 값이기 때문입니다. 두 번째 예치 시, 사용자의 올바른 본이자 합계는 100 * 1.03 + 100 = 203이 되어야 합니다. 할인 처리를 하지 않으면 두 번째 사용자가 100을 예치한 후의 본이자 합계는 (100+100) * 1.03 = 206이 되어 잘못된 값이 됩니다. 할인 처리를 하면 본이자 합계는 (100 + 100 / 1.03) * 1.03 = 103 + 100 = 203이 되어 203의 결과가 올바르게 됩니다.
공격 과정
0x25126……403907(hETHWBTC 풀)
0x5a63e……844e74(공격 계약 - 현금화)
초기 플래시 론 자금 대출 및 스테이킹
공격자는 먼저 Aave에서 플래시 론으로 2300 WBTC를 빌린 후, 그 중 2000개의 WBTC를 HopeLend에 예치(deposit)합니다. 자금은 HopeLend의 hEthWbtc 계약(0x251…907)으로 이전되며, 이에 따라 2000개의 hETHWBTC를 얻습니다.
빈 대출 풀을 이용한 초기 할인율 조작(liquidityIndex)
HopeLend에서 플래시 론으로 2000개의 WBTC를 빌립니다.
현재 가치가 1 hETHWBTC = 1 WBTC입니다.
정상적인 ETHWBTC를 WBTC로 교환하는 과정에서는 교환 비율에 영향을 미치지 않습니다(이자 수익이 발생해야만 교환 비율에 영향을 미침, 1 hETHWBTC는 더 많은 WBTC를 얻을 수 있음).
이때 해커는 일련의 복잡한 작업을 통해 할인율을 조작하기 시작합니다:
- 해커는 직접적으로 얻은 2000개의 WBTC를 HopeLend의 hEthWbtc 계약(0x251…907)으로 직접 전송(transfer)합니다. 이 단계는 대출 상환이 아닙니다.
- 해커는 이후 1단계에서 스테이킹(deposit)한 대부분의 WBTC(1999.999…)를 인출(withdraw)합니다. 따라서 이전 단계에서 WBTC를 다시 전송하여 풀 내 자산을 보충해야 했습니다.
- 마지막으로 해커는 최소 단위(1e-8)의 hEthWbtc만 남깁니다. 여기서 완전히 인출할 수 없는 이유는 할인율(liquidityIndex)을 계산할 때 기존 자산에 새로운 자산을 더해야 하기 때문입니다. 만약 0으로 만들면 할인율(liquidityIndex)이 0이 되어 풀 내 비율이 불균형해질 수 있습니다.
- 이전 단계에서 대부분의 hEthWbtc를 소각한 후, 남은 WBTC와 플래시 론으로 남은 WBTC를 더하여 HopeLend 풀에 빌린 플래시 론을 상환합니다. 총 2001.8개의 WBTC(이자 1.8개의 WBTC 포함)를 지불합니다.
- 위 과정에서 대부분의 hEthWbtc가 소각되고, 해커 계좌에는 1 최소 단위(1e-8)의 hEthWbtc만 남게 됩니다. 이렇게 되면 hETHWBTC의 총량이 줄어들고, 대출 풀에는 2001.8개의 WBTC가 남게 되어 할인율(liquidityIndex)은 놀라운 126,000,000에 도달합니다.
여기에는 하나의 지식이 포함됩니다. 예치 사용자의 이자는 본질적으로 풀 내 유동성의 증가에서 발생하며, 대출 풀은 예치율과 사용율에 따라 대출 및 예치 이율을 동적으로 조정합니다.
이때 풀은 플래시 론 이자(1.8 WBTC)로 추가 유동성을 얻을 때, 70% (126,000,000)가 liquidityIndex에 포함됩니다. 이 값은 각 단위 예치(hEthWbt)의 할인 가치를 계산하는 데 사용됩니다.
해커의 조작 전 풀은 비어 있었고, 상환 후 totalLiquidity는 1에 불과하며, amount는 126000000입니다. 초기 liquidityIndex는 1로, 결과는 126000001이 됩니다.
할인율 확대 지속
해커는 계속해서 HopeLend에서 플래시 론으로 2000개의 WBTC를 빌리고, 매번 추가로 1.8개의 WBTC를 상환하여 매번 LiquidityIndex가 126,000,000씩 누적되도록 합니다.
해커는 이 과정을 60회 반복하여 최종적으로 liquidityIndex는 7,560,000,001에 도달하고, 공격자가 보유한 1개의 최소 단위 hEthWBTC의 할인 가치는 75.6 WBTC(약 214만 달러)에 달하게 됩니다.
이로 인해 해커는 hEthWBTC를 조작하여 가치가 왜곡되었습니다.
다른 자금이 존재하는 대출 풀을 비우고 수익 형성
공격자는 이어서 1개의 최소 단위 hEthWBTC를 담보로 HopeLend의 다른 다섯 개 토큰 풀에서 대량의 자산을 빌렸습니다.
포함된 자산은:
- 175.4 - WETH
- 145,522.220985 - USDT
- 123,406.134999 - USDC
- 844,282.284002229528476039 - HOPE
- 220,617.821736563540747967 - stHOPE
이러한 토큰은 수익으로 Uniswap을 통해 WBTC와 WETH로 교환되었고, 각종 수수료를 제외한 후 최종적으로 해커는 약 263개의 WETH(뇌물 페이로드 263.9 WETH 제외)를 얻었습니다.
왜 해커가 다른 풀에서 대량의 자금을 빌릴 수 있었는가:
대출하거나 예금을 인출할 때, 대출 계약은 사용자의 담보 자산 상태를 검사하여 대출이 담보를 초과하지 않도록 합니다.
이전에 할인율이 해커에 의해 조작되었고, 할인율은 normalizedIncome 배수를 담보 가치 계산에 포함하기 때문에, 그가 보유한 1 단위 hEthWBTC의 담보 가치는 75.6 WBTC에 달하게 됩니다.
해커는 다른 풀에서 대출할 때마다 담보 자산 검증을 쉽게 통과했습니다.
이때 공격자는 HopeLend에 2000+1.8*60개의 WBTC를 투입하여 liquidityIndex를 조작하고, 1 단위의 hEtthWBTC만 남겼습니다.
핵심 취약점(정수 나누기 오류) 현금화
이전에 투자한 wBTC를 인출하기 위해 공격자는 또 다른 공격 계약을 배포했습니다: 0x5a63e……844e74, 그리고 그 안의 withdrawAllBtc() 메소드를 호출했습니다:
취약점 과정은 다음과 같습니다:
- 먼저 151.20000002개의 wBTC를 예치하고, 현재의 liquidityIndex(1 최소 단위 hEthWBTC=75.6wBTC)에 따라 공격자는 2개의 최소 단위 hEthWBTC를 얻습니다.
- 113.4개의 wBTC를 인출(withdraw)하고, 그에 해당하는 hEthWBTC 지분을 반산하여 hEthWBTC를 소각(burn)합니다.
- 113.4개의 wBTC를 소각하기 위해 1.9999999998 최소 단위의 hEthWBTC가 필요하지만, div 함수의 정밀도 문제로 인해 단지 1 최소 단위의 hEthWBTC만 소각되므로, 이는 악용 가능한 취약점이 되어 해커는 여전히 1개의 최소 단위 hEthWBTC를 보유할 수 있게 됩니다.
핵심 취약점
hEthWBTC의 burn 메소드는 고정밀 나누기 rayDiv를 호출합니다.
여기서:
a=11340000000(인출할 WBTC)
b=7560000001000000000000000009655610336(할인율)
비록 (a*1e27+b/2)/b = 1.9999999998이지만, 솔리디티의 기본 div 메소드는 절단되어 1을 반환합니다. 이는 11340000000 / 7560000001 나누기 후 소수점 자리가 잘린 것입니다.
0x5a63(공격 계약 - 현금화)는 계속해서 75.60000001 WBTC를 예치하여 정확히 1개의 최소 단위 hEthWBTC를 다시 얻어 2개의 최소 단위 hEthWBTC를 계속 보유하게 됩니다.
이렇게 113.40000000 WBTC를 인출하고 75.60000001 WBTC를 예치하는 작업을 반복함으로써, 공격자는 매번 37.8개의 WBTC를 무상으로 얻을 수 있습니다.
58회 반복 후, 공격자는 모든 초기 투자한 wBTC를 인출하고 Aave의 플래시 론을 성공적으로 상환했습니다.
결론
hEthWBTC 대출 풀이 초기화되지 않았기 때문에, 공격자는 liquidityIndex를 쉽게 조작하여 극대화할 수 있었고, 인출율이 분모로 사용되면서 크게 확대된 후, 정수 나누기의 절단 오차로 인해 이전의 투자를 한 블록 내에서 더 쉽게 인출할 수 있었습니다.
운영이 잘 되는 대출 풀에서는 이미 유동성이 존재하기 때문에 소량의 대출 이자 증가로 인해 할인율이 크게 증가하기 어렵습니다.