Решение задач Ethernaut. Puzzle Wallet. Proxy & Delegatecall
Нам даны 2 контракта: Контракт прокси и Реализация Кошелька
С первого взгляда выглядит корректно, но давайте изучим как работает прокси.
Для этого нам нужно понять что такое слоты памяти proxy и delegatecall
Слоты памяти
Слот памяти - 32 байтная ячейка памяти, в которой хранится переменная(ые) смарт контракта. Компилятор может оптимизировать память и помещать несколько переменных в один слот.
Главное что нужно помнить - у каждого контракта есть своя память
proxy & delegatecall
Из за того что смарт контракт нельзя изменить после загрузки в блокчейн, но есть необходимость вносить изменения в логику работы был придуман патерн разработки называемый proxy.
Это промежуточный смарт-контракт, который перенаправляет запрос пользователя в актуальную версию смарт-контракта
Но в такой простой реализации кроется проблема - если мы мы использовали call в контракт-реализацию, то если мы публикуем новую версию контракта-реализации, то все наши данные (настройки, балансы пользователей и т.п) теряются, т.к были использованы слоты памяти, которые остались на старой версии контракта.
Нужно что бы слоты памяти оставались прежними, а менялась только логика.
Тут приходит на помощь функция delegatecall.
Эта функция выполняет логику чужого контракта, но используя свои слоты памяти.
Разберем на примере
Для хранения переменных используются слоты в порядке их объявления в реализации
Если мы добавляем новую переменную в новой версии реализации, то будет использован соответствующий слот
НО если мы поменяем переменные местами, то будут использованы некорректные слоты и данные в них могут быть испорчены
Если мы хотим использовать какие-то переменные в проки-контракте, то необходимо реализовать отступ на X слотов, что бы избежать коллизии по номерам слотов
- для корректной работы все переменные в новой версии контракта должны идти строго в том же порядке как и в старой версии контракта, иначе данные могут быть перезаписаны, т.к. будет происходить обращение к другим слотам памяти
- Если в прокси используются какие то слоты памяти, то при некорректной реализации прокси контракта они могут изменены логикой вызванной через delegatecall. Необходимо делать отступ по слатам памяти, что бы они не пересекались
Вернемся к изначальной задаче
Теперь обладая новыми знаниями взглянем на контракты
Видим что прокси и реализация логики используют для хранения переменных те же слоты
Из этого следует, что переписать значение переменной admin в proxy мы можем вызвать изменение maxBalance в контракте реализующем логику.
Видим что у функции setMaxBalance есть модификатор onlyWhitelisted
Что бы добавить себя в вайтлист нужно вызвать функцию addToWhitelist
Но нам мешает require, который проверяет owner'a
Проверим что за адрес выдается по запросу owner'a
Изучив код контракта мы видим, что переменная owner занимает 1й слот и переменная pendingAdmin в прокси контракте находится в том же слоте
Воспользуемся этой уязвимостью - Что бы стать owner'ом вызовем фунцию proposeNewAdmin в прокси контракте, предложив в качестве нового админа свой адрес
Вызов этой функции не доступен из интерфейса ethernaut'а, поэтому перейдем в Remix скопировав код контракта прокси. Remix будет ругаться на импорт апгрейдебел прокси - удаляем импорт и наследование за ненадобностью.
Копируем адрес инстанса из ethernaut'а и вставляем в Remix в поле рядом с кнопкой At address. Нажимаем кнопку
У нас появился интерфейс для взаимодействия с прокси
Нажимаем кнопку proposeNewAdmin, вставив свой адрес в поле рядом. Ждем пока исполнится транзакция. Переходим в ethernaut и проверяем owner'a
Видим что теперь здесь наш адрес
Теперь, когда мы owner - добавляем так же свой адрес в вайтлист
Теперь нам доступен вызов функции setMaxBalance, но для ее исполнения нам нужно пройти require, который проверяет что текущий баланс равен нулю. На адресе находится 0.001 ETH от которых мы должны избавиться
Рассмотрев реализацию контракта PuzzleWallet мы видим, что в функции execute можем снять только те эфиры, которые задепазитили в функции deposit
Также мы видим что в функции multicall есть ограничение, которое не дает вызвать повторно функцию deposit в рамках мультиколла, что бы учесть полученные эфиры только один раз
Функция multicall принимает массив байт - последовательность селекторов функций
Например [ func1, func2, func3 ]
и в цикле вызывает переданные ей функции
Данная реализация multicall'a защищена от передачи такого параметра [deposit, deposit]
Но данная реализация не защитит контракт, если мы пошлем вызов multicall в multicall в который пошлем deposit 🤯.
Напишем контракт, который воспользуется этой уязвимостью, для повторного учета одних и тех же эфиров
Функция getCall заполняет массив таким образом [ deposit, multicall [ deposit ] ]
Функция multicallAttack принимает адрес инстанса прокси и она payable
В прокси мы передаем подготовленный массив из селекторов и эфиры.
На контракте прокси уже лежит 0.001 eth, поэтому мы пошлем еще 0.001, что бы после атаки на функцию multicall мы могли снять удвоенный балланс
И затем с помощью execute мы снимаем 0.002 eth
Загружаем контракт в сеть и добавляем адрес этого контракта в вайтлист
Теперь вызываем функцию multicallAttack передав адрес прокси и 0.001 eth
Можем убедиться в Tenderly, что мы передали 0.001, а получили 0.002 опустошив прокси
Теперь когда баланс прокси равен нулю рекваер в функции setMaxBalance нам не мешает и мы передаем свой адрес что бы перезаписать слот, хранящий адрес админа своим адресом
Убеждаемся что мы теперь админ