Solidity
March 22, 2023

solidity | Низкоуровневые вызовы

Привет, сегодня пойдет речь о низкоуровневых вызовах. Поговорим о сходствах и различиях таких вызовов и их уязвимостях.

Для чего нужны низкоуровневые вызовы?

Благодаря таким вызовам мы можем обращаться к функциям смарт-контракта, к которому у нас нет доступа и abi, только по адресу и выполнить ту или иную функцию.

Существует 3 вида вызовов.

1) call
2) delegatecall
3) staticcall ~ call

call

call нужен для вызова функций (только public exturnal), если у нас нет интерфейса или исходного кода того или иного смарт-контракта. Так же call обходит все ошибки, которые могу возникнуть при вызове функции (например ошибка тип данных). То есть транзакция пройдет в любом случае. Так же используется для вызова таких функций как fallback и recieve.

Рассмотрим пример:

Буду работать в remix

contract investContract{  
    string public message;     
    mapping (address => uint) public balances;    
    address public owner; 
    constructor(){        
        owner = msg.sender;    
    }    
    function invest(string calldata _message) public payable{        
        balances[msg.sender] += msg.value; 
        message = _message;   
    }
}

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

Теперь напишем новый контракт.

contract investorContract{
    mapping (address => uint) public balances;     
    address public investContractAddress;    
    address public owner;       
    constructor(address _investContractAddress){        
        owner = msg.sender;        
        investContractAddress = _investContractAddress;    
    }
    function investor(string calldata message) public payable{
    
    }
   
}

Тут мы передаем в переменную investContractAddress, адрес нашего первого смарт-контракта investContract в конструкторе при деплое. Далее пока что пустая функция investor.

Что будет происходит в функции investor:

function investor(string calldata message) public payable{       
    (bool success, ) = investContractAddress.call{value: msg.value}( 
        abi.encodeWithSignature("donate(string)", message)       
    );       
    require(success, "Faild!");    
}

Наш вызов call возвращает кортеж из двух элементов: это переменная bool true или false после вызова функции и переменная, которая попадает в return вызываемой функции если такая имеется, если ее нет как у меня, то просто пробел, это и будет означать, что нам не интересно что вернет эта функция.

Если есть переменная в return:

(bool success, bytes memory ans) = investContractAddress.call{value: msg.value}( 
        abi.encodeWithSignature("donate(string)", message)       
    );

Возвращаемая переменная всегда будет в байтах, дальше можно переводить в любой тип, который вам нужен. Можно использовать abi.decode() для этого.

Дальше мы обращаемся к адресу контракта, где находится та или иная функция и вызываем метод call, после чего мы в фигурных скобках пристыковываем деньги и в круглых скобках при помощи правильной кодировки функции через abi.encodeWithSignature мы можем обратиться к donate() и вызвать ее.

Как работает кодировка и байт код в EVM эфириума расскажу в другой раз

Из за особенности функции call, а именно то, что вызов будет совершен в любом случае, мы не узнаем есть ли ошибка или ее нет, то именно для этого нам и нужна булева переменная success, чтоб потом в require проверить прошла транзакция или нет.

Второй вариант кодирования:

function investor(string calldata message) public payable{       
    (bool success, ) = investContractAddress.call{value: msg.value}( 
     abi.encodeWithSelector(investContract.donate.selector, message)       
    );       
    require(success, "Faild!");    
}

Этот способ будет работать только в том случае, если у вас есть доступ к исходнику первого смарт-контракта.

Проверим работу

Чтоб проверить работу задеплоим первый смарт-контракт investContract, после чего второй investorContract, но с адресом первого контракта.

В ремиксе это делается так

И можно проверить вызвав функцию investor из investorContract.

Под цифрой (1) мы видим, что адрес нашего смарт-контракта investContract был успешно установлен в контракте investorContract.

Под цифрой (2) я вызываю функцию investor, которая в себе содержит вызов функции donate из смарт-контракта investContract, передаю сообщение и 2 эфира

Под цифрой (3) я проверяю переменную message в контракте investContract и вижу что там установилась моя строка

И под цифрой (4) я проверяю баланс моего адреса и вижу, что мой баланс стал равен 2 эфира.

Важно!
Я вызвал функцию investor с адреса первой учетки, то есть 0x5B38...C4, но деньги были зачислены на адрес 0xE73...065 (при проверке баланса). Потому что, хоть и вызов функции investor был с моей учетки, функция donate была вызвана с адреса смарт контракта investorContract, и в следствии этого адрес отправителя транзакции стал мой контракт investorContract.

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

delegatecall

Из названия понятно, что delegatecall используется для делегирования функций контракта A в контракт B со всеми переменными и синтаксисом контракта A.

По своей записи он почти ни чем не отличается от call, только мы уже не можем пристыковывать деньги.

Рассмотрим пример:

investContract оставляем без изменений, а вот investorContract нужно будет доработать.

contract investorContract{
    string public message;    
    mapping (address => uint) public balances;
    address public investContractAddress;    
    address public owner;    
    constructor(address _investContractAddress){        
        owner = msg.sender;        
        investContractAddress = _investContractAddress;    
    }
    function investor(string calldata _message) public payable{       
        (bool success, ) = investContractAddress.delegatecall(           
            abi.encodeWithSignature("donate(string)", _message)       
         );       
        require(success, "Faild!");    
    }
}

Так как мы делегируем функцию, то и все ее переменные нам нужно тоже делегировать в наш смарт-контракт. То есть в рамках контракта investorContract будет выполняться контракт investContract. Поэтому добавляем string message.

Так же call меняем на delegatecall, а все остальное также.

Важно!
В контракте, где есть delegatecall очень важно ставить переменные в том же порядке, что и в том смарт-контракте, откуда идет вызов. Каждая строка в solidity это свой слот, размером 32 байта, и даже если вы поставили в том же порядке переменные что и в другом смарт-контракте, но перед ними есть какие то другие переменные, то будет смещение по слотам и ошибки, что может привести к взломам и неприятностям.

Как я тут показал, что слоты у нас идут по порядку размером 32 байта. И поэтому нужно, чтоб слоты переменных, которые мы хотим перезаписать в функции, вызвав delegatecall совпадал со слотами в смарт-контракте, откуда идет вызов.

Теперь, если вы учли эту особенность, можно развернуть смарт-контракты и проверить их работу.

Под цифрой (1) мы видим, что адрес нашего смарт-контракта investContract был успешно установлен в контракте investorContract.

Под цифрой (2) я вызываю функцию investor, которая вызывает функцию donate из investContract,передаем сообщение и 3 эфира.

Под цифрой (3) мы видим, что сообщение установилось в смарт-контракте investorContract, а под цифрой (5), что в контракте investContract ни чего не произошло

Под цифрой (4) мы проверяем баланс и видим, что на этот раз баланс кошелька, который еще и является нашим аккаунтом, с которого была вызвана функция investor (0х5B38...C4), находиться в investorContract, а не в investContract, как это было в call.

Вот такая особенность в delegatecall.

Важно понимать, что из за того, что идет жесткая привязка по слотам, то при ошибке, можно легко перезаписать переменные в свою пользу, вплоть до того, что угнать овнера смарт-контракта. Так что нужно быть осторожным и применять этот вызов только в 100% ситуации.

Пример ошибки:

Допустим мы передаем адрес последней учетки, которая была инвестором, но поменяем местами переменные owner и lastInvestor местами. То есть 1 слот в investorContract это owner, а первый слот в investContract это lastInvestor.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract investorContract{    
    address public owner;    
    mapping (address => uint) public balances;    
    address public lastInvestor;    
    address public investContractAddress;    
    constructor(address _investContractAddress){        
        owner = msg.sender;        
        investContractAddress = _investContractAddress;    
    }    
    function investor(address _lastInvestor) public payable{       
        (bool success, ) = investContractAddress.delegatecall(
            abi.encodeWithSignature("donate(address)", _lastInvestor)     
        );       require(success, "Faild!");    
    }
}
contract investContract{    
    address public lastInvestor;    
    mapping (address => uint) public balances;    
    address public owner;    
    constructor(){        
        owner = msg.sender;    
    }    
    function donate(address _lastInvestor) public payable {        
        balances[msg.sender] += msg.value;        
        lastInvestor = _lastInvestor;    
    }   
}

Казалось бы в чем разница?

Но давайте напишем контракт hack и взломаем investorContract:

contract Hack{      
    investorContract public addressHack;    
    address public owner;    
    constructor(address _investorContractAddress){        
        owner = msg.sender;        
        addressHack = investorContract(_investorContractAddress);    
    }    
    function toHack(address _NewOwner) public {        
        addressHack.investor(_NewOwner);           
    }
}

Первая переменная будет нужна, для доступа к функциям контракта investorContract после чего в конструкторе мы указываем адрес этого контракта.

Дальше простая функция, которая просто вызывает функцию investor из investorContract.

Под цифрой (1) проверяем овнера смарт-контракта, там стоит 0x5B38...C4

Под цифрой (2) вызываем функцию toHack, передаем адрес нашего контракта (можно любой)

Под цифрой (3) мы видим, что овнером смарт-контракта investorContract стал наш контракт hack с адресом 0xE536...32.

И все это из за того, что не правильный лейаут переменных.

Надеюсь эта инфа была полезной, разобрали 2 типа низкоуровневых вызовов и способы их применения. Также узнали какие неприятности могут ждать при их использовании. Дальше больше. Будьте осторожны при добавлении таких вызовов и удачи.

tg: мой телеграмчик)