Статический анализ → уязвимость → профит

    В статьях про PVS-Studio всё чаще говорят об уязвимостях и дефектах безопасности, которые можно найти с помощью статического анализа. Авторов этих статей критикуют (и я в том числе), что не каждая ошибка является дефектом безопасности. Возникает однако интересный вопрос, можно ли пройти весь путь от сообщения статического анализатора до эксплуатации найденной проблемы и получения какой-то выгоды. В моём случае выгода всё же осталась теоретической, но эксплуатировать ошибку удалось, не особо вникая в код проекта.


    Представьте, что вы разрабатываете обфускатор для Java-классов. Ваш бизнес в том, чтобы затруднить извлечение исходного кода из .class-файлов, в том числе с использованием имеющихся на рынке декомпиляторов. Помимо стандартных техник обфускации вполне разумный подход — искать баги в известных декомпиляторах и эксплуатировать их. Если на сгенерированном вами коде популярный декомпилятор просто упадёт, клиенты будут очень рады.


    Один из популярных декомпиляторов — Fernflower от JetBrains, который входит в состав IntelliJ IDEA. JetBrains не очень заботится о том, чтобы его распространять отдельно, но его можно собрать из исходников, выкачав из репозитория IntelliJ Community Edition. Ещё проще стянуть с неофициального зеркала: тут не придётся выкачивать всю IDEA. Я возьму недавний коммит d706718. Собирается Fernflower запуском ant, внешних зависимостей не требует и производит fernflower.jar, который можно использовать как приложение командной строки:


    $ java -jar fernflower.jar
    Usage: java -jar fernflower.jar [-<option>=<value>]* [<source>]+ <destination>
    Example: java -jar fernflower.jar -dgs=true c:\my\source\ c:\my.jar d:\decompiled\

    После свежих улучшений статический анализатор IDEA поумнел и стал выдавать предупреждение в методе ConverterHelper::getNextClassName:


    int index = 0;
    while (Character.isDigit(shortName.charAt(index))) {
      index++;
    }
    
    if (index == 0 || index == shortName.length()) { // <<==
      return "class_" + (classCounter++);
    }
    else { ... }

    Предупреждение звучит так:


    Condition 'index == shortName.length()' is always 'false' when reached

    Такие предупреждения про всегда истинное или всегда ложное условие очень интересны. Часто они свидетельствуют о баге не в данном условии, а в каком-то другом месте выше. С непривычки бывает сложно даже разобраться, почему такой вывод был сделан. Здесь перед условием был цикл while, условие выхода в котором содержит shortName.charAt(index): получить символ строки по индексу. Существенно то, что индекс не может быть больше длины строки или равен ей: иначе charAt выпадет с исключением IndexOutOfBoundsException. Таким образом если цикл дошёл до index == shortName.length(), то выйти из цикла нормально мы не сможем, а гарантировано упадём. А если вышли из цикла нормально, то условие index == shortName.length() действительно всегда ложно.


    Далее следует разобраться, действительно ли исключение может произойти или просто условие лишнее. В рамках данного метода такой ситуации ничего не противоречит, достаточно лишь, чтобы вся строка shortName состояла из одних цифр. Отлично, пахнет реальным багом. Но может ли в этот метод попасть строка, состоящая из одних цифр? Смотрим две точки вызова этого метода: ClassesProcessor::new и IdentifierConverter::renameClass. В обоих случаях в качестве shortName передаётся имя класса без пакета, которое по правилам виртуальной машины Java вполне может состоять из цифр. И в обоих же случаях этот код выполняется под условием ConverterHelper::toBeRenamed. Условие немного мутное, но видно, что оно сработает, если имя класса начинается с цифры.


    Судя по всему, этот код отвечает за переименование классов, если их имя допустимо для виртуальной машины, но недопустимо для языка Java. Замечательно, давайте сгенерируем корректный класс с именем из цифр. Возьмём любимый ASM и вперёд. Классу желательно иметь конструктор. Напечатаем в нём что-нибудь:


    String className = "42";
    ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    // public class 42 extends Object {
    cw.visit(Opcodes.V1_6, ACC_PUBLIC | ACC_SUPER, className, null, "java/lang/Object", new String[0]);
    // private 42() {
    MethodVisitor ctor = cw.visitMethod(ACC_PRIVATE, "<init>", "()V", null, null);
    // super();
    ctor.visitIntInsn(ALOAD, 0);
    ctor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
    // System.out.println("In constructor!");
    callPrintln(ctor, "In constructor!");
    // return;
    ctor.visitInsn(RETURN);
    ctor.visitMaxs(-1, -1);
    ctor.visitEnd(); // }

    Ну и чтобы проверить, что класс действительно нормальный, сделаем ему main с честным Hello World:


    // public static void main(String[] args) {
    MethodVisitor main = cw.visitMethod(ACC_PUBLIC|ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
    // System.out.println("Hello World!");
    callPrintln(main, "Hello World!");
    // return;
    main.visitInsn(RETURN);
    main.visitMaxs(-1, -1);
    main.visitEnd(); // }
    
    cw.visitEnd(); // }

    Метод callPrintln несложный, вот он:


    private static void callPrintln(MethodVisitor mv, String string) {
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn(string);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }

    Сохраняем класс в файл:


    Files.write(Paths.get(className+".class"), cw.toByteArray());

    Отлично, класс генерируется и успешно запускается:


    $ java 42
    Hello World!

    Теперь попытаемся его декомпилировать:


    $ java -jar fernflower.jar 42.class dest
    INFO:  Decompiling class 42
    INFO:  ... done

    Незадача, не упал. Посмотрим содержимое полученного файла:


    public class 42 {
       private _2/* $FF was: 42*/() {
          System.out.println("In constructor!");
       }
    
       public static void main(String[] var0) {
          System.out.println("Hello World!");
       }
    }

    Непохоже, чтобы переименование вообще работало. Класс по-прежнему называется 42 и, конечно, не является правильным Java-классом. Более того, конструктор переименовался и вообще перестал быть конструктором. Конечно, хорошо, что декомпилятор не смог создать валидный Java-файл, но хотелось большего.


    Может переименование можно как-то включить? Есть некоторые опции, которые описаны прямо в README.md. И среди них опция ren:


    • ren (0): rename ambiguous (resp. obfuscated) classes and class elements

    Ну-ка попробуем:


    $ java -jar fernflower.jar -ren=1 42.class dest
    Exception in thread "main" java.lang.StringIndexOutOfBoundsException: String index out of range: 2
            at java.lang.String.charAt(Unknown Source)
            at ...renamer.ConverterHelper.getNextClassName(ConverterHelper.java:58)
            at ...renamer.IdentifierConverter.renameClass(IdentifierConverter.java:187)
            at ...renamer.IdentifierConverter.renameAllClasses(IdentifierConverter.java:169)
            at ...renamer.IdentifierConverter.rename(IdentifierConverter.java:63)
            at ...main.Fernflower.decompileContext(Fernflower.java:46)
            at ...main.decompiler.ConsoleDecompiler.decompileContext(ConsoleDecompiler.java:135)
            at ...main.decompiler.ConsoleDecompiler.main(ConsoleDecompiler.java:96)

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


    Так как я работаю в компании, которая производит декомпилятор, а не обфускатор, то, конечно, вместо эксплуатации уязвимости я сообщил о ней, и её закрыли. А чтобы воспользоваться обновлённым статическим анализатором IDEA и найти подобные ошибки в своём коде, вы можете собрать IntelliJ Community Edition из исходников или дождаться EAP-программы 2017.2. И не надо недооценивать статический анализ. Если вы не проанализируете свой код, это сделают конкуренты или злоумышленники и найдут там что-нибудь, что испортит вам жизнь.

    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 11
    • +1
      Эх, вот бы кто-то по отчёту прошёлся, касающегося недавней проверки FreeBSD и смог бы использовать как-то баг, превратив его в уязвимость. Нам бы для рекламных целей было полезно. :) Я даже наградить такого человека готов. Самим у нас не хватает сил и времени.
      • +7
        Спасибо :) Переименование я приделал в свое время по запросу одного товарища, которому оно нужно было в конкретном проекте. Потом особо до ума не доводил и тестировал только на чем под руку попало, видимо случай имени из одних цифр проскочил. Там наверняка еще есть подобные недочеты.
        • 0

          Ух ты, ничего себе. А я буквально сейчас ковырялся в истории нашего гита, чтобы найти изначальных авторов этого проекта. Думал, немец какой-то, судя по изначальному пакету de.fernflower. Не знаю, на чём вы карму просадили, но плюсик от меня заслужили :-) JetBrains у вас купил проект или вы были сотрудником компании?


          ЗЫ. Иногда кажется, что весь софт в мире русскоязычными людьми писан :-)

          • 0
            Давно видел проекты где намешаны разные языки: английский, немецкий, итальянский… и даже транслит)))
            «Закашеная каша кашевая укашистая»
            • 0
              Это называется идиш. «Драй хазерим дринкинг квас» (немецкий + иврит + английский + русский = идиш)
              • 0
                Может быть «хаверим»? (Или это другое слово или диалект?)
                • 0
                  Насколько помню: хазерим — свинюшки, хаверим — друзья.
        • +1
          Замечательно, давайте сгенерируем корректный класс с именем из цифр. Возьмём любимый ASM и вперёд.

          IMHO, использовать ASM для генерации «хитрых» классов неудобно, слишком много танцев с бубном.


          Лучше взять asmtools:


          super public class "42"
              version 50:0
          {
          
          private Method "<init>":"()V"
              stack 2 locals 1
          {
                  aload   0;
                  invokespecial   Method java/lang/Object."<init>":"()V";
                  getstatic   Field java/lang/System.out:"Ljava/io/PrintStream;";
                  ldc String "In constructor!";
                  invokevirtual   Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
                  return;
          }
          
          public static Method main:"([Ljava/lang/String;)V"
              stack 2 locals 1
          {
                  getstatic   Field java/lang/System.out:"Ljava/io/PrintStream;";
                  ldc String "Hello World!";
                  invokevirtual   Method java/io/PrintStream.println:"(Ljava/lang/String;)V";
                  return;
          }
          
          } // end Class 42
          

          К слову, в ConverterHelper.java есть ещё как минимум одна ошибка:


           private static final Set<String> RESERVED_WINDOWS_NAMESPACE = new HashSet<>(Arrays.asList(
              "aux", "prn", "aux", "nul",
          

          Очевидно, что вместо одного из "aux" должен быть "con".
          Тут не помешала бы инспекция, отлавливающая дубликаты в коллекции, используемой для инициализации множества.

          • 0
            IMHO, использовать ASM для генерации «хитрых» классов неудобно, слишком много танцев с бубном.

            Ну так-то у меня не сильно длиннее. Стоит на самом деле ByteBuddy попробовать, говорят, вкусно.


            Тут не помешала бы инспекция, отлавливающая дубликаты в коллекции, используемой для инициализации множества.

            Думали об этом. Может и сделаем.

            • 0

              Длина тут дело десятое, всё равно заготовку кода чаще всего получают, натравливая Asmifier на class-файл, а не пишут всё руками с нуля.


              Но в случае использования ASM нужно:


               0) добавить к сгенерированному коду обвязку для записи в файл (он её, вроде бы, не генерирует)
               1) скомпилировать генератор
               2) запустить генератор


              В случае asmtools пункты 0 и 1 не нужны.

              • 0

                Каждый из этих пунктов добавляет по одной строчке. Запись класса в файл — Files.write(Paths.get(className+".class"), cw.toByteArray());. Не, ну я согласен, что экономия пары строчек — тоже экономия. Просто взял привычный инструмент, который уже был под рукой. Скачивать и осваивать новый инструмент — тоже накладные расходы :-)

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.