Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

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

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
shiro — CVE-2026-49268 — Анализ и устранение уязвимости обхода аутентификации через LDAP-инъекцию | Kitploit
Инструменты/GitHubGitHub/sassoftware/shiro
Статический анализ кода (SAST)Анализ уязвимостейАнализ КодаВеб-безопасностьАутентификацияОбучение и Образование
GitHubsassoftware/shiro

shiro

CVE-2026-49268 — Анализ и устранение уязвимости обхода аутентификации через LDAP-инъекцию

Репозиторий
16 дней назадЕщё не проверено

Популярное

Смотреть все →

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

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

CVE-2026-49268 — Анализ и устранение уязвимости обхода аутентификации через LDAP-инъекцию

Ветка1.13-CVE-2026-49268
АвторJinwoo Hwang (https://JinwooHwang.com)

Эта ветка (1.13-CVE-2026-49268) содержит комплексное устранение уязвимости обхода аутентификации через LDAP-инъекцию в релизе Apache Shiro 1.13.

Официальное описание NVD

Удалённый атакующий может внедрить специальные символы LDAP в конструкцию Distinguished Name (DN) в классе DefaultLdapRealm. Вводимое пользователем имя пользователя напрямую конкатенируется в шаблон LDAP DN без какого-либо экранирования специальных символов RFC 2253. Это позволяет атакующему манипулировать структурой DN, используемой для аутентификации через LDAP bind, потенциально обходя аутентификацию или выдавая себя за других пользователей. Эта проблема затрагивает все версии Apache Shiro вплоть до 2.2.0 и 3.0.0-alpha-1 при использовании DefaultLdapRealm.


1. Обзор уязвимости


2. Резюме для руководства

Apache Shiro может аутентифицировать пользователей в каталоге LDAP. Для этого он преобразует переданное имя пользователя в Distinguished Name — адрес записи этого пользователя в каталоге — путём подстановки имени пользователя в настроенный шаблон, например uid={0},ou=users,dc=mycompany,dc=com.

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

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

Профиль риска. Что повышает риск: не требуется предварительный доступ или учётные данные — ввод поступает на границе входа в систему, доступной любому, кто может достичь приложения; и затронутый класс является стандартным, документированным способом подключения Shiro к LDAP, так что это не экзотическая конфигурация. Что понижает его: развёртывание должно действительно использовать DefaultLdapRealm (или JndiLdapRealm) с настроенным шаблоном DN; развёртывания, которые аутентифицируются другими способами или передают полный DN либо нетекстовые учётные данные, такие как сертификат, не затронуты. То, даст ли перенаправленный поиск пригодную для использования аутентификацию, также зависит от собственной структуры целевого каталога и правил доступа, которые различаются от сайта к сайту.

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


3. Анализ первопричины

3.1 Дефект

core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java, getUserDn(String), строки 227–250 в неисправленной базовой версии:```java protected String getUserDn(String principal) throws IllegalArgumentException, IllegalStateException { if (!StringUtils.hasText(principal)) { throw new IllegalArgumentException("User principal cannot be null or empty for User DN construction."); } String prefix = getUserDnPrefix(); String suffix = getUserDnSuffix(); if (prefix == null && suffix == null) { log.debug("userDnTemplate property has not been configured, indicating the submitted " + "AuthenticationToken's principal is the same as the User DN. Returning the method argument " + "as is."); return principal; }

root@kitploit:~
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
if (prefixLength > 0) {
    sb.append(prefix);
}
sb.append(principal);            // <-- inserted verbatim
if (suffixLength > 0) {
    sb.append(suffix);
}
return sb.toString();

}

root@kitploit:~
Шаблон разбивается один раз, во время конфигурации, вокруг токена `{0}`
(`setUserDnTemplate`, строки 181–200), на `prefix` и `suffix`. Во время аутентификации
principal конкатенируется между ними. **Кодирование не применяется ни на каком этапе.** Нигде
в `org/apache/shiro/realm/ldap/` нет вспомогательной функции экранирования.

### 3.2 Инвариант, который предполагался, но никогда не обеспечивался

Окружающий код рассматривает результат `getUserDn` как правильно сформированное Distinguished Name —
он передаётся напрямую в `LdapContextFactory.getLdapContext(...)` и оттуда в JNDI как
bind DN. Это корректно только в том случае, если подставляемый principal является *единственным значением атрибута*.

Конкатенация строк не может этого обеспечить. RFC 2253 (и RFC 4514) резервируют
`,` `+` `"` `\` `<` `>` `;` `=`, ведущий `#` и ведущие/замыкающие пробелы как структурный
синтаксис внутри DN. Когда любой из них появляется в principal, он читается парсером как
структура, а не как содержимое. Инвариант — *«principal занимает ровно одно значение RDN»* —
предполагался каждым нижестоящим потребителем и не обеспечивался никем.

### 3.3 Поток выполнения, от точки входа до дефекта```
  submitted credentials (username, password)
        │
        ▼
  DefaultLdapRealm.doGetAuthenticationInfo(AuthenticationToken)        [line 292]
        │
        ▼
  DefaultLdapRealm.getLdapPrincipal(AuthenticationToken)               [line 338]
        │   principal instanceof String ?
        │       ├── no  ──► return principal unchanged   ── NOT AFFECTED (e.g. X.509)
        │       └── yes ──┐
        ▼                 │
  DefaultLdapRealm.getUserDn(String)                                   [line 227]
        │   prefix == null && suffix == null ?
        │       ├── yes ──► return principal unchanged   ── NOT AFFECTED ("principal IS the DN")
        │       └── no  ──┐
        ▼                 │
  prefix + principal + suffix          ◄── DEFECT: unencoded concatenation  [line 246]
        │
        ▼
  LdapContextFactory.getLdapContext(userDn, credentials)
        │
        ▼
  JNDI bind against the directory using the constructed DN

3.4 Продемонстрированный эффект

Шаблон uid={0},ou=users,dc=mycompany,dc=com, principal jsmith,ou=admins:

Сконструированный DNРазобранные RDN
Предполагаемыйuid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com4
Базовый (неисправленный)uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com5

Имя приобретает дополнительный компонент. ou=users больше не является контейнером, к которому идёт обращение — вместо него подставляется ou=admins. Подтверждено разбором с помощью javax.naming.ldap.LdapName, который не зависит от какой-либо реализации экранирования.

Измеренное поведение базовой версии по всему зарезервированному набору (JDK 8, разбор LdapName):

Два символа изменяют структуру имени, а не только его содержимое: запятая и точка с запятой — которую RFC 1779 принимает в качестве альтернативного разделителя RDN. + вводит второе значение атрибута в тот же RDN. Только \ и ведущий # делают имя неразбираемым.

3.5 Смежный код, который корректен, и почему — радиус поражения

  • userDnTemplate не задан. getUserDn возвращает principal без изменений. Это документированный режим «переданный principal и есть DN»; вызывающая сторона предоставляет полный DN и отвечает за его корректность. Не затронуто.
  • Principal, не являющиеся String. getLdapPrincipal направляет в getUserDn только principal типа String; всё остальное (сертификаты X.509, пользовательские токены) проходит мимо. Не затронуто.
  • Валидация setUserDnTemplate (строки 181–200) корректно отклоняет шаблон, равный null, пустой или не содержащий {0}. Она проверяет шаблон, предоставленный оператором, который никогда не был недоверенным вводом — так что это корректный код, который просто не устраняет этот дефект.
  • JndiLdapRealm расширяет DefaultLdapRealm и не переопределяет getUserDn, поэтому он наследует дефект. Подтверждено тестом (§6.1). Входит в область действия и исправляется тем же изменением.

3.6 Смежный путь — ОЦЕНЁН, НЕ ЗАТРОНУТ

AbstractLdapRealm.searchFilter (строка 89) содержит вторую подстановку {0}, по умолчанию (&(objectClass=*)(userPrincipalName={0})), используемую на пути авторизации. Он не затронут.

Сканирование каждого вызова search(, каждого вызывающего getLdapContext( и каждого строкового литерала LDAP-формы во всех основных исходниках core, support и web обнаружило ровно одно использование searchFilter — ActiveDirectoryRealm.getRoleNamesForUser, строка 172:```java Object[] searchArguments = new Object[]{userPrincipalName}; NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);

root@kitploit:~
Это **параметризованная** перегрузка `DirContext.search(String, String, Object[], SearchControls)`.
Имя пользователя передаётся как *аргумент* фильтра и никогда не конкатенируется в строку
фильтра. Согласно javadoc JDK 8 `javax.naming.directory.DirContext`, дословно:

> "When a string-valued filter argument is substituted for a variable, the filter is
> interpreted as if the string were given in place of the variable, with any characters
> having special significance within filters (such as `'*'`) having been escaped according
> to the rules of RFC 2254."

Экранирование выполняет JNDI-провайдер. Историческое свидетельство этого находится в самом
объявлении поля.

**Источник: `core/src/main/java/org/apache/shiro/realm/ldap/AbstractLdapRealm.java`, строки 88–89**```java
    //SHIRO-115 - prevent potential code injection:
    protected String searchFilter = "(&(objectClass=*)(userPrincipalName={0}))";

Источник: core/src/main/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealm.java, строки 158–172```java protected Set getRoleNamesForUser(String username, LdapContext ldapContext) throws NamingException { Set roleNames; roleNames = new LinkedHashSet();

root@kitploit:~
    SearchControls searchCtls = new SearchControls();
    searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);

    String userPrincipalName = username;
    if (principalSuffix != null && !userPrincipalName.toLowerCase(Locale.ROOT).endsWith(principalSuffix.toLowerCase(Locale.ROOT))) {
        userPrincipalName += principalSuffix;
    }

    Object[] searchArguments = new Object[]{userPrincipalName};

    NamingEnumeration answer = ldapContext.search(searchBase, searchFilter, searchArguments, searchCtls);
root@kitploit:~
Имя пользователя попадает в каталог как `searchArguments[0]`, а не как текст, вставленный в
`searchFilter`. Токен `{0}` в фильтре разрешается провайдером JNDI, а не
Shiro.

`ActiveDirectoryRealm` в строке 108 передаёт необработанное имя пользователя в
`getLdapContext(username, password)`. Это значение становится единственной записью окружения JNDI
`SECURITY_PRINCIPAL`; оно не подставляется в шаблон, и из него не конструируется DN,
поэтому оно находится вне механизма данного дефекта.

**Проверено эмпирически на работающем сервере каталога.** Экранирование JNDI не было принято
на веру: оно наблюдалось на уровне сетевого обмена. Внутрипроцессный LDAP-сервер
(UnboundID `InMemoryDirectoryServer`) был инструментирован с помощью
`InMemoryOperationInterceptor`, который записывает фильтр каждого получаемого поискового запроса,
и стандартный `searchFilter` Shiro был отправлен через тот же параметризованный вызов JNDI, который
использует `ActiveDirectoryRealm`, со специально сформированным аргументом:```
filter template : (&(objectClass=*)(userPrincipalName={0}))
argument passed : *)(uid=jsmith
server received : (&(objectClass=*)(userPrincipalName=\2a\29\28uid=jsmith))

Символы *, ) и ( поступили на сервер как \2a, \29 и \28 — экранированные согласно RFC 2254. Фильтр сохраняет ровно два условия; аргумент не смог добавить третье.

Контрольный пример: тот же шаблон, но аргумент вставлен как необработанный текст, а не передан в качестве аргумента фильтра:``` CONTROL, raw splice: (&(objectClass=)(userPrincipalName=)(uid=jsmith)) server received : (&(objectClass=)(userPrincipalName=)(uid=jsmith))

root@kitploit:~
Управление достигает сервера с изменённой структурой — добавлено третье условие, а
проверка `userPrincipalName` сведена к подстановочному знаку. Параметризованная форма — нет. Это
подтверждает, путём наблюдения, а не по контракту, что путь `searchFilter` не
затронут.

---

## 4. Пошаговые шаги воспроизведения

Детерминированно, из чистого checkout. Рабочий каталог на протяжении всего времени — корень репозитория.

### 4.1 Предварительные требования

| Требование | Используемое значение |
|---|---|
| JDK | **8** — ветка устанавливает `jdk.version` 1.8. Результаты ниже получены с Zulu 1.8.0_432 (arm64). Перед выполнением любой команды в этом разделе укажите `JAVA_HOME` на установку JDK 8: |
| Сборка | Apache Maven, требуется доступ к сети (родительский POM `org.apache:apache:38`) |
| Базовая версия | `origin/1.13.x` |
| Конфигурация | `userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com` |
| Сервер каталогов | **Не требуется для §4.3-§4.4** — дефект находится в построении DN, до любого сетевого вызова, поэтому на этих шагах `LdapContextFactory` мокируется. §4.5 дополнительно воспроизводит весь путь против **реального** LDAP-сервера по HTTP. |

Установка `JAVA_HOME` на установку JDK 8:```bash
# macOS
export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)

# Linux (path varies by distribution and vendor)
export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64

# Windows (cmd)
set JAVA_HOME=C:\Program Files\Zulu\zulu-8

Проверьте с помощью mvn -v, который сообщает, какой JDK будет использовать Maven. Приведённые ниже команды предполагают, что это уже настроено.

4.2 Получение неисправленной базовой версии```bash

git clone https://github.com/apache/shiro.git cd shiro git checkout -b repro origin/1.13.x

root@kitploit:~
### 4.3 Наблюдение дефекта напрямую

Это минимальный триггер, не зависящий от сборки Shiro. Запишите `Repro.java` во временный
каталог:```java
import javax.naming.ldap.LdapName;

public class Repro {
    // reproduces DefaultLdapRealm.getUserDn line-for-line
    static String getUserDn(String prefix, String principal, String suffix) {
        StringBuilder sb = new StringBuilder(prefix.length() + principal.length() + suffix.length());
        sb.append(prefix);
        sb.append(principal);
        sb.append(suffix);
        return sb.toString();
    }

    public static void main(String[] args) throws Exception {
        String prefix = "uid=";
        String suffix = ",ou=users,dc=mycompany,dc=com";

        String benign = getUserDn(prefix, "jsmith", suffix);
        System.out.println("benign : " + benign + "  -> RDNs=" + new LdapName(benign).size());

        String crafted = getUserDn(prefix, "jsmith,ou=admins", suffix);
        System.out.println("crafted: " + crafted + "  -> RDNs=" + new LdapName(crafted).size());
    }
}

Запустите его:```bash javac Repro.java && java Repro

root@kitploit:~
Наблюдаемый вывод:```
benign : uid=jsmith,ou=users,dc=mycompany,dc=com  -> RDNs=4
crafted: uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com  -> RDNs=5

Количество компонентов изменяется с 4 на 5. Отправленное значение стало структурой имени.

4.4 Воспроизведение через собственный API Shiro

Из корня репозитория на неисправленной базовой версии примените тесты из §6.3 и запустите:```bash mvn -B clean verify

root@kitploit:~
Сборка останавливается на `Apache Shiro :: Core` из-за провала проверочных тестов — полный захваченный вывод см. в §6.1:```
DefaultLdapRealmTest   Tests run: 16, Failures: 4, Errors: 0, Skipped: 0
JndiLdapRealmTest      Tests run: 16, Failures: 4, Errors: 0, Skipped: 0

4.5 Сквозное воспроизведение по HTTP против работающего серверa каталогов

Воспроизведено через весь стек — реальный HTTP-запрос, реальный контейнер сервлетов, собственный FormAuthenticationFilter Shiro и реальный сервер LDAP. Ничто не имитируется ни на одном уровне.

Фикстура каталога — вложенный привилегированный контейнер, распространённая реальная структура:``` dc=mycompany,dc=com └── ou=users ├── uid=jsmith userPassword: userpass (ordinary account) └── ou=admins └── uid=jsmith userPassword: adminpass (privileged account)

root@kitploit:~
#### 4.5.1 Затронутая сборка

**Запрос**```http
POST /login HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded

username=jsmith%2Cou%3Dadmins&password=adminpass

Ответ```http HTTP/1.1 302 Found Location: http://127.0.0.1/;jsessionid=node0qu3v2n612vy71alsuyir2bgzz1.node0

root@kitploit:~
**Bind DN, полученный каталогом**```
uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com

302 — это успешный редирект FormAuthenticationFilter: запрос аутентифицирован. DN содержит пять компонентов, тогда как шаблон определяет четыре, и достигнутая запись — привилегированная, под ou=admins.

Контроль — то же имя пользователя с паролем обычной учётной записи```http POST /login HTTP/1.1 Content-Type: application/x-www-form-urlencoded

username=jsmith%2Cou%3Dadmins&password=userpass

root@kitploit:~
| `--no-color` | Отключить цветной вывод |
| `--debug` | Включить отладочный вывод |
| `--verbose` | Включить подробный вывод |
| `--silent` | Подавить весь вывод, кроме ошибок |
| `--json` | Выводить результаты в формате JSON |
| `--csv` | Выводить результаты в формате CSV |
| `--output <file>` | Записать вывод в файл |
| `--config <file>` | Использовать указанный файл конфигурации |
| `--timeout <seconds>` | Установить тайм-аут для операций |
| `--retry <count>` | Количество повторных попыток при неудачных операциях |
| `--threads <count>` | Количество потоков для использования |
| `--rate-limit <rps>` | Ограничить количество запросов в секунду |
| `--proxy <url>` | Использовать указанный прокси-сервер |
| `--user-agent <string>` | Установить строку User-Agent |
| `--header <header>` | Добавить пользовательский заголовок |
| `--cookie <cookie>` | Добавить пользовательский cookie |
| `--follow-redirects` | Следовать перенаправлениям HTTP |
| `--insecure` | Игнорировать ошибки сертификата SSL |
| `--version` | Показать информацию о версии |
| `--help` | Показать справочное сообщение |```http
HTTP/1.1 200 OK

Отклонено. Именно это устанавливает выдачу себя за другого, а не совпадение: созданное имя пользователя аутентифицируется с паролем привилегированной учётной записи и завершается неудачей с паролем обычного пользователя, поэтому проверяемые учётные данные принадлежат другой записи каталога, а не той, к которой обращается шаблон.

4.5.2 Исправленная сборка — идентичные запросы

Bind DN, полученный каталогом для созданного имени пользователя:``` affected : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (5 components) remediated : uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com (4 components)

root@kitploit:~
Обычные логины не изменяются — строка 1 аутентифицируется одинаково в обеих сборках, при этом
каталог получает `uid=jsmith,ou=users,dc=mycompany,dc=com`.

Оба запуска использовали один и тот же harness и одну и ту же фикстуру; различался только
`shiro-core` в classpath. Фактически загруженный класс был подтверждён с помощью `-verbose:class` для каждого запуска.

---

## 5. Детали исправления и код исправления

### 5.1 Основное исправление

Кодируйте principal как значение одного атрибута DN перед подстановкой, используя собственный
кодировщик JDK, соответствующий RFC 2253, `javax.naming.ldap.Rdn.escapeValue`. Это тот же механизм,
который JDK использует для построения имён, поэтому его вывод по построению согласован с парсером,
который его потребляет.

**Файл:** `core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java````diff
@@ -35,6 +35,7 @@ import org.slf4j.LoggerFactory;
 import javax.naming.AuthenticationNotSupportedException;
 import javax.naming.NamingException;
 import javax.naming.ldap.LdapContext;
+import javax.naming.ldap.Rdn;
 
 /**
  * An LDAP {@link org.apache.shiro.realm.Realm Realm} implementation utilizing Sun's/Oracle's
@@ -239,11 +240,14 @@ public class DefaultLdapRealm extends AuthorizingRealm {
 
         int prefixLength = prefix != null ? prefix.length() : 0;
         int suffixLength = suffix != null ? suffix.length() : 0;
-        StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
+        //the principal is a single attribute value within the resulting name, so it is encoded
+        //to keep any characters that are significant in a Distinguished Name within that value:
+        String value = Rdn.escapeValue(principal);
+        StringBuilder sb = new StringBuilder(prefixLength + value.length() + suffixLength);
         if (prefixLength > 0) {
             sb.append(prefix);
         }
-        sb.append(principal);
+        sb.append(value);
         if (suffixLength > 0) {
             sb.append(suffix);
         }

Что обеспечивает этот хунк. Rdn.escapeValue экранирует обратным слешем зарезервированный набор RFC 2253 (\ , = + < > # ; "), а также ведущие и завершающие пробелы. После этого подставленный текст может занимать только одно значение RDN: парсер читает экранированные символы как содержимое, поэтому количество компонентов результата фиксируется шаблоном и не может быть изменено принципалом. Подсказка ёмкости StringBuilder обновляется до закодированной длины — деталь размера, а не поведения.

Размещение. Кодирование находится после раннего возврата для случая несконфигурированного шаблона. Это сделано намеренно: в этом режиме вызывающая сторона передаёт полный DN, и его кодирование испортило бы его. Обе ветви сохраняют свои существующие контракты.

5.2 Изменение поведения для легитимных вызывающих сторон

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

  1. Ранее сломанные входы теперь работают. Имена пользователей, содержащие обратный слеш или начинающиеся с #, создавали DN, который не был корректно сформированным именем, поэтому привязка вообще не могла быть предпринята. Теперь они кодируются в допустимый DN и будет предпринята попытка привязки. Сайты могут увидеть, как учётные записи начинают аутентифицироваться, чего ранее не могли — это исправление, но заметное изменение. Имена пользователей, содержащие ,, ;, + или =, ранее не отклонялись; они разрешались в неправильную запись, а теперь разрешаются в нужную.
  2. Привязочные DN, отправляемые в каталог, различаются для затронутых имён пользователей. Журналы на стороне каталога, аудиторские следы и любое извлечение из журналов, сопоставляющее литеральные строки DN, увидят экранированные формы (uid=jsmith\,ou\=admins,...). Изменение конфигурации не требуется.
  3. Развёртывания, которые полагались на старое поведение для построения многокомпонентных DN из поля имени пользователя, сломаются. Это не поддерживаемая конфигурация — шаблон существует для определения структуры — но это единственная опасность при миграции, и это то же поведение, которое исправление призвано устранить.

getUserDnTemplate() реализован как getUserDn("{0}"). { и } не зарезервированы в RFC 2253, поэтому возвращаемое значение аксессора не изменяется. Подтверждено существовавшим ранее testUserDnTemplate, который проходит без изменений.


6. Верификация и тестирование

6.1 До — базовая линия, без исправления

Ветка 1.13-CVE-2026-49268, JDK Zulu 1.8.0_432. Команда как в §4.4.``` DefaultLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0 JndiLdapRealmTest Tests run: 16, Failures: 4, Errors: 0, Skipped: 0

root@kitploit:~
Захваченный вывод сбоя — это доказательство, и его невозможно восстановить после применения исправления:```
testGetUserDnPreservesTemplateStructure:238
  User DN gained or lost components relative to the template:
  uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
  expected:<4> but was:<5>

testGetUserDnPreservesTemplateStructureForReservedCharacters:262
  Component count changed for principal [jsmith,ou=admins]:
  uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com
  expected:<4> but was:<5>

testGetUserDnEncodesSubstitutedValue:280
  expected:<uid=jsmith[\,ou\]=admins,ou=users,dc=...>
   but was:<uid=jsmith[,ou]=admins,ou=users,dc=...>

testUserDnTemplateSubstitutionPreservesStructure:300
  Unexpected method call
    LdapContextFactory.getLdapContext("uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com", ...)
  expected:
    LdapContextFactory.getLdapContext("uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com", ...)
    expected: 1, actual: 0

testGetUserDnLeavesOrdinaryPrincipalUnchanged прошёл на базовой версии, как и задумано — это защита от чрезмерной коррекции, а не доказательный тест.

6.2 После — с применённым исправлением

Та же команда, тот же JDK:``` DefaultLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 JndiLdapRealmTest Tests run: 16, Failures: 0, Errors: 0, Skipped: 0

root@kitploit:~
Все четыре проверочных теста теперь проходят для обоих классов. Повторный запуск §4.3 с внесённым исправлением
даёт `RDNs=4` для сконструированного principal.

### 6.3 Добавленные тесты

**Файл:** `core/src/test/java/org/apache/shiro/realm/ldap/DefaultLdapRealmTest.java`
(+124 строки, 0 удалений). Поскольку `JndiLdapRealmTest extends DefaultLdapRealmTest`, каждый
тест ниже выполняется против **обоих** realm — 10 выполнений из 5 методов.

| Тест | Проверяет | Базовый результат |
|---|---|---|
| `testGetUserDnLeavesOrdinaryPrincipalUnchanged` | Обычный principal даёт точно ожидаемый DN, 4 RDN, значение не изменено. Защищает от избыточного экранирования. | **пройден** (защитный) |
| `testGetUserDnPreservesTemplateStructure` | Для `jsmith,ou=admins` DN по-прежнему содержит 4 компонента, а principal сохраняется как одно значение атрибута. | не пройден |
| `testGetUserDnPreservesTemplateStructureForReservedCharacters` | То же самое для всех 10 principal с зарезервированными символами; также завершает тест неудачей, если DN не является корректно сформированным именем. | не пройден |
| `testGetUserDnEncodesSubstitutedValue` | Подставленное значение закодировано так, что при обратном преобразовании получается исходный principal. | не пройден |
| `testUserDnTemplateSubstitutionPreservesStructure` | Сквозная проверка через `getAuthenticationInfo`: DN, передаваемый в `LdapContextFactory`, сохраняет структуру шаблона. | не пройден |

**Замечание по проектированию.** Структурные проверки разбирают результат с помощью `javax.naming.ldap.LdapName`
и сравнивают количество компонентов и значение листа после обратного преобразования,
а не проверяют ожидаемую экранированную строку. Таким образом, тесты проверяют фактически требуемое свойство и не
предполагают `Rdn.escapeValue` в качестве реализации — альтернативный корректный кодировщик
также прошёл бы проверку.

Команда:```bash
mvn -B clean verify

6.4 Регрессия — полная сборка

Полная сборка reactor проходит без флагов и без пропусков:``` mvn -B clean verify

root@kitploit:~
**СБОРКА УСПЕШНА — 907 тестов, 0 сбоев, 0 ошибок, 3 пропущено, по всему реактору.**
В этом запуске активны все проверки: модульные тесты (surefire), интеграционные тесты (failsafe),
аудит лицензий Apache RAT, maven-enforcer и japicmp.

| Проверка | Результат |
|---|---|
| Модульные тесты, весь реактор | **907 запущено, 0 сбоев, 0 ошибок, 3 пропущено** |
| Только модуль `core` | **321 запущено, 0 сбоев, 0 ошибок, 0 пропущено** |
| Тесты LDAP (`DefaultLdapRealmTest` + `JndiLdapRealmTest`) | **32 запущено, 0 сбоев** |
| Интеграционные тесты (failsafe) | выполнены по всему реактору, сбоев нет |
| Аудит лицензий Apache RAT | Не одобрено: 0, неизвестно: 0 |
| maven-enforcer | нарушений нет |
| japicmp | несовместимостей не обнаружено |

3 пропуска — это существовавшие ранее `@Ignore` в модулях, не связанных с этим изменением; `core` —
единственный затронутый модуль — ничего не пропускает.

## Ссылки

- Запись CVE (MITRE API): `https://cveawg.mitre.org/api/cve/CVE-2026-49268`
- Объявление upstream: `https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s`
- Отчёты о безопасности Apache Shiro: `https://shiro.apache.org/security-reports.html`
- RFC 2253 / RFC 4514 — строковое представление Distinguished Names в LDAP
- RFC 4515 — строковое представление фильтра поиска LDAP

## Контакты

Автор исследования и устранения уязвимостей безопасности: Jinwoo Hwang ([https://JinwooHwang.com](https://jinwoohwang.com/))
Скачать инструмент
ПолеЗначение
CVECVE-2026-49268 — Apache Shiro: LDAP DN Injection в DefaultLdapRealm
CWE IDCWE-90 — Improper Neutralization of Special Elements used in an LDAP Query ('LDAP Injection')
CVSS v4.08.8 HIGH — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N/S:P/AU:Y/R:A/RE:L/U:Red
CVSS v3.19.1 CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
Затронутые версииorg.apache.shiro:shiro-core от 0 до 2.2.0 включительно; от 3.0.0-alpha-0 до 3.0.0-alpha-1 включительно
Исправлено в upstream2.2.1 и 3.0.0-alpha-2
Ссылкаhttps://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s
PrincipalРезультат базовой версииЭффект
jsmith,ou=adminsразбирается, 5 RDNструктура изменена — подставляется контейнер
jsmith;ou=adminsразбирается, 5 RDNструктура изменена — ; является разделителем RDN согласно RFC 1779
jsmith+uid=adminразбирается, 4 RDNстановится многозначным RDN; uid получает второе значение
jsmith=adminразбирается, 4 RDNзначение повреждено
quo"te, angle<br>ackets, leadingSpace, trailingSpace разбирается, 4 RDNзначение повреждено
back\slashIllegalArgumentExceptionнекорректно — привязка не может быть выполнена
#leadingNumberSignIllegalArgumentExceptionнекорректно — привязка не может быть выполнена
УровеньКомпонент
HTTP-клиентHttpURLConnection, необработанный POST-запрос формы
Контейнер сервлетовВстроенный Jetty 9.4.58.v20250814
Фильтр безопасностиShiroFilter + EnvironmentLoaderListener, authc (FormAuthenticationFilter)
RealmDefaultLdapRealm, userDnTemplate = uid={0},ou=users,dc=mycompany,dc=com
КаталогUnboundID InMemoryDirectoryServer, инструментированный для записи каждого DN привязки
ЗапросУязвимаяИсправленная
username=jsmith&password=userpass302 аутентифицирован302 аутентифицирован
username=jsmith%2Cou%3Dadmins&password=adminpass302 аутентифицирован200 отклонён
username=jsmith%2Cou%3Dadmins&password=userpass200 отклонён200 отклонён