В любой разработке рано или поздно появляются тестовые стенды — отдельные инсталляции тестовой сборки вашей разрабатываемой системы, с тестовыми данными и тестовыми же учетными записями. Именно отсюда происходит знаменитое «admin/admin», которое вбивал в поля авторизации наверное каждый разработчик.

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

Jira, которая больше не просит паролей.
Jira, которая больше не просит паролей.

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

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

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

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

При этом всегда оказывается что доступ к тестовой учетной записи был у широкого круга лиц — «даже бобры все знают» (ц).

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

Да, у нас есть решение, в какой-то мере простое и очевидное.

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

Начнем с матчасти.

Матчасть

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

boolean matches(CharSequence rawPassword, String encodedPassword)

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

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 все возникающие при вызове обработчика ошибки тихо игнорируются и без try-catch вы их просто не увидите.

Но едем дальше:

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 сама обработает этот массив байт и загрузит его в виде класса.

Итого

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

Без необходимости вести и раздавать тестовые пароли, без рисков их утечки и без риска попадания на продакшн «вслепую», поскольку библиотека физически отделена от сборки.

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

Что добавляет 100 очков бодрости процессу.

Но это еще не все.

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

А без описанного инструмента регурярно происходила драма и огорчение посреди офиса.

"Жырная Жира"

Когда имеешь дело со столь старым проектом (а 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"

И перезапускаем.

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

Данная статья является отцензурированной версией (специально для Хабра) куда более фривольного оригинала, доступного в нашем блоге.

Оригинальная статья была написана еще в январе 2024го, а само решение применяем для ускорения разработки и тестирования последние 10 лет.

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

0x08 Software

Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.

Оживляем давно умершеечиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.

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


  1. sshmakov
    20.06.2024 08:22
    +1

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

    Аутентификации. Авторизация - это другое


  1. TachikomaGT
    20.06.2024 08:22

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

    Для тестовой среды можно завести отдельный spring profile, скажем, "test_staging", для которого будет создаваться primary bean PasswordEncoder с нужной функциональностью, а чтобы код не утёк в прод, для maven, хранить подобные небезопасные конфиги в src/main/test, падать при обнаружении prod-профилия, рисовать staging-баннер в UI или ещё что-нибудь.

    Но ещё более прямым способом, нежели создание своего PasswordEncoder, мне видится для тестового окружения через обычный security-конфиг добавлять тестового пользователя с обычным DelegatingPasswordEncoder: .withUser("test_admin").password("{noop}complex_password_@123!"). И в коде подсмотреть можно, и запомнить в браузере один раз труда не составит.


    1. alex0x08 Автор
      20.06.2024 08:22

      1. История с профилями заканчивается на моменте использования чужого решения. Врядли получится поиграться с профилями в чужом закрытом проекте, даже если там тоже используется Spring.

      2. Профили это плохо, поскольку у вас начнут отличаться среды выполнения для теста и продакшна. Что означает например возможность получить невоспроизводимые ошибки - на проде есть, на тесте не воспроизводится.


      1. TachikomaGT
        20.06.2024 08:22
        +1

        Они у вас и так отличаются, только завуалированным, неочевидным методом, который, может поломать сторонний код. Может, кому-то в проекте нужен bean BCryptPasswordEncoder для взаимодействия по API, а вы ему такую свинью подкладываете: локально работает, а на staging ломается и почему непонятно.


        1. alex0x08 Автор
          20.06.2024 08:22

          Понимаю что вам очень хочется обвинить меня в некомпентности, но увы:

          1. Это абсолютно рабочее решение, которое используется уже 10 лет на своих и чужих проектах, которые через нас проходят. И каких-либо проблем до сих пор не было.

          2. Реализация подменяется не в каком-то одном BCryptPasswordEncoder а во всех, включая кастомные (если они присутствуют в проекте)

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


    1. TachikomaGT
      20.06.2024 08:22

      И ещё, className.contains("org/springframework/security/") && className.endsWith("Encoder") никуда не годится, проверка наследования через рефлексию делается методом isAssignableFrom


      1. alex0x08 Автор
        20.06.2024 08:22

        Это не сработает в окружении JavaAgent, поскольку код агента отделен от кода приложения - т.е нет import org.springframework.security.PasswordEncoder.


        1. TachikomaGT
          20.06.2024 08:22

          Class.forName же


          1. alex0x08 Автор
            20.06.2024 08:22

            нет, поскольку на этой стадии еще нет загрузки класса - это делает JVM.


  1. Ksnz
    20.06.2024 08:22

    Т.е Вы предлагаете как решение: пускать на тестовый стенд по plain аутентификации кого угодно. Окей, почему тогда не реврайтить запрос через nginx на admin/admin? Те же яйца только проще. Не говоря уже о том, что plain аутентификация в настоящее время повсеместно заменяется Oauth SSO и ваше решение никак не поможет уйти от тестовых учеток/ролей/групп.


    1. alex0x08 Автор
      20.06.2024 08:22

      1. Самая главная проблема что уже достаточно давно нет возможности сделать «admin/admin» даже для тестов — теперь все хотят сложные пароли. И разумеется никакой nginx не поможет из‑за CSRF проверок.

      2. Для OAuth/SSO все тоже самое, просто отключение проверки пароля происходит на стороне OAuth сервера, после чего он спокойно авторизует клиентов. Проверено.


      1. Ksnz
        20.06.2024 08:22

        А если OAuth/SSO провайдер один для всех окружений? Или вообще сторонний, вроде Google/Github


        1. alex0x08 Автор
          20.06.2024 08:22

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

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

          Но отвечая на ваш вопрос: да, это тоже решаемо — эмуляцией такого провайдера своим тестовым.

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