Bien gérer les jetons d'emprunt d'identité Windows — emprunt des privilèges par thread et retour sécurisé au contexte d'origine

· Mis à jour le: · · Windows, Sécurité, Jeton d'accès, Emprunt d'identité, Win32, .NET, C#, Exploitation, Réutilisation des actifs existants

1. Ce qu’il faut comprendre avant tout

Quand on développe des applications Windows ou des services Windows, il arrive qu’on ait besoin d’exécuter « seulement ce traitement » en tant qu’un autre utilisateur.

Voici par exemple ce genre de situations :

  • Depuis un service Windows, accéder à un serveur de fichiers avec les privilèges propres de l’utilisateur
  • Dans une application d’administration, vérifier uniquement ce qu’un utilisateur donné peut voir
  • Dans un Named Pipe, un RPC, COM, IIS ou ASP.NET Core, exécuter une partie du traitement avec les privilèges de l’utilisateur appelant
  • Pour des raisons liées aux actifs existants, changer de compte Windows utilisé selon l’unité de traitement

C’est ici qu’interviennent l’emprunt d’identité, les jetons d’accès et les jetons d’emprunt d’identité.

Mais il y a d’abord un point à souligner.

L’emprunt d’identité sous Windows n’est pas « la magie qui vous transforme en administrateur ». C’est un mécanisme qui change, principalement au niveau du thread, le contexte de sécurité utilisé pour les contrôles d’accès.

Implémenter cela sans saisir cette distinction entraîne des problèmes comme ceux-ci :

  • On croit emprunter l’identité, mais l’accès à un fichier renvoie Access denied
  • Les fichiers locaux se lisent, mais seul le partage réseau échoue
  • Au milieu d’un Task.Run ou d’un async, on se retrouve sans s’en rendre compte revenu à l’utilisateur d’origine
  • La journalisation et les traitements suivants continuent de s’exécuter alors que l’emprunt d’identité est encore actif, ce qui brouille la frontière des privilèges
  • Le jeton principal et le jeton d’emprunt d’identité sont confondus, et le lancement du processus échoue
  • On reste bloqué sur « l’utilisateur appartient à Administrators, alors pourquoi ne peut-il pas écrire ? »

Cet article organise les idées pour manier en toute sécurité, en pratique, les jetons d’emprunt d’identité sous Windows.

Il ne s’agit pas de techniques d’attaque ni de prise de privilèges. Il s’agit de bien gérer les frontières de privilèges dans les applications Windows, les services Windows et les applications .NET.

Le code présenté dans cet article est publié sur GitHub sous la forme d’un ensemble d’exemples complets et compilables (une bibliothèque, une démonstration à exécuter sous Windows, et des tests unitaires pour la validation des arguments et le comportement des garde-fous).

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

2. Qu’est-ce qu’un jeton d’accès ?

Sous Windows, un jeton d’accès (access token) est utilisé pour représenter le contexte de sécurité d’un utilisateur ou d’un processus.

Un jeton d’accès contient à peu près ces informations :

  • Le SID de l’utilisateur
  • Les groupes d’appartenance
  • Les droits et privilèges
  • Le propriétaire par défaut
  • La DACL par défaut
  • Les SID restreints
  • Le niveau d’intégrité
  • L’état d’élévation
  • Le niveau d’emprunt d’identité
  • Le type de jeton

De nombreux objets Windows possèdent un descripteur de sécurité : fichiers, Registre, services, tubes nommés (named pipes), processus, threads, événements, mutex, etc.

Lorsqu’un thread tente d’ouvrir un objet protégé, Windows confronte les informations du jeton à l’ACL de l’objet cible.

Le thread qui effectue l'opération
  ↓
Dans quel contexte de sécurité l'accès a-t-il lieu ?
  ↓
Examen de l'utilisateur, des groupes et des privilèges du jeton
  ↓
Confrontation avec l'ACL de l'objet cible
  ↓
Décision d'autoriser / refuser

Comprendre ce « dans quel contexte de sécurité l’accès a-t-il lieu » est le point d’entrée pour comprendre les jetons d’emprunt d’identité.

3. Un processus possède un jeton principal

Chaque processus Windows possède normalement un jeton d’accès principal.

Par exemple, lorsqu’un utilisateur lance une application depuis le bureau, le processus reçoit un jeton principal représentant le contexte de sécurité de cet utilisateur.

Pour un service Windows, le processus reçoit le jeton principal du compte d’exécution du service.

Voici, par exemple, l’idée :

MyService.exe
  Primary Token: DOMAIN\svc-app

Si un thread de ce service n’emprunte l’identité de personne en particulier, c’est le jeton principal du processus qui est utilisé pour accéder aux fichiers ou au Registre.

Autrement dit, par défaut :

Thread A
  Impersonation Token: aucun
  ↓
Le contrôle d'accès utilise le jeton principal (Primary Token) du processus

Dans cet état, si l’on ouvre C:\Data\foo.txt, ce qui est vérifié est le droit d’accès de DOMAIN\svc-app.

4. Le jeton d’emprunt d’identité s’attache au thread

Quand l’emprunt d’identité commence, un jeton d’emprunt d’identité s’attache au thread. C’est le point essentiel : l’emprunt d’identité ne consiste pas fondamentalement à ce que « tout le processus devienne un autre utilisateur » — il est plus exact de considérer que ce thread subit les contrôles d’accès dans un contexte de sécurité différent.

MyService.exe
  Primary Token: DOMAIN\svc-app

Thread A
  Impersonation Token: DOMAIN\alice

Thread B
  Impersonation Token: aucun

Dans ce cas, lorsque Thread A ouvre un fichier, le contrôle d’accès s’effectue avec les privilèges de DOMAIN\alice.

Thread B, à l’inverse, n’emprunte aucune identité, donc le contrôle d’accès s’effectue avec les privilèges de DOMAIN\svc-app.

Ne pas comprendre cette différence entraîne ce genre de confusion :

// On croit emprunter l'identité sur Thread A
StartImpersonation(token);

// Mais le traitement est délégué à un autre thread
Task.Run(() =>
{
    File.ReadAllText(path);
});

// Et on rétablit le contexte immédiatement
RevertToSelf();

Dans ce cas, le thread qui lit réellement le fichier ne s’exécute pas forcément dans l’état d’emprunt d’identité attendu.

L’emprunt d’identité doit être géré en clarifiant sa relation avec la portée (scope), les threads et le traitement asynchrone.

5. « L’emprunt d’identité » n’est pas une élévation de privilèges

Le mot « emprunt d’identité » peut sembler fort, mais ce qui compte en pratique, c’est de ne pas le confondre avec une « élévation de privilèges ».

Ce que permet l’emprunt d’identité se résume fondamentalement à ceci :

Traiter avec les privilèges du processus serveur
  ↓
Faire subir le contrôle d'accès d'une partie seulement du traitement avec les privilèges de l'utilisateur client

Par exemple, si l’on veut utiliser tel quel l’ACL d’un serveur de fichiers comme mécanisme de contrôle des droits, mais que l’application serveur lit toujours les fichiers sous son compte de service, les ACL propres à chaque utilisateur ne sont jamais reflétées.

On emprunte alors l’identité de l’utilisateur appelant pour la seule partie du traitement de la requête qui accède aux fichiers.

Requête HTTP / RPC / Named Pipe
  User: DOMAIN\alice
      ↓
Application serveur
  Process: DOMAIN\svc-app
      ↓
Emprunt de l'identité DOMAIN\alice pour la seule partie accès fichier
      ↓
L'ACL du serveur de fichiers autorise / refuse

Cela est utile quand on veut s’appuyer sur les ACL existantes de Windows plutôt que sur une logique de contrôle des droits propre à l’application.

Cependant, si l’emprunt d’identité est pratique, une conception maladroite rend vite les frontières de privilèges difficiles à cerner.

  • Quel traitement s’exécute en tant que qui ?
  • Où l’emprunt d’identité a-t-il commencé ?
  • Où revient-on de manière fiable au contexte d’origine ?
  • Quels journaux sont écrits sous quels privilèges utilisateur ?
  • Revient-on aussi en cas d’exception ?
  • L’emprunt d’identité reste-t-il actif jusqu’à la fin d’un traitement asynchrone ?

Il est essentiel de rendre tout cela explicite dans le code.

6. Distinguer clairement jeton principal et jeton d’emprunt d’identité

Parmi les jetons Windows, la paire la plus facile à confondre est le jeton principal et le jeton d’emprunt d’identité.

En gros, on peut le voir ainsi :

Jeton Usage principal Exemples représentatifs
Jeton principal Représente le contexte de sécurité d’un processus Lancement de processus, CreateProcessAsUser
Jeton d’emprunt d’identité Permet à un thread de fonctionner dans un contexte de sécurité différent ImpersonateLoggedOnUser, SetThreadToken, emprunt d’identité du client d’un Named Pipe

Le point particulièrement important : pour lancer un processus, il faut en principe un jeton principal.

Posséder un jeton d’emprunt d’identité ne signifie pas qu’on peut l’utiliser tel quel pour lancer le processus d’un autre utilisateur.

Le déroulement typique est le suivant :

Emprunter l'identité du client
  ↓
Obtenir le jeton d'emprunt d'identité avec OpenThreadToken
  ↓
Créer un jeton principal avec DuplicateTokenEx
  ↓
Le transmettre à CreateProcessAsUser, etc.

À l’inverse, si l’objectif est « effectuer seulement l’accès fichier sous un autre utilisateur sur ce thread », c’est une affaire de jeton d’emprunt d’identité, pas de lancement de processus.

Mélanger les deux fait que, même avec des arguments d’API corrects, on s’égare face à des erreurs comme Access denied ou The parameter is incorrect.

7. Comprendre les niveaux d’emprunt d’identité

Les jetons d’emprunt d’identité ont un niveau d’emprunt d’identité.

Il en existe quatre niveaux représentatifs :

Niveau d’emprunt d’identité Signification approximative
Anonymous Le serveur ne peut pas obtenir l’identité du client
Identification Le serveur peut identifier le client, mais ne peut pas utiliser ses privilèges pour accéder à des objets
Impersonation Le serveur peut agir en tant que le client sur le système local
Delegation Le serveur peut aussi déléguer les privilèges du client à des systèmes distants

Ce sur quoi on bute le plus souvent en pratique, c’est la différence entre Identification et Impersonation.

Identification, comme son nom l’indique, est un niveau permettant de savoir qui est l’interlocuteur. Il est insuffisant pour ouvrir un fichier avec les privilèges de cet utilisateur.

D’où ceci :

WindowsIdentity.GetCurrent().Name affiche le nom d'utilisateur attendu
  ↓
Mais l'accès au fichier renvoie Access denied

Dans ce cas, il ne faut pas seulement regarder le nom, mais aussi vérifier le niveau d’emprunt d’identité.

En .NET, WindowsIdentity.ImpersonationLevel fournit un indice :

using System.Security.Principal;

WindowsIdentity identity = WindowsIdentity.GetCurrent();

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

L’accès à travers le réseau demande une vigilance supplémentaire.

Dans des configurations du type « emprunter l’identité de l’utilisateur sur un serveur web, puis accéder en tant que cet utilisateur à un autre serveur de fichiers ou serveur de base de données », on peut se heurter au fameux problème du double saut (double hop).

Dans ce cas, emprunter l’identité uniquement dans le code applicatif ne suffit pas forcément à résoudre le problème. Il faut concevoir en tenant compte de Kerberos, des SPN, de la délégation, de la délégation contrainte, des comptes de service et du mode d’authentification du serveur cible.

8. La forme de base de l’emprunt d’identité

La forme conceptuelle pour gérer l’emprunt d’identité avec l’API Win32 est la suivante :

1. Obtenir le jeton à utiliser pour l'emprunt d'identité
2. Faire emprunter cette identité au thread courant avec ce jeton
3. N'exécuter que le traitement nécessaire
4. Toujours revenir au contexte de sécurité d'origine
5. Fermer le handle du jeton

Dans le code, utilisez toujours try / finally.

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

try
{
    // N'exécuter que cette partie en tant qu'utilisateur dont l'identité est empruntée
    DoWorkAsImpersonatedUser();
}
finally
{
    if (!RevertToSelf())
    {
        // Un état où l'on ne peut pas revenir en arrière est dangereux : ne surtout pas poursuivre le traitement
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}

Ce qui compte encore plus que le début de l’emprunt d’identité, c’est le retour fiable au contexte d’origine. Oublier de revenir en arrière fait que les traitements suivants, sur ce thread, continuent de s’exécuter en tant qu’utilisateur emprunté.

En particulier dans les applications utilisant des pools de threads, ce qui était censé être un traitement isolé peut affecter une autre requête ou un autre traitement.

C’est pourquoi il faut voir l’emprunt d’identité non pas comme « on commence, puis on revient », mais comme quelque chose qu’il faut enfermer dans une petite portée.

9. En .NET, utilisez WindowsIdentity.RunImpersonated

En .NET, dans la mesure du possible, WindowsIdentity.RunImpersonated permet d’exprimer facilement dans le code la portée de l’emprunt d’identité.

Si vous disposez d’un SafeAccessTokenHandle, vous pouvez écrire :

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

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

L’avantage de cette forme, c’est que la zone où l’identité est empruntée est enfermée dans l’expression lambda.

RunImpersonated(token, () =>
{
    // Emprunt d'identité seulement ici
});

// À partir d'ici, on est de retour dans le contexte d'origine

Quand on veut n’emprunter l’identité que pour un traitement précis — accès fichier, accès au Registre, appel à une bibliothèque existante — cette forme est lisible et sûre.

Pour les traitements asynchrones, utilisez 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);
    });
}

Ce qu’il faut éviter, c’est de lancer une tâche fire-and-forget depuis l’intérieur de la portée d’emprunt d’identité.

// Mauvais exemple
WindowsIdentity.RunImpersonated(token, () =>
{
    _ = Task.Run(() =>
    {
        File.WriteAllText(path, text);
    });
});

Ce code rend difficile de savoir quand, et dans quel contexte d’exécution, s’exécute réellement « le traitement qui écrit le fichier ».

Pour un traitement asynchrone qui doit s’exécuter avec l’identité empruntée, faites un await à l’intérieur de RunImpersonatedAsync, et ne sortez de la portée qu’une fois le traitement terminé.

10. Obtenir un jeton avec LogonUser

LogonUser est l’API classique pour obtenir un jeton à partir des identifiants d’un autre utilisateur, mais c’est une API à manier avec prudence.

LogonUser prend en paramètres un nom d’utilisateur, un domaine et un mot de passe. Autrement dit, c’est l’application elle-même qui manipule les identifiants.

En pratique, il faut prêter attention à ceci :

  • Ne jamais placer de mot de passe en clair dans le code ou un fichier de configuration
  • Préférer, si possible, l’authentification de l’OS, les comptes de service, la délégation ou l’authentification Windows existante
  • Gérer les secrets dans un coffre à secrets (Secret Store) ou une plateforme d’exploitation adaptée
  • Toujours fermer les handles de jeton
  • Ne jamais journaliser de secret autre que le nom d’utilisateur
  • Réduire au minimum la portée de l’emprunt d’identité

Voici un exemple minimal.

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);
    });
}

Cet exemple sert uniquement à montrer la forme de l’API.

En pratique, demandez-vous d’abord si « l’application doit vraiment recevoir un mot de passe ».

Dans la plupart des cas, les alternatives suivantes sont plus sûres.

Ce que l’on veut faire Alternative
Tout le service doit accéder à une ressource spécifique Attribuer à un compte de service dédié l’ACL strictement nécessaire
Accéder aux fichiers avec les privilèges propres de l’utilisateur Utiliser l’authentification Windows et une conception de délégation
Effectuer une opération nécessitant des privilèges administrateur Prévoir côté service une API d’administration explicite, contrôlée par l’autorisation côté application
Faire passer seulement une partie du traitement sous un autre compte Enfermer la portée de l’emprunt d’identité au niveau de la méthode et conserver un journal d’audit

11. Prêter attention au type d’ouverture de session (logon type) de LogonUser

Les propriétés du jeton obtenu via LogonUser varient selon le type d’ouverture de session (logon type).

En particulier, copier-coller sans comprendre cette différence ne fonctionnera pas comme attendu.

Type d’ouverture de session Point de vigilance
Interactive Proche d’une ouverture de session interactive. Pratique pour les opérations locales, mais dépend de l’environnement d’exécution et des droits
Network Destiné aux ouvertures de session réseau. Le jeton renvoyé n’est parfois pas directement utilisable pour lancer un processus
NewCredentials Localement proche des identifiants courants ; parfois utilisé pour n’appliquer les identifiants indiqués que lors de connexions distantes

Ce qui compte ici n’est pas de mémoriser un type d’ouverture de session particulier.

Deux points sont essentiels :

  1. Le type d’ouverture de session change le comportement pour l’accès local, l’accès réseau et le lancement de processus
  2. Il faut vérifier si le jeton renvoyé est un jeton principal ou un jeton d’emprunt d’identité

Par exemple, un jeton obtenu avec LOGON32_LOGON_NETWORK transmis tel quel à CreateProcessAsUser, qui échoue, est une confusion classique.

Si l’objectif est de lancer un processus, il faut un jeton principal. On en vient alors, selon le besoin, à en créer un avec DuplicateTokenEx.

12. Ne pas prendre RevertToSelf à la légère

Lorsqu’on démarre un emprunt d’identité via l’API Win32, on y met fin avec RevertToSelf. Ce retour en arrière n’est pas un simple nettoyage : c’est le traitement essentiel qui restaure la frontière de sécurité.

Voici un mauvais exemple.

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

À première vue, cela semble correct, mais si DoWork() lève une exception, RevertToSelf() n’est jamais appelé.

Utilisez toujours finally.

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

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

Continuer le traitement après un échec de RevertToSelf est également dangereux. Poursuivre alors qu’on ne sait pas si l’on est réellement revenu aux privilèges d’origine fait que le traitement continue avec les privilèges d’un utilisateur non voulu. Au minimum, traitez cette unité de travail comme un échec et penchez du côté de la sécurité.

Les RunImpersonated / RunImpersonatedAsync de .NET sont utiles précisément comme expressions de portée pour éviter cet oubli de retour en arrière.

13. Réduire autant que possible la portée de l’emprunt d’identité

Le principe de conception le plus important pour l’emprunt d’identité est de n’emprunter l’identité que pour le traitement qui en a besoin.

Voici un mauvais exemple.

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

Emprunter l’identité sur une portée aussi large rend difficile de savoir quelle opération s’exécute sous quels privilèges.

Par exemple, l’accès à la destination des journaux pourrait s’effectuer avec les privilèges de l’utilisateur emprunté et l’écriture du journal pourrait échouer. La connexion à la base de données pourrait être tentée avec les identifiants de l’utilisateur emprunté plutôt qu’avec ceux du compte de service. Même la notification ou la création de fichiers temporaires pourraient subir l’effet d’un contexte de privilèges non voulu.

Le bon exemple consiste à isoler uniquement les opérations qui nécessitent l’emprunt d’identité.

ValidateRequest();
LoadConfiguration();

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

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

Sous cette forme, on voit clairement que seule la partie File.ReadAllText nécessite l’emprunt d’identité.

L’emprunt d’identité est pratique, mais plus on l’étend, plus le code devient difficile à lire et plus les incidents deviennent probables.

14. En asynchrone, vérifiez « l’emprunt d’identité reste-t-il actif jusqu’à la fin »

Dans les applications .NET modernes, une grande partie du traitement est asynchrone : fichiers, HTTP, base de données, files d’attente, stockage.

C’est pourquoi combiner emprunt d’identité et async / await demande de la prudence.

Le principe de base tient en une phrase :

Le traitement asynchrone nécessitant l'emprunt d'identité doit être attendu (await) à l'intérieur de RunImpersonatedAsync

Voici un bon exemple.

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);
});

Mais même sous cette forme, il reste un point à considérer.

ProcessTextAsync doit-il vraiment s’exécuter sous l’identité empruntée jusqu’au bout ?

Si seule la lecture du fichier nécessite l’emprunt d’identité, il est plus sûr de le séparer ainsi.

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

await ProcessTextAsync(text);

Ce n’est pas parce qu’on peut faire un await à l’intérieur de la portée d’emprunt d’identité que tout doit y être inclus.

Même en asynchrone, réduisez au minimum la portée de l’emprunt d’identité.

15. ASP.NET Core et l’emprunt d’identité

Quand on utilise l’authentification Windows dans ASP.NET Core, l’emprunt d’identité demande également de la prudence.

Il est dangereux de supposer que « puisque l’on est connecté via l’authentification Windows, tout le traitement de la requête s’exécute en tant que cet utilisateur ».

En général, le processus de l’application lui-même s’exécute sous l’identité du pool d’applications ou le compte d’exécution du service. L’identité Windows de l’utilisateur est disponible en tant qu’information d’authentification, mais cela ne signifie pas que tout le traitement s’effectue automatiquement avec les privilèges de l’utilisateur.

Si une action précise doit s’exécuter avec les privilèges de l’utilisateur, créez explicitement une portée avec RunImpersonated / RunImpersonatedAsync.

Voici l’idée en code.

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");
});

Dans cet exemple aussi, seule la lecture du fichier emprunte l’identité de l’utilisateur.

Il faut réfléchir avec prudence pour savoir si la génération de la réponse, la journalisation et le contrôle d’autorisation propre à l’application doivent tous entrer dans la portée d’emprunt d’identité.

16. Comment interpréter Access denied

Dans les implémentations utilisant l’emprunt d’identité, l’erreur qui revient le plus souvent est Access denied.

Se contenter, en voyant cette erreur, de penser « l’emprunt d’identité a échoué » fait perdre du temps.

Séparons les angles de vérification.

Angle Ce qu’il faut vérifier
L’identité est-elle réellement empruntée ? Vérifier WindowsIdentity.GetCurrent().Name à l’intérieur de la portée d’emprunt d’identité
Le niveau d’emprunt d’identité est-il suffisant ? Est-on au niveau requis, et non à Identification ?
L’ACL de la ressource cible est-elle correcte ? L’utilisateur emprunté a-t-il les droits de lecture / écriture ?
Local ou distant ? L’accès réussit-il localement mais échoue-t-il seulement en UNC ?
S’agit-il d’un double saut ? Cherche-t-on à aller d’un serveur web à un serveur de fichiers avec les privilèges de l’utilisateur ?
A-t-on quitté la portée d’emprunt d’identité ? Les E/S réelles s’exécutent-elles hors de la portée ou dans une autre tâche ?
Le type de jeton est-il le bon ? Transmet-on un jeton d’emprunt d’identité pour lancer un processus ?
Y a-t-il un effet de l’UAC / du niveau d’intégrité ? Même dans Administrators, le jeton n’est-il pas non élevé ?

Il est surtout important de ne pas se rassurer en vérifiant seulement le nom.

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

Cette ligne de journal est utile, mais insuffisante.

Regardez au minimum aussi ceci.

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

Et si la cible est un partage réseau, vérifiez, au-delà du code applicatif, le mode d’authentification, les paramètres de délégation, les SPN, les comptes de service et l’ACL côté serveur de fichiers.

17. UAC et le problème « je suis administrateur, mais ça échoue quand même »

Sous Windows, appartenir au groupe Administrators et avoir un jeton actuellement élevé ne sont pas la même chose.

Dans un environnement où l’UAC est activé, même un utilisateur administrateur exécute normalement ses processus avec un jeton restreint, et les opérations nécessitant des privilèges administrateur exigent une élévation.

D’où cela :

L'utilisateur appartient à Administrators
  ↓
Mais le jeton actuel n'est pas élevé
  ↓
L'écriture dans Program Files ou HKLM renvoie Access denied

C’est la même chose avec l’emprunt d’identité.

Plutôt que de penser « l’utilisateur dont on emprunte l’identité est administrateur, donc l’écriture devrait fonctionner », il faut vérifier l’état réel du jeton transmis.

Lors du débogage, examinez ces points :

  • Les groupes d’appartenance
  • L’activation / désactivation des privilèges
  • Le niveau d’intégrité
  • L’état d’élévation
  • S’il s’agit d’un jeton restreint
  • L’existence d’un jeton élevé lié

Avec l’API Win32, GetTokenInformation permet de vérifier TokenType, TokenImpersonationLevel, TokenElevationType, TokenIntegrityLevel, etc.

Cependant, comme choix de conception pratique, plutôt que « emprunter l’identité d’un administrateur pour tout faire », il est plus sûr d’enfermer les opérations strictement nécessaires dans un service ou une API d’administration dédiés.

18. Partages réseau et double saut

L’une des demandes les plus fréquentes autour de l’emprunt d’identité concerne l’accès aux partages réseau.

PC client
  ↓ Authentification Windows
Serveur web / serveur API
  ↓ Veut accéder en empruntant l'identité
Serveur de fichiers

Dans cette configuration, il arrive que « le nom d’utilisateur soit disponible sur le serveur web, mais que l’accès échoue en allant vers le serveur de fichiers ».

C’est la question de savoir si les identifiants de l’utilisateur peuvent être redélégués à un autre serveur.

L’emprunt d’identité sur le serveur local et la délégation vers un autre serveur ne sont pas la même chose.

Un jeton de niveau Impersonation peut suffire pour des opérations locales, mais être insuffisant pour se comporter en tant que client vis-à-vis d’un serveur distant.

Si l’on veut utiliser les privilèges propres de l’utilisateur à travers le réseau, il faut concevoir en intégrant la délégation Kerberos, la délégation contrainte, les SPN, les comptes de service et le mode d’authentification.

D’un autre côté, selon les exigences métier, il n’est pas toujours nécessaire d’aller jusqu’au serveur de fichiers avec les privilèges Windows propres de l’utilisateur.

Dans ce cas, la conception suivante est plus simple.

L'authentification et l'autorisation de l'utilisateur sont gérées par l'application
  ↓
L'accès au serveur de fichiers s'effectue avec un compte de service dédié
  ↓
Le journal d'exploitation enregistre l'identifiant utilisateur et le fichier cible

Il ne s’agit pas de « faire de l’ACL de l’OS la décision finale », mais de « faire de l’autorisation applicative la décision finale ».

Ce qui est correct dépend des exigences métier.

Mais si l’on ne précise pas clairement l’approche retenue, l’emprunt d’identité, la délégation, les ACL et l’autorisation applicative finissent par se mélanger et devenir difficiles à suivre.

19. Durée de vie des handles de jeton

Un jeton est un handle vers un objet du noyau ; une fois obtenu, il doit donc être fermé dès qu’il n’est plus nécessaire.

En .NET, la règle de base est d’utiliser SafeAccessTokenHandle et de gérer la portée avec using.

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

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

Voici un mauvais exemple.

// Mauvais exemple : conserver le jeton indéfiniment dans une variable globale
private static SafeAccessTokenHandle? _cachedToken;

Conserver un jeton longtemps entraîne des problèmes de ce type :

  • Fuite de handle
  • On ne sait plus quel traitement utilise quel jeton
  • La cohérence avec la désactivation d’un compte ou un changement de droits devient difficile à suivre
  • La conception tend à conserver les identifiants trop longtemps
  • Difficile à justifier lors d’un audit

Le principe est le suivant :

Obtenir au moment où c'est nécessaire
  ↓
Utiliser sur la portée la plus restreinte possible
  ↓
Toujours fermer

Bien sûr, selon le coût de l’authentification et les exigences d’exploitation, il peut être envisagé de mettre en cache. Mais même dans ce cas, la conception doit intégrer l’expiration, la destruction, les changements de compte, le journal d’audit et le traitement lors d’un changement de droits.

20. Ce qu’il faut consigner dans le journal d’audit

Pour les traitements utilisant l’emprunt d’identité, la conception des journaux compte également.

Pouvoir consigner au minimum les informations de ce tableau facilite grandement les investigations ultérieures.

Élément Exemple
Utilisateur ayant fait la demande DOMAIN\alice
Compte du processus d’exécution DOMAIN\svc-app
Compte dont l’identité a été empruntée DOMAIN\alice ou un compte dédié
Ressource cible Chemin de fichier, nom de partage, clé de Registre, etc.
Opération Read, Write, Delete, CreateProcess, etc.
Résultat Success, AccessDenied, Timeout, UnexpectedError
Code d’erreur Code d’erreur Win32, HRESULT, type d’exception
Portée de l’emprunt d’identité Quelle méthode, quelle unité de traitement a emprunté l’identité

Il y a aussi des éléments qui ne doivent jamais apparaître dans les journaux.

  • Mots de passe
  • Valeurs des jetons d’accès
  • En-têtes d’authentification
  • Tickets Kerberos ou identifiants eux-mêmes
  • Contenu de fichiers contenant des données personnelles

L’objectif du journal est de pouvoir retracer, après coup, « à la demande de qui, sous quel compte, quoi a été tenté, et comment cela a échoué ou réussi ».

Il n’est pas nécessaire d’enregistrer les identifiants eux-mêmes.

21. Anti-patterns fréquents

Voici les implémentations dangereuses les plus souvent rencontrées autour de l’emprunt d’identité.

21.1 Emprunter l’identité pour toute l’application

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

Emprunter l’identité pour toute l’application fait qu’on ne peut plus dire quelle opération s’exécute sous quels privilèges.

Limitez l’emprunt d’identité aux E/S nécessaires et aux appels d’API précis.

21.2 Revenir en arrière sans finally

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

Dangereux car il n’y a pas de retour en cas d’exception.

Utilisez toujours try / finally, ou RunImpersonated.

21.3 Fire-and-forget pendant l’emprunt d’identité

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

On sort de la portée d’emprunt d’identité avant la fin du traitement, qui peut donc s’exécuter avec des privilèges inattendus.

Si le traitement a besoin de l’emprunt d’identité, faites un await à l’intérieur de RunImpersonatedAsync.

21.4 Juger du succès sur le seul nom d’utilisateur

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

Même avec le nom d’utilisateur attendu, le niveau d’emprunt d’identité ou les privilèges peuvent être insuffisants.

Vérifiez aussi ImpersonationLevel, l’ACL cible, le type d’ouverture de session et la délégation réseau.

21.5 Emprunter l’identité d’un compte administrateur comme compte de confort

La conception « ce traitement ne doit pas échouer, alors on emprunte l’identité d’un compte administrateur » est dangereuse.

Il est plus sûr de préparer un compte dédié disposant du minimum de privilèges nécessaires et de restreindre les opérations concernées.

21.6 Placer le mot de passe dans un fichier de configuration

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

Cela doit être évité.

Si l’on doit manipuler des identifiants, utilisez un mécanisme adapté à l’environnement : un coffre à secrets, le Gestionnaire d’identification Windows (Windows Credential Manager), DPAPI, un Key Vault cloud, ou la gestion des secrets de votre plateforme d’exploitation.

21.7 Traiter le lancement de processus et l’accès fichier comme un seul et même sujet

Pour le seul accès fichier, un jeton d’emprunt d’identité peut suffire.

En revanche, lancer un processus en tant qu’un autre utilisateur soulève d’autres questions : jeton principal, profil, bureau, variables d’environnement, session, privilèges.

Si vous utilisez CreateProcessAsUser ou CreateProcessWithTokenW, traitez cela comme une conception distincte de l’emprunt d’identité.

22. Angles de test

Vérifier l’emprunt d’identité uniquement dans son environnement administrateur local fait apparaître des angles morts.

Prévoyez au minimum ces cas de test.

Cas Ce qu’il faut vérifier
Utilisateur avec les droits Peut lire / écrire le fichier cible
Utilisateur sans les droits Échoue correctement avec Access denied
Utilisateur inexistant Traité comme un échec d’authentification
Mot de passe erroné Échoue sans exposer de secret dans les journaux
Partage réseau Vérifier la différence de comportement entre local et UNC
Traitement asynchrone Fonctionne dans la portée attendue même après un await
Exception levée L’emprunt d’identité est toujours annulé
Requêtes concurrentes Les emprunts d’identité de différents utilisateurs ne se mélangent jamais
Exécution en service Fonctionne sous le compte de service réel, pas sous la session interactive du développeur

Deux points en particulier sont incontournables :

Le succès doit réussir
L'échec doit échouer quand il le faut

Pour l’emprunt d’identité, testez non seulement les cas de succès, mais aussi le refus fiable des utilisateurs sans droits.

Si une opération censée être refusée réussit, c’est probablement que la conception de l’emprunt d’identité ou de l’autorisation est erronée.

23. Liste de contrôle d’implémentation

Avant et après l’implémentation, vérifiez cette liste de contrôle.

Angle Point à vérifier
Objectif Peut-on expliquer pourquoi l’emprunt d’identité est nécessaire ?
Alternatives A-t-on envisagé si un compte de service ou une autorisation applicative suffirait ?
Portée La portée d’emprunt d’identité est-elle minimale ?
Retour Revient-on de façon fiable, même en cas d’exception ?
Asynchrone Fait-on un await jusqu’à la fin, à l’intérieur de RunImpersonatedAsync ?
Jetons Le jeton principal et le jeton d’emprunt d’identité ne sont-ils pas confondus ?
Niveau d’emprunt d’identité Distingue-t-on Identification de Impersonation / Delegation ?
Réseau A-t-on vérifié le besoin d’UNC, de double saut, de délégation Kerberos ?
UAC Ne confond-on pas appartenance à Administrators et jeton élevé ?
Secrets Un mot de passe est-il stocké en clair quelque part ?
Handles SafeAccessTokenHandle est-il fermé via using ?
Journalisation Peut-on retracer l’utilisateur, le compte emprunté, la cible et le résultat ?
Tests A-t-on vérifié avec / sans droits, les exceptions et la concurrence ?

Si de nombreux points de cette liste posent problème, il vaut mieux revoir la conception avant d’écrire le code.

24. Savoir où l’utiliser

Les jetons d’emprunt d’identité sont puissants, mais ce n’est pas toujours le premier outil à choisir.

Sur le plan de la conception, raisonner par cas d’usage évite de s’égarer.

24.1 Quand on veut utiliser directement les ACL de l’OS

Si les ACL des serveurs de fichiers et des dossiers partagés constituent le cœur des règles métier, et que l’application doit s’y conformer, emprunter l’identité de l’utilisateur final a du sens.

Accès en tant qu'utilisateur final
  ↓
Les ACL Windows font la décision finale

Dans ce cas, il faut concevoir l’ensemble : authentification Windows, niveaux d’emprunt d’identité, délégation et topologie réseau.

24.2 Quand l’application doit autoriser elle-même

Si les règles métier résident dans l’application, et que le serveur de fichiers ou la base de données sont sous le contrôle de l’application, accéder avec un compte de service et autoriser au niveau applicatif est souvent plus clair.

Authentifier l'utilisateur
  ↓
Autoriser dans l'application
  ↓
Accéder aux ressources avec le compte de service
  ↓
Consigner l'identifiant de l'utilisateur dans le journal d'audit

Dans cette approche, au lieu de l’emprunt d’identité, ce sont la logique d’autorisation applicative et les journaux d’audit qui portent l’essentiel du poids.

24.3 Quand des opérations d’administration sont nécessaires

Plutôt que d’exécuter directement des opérations d’administration avec le jeton de l’utilisateur, il est en général plus sûr de fournir un service ou une API d’administration dédié qui assure l’autorisation, la validation des entrées, l’audit et l’annulation (rollback).

Client
  ↓
Requête vers l'API d'administration
  ↓
L'API d'administration autorise
  ↓
Opération avec les privilèges strictement nécessaires
  ↓
Journal d'audit

« Emprunter simplement l’identité d’un administrateur » paraît facile à court terme.

Mais à long terme, cela complique l’audit, l’investigation d’incidents, les changements de droits et les revues de sécurité.

25. Conclusion

Les jetons d’emprunt d’identité sous Windows sont un mécanisme important pour utiliser correctement la gestion des privilèges de Windows.

Cependant, c’est aussi un domaine où du code utilisé sans en comprendre les rouages a tendance à paraître fonctionner tout en étant en réalité dangereux.

Voici les points essentiels à retenir :

  • Un jeton d’accès représente un contexte de sécurité : utilisateur, groupes, privilèges, etc.
  • Un processus possède un jeton principal
  • Le jeton d’emprunt d’identité s’attache principalement au thread et sert dans les contrôles d’accès
  • L’emprunt d’identité n’est pas une élévation de privilèges
  • Le jeton principal et le jeton d’emprunt d’identité ont des usages différents
  • Le niveau d’emprunt d’identité détermine si l’on peut simplement identifier, réellement accéder, ou déléguer vers des systèmes distants
  • En cas d’emprunt d’identité via l’API Win32, appelez toujours RevertToSelf dans un try / finally
  • En .NET, exprimez de petites portées avec WindowsIdentity.RunImpersonated / RunImpersonatedAsync
  • En cas d’usage de LogonUser, faites attention à la gestion des identifiants et au type d’ouverture de session
  • Pour les partages réseau, la délégation Kerberos et la conception des comptes de service comptent autant que l’emprunt d’identité
  • Gérez les handles de jeton avec SafeAccessTokenHandle et using
  • Testez non seulement les cas de succès, mais aussi les cas qui doivent être refusés

Ce qui compte dans une implémentation d’emprunt d’identité, ce n’est pas le seul fait « d’avoir fonctionné sous un autre utilisateur », mais de pouvoir expliquer tout ceci :

Quel traitement,
à la demande de qui,
sous quel compte,
dans quelle portée seulement,
où a-t-il été rétabli,
et comment le succès et l'échec sont-ils enregistrés.

Une fois tout cela clarifié, l’emprunt d’identité n’a rien d’effrayant.

Il devient un outil pratique pour tirer parti des ACL Windows, des comptes de service, des serveurs de fichiers existants et des actifs de votre domaine interne.

Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Qu'est-ce qu'un jeton d'emprunt d'identité (impersonation) sous Windows, et est-ce une élévation de privilèges ?
Un jeton d'emprunt d'identité permet à un thread de subir des contrôles d'accès dans un contexte de sécurité différent — ce n'est pas une magie qui vous transforme en administrateur. Un processus possède un jeton principal représentant son compte d'exécution, et l'emprunt d'identité attache un jeton différent à un thread précis pour, par exemple, qu'un service accède à des fichiers avec les privilèges propres de l'utilisateur appelant et laisse l'ACL du serveur de fichiers rendre la décision finale. Cela change le contexte de sécurité utilisé pour les contrôles d'accès ; cela n'accorde aucun nouveau privilège.
Pourquoi ai-je Access denied alors que l'emprunt d'identité semble fonctionner ?
Il faut vérifier plus que le nom d'utilisateur. Le niveau d'emprunt d'identité peut être Identification, qui permet au serveur d'identifier le client mais pas d'accéder à des objets avec ses privilèges. L'ACL de la ressource cible peut ne pas accorder de droits à l'utilisateur emprunté, les E/S réelles peuvent s'exécuter hors de la portée de l'emprunt d'identité ou dans une autre tâche, ou vous transmettez peut-être un jeton d'emprunt d'identité là où un jeton principal est requis. L'UAC compte aussi : un utilisateur du groupe Administrators peut malgré tout détenir un jeton non élevé, ce qui fait échouer les écritures vers Program Files ou HKLM.
Comment utiliser l'emprunt d'identité en toute sécurité avec async/await en .NET ?
Faites un await du traitement asynchrone nécessitant l'emprunt d'identité à l'intérieur de WindowsIdentity.RunImpersonatedAsync, et ne sortez de la portée qu'une fois le traitement terminé. Ne lancez jamais de tâches fire-and-forget depuis l'intérieur de la portée d'emprunt d'identité, car ce traitement pourrait alors s'exécuter dans un contexte d'exécution inattendu une fois la portée rétablie. Réduisez aussi la portée au minimum : si seule la lecture du fichier a besoin de l'emprunt d'identité, empruntez l'identité uniquement pour cette lecture et exécutez le traitement en aval hors de la portée.
Pourquoi l'emprunt d'identité échoue-t-il lors de l'accès à un partage réseau depuis un serveur ?
C'est en général le problème du double saut (double hop). Emprunter l'identité sur le serveur local et déléguer les identifiants de l'utilisateur à un autre serveur ne sont pas la même chose : un jeton de niveau Impersonation peut suffire pour des opérations locales tout en étant insuffisant pour agir comme le client vis-à-vis d'un serveur de fichiers distant. Utiliser les privilèges propres de l'utilisateur final à travers le réseau exige une conception intégrant la délégation Kerberos, la délégation contrainte et les SPN, ainsi que des comptes de service. Autre option : autoriser dans l'application et accéder au serveur de fichiers avec un compte de service dédié, en consignant l'utilisateur dans le journal d'audit.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog