Привет, Хабр! Был тёплый пятничный вечер, хотелось скорее бежать домой, пересесть из компьютерного кресла на кресло настоящее в полутора метрах, а тесты всё никак не проходили. Причём не проходили они самым изощрённым образом: падая прямо где-то в недрах библиотеки.
Самое обидное было то, что состояние временной базы, поднятой в докере на время работы этих псевдоинтергационных тестов, было корректное, и фичу можно было отдавать на ревью –
Вот как-то так:
Что использованная в проекте версия 2.6.0, что самая новая 2.7.0 – увы.
Вечер был тёплый, ноутбук грелся, но вентилятор не включал во имя тишины и троттлился до желанных в многие годы назад 600 МГц, и были варианты действий:
0. Зарепортить баг в dbunit и ждать новой его версии, если pull-request вообще примут.
1. Закостылить тесты, чтобы они не проверяли те uuid-колонки, которые null, хотя именно это они и должны были проверять.
2. Вообще убрать тесты. Кстати, кто-нибудь пишет тесты на тесты?..
3. Закончить рабочий день и нырнуть с головой в исследования.
Вариант 0 был бы слишком долог (да и не факт, что это некорректное поведение, но зарепортить надо бы), вариант 1 оставался как запасной, вариант 2 не проходил по личному перфекционизму и правилам компании. А вот третий вариант был интересен.
Давайте посмотрим на виновника:
Налицо нарушение какого-то неявного контракта.
Быстрое гугление показывает редактор байткода JBE – Java Bytecode Editor. Вызывает некоторое опасение, что он требует 1.5 джаву для работы, а проект вовсю использует 11. Ну, исследования на то и исследования, чтобы стремглав бежать навстречу приключениям, попутно документирую свои действия.
Открываем из локального
Что же,
Поскольку руками править байт-код мне впервые, хорошо бы посмотреть на то, что мне надо в итоге получить. Создаём временный класс с двумя методами: похожим на имеющимся и похожим на желаемый:
Запихиваем их в JBE и видим, что код метода из временного класса как две капли воды похож на заменямый. Это хорошо, это было ожидаемо:
Код нового метода сильно больше:
Строки 5-6-7 остались неизменными, а вот сверху…
Важно другое, что в оригинальном классе
Копируем-вставляем ассемблерное представление, сохраняем…
О, не может записать в архив? А, ну, ок. Вытащим
Вот, теперь другое дело! Правда, после сохранения деактивировался пункт кода в дереве слева, прямо перестал нажиматься. Но не суть важно.
Откроем его с декомпиляторе Идеи – чисто для ознакомления и подтверждения того, что манипуляция была правильной, никаких некорректных использований, дорогой юридический отдел! Я бы и так мог подложить изменённый jar-архив, без этой проверки.
Кстати, об подкладывании. В
Убираем
Проверим, что оно точно подхватилось.
Запускаем тесты – и…
Падение, причём интересное. Я планировал закончить статью на этом моменте, нам (не) повезло. Исследование продолжается, дорогой читатель!
Ответ на StackOverflow подробно рассказывает, что в этом виновато отсутствие
Откроем обе версии
Сверху – оригинал, снизу – после модификации через JBE
Вы заметили, да? У нового варианта до метки строки L0 появились инструкции, но у них нет никакой метки. Возможно, в этом дело? Стоит посмотреть на байткод класса
Появился какой-то новый
Открываем спецификацию на JVM и начинаем смотреть, что за
У него следующий формат:
Строка в константном пуле в этом же
Чуть ниже описания
Вот, откуда тот
Поскольку мы всё же редактируем не только таблицу фреймов, но и сам код метода, вот структура метода:
У аттрибутов тоже есть своя структура:
У меня создаётся впечатление, что это уже не статья, а перепечатка из документации по JVM. Тут и так понятно, что это за поля, и что
Кстати, а две из этих строк нам нужны: «Code» и «StackMapTable»! Круг замыкается, ура!
А что за аттрибут со строкой «Code» такой?
Ох. Нет, пожалуй, ограничимся только
Итак, пытаемся приступить к практике. Смотрим в JBE индекс в константном пуле для
Попутно находим, что метод
«Code», кстати, находится по индексу 9.
Вооружившись структурой
Следующий аттрибут интереснее, он начинается на
Во имя тех, кто выключил загрузку каринок и питается бинарниками:
Скорее всего, здесь только лишь одна строка в таблице строк. Но давайте и её распарсим:
И на этом вся громоздкая секция аттрибута «Code» закончилась. Но где же «StackMapTable»?!
Я ожидал, что у метода будет аттрибут кода, в котором будет аттрибут таблицы фреймов:
Но, похоже, из-за того, что я документацию читал по диагонали, и сам этот аттрибут появился позже остальных, он был смещён в конец
Но он находится в конце файла, под большой громоздкой функцией
За чем же мы тогда всё это время гонялись?..
Если чуть внимательнее почитать документацию, можно увидеть строку, говорящую, что первый фрейм создаётся неявно, на основе параметров метода: «Each stack map frame described in the entries table relies on the previous frame for some of its semantics. The first stack map frame of a method is implicit, and computed from the method descriptor by the type checker (§4.10.1.6). The stack_map_frame structure at entries[0] therefore describes the second stack map frame of the method.» Иными словами, для такого простого метода JVM не требуется дополнительная помощь для валидации байткода.
Была эта работа напрасна? Нет, я чуть лучше разобрался, как устроен
Что ж, дабы не раздувать эту статью бесполезными листингами какого-то очень конкретного кода, я частично проделаю эту работу в фоне и оставлю только важные моменты.
Вот все константы в пуле констант, и вдобавок видно, что у изменённого метода всё-таки есть «StackMapTable»:
Нам однозначно пригодятся шестнадцатеричные индексы следующих строк:
По ходу пригодятся и другие, но они менее значимы.
Ищем, находим и парсим нечто, начинающееся с
На этом описание нового метода закончилось, осталось только разобраться в самих инструкциях. К сожалению, это куда менее увлекательно, чем растаскивать нечитамый бинарник на приятные блоки. Для этого достаточно взять другой том документации и найти по
Что же, тело оригинального метода
И тело модифицированного
Теперь касательно «StackMapTable». В оригинальном методе его не было в явном виде, поскольку программа была линейна, но в заменяемом методе есть целых два перехода. Мне кажется, что проще начать с конца, поскольку первый фрейм начинается с начала и длится неопредлённое число байт. Возиожно, что до первого прыжка, но я не уверен. Зачем гадать, когда мы точно знаем размеры всех фреймов, и что они идут друг за другом?
Единственный момент, который я не понял, как трактовать, так это фразу из пункта документации, который говорит, что к
Последний фрейм занимает три плюс один байт и требует проверить, что на стеке есть
Предыдущий фрейм занимал последние четыре байта из тринадцати, остаётся девять. Если следовать правилу, что этот фрейм не является начальным, к его восьми байтам тоже надо прибавить один. И он занимает девять байт и не требует проверок.
Значит ли это, что начальный фрейм с пустым стеком действителен ноль байт, поскольку первая инструкция сразу же его меняет? Или же я неправильно интерпретировал документацию? Прошу к этому параграфу относиться с великим сомнением.
Итак, в чём же основная разница между методами:
1. Разные элементы в пуле констант, надо менять вручную.
1а. Несмотря на различия из пункте 1, «Exceptions», «LineNumberTable» и «LocalVariableTable» логически одинаковы.
2. Различаются инструкции, в чём и есть цель.
3. Присутствие «StackMapTable» ввиду предыдущего пункта.
Теперь всё готово к хирургическому вмешательству.
План действий такой:
1. Вставить новый код поверх старого.
2. Починить сломанные ссылки на пул констант в том же куске кода. Там только ссылка на
3. Поменять длину субаттрибута инструкций в аттрибуте кода с
4. Увеличить длину аттрибута кода на 13-5=8 байт.
5. Увеличить число субаттрибутов в аттрибуте кода на 1.
6. Присобачить «StackMapTable» изолентой в конец секции кода.
7. Заменить индексы пула констант.
8. Увеличить длину аттрибута кода на 13 байт.
Результат этих манипуляций выглядит так:
Красное – изменённые куски кода
И Идея стала показывать что-то гораздо более осмысленное:
(Я по-прежнему в этом ничего не понимаю)
Но самое главное – тесты стали зелёные.
З.Ы. В статье кликабельны только мелкие каринки, к ним нет подписей, отсутствует заголовки частей, если таковые вообще есть, да и опечатки в корявосочинённых конструкциях могут иметь место быть. Не устраивает оформление – милости прошу в личку. Может, даже пофиксим что-то. :D
Самое обидное было то, что состояние временной базы, поднятой в докере на время работы этих псевдоинтергационных тестов, было корректное, и фичу можно было отдавать на ревью –
dbunit
почему-то считал, что у постгреса в колонке с типом uuid
не может быть null
-значения, и падал при валидации.Вот как-то так:
java.lang.NullPointerException: null
at org.dbunit.ext.postgresql.UuidType.typeCast (UuidType.java:67)
at org.dbunit.dataset.datatype.AbstractDataType.compare (AbstractDataType.java:83)
at org.dbunit.assertion.comparer.value.IsActualEqualToExpectedValueComparer.isExpected (IsActualEqualToExpectedValueComparer.java:22)
...
at java.util.concurrent.FutureTask.run (FutureTask.java:264)
at java.util.concurrent.ThreadPoolExecutor.runWorker (ThreadPoolExecutor.java:1128)
at java.util.concurrent.ThreadPoolExecutor$Worker.run (ThreadPoolExecutor.java:628)
at java.lang.Thread.run (Thread.java:834)
Что использованная в проекте версия 2.6.0, что самая новая 2.7.0 – увы.
Вечер был тёплый, ноутбук грелся, но вентилятор не включал во имя тишины и троттлился до желанных в многие годы назад 600 МГц, и были варианты действий:
0. Зарепортить баг в dbunit и ждать новой его версии, если pull-request вообще примут.
1. Закостылить тесты, чтобы они не проверяли те uuid-колонки, которые null, хотя именно это они и должны были проверять.
2. Вообще убрать тесты. Кстати, кто-нибудь пишет тесты на тесты?..
3. Закончить рабочий день и нырнуть с головой в исследования.
Вариант 0 был бы слишком долог (да и не факт, что это некорректное поведение, но зарепортить надо бы), вариант 1 оставался как запасной, вариант 2 не проходил по личному перфекционизму и правилам компании. А вот третий вариант был интересен.
Давайте посмотрим на виновника:
Налицо нарушение какого-то неявного контракта.
Быстрое гугление показывает редактор байткода JBE – Java Bytecode Editor. Вызывает некоторое опасение, что он требует 1.5 джаву для работы, а проект вовсю использует 11. Ну, исследования на то и исследования, чтобы стремглав бежать навстречу приключениям, попутно документирую свои действия.
Открываем из локального
.m2
репозитория jar-файл, и смотрим на… Как его назвать-то? Ассемблерное представление байт-кода. Почти никогда так глубоко не погружался:Что же,
aload_1
явно считывает переданный аргумент. Полагаю, aload_0
считывал this
, но в этом методе используется только таблица виртуальных методов от всей мощи классов. Затем идёт вызов самой банальной toString()
. Забыл сказать, но если этот метод вернёт null
, то ничего страшного не произойдёт, поскольку в вызывающем его AbstractDataType#compare
есть проверка на null
для двух сравниваемых аргументов. Результат функции возвращается на стек, поэтому следующая areturn
вернёт ссылку на результат преобразования в строку. Спасибо википедии за шпаргалку.Поскольку руками править байт-код мне впервые, хорошо бы посмотреть на то, что мне надо в итоге получить. Создаём временный класс с двумя методами: похожим на имеющимся и похожим на желаемый:
Запихиваем их в JBE и видим, что код метода из временного класса как две капли воды похож на заменямый. Это хорошо, это было ожидаемо:
Код нового метода сильно больше:
Строки 5-6-7 остались неизменными, а вот сверху…
goto
?! И зачем-то лишний aload_1
. Шпаргалка говорит, что ifnonnull
уничтожает верхний элемент стека, так что он не лишний. Но не суть важно.Важно другое, что в оригинальном классе
Object#toString()
находится в пуле констант по адресу #68, а в новом – по #2. Ох, JBE, надеюсь, ты спасёшь меня от ручного редактирования файла:Копируем-вставляем ассемблерное представление, сохраняем…
О, не может записать в архив? А, ну, ок. Вытащим
.class
из архива, поменяем и положим обратно.Вот, теперь другое дело! Правда, после сохранения деактивировался пункт кода в дереве слева, прямо перестал нажиматься. Но не суть важно.
Откроем его с декомпиляторе Идеи – чисто для ознакомления и подтверждения того, что манипуляция была правильной, никаких некорректных использований, дорогой юридический отдел! Я бы и так мог подложить изменённый jar-архив, без этой проверки.
Кстати, об подкладывании. В
.m2
-репозитории лежат контрольные суммы файлов. Их пересчитывать лениво, да и всё равно модифицированную либу придётся тащить с собой в гит-репозиторий.Убираем
dbunit
из зависимостей тестового фреймворка и подкладываем свой в system
-scope.Проверим, что оно точно подхватилось.
mvn dependency:tree
Запускаем тесты – и…
Падение, причём интересное. Я планировал закончить статью на этом моменте, нам (не) повезло. Исследование продолжается, дорогой читатель!
Ответ на StackOverflow подробно рассказывает, что в этом виновато отсутствие
StackMapTable
, и как с этим справиться. Имеем в запасе план Б – отключить проверятор байткода. Но не будет ли интереснее попытаться починить невалидный файл? Всё же неспроста казалось, что работающий на 1.5 Java Bytecode Editor будет себя вести несколько странно на 11 версии джавы, поскольку этот проверятор байткода появился в 1.6 версии языка, эволюционировал в 1.7 и, наконец, стал обязательным в 1.8.Откроем обе версии
.class
-файла в байткодовом просмотрщике Идеи:Сверху – оригинал, снизу – после модификации через JBE
Вы заметили, да? У нового варианта до метки строки L0 появились инструкции, но у них нет никакой метки. Возможно, в этом дело? Стоит посмотреть на байткод класса
Tmp
:Появился какой-то новый
frame same
. Но что это? И в шпаргалке его нет. К счастью, есть более крутой ответ на Stackoverflow, который рассказывает, что это за фреймы такие, и, что интересует нас в этот момент, что они относятся к StackMapTable
.
Немного про фреймы
В общем, джава когда-то давно проверяла на логическую валидность последовательность инструкций байт-кода, но делала это медленно. И чем больше программа, тем медленнее. В версии 1.6 теперь уже Оракл решил сделать новый валидатор, который мог бы валидировать байткод за один проход. Но менять формат байткода противоречило парадигме обратной совместимости (но ради дженериков стоило бы. :/), поэтому в версии 1.7 нужную для этого информацию положили в вспомогательную структуру под именем
Поскольку в больших методах информацию о каждом типе для каждой инструкции хранить несколько нецелесообразно, её хранят только для тех инструкций, на которые указывают операторы условного или безусловного перехода. То, что находится между ними, свой тип не меняет, поскольку выполнение линейное. Ну или что-то в этом роде, для повествования это не важно. Надо всего лишь подделать этот кусочек кода, а кто хочет больше информации – лучше почитать ответ на StackOverflow в оригинале.
StackMapTable
.Поскольку в больших методах информацию о каждом типе для каждой инструкции хранить несколько нецелесообразно, её хранят только для тех инструкций, на которые указывают операторы условного или безусловного перехода. То, что находится между ними, свой тип не меняет, поскольку выполнение линейное. Ну или что-то в этом роде, для повествования это не важно. Надо всего лишь подделать этот кусочек кода, а кто хочет больше информации – лучше почитать ответ на StackOverflow в оригинале.
Открываем спецификацию на JVM и начинаем смотреть, что за
StackMapTable
такой, и что с ним делать, чтобы было лучше.У него следующий формат:
StackMapTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_entries;
stack_map_frame entries[number_of_entries];
}
attribute_name_index
указывает на строку в пуле строк со значением «StackMapTable».attribute_length
, похоже, указывает длину всех фреймов, содержащих инструкции, за исключением первых шести байт, выделенных под это и предыдущее поле.number_of_entries
указывает число фреймов в этом методе(методе ли?).entries[]
, собственно, и есть те фреймы с кусками кода, которые надо поменять.
Строка в константном пуле в этом же
.class
-файле, на которую ссылаются, выглядит так:CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
tag
– всегда константа 01
, а длина и содержимое строки в особом представлении не нуждаются.Чуть ниже описания
StackMapTable_attribute
лежит описание другой структуры, которая в нём используется:union stack_map_frame {
same_frame;
same_locals_1_stack_item_frame;
same_locals_1_stack_item_frame_extended;
chop_frame;
same_frame_extended;
append_frame;
full_frame;
}
Вот, откуда тот
frame same
взялся в байткодном просмотрщике идеи! Какая-то структура из сишного union
одного байта, после которого могут идти другие байты. Если этот байт 0 до 63, то это same_frame
, и за ним ничего не идёт. Если честно, я мало понял, зачем оно надо и как работает. Но зато понял, что именно стоит искать и как мимикрировать под корректный байткод восьмой джавы.Поскольку мы всё же редактируем не только таблицу фреймов, но и сам код метода, вот структура метода:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags
– нам достаточно того, что 0x0001 – public, мы не будем их трогать. name_index
и descriptor_index
– ссылки на имя функции и сигнатуры в пуле строк. attributes_count
– число аттрибутов, а attributes[]
– сами аттрибуты.У аттрибутов тоже есть своя структура:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
У меня создаётся впечатление, что это уже не статья, а перепечатка из документации по JVM. Тут и так понятно, что это за поля, и что
attribute_name_index
опять ссылается на какую-то строку.Кстати, а две из этих строк нам нужны: «Code» и «StackMapTable»! Круг замыкается, ура!
А что за аттрибут со строкой «Code» такой?
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
Ох. Нет, пожалуй, ограничимся только
code_length
и code[]
.Итак, пытаемся приступить к практике. Смотрим в JBE индекс в константном пуле для
StackMapTable
. Вот он, номер 145, или 9116
:Попутно находим, что метод
typeCast
и его сигнатура находятся по адресам 66 и 67, то есть 4216
и 9143
:«Code», кстати, находится по индексу 9.
Вооружившись структурой
method_info
, начнём искать нечто, начинающееся на 00 01
для public
, 00 42
для typeCast
и 00 43
для (Ljava/lang/Object;)Ljava/lang/Object;
. Мы их уже видели в ассемблерном представлении байткода, а вот они в сыром формате:00 01 00 42 00 43
– то, что мы искали, а вот 00 02
говорит, что у этого метода есть два аттрибута. И первый из них ссылается на нечто 00 20
– JBE говорит, что это ссылка на строку «Exceptions» под номером 32. Нас этот аттрибут не интересует, мы его пропускаем. Для этого, правда, придётся подсмотреть в документацию, сколько именно байт надо пропустить:Exceptions_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 number_of_exceptions;
u2 exception_index_table[number_of_exceptions];
}
00 20 00 00 00 04 00 01 00 23
, на случай, когда картинка умрёт. Или для копии в вебархиве.00 20
– уже упомянутая ранее ссылка на Exceptions,00 00 00 04
– длина полезной нагрузки в 4 байта сверх шести байт шапки,00 01
– одно задекларированное исключение,00 23
– очередная ссылка на константный пул. JBE говорит, что #35 – org/dbunit/dataset/datatype/TypeCastException
.Следующий аттрибут интереснее, он начинается на
00 09
– секция «Code»:Во имя тех, кто выключил загрузку каринок и питается бинарниками:
00 09 00 00 00 39 00 01 00 02 00 00 00 05 2B B6 00 44 B0 00 00 00 02 00 12 00 00 00 06 00 01 00 00 00 43 00 13 00 00 00 16 00 02 00 00 00 05 00 1C 00 1D 00 00 00 00 00 05 00 4A 00 3F 00 01
00 09
– аттрибут «Code» для константного пула конкретно этого файла,00 00 00 39
– длина аттрибута. Если добавить шесть этих байт, то будет 3F байт на код с информацией.00 01
– max_stack
– мы только считываем arg0
, вызываем на нём toString()
и сразу же возвращаем. Да, каждая операция меняет стек, но он глубже единицы не уходит.00 02
– max_locals
– у нас только this
и arg0
.00 00 00 05
– пять байт инструкций? Так мало?2B B6 00 44 B0
– очевидно, сами команды.00 00
– exception_table_length
. Хорошо, что это маленький кусочек кода.00 02
– два аттрибута к этому аттрибуту. Рекурсия-с.00 12 00 00 00 06 00 01 00 00 00 43 00 13 00 00 00 16 00 02 00 00 00 05 00 1C 00 1D 00 00 00 00 00 05 00 4A 00 3F 00 01
– что осталось. Первый аттрибут начинается на 00 12
, мы такого ещё не встречали. JBE говорит, что это «LineNumberTable». Документация говорит, что это вспомогательная информация для отладчика, чтобы он мог определить строку по выполняемой инструкции. Вот его структура:LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
Скорее всего, здесь только лишь одна строка в таблице строк. Но давайте и её распарсим:
00 12
– аттрибут «LineNumberTable»,00 00 00 06
– о шести дополнительных байтах,00 01
– с одной записью в таблице строк, в чём мы и не сомневались,00 00
– start_pc
– похоже, что начиная с нулевой инструкции,00 43
– идёт 67 строка исходника.00 13 00 00 00 16 00 02 00 00 00 05 00 1C 00 1D 00 00 00 00 00 05 00 4A 00 3F 00 01
– остаётся. Видимо, другой аттрибут. JBE говорит, что этому индексу в константному пуле соответсвует строка «LocalVariableTable». Тоже вспомогательная информация для отладчика, для вычисления текущих значений переменных. И на него есть документация: LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{ u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
00 13
– аттрибут «LocalVariableTable»,00 00 00 16
– длинный, целых 22 байта,00 02
– и две переменных, что коррелирует с max_locals
,00 00
– переменная имеет смысл от start_pc
,00 05
– и до start_pc
+ length
,00 1C
– имя переменной. JBE говорит, это this
00 1D
– тип переменной. Lorg/dbunit/ext/postgresql/UuidType;
00 00
– индекс переменной в текущем фрейме. Наконец-то он был упомянут.00 00
– те же границы области видимости,00 05
,00 4A
– но другое имя: arg0
00 3F
– и тип: Ljava/lang/Object;
00 01
– и следующий порядковый номер во фрейме.И на этом вся громоздкая секция аттрибута «Code» закончилась. Но где же «StackMapTable»?!
Я ожидал, что у метода будет аттрибут кода, в котором будет аттрибут таблицы фреймов:
Но, похоже, из-за того, что я документацию читал по диагонали, и сам этот аттрибут появился позже остальных, он был смещён в конец
.class
-файла. (Это не так, если что.) Ищем и находим 00 91
:00 91 00 00 00 29 00 06 FF 00 6F 00 04 07 00 01 07 00 45 07 00 92 07 00 45 00 01 07 00 76 4D 07 00 78 4D 07 00 7A 4D 07 00 7C 4D 07 00 7E 0D
Но он находится в конце файла, под большой громоздкой функцией
getUUID
, поэтому явно должен отнситься к ней. И да, JBE показывает, что этот аттрибу относится не к нашей функции, а к большой:За чем же мы тогда всё это время гонялись?..
Если чуть внимательнее почитать документацию, можно увидеть строку, говорящую, что первый фрейм создаётся неявно, на основе параметров метода: «Each stack map frame described in the entries table relies on the previous frame for some of its semantics. The first stack map frame of a method is implicit, and computed from the method descriptor by the type checker (§4.10.1.6). The stack_map_frame structure at entries[0] therefore describes the second stack map frame of the method.» Иными словами, для такого простого метода JVM не требуется дополнительная помощь для валидации байткода.
Была эта работа напрасна? Нет, я чуть лучше разобрался, как устроен
.class
-файл, и понял, в какое место стоит встраивать изменения. Вполне возможно, что у версии с тернарным оператором будут менее трививиальные для JVM фреймы, и там будет пресловутый «StackMapTable».Что ж, дабы не раздувать эту статью бесполезными листингами какого-то очень конкретного кода, я частично проделаю эту работу в фоне и оставлю только важные моменты.
Вот все константы в пуле констант, и вдобавок видно, что у изменённого метода всё-таки есть «StackMapTable»:
Нам однозначно пригодятся шестнадцатеричные индексы следующих строк:
13
– имя метода modified
, байт-кодом которого мы хотим заменить typeCast
,0D
– сигнатура обоих методов, и исходного и модифицированного,10
– «Exceptions» – не особенно надо, но мы будем знать, что за аттрибут мы пропускаем,07
– «Code» – что надо,08
– «LineNumberTable», не особо надо, но может пригодиться для переопределения фрейма,09
– «LocalVariableTable», аналогично,14
– «StackMapTable», ему будет уделено особенное внимание.По ходу пригодятся и другие, но они менее значимы.
Ищем, находим и парсим нечто, начинающееся с
00 01 00 13 00 0D
:00 01
– public
,00 13
– modified
00 0D
– принимает и возвращает Object
,00 03
– у этого метода есть три аттрибута:00 07
– первый – «Code»,00 00 00 4E
– длиной 78 + 6 байт00 01
– глубиной стека 1 инт,00 02
– и о двух локальных переменных,00 00 00 0D
– содержит 13 байт инструкций,2B C7 00 07 01 A7 00 07 2B B6 00 02 B0
– инструкции, о которых будет дальше,00 00
– не содержит внутренних исключений,00 03
– и имеет три субаттрибута:00 08
– первый-первый «LineNumberTable»,00 00 00 06
– о шести дополнительных байтах,00 01
– с одной строкой,00 00
– начиная с нулевой инструкции,00 0A
– находится 10 строка исходника,00 09
– первый-второй «LocalVariableTable»,00 00 00 16
– о двадцати двух дополнительных байтах,00 02
– с двумя переменными,00 00
– начинающейся с начала секции кода,00 0D
– и идущей 13 инструкций, то есть до конца,00 0A
– по имени #10, this
,00 0B
– с типом #11, Lcom/xobotun/habr/Tmp;
,00 00
– с порядковым номером ноль в этом фрейме,00 00
– другой переменной,00 0D
– с тем же временем жизни,00 0E
– по имени #14, arg0
,00 0F
– с типом #15, Ljava/lang/Object;
,00 01
– с порядковым номером один в этом фрейме,00 14
– долгожданный «StackMapTable». Поскольку мы разбираем его в первый раз, стоит вернуться к более расширенному стилю описания. Только что было поле attribute_name_index
,00 00 00 07
– attribute_length
сообщает от дополнительных семи байтах сверх этих шести,00 02
– number_of_entries
рапортует о двух stack_map_frame
элементах последующего массива:08
– второй фрейм, поскольку начальный рассчитывается автоматически. Фреймы типа SAME_FRAME
лежат в диапазоне [0, 63], и этот имеет размер в 8 байтов инструкции. Означает неизменность стека, что он не изменяется после прыжка,43
– третий фрейм. Лежит в диапазоне [64, 127] поэтому его тип – SAME_LOCALS_1_STACK_ITEM
. Применяется к инструкции, на протяжении 67-64=3 байта. Означает, что при попадании в этот фрейм, необходимо выполнить проверку одного элемента стека. Для этого он содержит в себе структуру-объекдинение verification_type_info
, которая в конкретно этом случае раскладывается так:07
– tag
– тип проверки ITEM_Object
, за которым следует ссылка на элемент пула констант:00 04
– cpool_index
– структура class_info
, ссылающаяся на строку #26 java/lang/Object
. То есть при попадании в конкретно этот фрейм JVM выполняет проверку, что сверху на стеке лежит объект, и он принадлежит самому общему типу объектов.00 10
– второй аттрибут нашего метода, «Exceptions», но мы его уже видели, ничего нового,00 00 00 04
– длиной четыре байта и шесть байт шапки,00 01
– одно исключение,00 11
– типа java/lang/RuntimeException
, поскольку мы его явно объявили,00 12
– аттрибут "MethodParameters", такого ещё не было. (Был, мы просто в прошлый раз до него не дошли. >_<) В нём хранятся имена параметров метода и флаги доступа, например, final
,00 00 00 05
– длиной лишних пять байт,01
– один параметр,00 0E
– по имени arg0
,00 00
– без особых флагов.На этом описание нового метода закончилось, осталось только разобраться в самих инструкциях. К сожалению, это куда менее увлекательно, чем растаскивать нечитамый бинарник на приятные блоки. Для этого достаточно взять другой том документации и найти по
Ctrl+F
в нём все эти байты инструкций.Что же, тело оригинального метода
2B B6 00 44 B0
:aload_1 = 43 (0x2b)
. Считывает из переменной №1 текущего фрейма. В явном виде мы его не нашли, но в аттрибуте «LocalVariableTable» переменной с этим индексом былаarg0
. Считанное значение уходит на стек.
invokevirtual = 182 (0xb6)
. Применяет функцию к объекту на стеке. Функция передаётся в виде ссылки на строку в пуле констант. Здесь00 44
–[068] Methodref_info
. Простите, ссылки на структуру, которая ссылается на 69 и 71, которые тоже не строки:[069] Class_info
и[071] NameAndType_info
. А вот 69 ссылается уже на строку[070] Utf8_info: java/lang/Object
, 71 ссылается на строки[072] Utf8_info: toString
и[073] Utf8_info: ()Ljava/lang/String;
. Всё же stringly-typed язык.areturn = 176 (0xb0)
. Возвращает результат со стека.
И тело модифицированного
2B C7 00 07 01 A7 00 07 2B B6 00 02 B0
:aload_1 = 43 (0x2b)
, то же начало.ifnonnull = 199 (0xc7)
. Проверяет вершину стека на соответствиеnull
, и если там лежит нормальный объект, прыгает на00 07
байт вперёд от первого байта этой инструкции, то есть на инструкцию 5.aconst_null = 1 (0x1)
. Просто помещает null на вершину стека.goto = 167 (0xa7)
. Прыгает на00 07
байт вперёд к инструкции 7.aload_1 = 43 (0x2b)
. Повторное чтение, посколькуifnonnull
только что уничтожил вершину стека для проверки.invokevirtual = 182 (0xb6)
. То же самое, что и в заменямом методе, толькоMethodref_info: java/lang/Object/toString()Ljava/lang/String;
находится по индексу00 02
в пуле констант.areturn = 176 (0xb0)
. La Fin.
Теперь касательно «StackMapTable». В оригинальном методе его не было в явном виде, поскольку программа была линейна, но в заменяемом методе есть целых два перехода. Мне кажется, что проще начать с конца, поскольку первый фрейм начинается с начала и длится неопредлённое число байт. Возиожно, что до первого прыжка, но я не уверен. Зачем гадать, когда мы точно знаем размеры всех фреймов, и что они идут друг за другом?
Единственный момент, который я не понял, как трактовать, так это фразу из пункта документации, который говорит, что к
offset_delta
фрейма, косвенно лежащую в поле tag
, надо прибавлять единицу для всех фреймов, кроме начального.Последний фрейм занимает три плюс один байт и требует проверить, что на стеке есть
Object
. Это логично, поскольку этот Object
нам надо либо вернуть, либо применить на нём toString
, в зависимости от того, как мы в этот фрейм попадаем.Предыдущий фрейм занимал последние четыре байта из тринадцати, остаётся девять. Если следовать правилу, что этот фрейм не является начальным, к его восьми байтам тоже надо прибавить один. И он занимает девять байт и не требует проверок.
Значит ли это, что начальный фрейм с пустым стеком действителен ноль байт, поскольку первая инструкция сразу же его меняет? Или же я неправильно интерпретировал документацию? Прошу к этому параграфу относиться с великим сомнением.
Итак, в чём же основная разница между методами:
1. Разные элементы в пуле констант, надо менять вручную.
1а. Несмотря на различия из пункте 1, «Exceptions», «LineNumberTable» и «LocalVariableTable» логически одинаковы.
2. Различаются инструкции, в чём и есть цель.
3. Присутствие «StackMapTable» ввиду предыдущего пункта.
Теперь всё готово к хирургическому вмешательству.
План действий такой:
1. Вставить новый код поверх старого.
2B B6 00 44 B0
→ 2B C7 00 07 01 A7 00 07 2B B6 00 02 B0
.2. Починить сломанные ссылки на пул констант в том же куске кода. Там только ссылка на
toString()
, поэтому меняем инструкцию B6 00 02
→ B6 00 44
.3. Поменять длину субаттрибута инструкций в аттрибуте кода с
00 00 00 05
на 00 00 00 0D
.4. Увеличить длину аттрибута кода на 13-5=8 байт.
00 00 00 39
→ 00 00 00 41
5. Увеличить число субаттрибутов в аттрибуте кода на 1.
00 02
→ 00 03
6. Присобачить «StackMapTable» изолентой в конец секции кода.
00 14 00 00 00 07 00 02 08 43 07 00 04
7. Заменить индексы пула констант.
00 14
→ 00 91
, 00 04
→ 00 45
8. Увеличить длину аттрибута кода на 13 байт.
00 00 00 41
→ 00 00 00 4E
Результат этих манипуляций выглядит так:
Красное – изменённые куски кода
И Идея стала показывать что-то гораздо более осмысленное:
(Я по-прежнему в этом ничего не понимаю)
Но самое главное – тесты стали зелёные.
З.Ы. В статье кликабельны только мелкие каринки, к ним нет подписей, отсутствует заголовки частей, если таковые вообще есть, да и опечатки в корявосочинённых конструкциях могут иметь место быть. Не устраивает оформление – милости прошу в личку. Может, даже пофиксим что-то. :D