- PVSM.RU - https://www.pvsm.ru -
Почти в каждой компании с Active Directory или OpenLDAP есть одна и та же рутина: завести сотрудника, сбросить пароль, добавить в группу, отключить уволенного. Её хочется отдать хелпдеску, но так, чтобы хелпдеск не мог сделать себя администратором, а каждое действие оставляло след.
Под эту задачу я пишу Axia — веб-консоль и REST API для AD и OpenLDAP с открытым кодом (.NET 10, React). Статья не про её возможности, а про грабли: пять мест, где очевидная реализация оказалась дырой или багом, и что пришлось с этим сделать. Большинство из них не зависят от Axia и встретятся любому, кто пишет поверх LDAP что-то с правами доступа. Ссылка на код — в конце.
Правильный способ решить эту задачу известен — делегирование. В 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 |
У вас только AD, администраторы работают через RSAT, а делегирование уже настроено. Менять ничего не требуется: AdminSDHolder и так защищает привилегированные учётные записи, регулярно сбрасывая с них делегированные права.
У вас OpenLDAP, и вы уверенно пишете правила olcAccess. В этом случае LAM (LDAP Account Manager) даст удобный веб-интерфейс без создания сервисного посредника с широкими полномочиями. Это архитектурно безопаснее модели Axia.
Нужны согласования заявок, сложные сценарии автоматизации, отчёты для аудиторов и поддержка вендора. Это территория Adaxes и ADManager Plus.
Когда каталогов несколько (или среди них есть OpenLDAP), права удобнее выдавать на базе ролей и групп, а не через разрозненные ACL, и требуется единый журнал аудита, фиксирующий в том числе отказы в доступе. А также если вам нужен полноценный REST API, а не только веб-интерфейс.
Право users.update определяет, какое изменение можно сделать, но не указывает — над кем. А в каталог все изменения пишет сервисная учётка, которая может поменять кого угодно. В ранней версии этого хватало, чтобы пользователь с ролью UserEditor мог сбросить пароль администратору и войти под его учёткой, users.delete позволяло удалить всех администраторов, а groups.manage — добавить себя в группу, сопоставленную с ролью Admin.
Отсюда правило: нельзя действовать на того, кто сильнее тебя. «Сильнее» проверяется на двух уровнях:
В приложении. Права целевой учётки должны быть строгим подмножеством прав того, кто выполняет действие.
В каталоге. Учётные записи и группы, которые сам каталог считает привилегированными (adminCount в AD и группы из настройки DirectoryProtection), может менять только пользователь, обладающий всем набором прав приложения. Без этого правила доменный администратор, чьи группы не были сопоставлены ни с одной ролью в Axia, выглядел для системы обычным пользователем, и его мог отредактировать любой оператор хелпдеска.
Сервисную учётную запись не может менять никто: сброс её пароля через приложение в лучшем случае ломает работу сервиса, а в худшем — передаёт её полномочия тому, кто установил новый пароль.
Правило было зафиксировано, тесты написаны. Но потом обнаружилась уязвимость с неожиданной стороны. Роли сопоставляются с группами по DN: cn=axia-admins,ou=groups,dc=example,dc=org → Admin. Однако DN — это лишь путь (адрес), а не неизменяемый идентификатор. Пользователю с правами orgunits.manage и users.update хватало всего четырёх шагов:
Перенести OU, в котором лежит группа администраторов. DN группы меняется, и сопоставление с ролью Admin перестаёт совпадать.
На ближайшем же запросе администратор становится для приложения обычным пользователем, и ограничение на сброс прав перестаёт работать.
Сбросить ему пароль.
Перенести OU обратно. Всё выглядит так же, как и было, но пароль администратора теперь известен злоумышленнику.
Ни один шаг по отдельности правило не нарушал. Эта уязвимость присутствовала в первом публичном релизе (0.1.0) и была закрыта в 0.2.0. Исправление — оценивать OU по его содержимому. Переименование или перенос меняет DN всего поддерева, поэтому проверяется абсолютно всё, что в нём находится:
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);
## Грабли 2: memberOf, которого нет
Чтобы проверить права, нужно знать группы учётки. Самый простой способ — атрибут `memberOf`, и сначала Axia читала только его. В AD это работает: там `memberOf` вычисляется и есть всегда. В OpenLDAP его нет, пока не подключён оверлей memberof. В установке по умолчанию он не загружен, а там, где загружен, часто настроен на `groupOfNames` и не видит `groupOfUniqueNames`.
Последствия оказались несимметричными. Администратор входил как обычный User — это безопасный отказ, его замечают в первый же день. Но тот же пустой список означал, что никто не состоит в защищённых группах. Правило «не трогать привилегированных» не срабатывало ни на ком, и любой, кому можно сбрасывать пароли, мог сбросить его администратору. Эту половину не замечает никто, пока ею не воспользуются.
Теперь группы ищутся с другой стороны — как группы, которые называют учётку, во всех трёх формах, в которых каталоги хранят членство:
```cs
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 тем же путём, что и вход.
Вывод: если одни и те же данные можно получить дёшево или честно, дешёвый способ рано или поздно попадёт туда, где нужен честный. Особенно через кэш.
Подключения к каталогам хранятся в базе: хост, порт, режим шифрования, 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 с другой стороны: хост прежний, но шифрование выключается, и пароль уходит по сети открытым текстом всем, кто слушает.
Сессия в 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 минут, без блокировки в каталоге и без обрыва уже открытой сессии. Ответ одинаков для существующих и несуществующих логинов, так что по нему не узнать, кто есть в каталоге.
В ранней версии пароль писался как обычный атрибут 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 [1] и OpenLDAP с демо-учётками; пароль у всех — Axia-Demo-1. Интереснее всего войти как editor.demo и попробовать сбросить пароль admin.demo. Демо собрано так же, как стоит собирать боевую установку: LDAPS с проверкой сертификата, ключи генерируются при первом запуске, наружу открыт только localhost.
Чего нет — лучше знать до развёртывания:
Выхода из сессии и отзыва токенов. Отключение учётки обрывает сессию за 30 секунд, но украденный токен живёт до истечения, 60 минут по умолчанию.
Двухфакторной аутентификации и входа через OIDC. Это первое в планах.
Проверки отзыва сертификатов каталога (CRL, OCSP).
Нескольких экземпляров. Массовые задачи живут в памяти, база — SQLite.
Вложенных групп. Членство учитывается только прямое.
Почти все истории выше об одном. Как только между человеком и каталогом встаёт посредник с широкими правами, каталог перестаёт защищать от эскалации, и каждую его гарантию приходится воспроизводить в приложении. Причём ломается обычно не главное правило, а то, что незаметно меняет его входные данные: DN группы, список групп, адрес каталога.
Код — на GitHub: github.com/Nelsar/Axia [2], лицензия Apache-2.0, версия 0.2.0. Особенно интересно, как Axia поведёт себя на необычных схемах и на eDirectory. Уязвимости лучше присылать через приватные сообщения на вкладке Security, а вопросы и критику — в комментарии.
Автор: Nurlan-Turkpenbay
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/open-source/460451
Ссылки в тексте:
[1] http://localhost:8080: http://localhost:8080
[2] github.com/Nelsar/Axia: https://github.com/Nelsar/Axia
[3] Источник: https://habr.com/ru/articles/1096656/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1096656
Нажмите здесь для печати.