Как правильно работать с токенами олицетворения в Windows — заимствование прав на уровне потока и безопасный откат

· · Windows, Безопасность, Токен доступа, Олицетворение, Win32, .NET, C#, Эксплуатация, Использование существующих активов

1. Что нужно понять в первую очередь

При разработке Windows-приложений и Windows-служб иногда возникает задача: «выполнить именно эту операцию от имени другого пользователя».

Например, такие ситуации:

  • Из Windows-службы нужно обратиться к файловому серверу с правами самого пользователя
  • В административном приложении нужно проверить только то, что видно конкретному пользователю
  • В Named Pipe, RPC, COM, IIS, ASP.NET Core и подобных технологиях часть обработки нужно выполнить с правами вызывающего пользователя
  • Из-за особенностей существующих систем нужно переключать используемую учётную запись Windows для каждой единицы обработки

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

Но сразу хочется подчеркнуть одну вещь.

Олицетворение в Windows — это не «магия, превращающая вас в администратора». Это механизм, который переключает контекст безопасности, используемый при проверке доступа, в основном на уровне отдельного потока.

Если реализовать это, не осознав данное отличие, возникают такие проблемы:

  • Вы думаете, что олицетворяете пользователя, а доступ к файлу всё равно завершается Access denied
  • Локальные файлы читаются, а сетевые общие папки — нет
  • Где-то посреди Task.Run или async вы незаметно возвращаетесь к исходному пользователю
  • Олицетворение остаётся активным вплоть до вывода логов и последующей обработки, из-за чего граница прав размывается
  • Первичный токен и токен олицетворения перепутаны, из-за чего запуск процесса завершается ошибкой
  • Возникает ступор: «пользователь состоит в группе Administrators, почему тогда нельзя записать файл?»

В этой статье мы разберём, как безопасно работать с токенами олицетворения Windows на практике.

Речь пойдёт не о техниках атак и не о захвате привилегий. Это статья о том, как корректно работать с границами прав в Windows-приложениях, Windows-службах и приложениях на .NET.

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

windows-impersonation-token - komurasoft-blog-samples (GitHub)

2. Что такое токен доступа

В Windows контекст безопасности пользователя или процесса представлен через токен доступа.

В токене доступа хранится примерно такая информация:

  • SID пользователя
  • группы, в которые входит пользователь
  • права и привилегии (Privilege)
  • владелец по умолчанию
  • DACL по умолчанию
  • ограничивающие SID
  • уровень целостности
  • состояние повышения прав
  • уровень олицетворения
  • тип токена

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

Когда поток пытается открыть защищённый объект, Windows сверяет данные токена со списком ACL целевого объекта.

Поток, выполняющий операцию
  ↓
В каком контексте безопасности выполняется доступ
  ↓
Смотрим на пользователя, группы и права токена
  ↓
Сверяем их с ACL целевого объекта
  ↓
Принимаем решение: разрешить / запретить

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

3. У процесса есть первичный токен

У каждого процесса Windows обычно есть первичный токен доступа.

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

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

Схематично это выглядит так:

MyService.exe
  Primary Token: DOMAIN\svc-app

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

То есть по умолчанию это выглядит так:

Thread A
  Impersonation Token: нет
  ↓
При проверке доступа используется Primary Token процесса

В таком состоянии открытие C:\Data\foo.txt проверяет, есть ли права доступа у DOMAIN\svc-app.

4. Токен олицетворения присоединяется к потоку

Когда начинается олицетворение, к потоку присоединяется токен олицетворения. Это ключевой момент: олицетворение — это в первую очередь не «весь процесс становится другим пользователем», а точнее описывается как проверка доступа для этого конкретного потока в другом контексте безопасности.

MyService.exe
  Primary Token: DOMAIN\svc-app

Thread A
  Impersonation Token: DOMAIN\alice

Thread B
  Impersonation Token: нет

В этот момент, если Thread A открывает файл, проверка доступа выполняется с правами DOMAIN\alice.

А Thread B олицетворение не выполняет, поэтому проверка доступа для него идёт с правами DOMAIN\svc-app.

Без понимания этого различия возникает вот такая путаница:

// Думали, что олицетворяем в Thread A
StartImpersonation(token);

// Но работа передаётся в другой поток
Task.Run(() =>
{
    File.ReadAllText(path);
});

// И сразу же откатываем
RevertToSelf();

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

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

5. «Олицетворение» — это не повышение привилегий

Слово «олицетворение» звучит довольно сильно, но на практике важно не путать его с «повышением привилегий».

По сути, олицетворение позволяет вот что:

Обрабатывать запрос с правами серверного процесса
  ↓
Для части операций выполнять проверку доступа с правами клиентского пользователя

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

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

Запрос HTTP / RPC / Named Pipe
  User: DOMAIN\alice
      ↓
Серверное приложение
  Process: DOMAIN\svc-app
      ↓
Только для части с доступом к файлу выполняется олицетворение DOMAIN\alice
      ↓
ACL файлового сервера разрешает / запрещает доступ

Это полезно, когда вы хотите использовать уже существующие ACL Windows, а не собственную логику авторизации приложения.

Однако при всём удобстве олицетворения ошибки в проектировании легко размывают границы прав.

  • Какая операция выполняется от чьего имени
  • Где начинается олицетворение
  • Где оно гарантированно завершается откатом
  • С правами какого пользователя выводится тот или иной лог
  • Происходит ли откат и при исключениях
  • Остаётся ли олицетворение активным до самого конца асинхронной обработки

Важно сделать всё это явным прямо в коде.

6. Разделяйте в голове первичный токен и токен олицетворения

Среди токенов Windows чаще всего путают первичный токен и токен олицетворения.

В общих чертах их можно представить так:

Токен Основное назначение Типичные примеры
Первичный токен Представляет контекст безопасности процесса Запуск процесса, CreateProcessAsUser
Токен олицетворения Используется, чтобы поток работал в другом контексте безопасности ImpersonateLoggedOnUser, SetThreadToken, олицетворение клиента в Named Pipe

Особенно важно помнить: чтобы запустить процесс, как правило, нужен именно первичный токен.

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

Типичная последовательность выглядит так:

Олицетворяем клиента
  ↓
Получаем токен олицетворения через OpenThreadToken
  ↓
Создаём первичный токен через DuplicateTokenEx
  ↓
Передаём его, например, в CreateProcessAsUser

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

Если перепутать эти два случая, вы будете озадачены ошибками вроде Access denied или The parameter is incorrect, хотя аргументы API вроде бы верны.

7. Разбираемся в уровнях олицетворения

У токена олицетворения есть уровень олицетворения.

Есть четыре основных уровня.

Уровень олицетворения Примерный смысл
Anonymous Сервер не может получить сведения о личности клиента
Identification Сервер может определить, кто такой клиент, но не может использовать его права для доступа к объектам
Impersonation Сервер может действовать с правами клиента в пределах локальной системы
Delegation Сервер может делегировать права клиента и для удалённых систем

На практике чаще всего спотыкаются на разнице между Identification и Impersonation.

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

Поэтому случается вот что:

WindowsIdentity.GetCurrent().Name показывает ожидаемое имя пользователя
  ↓
Но доступ к файлу всё равно завершается Access denied

В этом случае нужно проверять не только имя, но и уровень олицетворения.

В .NET подсказку даёт свойство WindowsIdentity.ImpersonationLevel.

using System.Security.Principal;

WindowsIdentity identity = WindowsIdentity.GetCurrent();

Console.WriteLine(identity.Name);
Console.WriteLine(identity.ImpersonationLevel);

При доступе через сеть нужна ещё большая осторожность.

В конфигурациях вида «олицетворить пользователя на веб-сервере и от его имени обратиться к другому файловому серверу или серверу БД» можно столкнуться с так называемой проблемой двойного прыжка (double hop).

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

8. Базовая схема олицетворения

Концептуально работа с олицетворением через Win32 API выглядит так:

1. Получить токен, который будет использоваться для олицетворения
2. Олицетворить текущий поток этим токеном
3. Выполнить только необходимую операцию
4. Обязательно вернуться в исходный контекст безопасности
5. Закрыть хендл токена

В коде это всегда оформляется через try / finally.

if (!ImpersonateLoggedOnUser(tokenHandle))
{
    throw new Win32Exception(Marshal.GetLastWin32Error());
}

try
{
    // Выполняем только это как олицетворённый пользователь
    DoWorkAsImpersonatedUser();
}
finally
{
    if (!RevertToSelf())
    {
        // Если откат не удался - это опасно; как минимум нельзя продолжать обработку
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

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

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

Поэтому олицетворение стоит воспринимать не как «начал — потом откатил», а как заключение в максимально маленькую область видимости.

9. В .NET используйте WindowsIdentity.RunImpersonated

В .NET, где это возможно, стоит использовать WindowsIdentity.RunImpersonated — так область олицетворения проще выразить прямо в коде.

Если у вас есть SafeAccessTokenHandle, можно написать так:

using Microsoft.Win32.SafeHandles;
using System.Security.Principal;

static string ReadFileAsUser(SafeAccessTokenHandle token, string path)
{
    return WindowsIdentity.RunImpersonated(token, () =>
    {
        return File.ReadAllText(path);
    });
}

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

RunImpersonated(token, () =>
{
    // Олицетворение действует только здесь
});

// А за пределами этого блока - исходный контекст

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

Для асинхронной работы используется RunImpersonatedAsync.

using Microsoft.Win32.SafeHandles;
using System.Security.Principal;

static Task WriteFileAsUserAsync(
    SafeAccessTokenHandle token,
    string path,
    string text,
    CancellationToken cancellationToken)
{
    return WindowsIdentity.RunImpersonatedAsync(token, async () =>
    {
        await File.WriteAllTextAsync(path, text, cancellationToken);
    });
}

Чего стоит избегать — это запуска fire-and-forget задач изнутри области олицетворения.

// Плохой пример
WindowsIdentity.RunImpersonated(token, () =>
{
    _ = Task.Run(() =>
    {
        File.WriteAllText(path, text);
    });
});

В этом коде непонятно, когда именно и в каком контексте выполнения на самом деле выполнится запись в файл.

Асинхронную работу, которая должна выполняться под олицетворением, нужно ожидать (await) внутри RunImpersonatedAsync и выходить из области только после её завершения.

10. Получение токена через LogonUser

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

В LogonUser передаются имя пользователя, домен и пароль. То есть само приложение начинает работать с учётными данными.

На практике здесь стоит обратить внимание на следующее:

  • Не хранить пароль в открытом виде в коде или файлах конфигурации
  • По возможности использовать аутентификацию ОС, служебные учётные записи, делегирование или уже имеющуюся аутентификацию Windows
  • Хранить секреты в подходящем хранилище секретов или средствами эксплуатационной инфраструктуры
  • Обязательно закрывать хендлы токенов
  • Не выводить в логи ничего секретного, кроме имени пользователя
  • Минимизировать область олицетворения

Приведём минимальный пример.

using Microsoft.Win32.SafeHandles;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;

internal static class NativeMethods
{
    private const int LOGON32_LOGON_INTERACTIVE = 2;
    private const int LOGON32_PROVIDER_DEFAULT = 0;

    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    internal static extern bool LogonUser(
        string lpszUsername,
        string? lpszDomain,
        string lpszPassword,
        int dwLogonType,
        int dwLogonProvider,
        out SafeAccessTokenHandle phToken);

    public static SafeAccessTokenHandle Logon(
        string userName,
        string? domain,
        string password)
    {
        bool ok = LogonUser(
            userName,
            domain,
            password,
            LOGON32_LOGON_INTERACTIVE,
            LOGON32_PROVIDER_DEFAULT,
            out SafeAccessTokenHandle token);

        if (!ok)
        {
            throw new Win32Exception(Marshal.GetLastWin32Error());
        }

        return token;
    }
}

public static string ReadFileWithExplicitCredential(
    string userName,
    string? domain,
    string password,
    string path)
{
    using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);

    return WindowsIdentity.RunImpersonated(token, () =>
    {
        return File.ReadAllText(path);
    });
}

Этот пример нужен лишь для того, чтобы показать форму API.

На практике сначала стоит задать вопрос: а нужно ли вообще приложению принимать пароль на вход?

Во многих случаях безопаснее рассмотреть такие альтернативы:

Что нужно сделать Альтернатива
Всей службе нужен доступ к определённому ресурсу Выделить специальную служебную учётную запись с минимально необходимыми ACL
Нужен доступ к файлу с правами самого пользователя Использовать аутентификацию Windows и продуманное делегирование
Нужна операция, требующая прав администратора Создать на стороне службы явный административный API, управляемый авторизацией приложения
Только часть обработки должна выполняться от другой учётной записи Заключить олицетворение в границы конкретного метода и вести журнал аудита

11. Обращайте внимание на тип входа (logon type) в LogonUser

Свойства токена, получаемого через LogonUser, зависят от типа входа (logon type).

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

Тип входа На что обратить внимание
Interactive Близко к интерактивному входу. Удобен для локальных операций, но зависит от окружения выполнения и прав
Network Предназначен для сетевого входа. Возвращённый токен не всегда можно напрямую использовать для запуска процесса
NewCredentials Локально ведёт себя почти как текущие учётные данные, а указанные учётные данные применяются только при удалённых подключениях

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

Важны два момента.

  1. Тип входа меняет поведение при локальном доступе, сетевом доступе и запуске процессов
  2. Нужно проверять, каким получен возвращённый токен — первичным или токеном олицетворения

Типичная путаница — передать токен, полученный с LOGON32_LOGON_NETWORK, напрямую в CreateProcessAsUser и получить ошибку.

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

12. Не относитесь к RevertToSelf легкомысленно

Если олицетворение запущено через Win32 API, завершается оно вызовом RevertToSelf. Этот откат — не просто уборка за собой, а важная операция, восстанавливающая границу безопасности.

Плохой пример:

ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();

На первый взгляд всё выглядит нормально, но если в DoWork() возникнет исключение, RevertToSelf() не будет вызван.

Поэтому обязательно используйте finally:

if (!ImpersonateLoggedOnUser(token))
{
    throw new Win32Exception(Marshal.GetLastWin32Error());
}

try
{
    DoWork();
}
finally
{
    if (!RevertToSelf())
    {
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

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

RunImpersonated / RunImpersonatedAsync в .NET полезны именно как способ выразить область видимости, который помогает не забыть про откат.

13. Делайте область олицетворения как можно меньше

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

Плохой пример:

WindowsIdentity.RunImpersonated(token, () =>
{
    ValidateRequest();
    LoadConfiguration();
    WriteDebugLog();
    ReadUserFile();
    UpdateDatabase();
    SendNotification();
});

Если олицетворять настолько широкую область, становится непонятно, какая операция выполняется с какими правами.

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

Хороший пример — выделить в отдельный блок только те операции, которым действительно нужно олицетворение.

ValidateRequest();
LoadConfiguration();

string content = WindowsIdentity.RunImpersonated(token, () =>
{
    return File.ReadAllText(userFilePath);
});

UpdateDatabase(content);
WriteAuditLog(userName, userFilePath, success: true);

В таком виде сразу понятно, что олицетворение нужно только для части с File.ReadAllText.

Олицетворение удобно, но чем шире его область, тем сложнее читать код и тем легче допустить ошибку.

14. В асинхронном коде проверяйте, «остаётся ли олицетворение активным до конца»

В современных приложениях на .NET многие операции — с файлами, HTTP, БД, очередями, хранилищами — выполняются асинхронно.

Поэтому сочетание олицетворения с async / await требует внимания.

Базовое правило только одно:

Асинхронную работу, которая требует олицетворения, нужно await-ить внутри RunImpersonatedAsync

Хороший пример:

await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
    await using FileStream stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    string text = await reader.ReadToEndAsync();

    await ProcessTextAsync(text);
});

Но и в такой форме есть о чём подумать.

Нужно ли, чтобы ProcessTextAsync тоже выполнялся от имени олицетворённого пользователя?

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

string text = await WindowsIdentity.RunImpersonatedAsync(token, async () =>
{
    return await File.ReadAllTextAsync(path);
});

await ProcessTextAsync(text);

То, что внутри области олицетворения можно использовать await, ещё не значит, что туда стоит помещать всё подряд.

И в асинхронном коде область олицетворения нужно минимизировать.

15. ASP.NET Core и олицетворение

При использовании аутентификации Windows в ASP.NET Core тоже нужно аккуратно работать с олицетворением.

Опасно считать, что раз пользователь вошёл через аутентификацию Windows, вся обработка запроса автоматически идёт от его имени.

Как правило, сам процесс приложения работает от имени идентификатора пула приложений или служебной учётной записи. Windows-идентификатор пользователя доступен как сведения об аутентификации, но это не значит, что вся дальнейшая обработка автоматически идёт с правами пользователя.

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

Пример кода:

app.MapGet("/download", async (HttpContext context) =>
{
    if (context.User.Identity is not WindowsIdentity user)
    {
        return Results.Unauthorized();
    }

    string path = GetPathFromRequest(context);

    byte[] bytes = await WindowsIdentity.RunImpersonatedAsync(
        user.AccessToken,
        async () => await File.ReadAllBytesAsync(path));

    return Results.File(bytes, "application/octet-stream");
});

И в этом примере олицетворяется только часть с чтением файла.

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

16. Как разбираться с Access denied

Самая частая ошибка в реализациях с олицетворением — Access denied.

Если при её появлении думать только «олицетворение не удалось», это приводит к долгому и лишнему поиску.

Разобьём проверку по отдельным аспектам.

Аспект Что проверить
Действительно ли выполняется олицетворение Проверить WindowsIdentity.GetCurrent().Name внутри области олицетворения
Достаточен ли уровень олицетворения Убедиться, что это не Identification, а нужный уровень
Корректен ли ACL целевого ресурса Есть ли у олицетворённого пользователя права на чтение / запись
Локальный ресурс или удалённый Успешен ли доступ к локальным файлам, а к UNC - нет
Не проблема ли это двойного прыжка Не пытаетесь ли вы пройти с веб-сервера на файловый сервер с правами пользователя
Не вышли ли вы за пределы области олицетворения Не выполняется ли фактический ввод-вывод вне области или в другой задаче
Верен ли тип токена Не передаётся ли токен олицетворения туда, где нужен для запуска процесса
Нет ли влияния UAC / уровня целостности Не является ли токен неповышенным, даже если пользователь состоит в Administrators

Особенно важно не успокаиваться, проверив только имя пользователя.

WindowsIdentity identity = WindowsIdentity.GetCurrent();
Console.WriteLine(identity.Name);

Такой лог полезен, но его недостаточно.

Как минимум стоит также посмотреть на:

Console.WriteLine(identity.ImpersonationLevel);
Console.WriteLine(identity.IsAuthenticated);

И если целевой ресурс — сетевая общая папка, проверяйте не только код приложения, но и метод аутентификации, настройки делегирования, SPN, служебную учётную запись и ACL на самом файловом сервере.

17. UAC и проблема «я администратор, но операция не проходит»

В Windows членство пользователя в группе Administrators и то, что текущий токен уже повышен, — не одно и то же.

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

Поэтому случается вот что:

Пользователь состоит в группе Administrators
  ↓
Но текущий токен не повышен
  ↓
Запись в Program Files или HKLM завершается Access denied

С олицетворением всё то же самое.

Не стоит думать «олицетворяемый пользователь — администратор, значит запись пройдёт»: нужно проверять фактическое состояние переданного токена.

При отладке стоит смотреть на такие моменты:

  • группы, в которые входит пользователь
  • включены или отключены отдельные Privilege
  • уровень целостности
  • состояние повышения прав
  • является ли токен ограниченным
  • есть ли связанный повышенный токен

В Win32 API это можно проверить через GetTokenInformation, получая TokenType, TokenImpersonationLevel, TokenElevationType, TokenIntegrityLevel и другие атрибуты.

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

18. Сетевые общие папки и проблема двойного прыжка

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

Клиентский ПК
  ↓ аутентификация Windows
Веб-сервер / API-сервер
  ↓ хотим получить доступ через олицетворение
Файловый сервер

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

Это вопрос о том, можно ли повторно делегировать учётные данные пользователя другому серверу.

Олицетворение на локальном сервере и делегирование другому серверу — не одно и то же.

Уровня Impersonation может хватать для локальных операций, но его может быть недостаточно, чтобы выступать в роли клиента перед удалённым сервером.

Чтобы использовать права самого пользователя через сеть, нужно спроектировать делегирование Kerberos, ограниченное делегирование, SPN, служебные учётные записи и метод аутентификации.

С другой стороны, в зависимости от бизнес-требований, обращаться к файловому серверу именно с Windows-правами самого пользователя может быть и не нужно.

В таком случае проще следующий подход:

Аутентификация и авторизация пользователя выполняются на стороне приложения
  ↓
Доступ к файловому серверу выполняется от специальной служебной учётной записи
  ↓
В журнал операций записываются ID пользователя и целевой файл

Это подход, в котором финальное решение принимает не «ACL операционной системы», а «авторизация приложения».

Какой из вариантов правильный, зависит от бизнес-требований.

Но если не зафиксировать явно, какой из подходов выбран, олицетворение, делегирование, ACL и авторизация приложения перемешиваются и становится сложно во всём разобраться.

19. Время жизни хендлов токенов

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

В .NET базовый подход — использовать SafeAccessTokenHandle и управлять его временем жизни через using.

using SafeAccessTokenHandle token = NativeMethods.Logon(userName, domain, password);

string result = WindowsIdentity.RunImpersonated(token, () =>
{
    return File.ReadAllText(path);
});

Плохой пример:

// Плохой пример: хранение токена глобально бесконечно долго
private static SafeAccessTokenHandle? _cachedToken;

Долгое хранение токена приводит к таким проблемам:

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

Принцип таков:

Получать токен только тогда, когда он нужен
  ↓
Использовать в минимально необходимой области
  ↓
Обязательно закрывать

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

20. Что нужно фиксировать в журнале аудита

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

Если сохранять хотя бы информацию из этой таблицы, последующее расследование становится намного проще.

Поле Пример
Запросивший пользователь DOMAIN\alice
Учётная запись выполняющего процесса DOMAIN\svc-app
Олицетворённая учётная запись DOMAIN\alice или специальная учётная запись
Целевой ресурс Путь к файлу, имя общей папки, ключ реестра и т. п.
Операция Read, Write, Delete, CreateProcess и т. п.
Результат Success, AccessDenied, Timeout, UnexpectedError
Код ошибки Код ошибки Win32, HRESULT, тип исключения
Область олицетворения В каком методе, для какой единицы операции выполнялось олицетворение

При этом кое-что в лог выводить нельзя.

  • пароли
  • значения токенов доступа
  • заголовки аутентификации
  • билеты Kerberos и сами учётные данные
  • содержимое файлов с персональными данными

Цель журнала — иметь возможность впоследствии проследить, «по чьему запросу, от какой учётной записи, что было предпринято и как это завершилось - успехом или неудачей».

Сами учётные данные фиксировать не нужно.

21. Частые антипаттерны

Соберём опасные реализации, которые чаще всего встречаются вокруг олицетворения.

21.1 Олицетворение всего приложения целиком

WindowsIdentity.RunImpersonated(token, () =>
{
    RunEntireApplication();
});

Если олицетворять всё приложение целиком, становится непонятно, какая операция выполняется с какими правами.

Олицетворение стоит ограничивать необходимым вводом-выводом и конкретными вызовами API.

21.2 Откат без finally

ImpersonateLoggedOnUser(token);
DoWork();
RevertToSelf();

Это опасно, потому что при исключении откат не произойдёт.

Всегда используйте try / finally или RunImpersonated.

21.3 Fire-and-forget во время олицетворения

WindowsIdentity.RunImpersonated(token, () =>
{
    _ = Task.Run(DoWorkAsync);
});

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

Если операции нужно олицетворение, используйте await внутри RunImpersonatedAsync.

21.4 Определение успеха только по имени пользователя

Console.WriteLine(WindowsIdentity.GetCurrent().Name);

Даже если имя пользователя соответствует ожиданиям, уровня олицетворения или прав может не хватать.

Проверяйте также ImpersonationLevel, ACL целевого ресурса, тип входа и настройки сетевого делегирования.

21.5 Использование администраторской учётной записи как «удобной» для олицетворения

Подход «эта операция не должна падать, поэтому олицетворяем администраторскую учётную запись» опасен.

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

21.6 Хранение пароля в файле конфигурации

{
  "UserName": "DOMAIN\\admin",
  "Password": "P@ssw0rd!"
}

Этого следует избегать.

Если приходится работать с учётными данными, используйте механизм, подходящий вашему окружению: хранилище секретов, Windows Credential Manager, DPAPI, облачный Key Vault или средства управления секретами вашей эксплуатационной платформы.

21.7 Смешение запуска процесса и доступа к файлу в одну задачу

Если нужен только доступ к файлу, зачастую достаточно токена олицетворения.

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

Если вы используете CreateProcessAsUser или CreateProcessWithTokenW, стоит рассматривать это как отдельную от олицетворения задачу проектирования.

22. Что проверять в тестах

Если проверять код с олицетворением только в своём локальном окружении с правами администратора, легко что-то упустить.

Как минимум стоит подготовить такие тестовые сценарии:

Сценарий Что проверить
Пользователь с правами Может читать / записывать целевой файл
Пользователь без прав Корректно завершается ошибкой Access denied
Несуществующий пользователь Обрабатывается как ошибка аутентификации
Неверный пароль Завершается ошибкой, не раскрывая секреты в логах
Сетевая общая папка Проверена разница поведения между локальным ресурсом и UNC
Асинхронная обработка Работает в ожидаемой области и после await
Возникновение исключения Олицетворение гарантированно снимается
Параллельные запросы Олицетворения разных пользователей не смешиваются
Запуск как служба Работает под реальной служебной учётной записью, а не под интерактивным входом разработчика

Особенно важны эти два момента:

Успешное выполнение
Отказ именно тогда, когда он должен произойти

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

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

23. Чек-лист для реализации

До и после реализации стоит пройтись по этому чек-листу.

Аспект Что проверить
Цель Можете ли вы объяснить, зачем нужно олицетворение
Альтернативы Рассмотрели ли вы, не обойтись ли служебной учётной записью или авторизацией приложения
Область Минимальна ли область олицетворения
Откат Гарантированно ли выполняется откат при исключениях
Асинхронность Ожидается ли (await) всё до завершения внутри RunImpersonatedAsync
Токены Не путаете ли вы первичный токен с токеном олицетворения
Уровень олицетворения Учитываете ли разницу между Identification и Impersonation / Delegation
Сеть Проверили ли необходимость UNC, риск двойного прыжка, делегирования Kerberos
UAC Не путаете ли вы членство в администраторах с фактическим повышением прав
Секреты Не хранится ли пароль в открытом виде
Хендлы Закрывается ли SafeAccessTokenHandle через using
Логирование Можно ли проследить пользователя, олицетворённую учётную запись, ресурс и результат
Тесты Проверены ли случаи с правами / без прав / исключения / параллельная работа

Если по этому чек-листу возникает много вопросов, лучше пересмотреть проектирование ещё до написания кода.

24. Определяем, когда это уместно

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

Если разложить проектирование по сценариям использования, сомнений станет меньше.

24.1 Когда нужно использовать ACL операционной системы напрямую

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

Доступ от имени самого пользователя
  ↓
Финальное решение принимают ACL Windows

В этом случае проектировать нужно целиком: аутентификацию Windows, уровень олицетворения, делегирование и сетевую конфигурацию.

24.2 Когда авторизацию нужно выполнять на стороне приложения

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

Аутентифицируем пользователя
  ↓
Авторизуем в приложении
  ↓
Обращаемся к ресурсу от служебной учётной записи
  ↓
Фиксируем ID пользователя в журнале аудита

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

24.3 Когда нужны административные операции

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

Клиент
  ↓
Запрос к административному API
  ↓
Административный API выполняет авторизацию
  ↓
Операция выполняется с минимально необходимыми правами
  ↓
Журнал аудита

«Просто олицетворить администратора для начала» выглядит проще в краткосрочной перспективе.

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

25. Заключение

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

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

Соберём ключевые моменты, которые нужно держать в голове:

  • Токен доступа представляет контекст безопасности - пользователя, группы, права и прочее
  • У процесса есть первичный токен
  • Токен олицетворения присоединяется в основном к потоку и используется при проверке доступа
  • Олицетворение - это не повышение привилегий
  • У первичного токена и токена олицетворения разное назначение
  • Уровень олицетворения определяет, можно ли только идентифицировать клиента, действительно получить доступ или делегировать права удалённым системам
  • При олицетворении через Win32 API обязательно вызывайте RevertToSelf в try / finally
  • В .NET выражайте небольшую область через WindowsIdentity.RunImpersonated / RunImpersonatedAsync
  • При использовании LogonUser обращайте внимание на управление учётными данными и тип входа
  • Для сетевых общих папок важно не только олицетворение, но и делегирование Kerberos, и проектирование служебных учётных записей
  • Управляйте хендлами токенов через SafeAccessTokenHandle и using
  • Тестируйте не только успешные сценарии, но и те, что должны заканчиваться отказом

В реализации с токенами олицетворения важен не сам факт «сработало от имени другого пользователя», а то, чтобы вы могли объяснить всё перечисленное ниже.

Какая операция
по чьему запросу
от какой учётной записи
в каких пределах выполнена
где произошёл откат
и как зафиксированы успех и неудача

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

Оно становится практичным инструментом, который помогает по-настоящему задействовать ACL Windows, служебные учётные записи, существующие файловые серверы и доменную инфраструктуру компании.

Справочные материалы

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

Как создавать и эксплуатировать службы Windows — от выбора между планировщиком заданий и службами до превращения BackgroundService в службу Windows

Разбираем, стоит ли превращать резидентную обработку в службу Windows или достаточно планировщика заданий: таблица решений, создание служ...

Что сделать перед утилизацией Windows PC — практический чек-лист по стиранию данных, отвязке учётных записей и резервному копированию

Разбираем, что нужно сделать перед утилизацией, передачей, продажей или возвратом по лизингу Windows PC: резервное копирование, стирание ...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Что такое токен олицетворения в Windows?
Токен олицетворения — это токен, который присоединяется к потоку, чтобы этот поток проходил проверки доступа в другом контексте безопасности. Речь не о том, что весь процесс становится другим пользователем: только олицетворяющий поток проходит проверки доступа к файлам, реестру и другим объектам с правами этого пользователя. Это не «магия, превращающая вас в администратора», то есть не повышение привилегий, а механизм, который позволяет проверять доступ лишь для части операций серверного процесса с правами клиентского пользователя.
Чем первичный токен отличается от токена олицетворения?
Первичный токен представляет контекст безопасности процесса и используется для запуска процессов, например через CreateProcessAsUser. Токен олицетворения нужен, чтобы поток работал в другом контексте безопасности, — он появляется при использовании ImpersonateLoggedOnUser или при олицетворении клиента через Named Pipe. Если нужно запустить процесс, как правило требуется именно первичный токен: если под рукой есть только токен олицетворения, его превращают в первичный с помощью DuplicateTokenEx. Если эти два токена путать, легко застрять на ошибках вроде Access denied.
Почему возникает Access denied, хотя олицетворение вроде бы выполняется?
Проверять нужно сразу несколько моментов. Внутри области олицетворения смотрите не только на Name из WindowsIdentity.GetCurrent(), но и на ImpersonationLevel — убедитесь, что уровень не Identification, а хотя бы Impersonation. Проверьте ACL целевого ресурса, не выполняется ли фактический ввод-вывод за пределами области олицетворения или в другой задаче, не столкнулись ли вы с проблемой двойного прыжка (double hop), когда локальный доступ проходит, а UNC — нет, и не влияет ли на ситуацию неповышенный токен из-за UAC. Даже если имя пользователя соответствует ожиданиям, прав может не хватать.
Как безопасно реализовать олицетворение в .NET?
Базовый подход — использовать WindowsIdentity.RunImpersonated / RunImpersonatedAsync и заключать область олицетворения внутрь лямбда-выражения. Асинхронную работу, которая должна выполняться под олицетворением, нужно ожидать (await) внутри RunImpersonatedAsync, не запуская fire-and-forget задачи изнутри этой области. При олицетворении через Win32 API обязательно вызывайте RevertToSelf в блоке try/finally. Область олицетворения стоит сводить только к необходимому вводу-выводу, а хендлы токенов вести через SafeAccessTokenHandle и using.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

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

Публичные ссылки

Вернуться в блог