NullPointerException в чужой библиотеке, или некоторые манипуляции с байткодом +13


Привет, Хабр! Был тёплый пятничный вечер, хотелось скорее бежать домой, пересесть из компьютерного кресла на кресло настоящее в полутора метрах, а тесты всё никак не проходили. Причём не проходили они самым изощрённым образом: падая прямо где-то в недрах библиотеки.

Самое обидное было то, что состояние временной базы, поднятой в докере на время работы этих псевдоинтергационных тестов, было корректное, и фичу можно было отдавать на ревью – 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 не проходил по личному перфекционизму и правилам компании. А вот третий вариант был интересен.

Давайте посмотрим на виновника:

culprit.png

Налицо нарушение какого-то неявного контракта.

Быстрое гугление показывает редактор байткода JBE – Java Bytecode Editor. Вызывает некоторое опасение, что он требует 1.5 джаву для работы, а проект вовсю использует 11. Ну, исследования на то и исследования, чтобы стремглав бежать навстречу приключениям, попутно документирую свои действия.

Открываем из локального .m2 репозитория jar-файл, и смотрим на… Как его назвать-то? Ассемблерное представление байт-кода. Почти никогда так глубоко не погружался:

jbe_before.png

Что же, aload_1 явно считывает переданный аргумент. Полагаю, aload_0 считывал this, но в этом методе используется только таблица виртуальных методов от всей мощи классов. Затем идёт вызов самой банальной toString(). Забыл сказать, но если этот метод вернёт null, то ничего страшного не произойдёт, поскольку в вызывающем его AbstractDataType#compare есть проверка на null для двух сравниваемых аргументов. Результат функции возвращается на стек, поэтому следующая areturn вернёт ссылку на результат преобразования в строку. Спасибо википедии за шпаргалку.

Поскольку руками править байт-код мне впервые, хорошо бы посмотреть на то, что мне надо в итоге получить. Создаём временный класс с двумя методами: похожим на имеющимся и похожим на желаемый:

donor.png

Запихиваем их в JBE и видим, что код метода из временного класса как две капли воды похож на заменямый. Это хорошо, это было ожидаемо:

jbe_donor_orig.png

Код нового метода сильно больше:

jbe_donor.png

Строки 5-6-7 остались неизменными, а вот сверху… goto?! И зачем-то лишний aload_1. Шпаргалка говорит, что ifnonnull уничтожает верхний элемент стека, так что он не лишний. Но не суть важно.

Важно другое, что в оригинальном классе Object#toString() находится в пуле констант по адресу #68, а в новом – по #2. Ох, JBE, надеюсь, ты спасёшь меня от ручного редактирования файла:

jbe_different_constant_pool.png

Копируем-вставляем ассемблерное представление, сохраняем…

jbe_save_failed.png

О, не может записать в архив? А, ну, ок. Вытащим .class из архива, поменяем и положим обратно.

Вот, теперь другое дело! Правда, после сохранения деактивировался пункт кода в дереве слева, прямо перестал нажиматься. Но не суть важно.

jbe_after_save.png

Откроем его с декомпиляторе Идеи – чисто для ознакомления и подтверждения того, что манипуляция была правильной, никаких некорректных использований, дорогой юридический отдел! Я бы и так мог подложить изменённый jar-архив, без этой проверки.

modified.png

Кстати, об подкладывании. В .m2-репозитории лежат контрольные суммы файлов. Их пересчитывать лениво, да и всё равно модифицированную либу придётся тащить с собой в гит-репозиторий.

Убираем dbunit из зависимостей тестового фреймворка и подкладываем свой в system-scope.

maven_exclude.png и maven_include.png

Проверим, что оно точно подхватилось. mvn dependency:tree

maven_dependency_tree.png

Запускаем тесты – и…

failure_1.png

Падение, причём интересное. Я планировал закончить статью на этом моменте, нам (не) повезло. Исследование продолжается, дорогой читатель!

Ответ на StackOverflow подробно рассказывает, что в этом виновато отсутствие StackMapTable, и как с этим справиться. Имеем в запасе план Б – отключить проверятор байткода. Но не будет ли интереснее попытаться починить невалидный файл? Всё же неспроста казалось, что работающий на 1.5 Java Bytecode Editor будет себя вести несколько странно на 11 версии джавы, поскольку этот проверятор байткода появился в 1.6 версии языка, эволюционировал в 1.7 и, наконец, стал обязательным в 1.8.

Откроем обе версии .class-файла в байткодовом просмотрщике Идеи:

idea_bytecode_old.png и idea_bytecode_new.png
Сверху – оригинал, снизу – после модификации через JBE

Вы заметили, да? У нового варианта до метки строки L0 появились инструкции, но у них нет никакой метки. Возможно, в этом дело? Стоит посмотреть на байткод класса Tmp:

idea_bytecode_tmp.png

Появился какой-то новый frame same. Но что это? И в шпаргалке его нет. К счастью, есть более крутой ответ на Stackoverflow, который рассказывает, что это за фреймы такие, и, что интересует нас в этот момент, что они относятся к StackMapTable.

Немного про фреймы
В общем, джава когда-то давно проверяла на логическую валидность последовательность инструкций байт-кода, но делала это медленно. И чем больше программа, тем медленнее. В версии 1.6 теперь уже Оракл решил сделать новый валидатор, который мог бы валидировать байткод за один проход. Но менять формат байткода противоречило парадигме обратной совместимости (но ради дженериков стоило бы. :/), поэтому в версии 1.7 нужную для этого информацию положили в вспомогательную структуру под именем 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:

jbe_stackmaptable_145.png

Попутно находим, что метод typeCast и его сигнатура находятся по адресам 66 и 67, то есть 4216 и 9143:

jbe_method_name.png

«Code», кстати, находится по индексу 9.

Вооружившись структурой method_info, начнём искать нечто, начинающееся на 00 01 для public, 00 42 для typeCast и 00 43 для (Ljava/lang/Object;)Ljava/lang/Object;. Мы их уже видели в ассемблерном представлении байткода, а вот они в сыром формате:

hxd_method_info_new.png

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];
}

hxd_exceptions.png
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»:

hxd_code_1.png
Во имя тех, кто выключил загрузку каринок и питается бинарниками: 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 01max_stack – мы только считываем arg0, вызываем на нём toString() и сразу же возвращаем. Да, каждая операция меняет стек, но он глубже единицы не уходит.
00 02max_locals – у нас только this и arg0.
00 00 00 05 – пять байт инструкций? Так мало?
2B B6 00 44 B0 – очевидно, сами команды.
00 00exception_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 00start_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»?!
Я ожидал, что у метода будет аттрибут кода, в котором будет аттрибут таблицы фреймов:
doc_locations.png

Но, похоже, из-за того, что я документацию читал по диагонали, и сам этот аттрибут появился позже остальных, он был смещён в конец .class-файла. (Это не так, если что.) Ищем и находим 00 91:

hxd_framemap.png
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 показывает, что этот аттрибу относится не к нашей функции, а к большой:

jbe_framemap.png

За чем же мы тогда всё это время гонялись?..

Если чуть внимательнее почитать документацию, можно увидеть строку, говорящую, что первый фрейм создаётся неявно, на основе параметров метода: «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»:

jbe_donor_pool.png

Нам однозначно пригодятся шестнадцатеричные индексы следующих строк:

13 – имя метода modified, байт-кодом которого мы хотим заменить typeCast,
0D – сигнатура обоих методов, и исходного и модифицированного,
10 – «Exceptions» – не особенно надо, но мы будем знать, что за аттрибут мы пропускаем,
07 – «Code» – что надо,
08 – «LineNumberTable», не особо надо, но может пригодиться для переопределения фрейма,
09 – «LocalVariableTable», аналогично,
14 – «StackMapTable», ему будет уделено особенное внимание.
По ходу пригодятся и другие, но они менее значимы.

Ищем, находим и парсим нечто, начинающееся с 00 01 00 13 00 0D:

hxd_modified.png

00 01public,
00 13modified
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 07attribute_length сообщает от дополнительных семи байтах сверх этих шести,
00 02number_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, которая в конкретно этом случае раскладывается так:
07tag – тип проверки ITEM_Object, за которым следует ссылка на элемент пула констант:
00 04cpool_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:

  1. aload_1 = 43 (0x2b). Считывает из переменной №1 текущего фрейма. В явном виде мы его не нашли, но в аттрибуте «LocalVariableTable» переменной с этим индексом была arg0. Считанное значение уходит на стек.
  2. 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 язык.
  3. areturn = 176 (0xb0). Возвращает результат со стека.

И тело модифицированного 2B C7 00 07 01 A7 00 07 2B B6 00 02 B0:

  1. aload_1 = 43 (0x2b), то же начало.
  2. ifnonnull = 199 (0xc7). Проверяет вершину стека на соответствие null, и если там лежит нормальный объект, прыгает на 00 07 байт вперёд от первого байта этой инструкции, то есть на инструкцию 5.
  3. aconst_null = 1 (0x1). Просто помещает null на вершину стека.
  4. goto = 167 (0xa7). Прыгает на 00 07 байт вперёд к инструкции 7.
  5. aload_1 = 43 (0x2b). Повторное чтение, поскольку ifnonnull только что уничтожил вершину стека для проверки.
  6. invokevirtual = 182 (0xb6). То же самое, что и в заменямом методе, только Methodref_info: java/lang/Object/toString()Ljava/lang/String; находится по индексу 00 02 в пуле констант.
  7. 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

Результат этих манипуляций выглядит так:

hxd_final.png
Красное – изменённые куски кода

И Идея стала показывать что-то гораздо более осмысленное:

idea_bytecode_final.png
(Я по-прежнему в этом ничего не понимаю)

Но самое главное – тесты стали зелёные.

З.Ы. В статье кликабельны только мелкие каринки, к ним нет подписей, отсутствует заголовки частей, если таковые вообще есть, да и опечатки в корявосочинённых конструкциях могут иметь место быть. Не устраивает оформление – милости прошу в личку. Может, даже пофиксим что-то. :D




Комментарии (13):

  1. mrise
    /#22939302

    Я не хочу убивать всё удовольствие от дотошно проделанной работы, и допускаю, что эта статья написана в больше образовательных, чем практичных целях, но не могу не задать вопрос:


    А почему нельзя было сделать новый проект с единственной зависимостью — ошибочным джарником — и единственным классом — изменённой версией файла?


    Или у класса было слишком много зависимостей? Но на это не похоже. Или это нельзя было юридически? Но код же в любом случае был декомпилен и модифицирован.

    • MamOn
      /#22939412 / +1

      Юридически код в общем-то под LGPL с открытыми исходниками. И всё бы понятно, академический интерес и всё такое, но настораживает отсутствие в списке вариантов действий — просто скачать исходники и подправить нужный файлик...

      • Xobotun
        /#22939438

        Это было бы не так интересно лично для меня. :)


        Если уж совсем придираться, то список вариантов действий далеко не исчерпывающий. Можете считать, что вариант "0", который про pull-request в репозиторий, является подмножеством "скачать исходники и подправить нужный файлик", но с дополнительным шагом "не только подправить, но ещё и поделиться".


        Да и статьи бы в таком случае не было бы.

    • Xobotun
      /#22939414

      Да, я думал об этом, что можно просто целиком и полностью перекомпилировать ошибочный класс, но это было бы не столь интересно. И мне хотелось чуть поглубже нырнуть в JVM в хаотичном поиске знаний, как первопричина.


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

  2. lampa
    /#22939756

    Кстати всё это можно попробовать проделать и через asm:

    Заголовок спойлера
    public class BadClass {
        public String badCheck(String value) {
            System.out.println("123");
            return value;
        }
    }
    
    public class GoodClass {
        public static String goodCheck(String value) {
            System.out.println("321 " + value);
            return Objects.requireNonNullElse(value, "hello!");
        }
    }
    
    public class Application {
        public static void main(String[] args) throws Exception {
            ClassReader reader = new ClassReader(Application.class.getResourceAsStream("BadClass.class"));
            ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS);
            reader.accept(new YourClassVisitor(writer), ClassReader.EXPAND_FRAMES);
    
            FileOutputStream out = new FileOutputStream(Application.class.getResource("BadClass.class").getFile());
            out.write(writer.toByteArray());
            out.close();
    
            new BadClass().badCheck(" test ");
        }
    
        public static class BadClassVisitor extends ClassVisitor {
            public YourClassVisitor(ClassVisitor cv) {
                super(Opcodes.ASM9, cv);
            }
    
            @Override
            public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
                if (name.equals("badCheck")) {
                    return new BadMethodVisitor(super.visitMethod(access, name, desc, signature, exceptions));
                }
                return super.visitMethod(access, name, desc, signature, exceptions);
            }
    
            private static class BadMethodVisitor extends MethodVisitor {
                public YourMethodVisitor(MethodVisitor mv) {
                    super(Opcodes.ASM9, mv);
                }
    
                @Override
                public void visitCode() {
                    mv.visitVarInsn(Opcodes.ALOAD, 1);
                    mv.visitMethodInsn(Opcodes.INVOKESTATIC,"com/lampa/liqui/GoodClass","goodCheck","(Ljava/lang/String;)Ljava/lang/String;",false);
                    mv.visitInsn(Opcodes.ARETURN);
                }
            }
        }
    }
    

    • Xobotun
      /#22939986

      Это этот asm? Не слышал о нём ранее. Впрочем, манипуляция байткодом – столь специфичный раздел, что в этом нет ничего удивительного.


      Спасибо информацию. Кажется, я знаю, чем я буду развлекаться в следующий раз.

      • lampa
        /#22940286 / +1

        Он самый. В идеале прямо в рантайме хуки добавлять, но этого я еще не пробовал)

        • Xobotun
          /#22940410

          Мне, если честно, было боязно смотреть, через что можно сделать это в рантайме. Например, непонятно, через какой класслоадер этот код был загружен, и мог ли бы он быть загружен через ещё какой-нибудь другой класслоадер тестовым фреймворком.


          Я не очень хорошо представляю, какие побочные эффекты рантаймовое изменение может иметь, вся тема instrumentationдля меня пока сокрыта. :)

  3. Beholder
    /#22940184

    Скачать сорцы (git clone git://git.code.sf.net/p/dbunit/code.git dbunit-code.git), git checkout dbunit-2.7.0, отредактировать файл, mvn -DskipTests install — не вариант? Не дольше 5 минут.

    • Xobotun
      /#22940422

      Вариант, прекрасный вариант, если баг необходимо пофиксить как можно быстрее. У меня на это ковыряние ушло часа три с перерывом, и оно мне было интересно. :)


      А на написание и вычитку статьи ушло полтора дня. Попытка получения виртуальных очков в интернете была более сомнительным поступком, чем самообразование. :/

  4. Maccimo
    /#22947274

    Как способ быстро вникнуть в основы строения class-файла с нуля такой подход неплох.
    С практической же точки зрения можно было:


    1. Понизить версию class-файла до 49.0 (Java 1.5). В этом случае верификация будет проводиться старым алгоритмом, без использования StackMapTable. Естественно, это подходит только для быстрой проверки гипотезы и только чуть лучше флага -noverify. Не сработает, если в class-файле будет что-то, чего не было в Java 1.5. Те же лямбды, например.


    2. Воспользоваться для редактирования class-файла нормальным инструментом, а именно — asmtools. Он поддерживается в актуальном состоянии и, к примеру, метод modified() выглядит в нём так:



      public Method modified:"(Ljava/lang/Object;)Ljava/lang/Object;"
        throws java/lang/RuntimeException
        stack 1 locals 2
      {
            aload_1;
            ifnonnull   L8;
            aconst_null;
            goto    L12;
        L8: stack_frame_type same;
            aload_1;
            invokevirtual   Method java/lang/Object.toString:"()Ljava/lang/String;";
        L12:
            stack_frame_type stack1;
            stack_map class java/lang/Object;
            areturn;
    
      }
    

    Дизассемблировать, пропатчить нужный метод и ассемблировать обратно — дело пяти минут.
    Более скучные варианты с перекомпиляцией из исходников рассматривать не будем.

  5. YuryB
    /#22947830

    если так уж вышло, что исходников, к примеру, нет, то скорее всего можно декомпелировать этот 1 файлик, поменять и собрать его опять через javac. а так вы конечно приобрели много новых, но, по большому счёту, не нужных в обычной жизни знаний :)

    • Maccimo
      /#22952668

      С декомпиляцией могут возникнуть небольшие проблемы.
      Пример: Gist.