solidity | Низкоуровневые вызовы
Привет, сегодня пойдет речь о низкоуровневых вызовах. Поговорим о сходствах и различиях таких вызовов и их уязвимостях.
Для чего нужны низкоуровневые вызовы?
Благодаря таким вызовам мы можем обращаться к функциям смарт-контракта, к которому у нас нет доступа и abi, только по адресу и выполнить ту или иную функцию.
Существует 3 вида вызовов.
1) call
2) delegatecall
3) staticcall ~ call
call
call нужен для вызова функций (только public exturnal), если у нас нет интерфейса или исходного кода того или иного смарт-контракта. Так же call обходит все ошибки, которые могу возникнуть при вызове функции (например ошибка тип данных). То есть транзакция пройдет в любом случае. Так же используется для вызова таких функций как fallback и recieve.
Рассмотрим пример:
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: мой телеграмчик)