ESPNTransfer rumors, news: Premier League clubs eye Alexander-Arnold amid Real Madrid uncertaintyInquirerGeneral Santos City heat index hits 55°C Tuesday morningThe Jerusalem PostManhunt launched as suspect flees scene of attempted ramming of police officer near Modi'inRTP DesportoCarlos Vicens despedido do Sp. BragaPunchPolitical elite do not believe in free, fair elections – Ex-INEC commissionerZDF heuteEntdecken Sie das ZDF-NachrichtenstudioNHK 社会遺体なき傷害致死事件 夫に懲役12年6か月の判決 大阪地裁n-tvZinsen mehr als vervierfacht: Wenn der Immobilienkredit plötzlich 150.000 Euro mehr kostetDaily MaverickBANA PELE: ECD subsidy expands as government races to close SA’s one-million-child gapABC NewsMidterm vote is about 'what's best for my family': Voters talk 2026 midtermsNOSMan opgepakt voor brand in stadhuis Limoges, is bekende van Franse politieAntara NewsIndonesia pushes universities to accelerate net-zero transition
The Daily Newsstand · Free, Always
Tuesday, October 20, 2026

Веб-консоль для AD и OpenLDAP: как не дать хелпдеску сбросить пароль администратору

Translate

Почти в каждой компании с Active Directory или OpenLDAP есть одна и та же рутина: завести сотрудника, сбросить пароль, добавить в группу, отключить уволенного. Её хочется отдать хелпдеску, но так, чтобы хелпдеск не мог сделать себя администратором, а каждое действие оставляло след.

Под эту задачу я пишу Axia — веб-консоль и REST API для AD и OpenLDAP с открытым кодом (.NET 10, React). Статья не про её возможности, а про грабли: пять мест, где очевидная реализация оказалась дырой или багом, и что пришлось с этим сделать. Большинство из них не зависят от Axia и встретятся любому, кто пишет поверх LDAP что-то с правами доступа. Ссылка на код — в конце.

Почему не ACL в каталоге

Правильный ответ на «дать хелпдеску сбрасывать пароли» известен — делегирование. В AD это права на OU через мастер делегирования, в OpenLDAP — правила olcAccess. Каталог сам проверяет, кто что может, и посредник с широкими правами не нужен.

На практике с этим две проблемы. Первая — сложность. Правило «поддержка сбрасывает пароли в ou=people, кроме членов cn=admins» в OpenLDAP записать можно, но читать и сопровождать такие ACL тяжело, а ошибка в порядке правил молча открывает лишнее. Вторая — правила живут в каталоге, а вопросы задают про людей: ответ на «что может Петров?» приходится собирать из ACL нескольких OU.

Axia идёт другим путём, и у него есть цена, которую лучше назвать сразу. Приложение подключается к каталогу под сервисной учёткой с широкими правами, а кто что может, решает само: группы каталога сопоставляются с ролями, роли — с набором из 17 прав.

Сопоставление групп каталога с ролями

Сопоставление групп каталога с ролями

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

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

Чем это отличается от того, что уже есть

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

Делегирование в AD + RSAT

phpLDAPadmin, LAM

Adaxes, ADManager Plus

Axia

Кто проверяет права

каталог (ACL)

каталог (ACL)

приложение

приложение

Широкие права у самого инструмента

нет

нет, вход под учёткой пользователя

да, сервисная учётка

да, сервисная учётка

Каталоги

AD

любой LDAP

AD, Microsoft 365

AD, OpenLDAP, eDirectory

Защита администраторов от сброса пароля

AdminSDHolder, из коробки

как настроите ACL

своя модель делегирования

adminCount, защищённые группы, «не сильнее себя»

Журнал изменений

журнал безопасности DC, если включён аудит

accesslog в OpenLDAP, если настроен

да

да, включая отказы

Что нужно для работы

Windows с RSAT

веб-сервер с PHP

сервер и лицензия

Docker или .NET

Зрелость

годы

годы

годы, поддержка вендора

MVP, версия 0.2

Когда Axia не нужна:

  • Только AD, администраторы живут в RSAT, делегирование настроено. Менять нечего: AdminSDHolder уже снимает делегированные права с привилегированных учёток.

  • OpenLDAP, и вы уверенно пишете olcAccess. LAM даст веб-интерфейс без посредника с широкими правами. Это строго безопаснее модели Axia.

  • Нужны согласование заявок, сценарии, отчёты для аудиторов и поддержка вендора. Это территория Adaxes и ADManager Plus.

Axia имеет смысл, когда каталогов несколько или среди них есть OpenLDAP, права хочется выдавать группами, а не ACL, и нужен журнал, где видны и отказы. И когда нужен API, а не только интерфейс.

Грабли 1: перенос OU как способ стать администратором

Право users.update говорит, какое изменение можно сделать, но не кому. А в каталог все изменения пишет сервисная учётка, которая может поменять кого угодно. В ранней версии этого хватало, чтобы роль UserEditor сбросила пароль администратору и вошла под ним, users.delete — чтобы удалить всех администраторов, а groups.manage — чтобы добавить себя в группу, сопоставленную с ролью Admin.

Отсюда правило: нельзя действовать на того, кто сильнее тебя. «Сильнее» меряется в двух местах:

  • В приложении. Права цели должны быть подмножеством прав того, кто действует.

  • В каталоге. Учётки и группы, которые каталог сам считает привилегированными (adminCount в AD и группы из настройки DirectoryProtection), меняет только тот, у кого есть все права приложения. Без этого пункта доменный администратор, чьи группы не сопоставлены ни с одной ролью, был для Axia обычным User, и его обгонял любой редактор.

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

Правило было, тесты на него были. А потом нашлась дыра сбоку. Роли сопоставляются с группами по DN: cn=axia-admins,ou=groups,dc=example,dc=org → Admin. Но DN — это адрес, а не идентификатор. Пользователю с правами orgunits.manage и users.update хватало четырёх шагов:

  1. Перенести OU, в которой лежит группа администраторов. DN группы меняется, и сопоставление с ролью Admin перестаёт совпадать.

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

  3. Сбросить ему пароль.

  4. Перенести OU обратно. Всё выглядит как было, только пароль администратора теперь известен.

Ни один шаг по отдельности правило не нарушал. Эта дыра была в первом публичном релизе 0.1.0 и закрыта в 0.2.0. Исправление — оценивать OU по содержимому. Переименование или перенос меняет адрес всего поддерева, поэтому проверяется всё, что в нём лежит:

var subtree = await _ldapService.InspectSubtreeAsync(unit.Value);
if (subtree.HoldsServiceAccount)
    throw Refuse(unit.Value, "it holds the service account", ...);

var held = await CallerPermissionsAsync(ct);
if ((subtree.HoldsPrivileged || holdsProtectedGroup) && !HoldsEverything(held))
    throw Refuse(unit.Value, "it holds entries privileged in the directory", ...);

// Группы внутри OU, сопоставленные с ролями по DN
var roles = (await _roleMappings.GetEffectiveMappingsAsync(providerId))
    .Where(m => !m.IdentifiesGroupByNameOnly
                && DistinguishedName.TryCreate(m.GroupName, out var group)
                && group!.IsWithin(unit))
    .Select(m => m.RoleName)
    .ToHashSet(StringComparer.OrdinalIgnoreCase);

var conferred = await _rolePermissions.GetPermissionsForRolesAsync(roles, ct);
EnsureCallerHolds(held, conferred, unit.Value);

Удаление OU такой проверки не требует: каталог сам не даст удалить непустую OU. Компьютеры живут по тем же правилам — учётку контроллера домена может менять только полный администратор.

Каждый отказ пишется в журнал аудита вместе с тем, кто пытался:

Отказ UserEditor при сбросе пароля администратору в журнале аудита

Отказ UserEditor при сбросе пароля администратору в журнале аудита

До версии 0.2.0 в журнал не попадал самый частый отказ — когда нужного права нет вообще и запрос отбивается ещё на входе в API, до бизнес-логики. Теперь пишется и он.

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

Грабли 2: memberOf, которого нет

Чтобы проверить права, нужно знать группы учётки. Самый простой способ — атрибут memberOf, и сначала Axia читала только его. В AD это работает: там memberOf вычисляется и есть всегда. В OpenLDAP его нет, пока не подключён оверлей memberof. В установке по умолчанию он не загружен, а там, где загружен, часто настроен на groupOfNames и не видит groupOfUniqueNames.

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

Теперь группы ищутся с другой стороны — как группы, которые называют учётку, во всех трёх формах, в которых каталоги хранят членство:

internal static string GroupMembershipFilter(string distinguishedName, string? uid)
{
    var dn = EscapeLdapFilterValue(distinguishedName);
    return "(|"
        + $"(&(objectClass=groupOfNames)(member={dn}))"
        + $"(&(objectClass=group)(member={dn}))"
        + $"(&(objectClass=groupOfUniqueNames)(uniqueMember={dn}))"
        + (string.IsNullOrWhiteSpace(uid)
            ? string.Empty
            : $"(&(objectClass=posixGroup)(memberUid={EscapeLdapFilterValue(uid)}))")
        + ")";
}

Результат объединяется с memberOf, а не заменяет его. DN экранируется: в имени записи бывают скобки и звёздочка, и без экранирования фильтр закончится раньше времени или захватит чужие группы. Вложенные группы не разворачиваются — memberOf их тоже не разворачивает.

У истории есть продолжение. Поиск групп — это лишний запрос на каждую учётку. Для входа и проверки прав это нормально, а для списка из тысяч пользователей нет, поэтому список читает только memberOf. Список кэшируется, а карточка пользователя бралась из того же кэша. В итоге в каталоге без memberOf карточка показывала «не состоит в группах» для тех, кто состоит. Права это не ломало, проверка идёт своим путём, но администратор видел неправду. В 0.2.0 карточка перечитывает учётку по DN тем же путём, что и вход.

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

Грабли 3: pass-back, или как вытащить пароль сервисной учётки

Подключения к каталогам хранятся в базе: хост, порт, режим шифрования, DN и пароль сервисной учётки. Пароль зашифрован (AES-GCM) и через API не отдаётся никогда, даже администратору. Казалось бы, украсть его через приложение нельзя.

Можно. Достаточно поменять в подключении хост на свой и не трогать пароль. На следующем запросе сервер сам подключится к новому адресу и выполнит simple bind — то есть отправит пароль сервисной учётки как есть. Это известная атака LDAP pass-back. Обычно её показывают на МФУ с LDAP-адресной книгой, но веб-консоль ничем не лучше.

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

Правило получилось простое: если подключение начнёт ходить туда, куда раньше не ходило, пароль нужно ввести заново. А ввести его может только тот, кто его и так знает.

var keepsStoredPassword = string.IsNullOrEmpty(request.AdminPassword)
                          && !existing.EncryptedAdminPassword.IsEmpty;

if (keepsStoredPassword && existing.WouldReachNewEndpoint(request.Host, port, request.FailoverHosts))
    throw new DomainValidationException(ApplicationMessages.BindPasswordRequiredForNewEndpoint());

if (keepsStoredPassword && existing.WouldDropEncryption(security))
    throw new DomainValidationException(ApplicationMessages.BindPasswordRequiredForPlainLdap());

«Новым» считается любой хост, основной или резервный, которого раньше не было, и любой новый порт на старом хосте. Удалить резервный хост или поменять порядок хостов можно без пароля. Второе условие — тот же pass-back с другой стороны: хост прежний, но шифрование выключается, и пароль уходит по сети открытым текстом всем, кто слушает.

Грабли 4: блокировка учётки как DoS

Сессия в Axia — JWT на 60 минут. Чтобы уволенный сотрудник не работал ещё час, на каждом запросе проверяется, не отключена ли учётка в каталоге (с кэшем на 30 секунд). «Отключена» каждый каталог записывает по-своему: бит ACCOUNTDISABLE в userAccountControl или истёкший accountExpires в AD, pwdAccountLockedTime в OpenLDAP с оверлеем ppolicy, loginDisabled в eDirectory.

Самое интересное здесь то, что сознательно не считается отключением: блокировка после неудачных попыток входа. Вызвать её может кто угодно — достаточно несколько раз ввести неверный пароль к чужому логину. Если бы она обрывала сессию, любой сотрудник мог бы выкинуть администратора из консоли посреди работы. Поэтому блокировка мешает новому входу (каталог откажет при bind), но живую сессию не трогает. В OpenLDAP обе блокировки лежат в одном атрибуте, и различать их приходится по значению:

// Это значение ppolicy пишет для блокировки «до ручного снятия»;
// у блокировки за неудачные входы там время, и она истечёт сама.
private const string PermanentLockTime = "000001010000Z";

if (string.Equals(Get(attrs, "pwdAccountLockedTime").Trim(), PermanentLockTime, StringComparison.Ordinal))
    return true;

Та же логика привела к лимиту входов по учётке. Лимит по IP-адресу (5 попыток в минуту) не помогает против перебора с множества адресов, а каждая попытка, которую API пропускает в каталог, — это bind, который приближает блокировку учётки уже в самом каталоге. Теперь после 5 неудачных входов за 15 минут попытки для этого логина отклоняются с ответом 429 и до каталога не доходят. Если порог ниже, чем у блокировки в каталоге, перебор через Axia не заблокирует учётку там.

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

Грабли 5: пароль — это не атрибут

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

  • В AD userPassword по умолчанию — обычный строковый атрибут (если dSHeuristics не говорит иного). Настоящий пароль учётки не менялся, а новый ложился рядом открытым текстом и был виден всем, кому можно читать запись.

  • В OpenLDAP без политики хэширования хранится ровно то, что записано. Каждый пароль, заданный через приложение, лежал в каталоге нехэшированным.

Правильно — дать каталогу самому принять пароль, захэшировать его и проверить по своей политике:

  • AD: атрибут unicodePwd, значение — пароль в двойных кавычках в UTF-16LE. Принимается только по зашифрованному соединению, по открытому AD отвечает unwillingToPerform.

  • OpenLDAP и остальные: расширенная операция Password Modify (RFC 3062).

public static byte[] ActiveDirectoryValue(string password) =>
    Encoding.Unicode.GetBytes($"\"{password}\"");

Какой путь выбрать, определяется по root DSE, а не по настройке: AD объявляет о себе capability 1.2.840.113556.1.4.800, поддержка Password Modify видна в supportedExtension. Если нет ни того, ни другого, пароль не пишется вовсе — лучше ошибка, чем пароль открытым текстом. На слишком простой пароль оба каталога отвечают одинаково, constraintViolation, и это превращается в понятную ошибку 400, а не в «каталог недоступен».

Бонус: каталог, который принял соединение и замолчал

Таймауты на операции LDAP были, но один вызов их игнорировал. LdapConnection.StartTls() в библиотеке Novell.Directory.Ldap, пока ждёт ответа, держит блокировку записи соединения, а таймаут и закрытие соединения ждут ту же блокировку. Каталог, который принял TCP-соединение и молчит, держал поток запроса бесконечно. LDAPS и обычный LDAP в том же тесте сдавались за секунду.

Обход: перед StartTLS спросить у каталога что-нибудь безобидное, что таймаут соблюдает, — прочитать root DSE без атрибутов. Подойдёт любой ответ, даже отказ: он показывает, что на том конце кто-то есть. Нет ответа — LdapTimeout при свободной блокировке, и соединение закрывается штатно. Учётные данные при этом никуда не уходят: они отправляются только после TLS.

private static void StartTlsWithinLimit(LdapConnection conn)
{
    try
    {
        var rootDse = conn.Search(string.Empty, LdapConnection.ScopeBase, "(objectClass=*)", ["1.1"], false);
        while (rootDse.HasMore())
            rootDse.Next();
    }
    catch (LdapException ex) when (ex.ResultCode != LdapException.LdapTimeout)
    {
        // Ответ, пусть и не тот, — значит, сервер на месте.
    }

    conn.StartTls();
}

Гарантии это не даёт: сервер может ответить на чтение и зависнуть уже на StartTLS. Но частый случай закрывает — адрес, на котором висит не тот сервис, или полумёртвый каталог.

Что в итоге получилось

Если отвлечься от граблей, Axia — это веб-консоль и REST API (OpenAPI) для пользователей, групп, OU и компьютеров: массовые операции, генератор логинов с транслитерацией, журнал аудита. Сервер на .NET 10, консоль на React 19, несколько каталогов в одной установке, девять языков интерфейса, из которых вычитаны английский и русский.

Список пользователей в консоли

Список пользователей в консоли

Попробовать можно за пару минут, нужен только Docker с Compose v2:

git clone https://github.com/Nelsar/Axia.git
cd Axia
docker compose up --build

Поднимутся API, консоль на http://localhost:8080 и OpenLDAP с демо-учётками; пароль у всех — Axia-Demo-1. Интереснее всего войти как editor.demo и попробовать сбросить пароль admin.demo. Демо собрано так же, как стоит собирать боевую установку: LDAPS с проверкой сертификата, ключи генерируются при первом запуске, наружу открыт только localhost.

Чего нет — лучше знать до развёртывания:

  • Выхода из сессии и отзыва токенов. Отключение учётки обрывает сессию за 30 секунд, но украденный токен живёт до истечения, 60 минут по умолчанию.

  • Двухфакторной аутентификации и входа через OIDC. Это первое в планах.

  • Проверки отзыва сертификатов каталога (CRL, OCSP).

  • Нескольких экземпляров. Массовые задачи живут в памяти, база — SQLite.

  • Вложенных групп. Членство учитывается только прямое.

Вместо заключения

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

Код — на GitHub: github.com/Nelsar/Axia, лицензия Apache-2.0, версия 0.2.0. Особенно интересно, как Axia поведёт себя на необычных схемах и на eDirectory. Уязвимости лучше присылать через приватные сообщения на вкладке Security, а вопросы и критику — в комментарии.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.