Vulnérabilité

Compromission via l’AD CS : 2026, here we go again

OWN-CERT
-
6/8/2026
Certighost (CVE-2026-54121) : faille AD CS exploitable par un simple utilisateur, permettant d'usurper un contrôleur de domaine et de compromettre tout l'AD.OWN Security

Chez Own, nous apprécions tout ce qui touche à l'ADCS. Entre les vulnérabilités, les techniques d'exploitation et les méthodes d'investigation, c'est un sujet que nous suivons de près depuis plusieurs années (cf “Etat de l’art de la compromission AD CS”  et “Analyser une compromission AD CS”), avec une veille continue sur les nouvelles attaques et les moyens de les détecter.

Certighost est le nom donné à une vulnérabilité affectant Active Directory Certificate Services (AD CS), référencée sous l'identifiant CVE-2026-54121 dévoilée le 14/07/2026. Cette vulnérabilité résulte d'un défaut de validation lors du processus d'émission de certificats, permettant à un attaquant authentifié disposant d’un simple compte utilisateur dans le domaine d'obtenir un certificat au nom d'un contrôleur de domaine menant à une compromission complète du domaine Active Directory.

La vulnérabilité a été corrigée dans le correctif de Microsoft Windows mensuel de juillet 2026. La mise à jour des machines critiques tel que le serveur de PKI (Public Key Infrastructure) peut mettre du temps à être appliquée d’autant plus durant la période estivale, propice aux gels de mise en production sur certains périmètres.  

Prérequis d’exploitation

L'exploitation nécessite que l'attaquant dispose d'un accès authentifié au domaine Active Directory, un simple compte utilisateur est suffisant.

Celui-ci doit pouvoir créer un nouvel objet « ordinateur » : le quota de création de machines (MachineAccountQuota) ne doit pas avoir été modifié lors de la configuration, par défaut : 10.  

L'attaquant doit également pouvoir communiquer avec l'autorité de certification (AD CS) et disposer d'un modèle de certificat autorisant l'émission de certificats pour les comptes machines, comme le modèle « Machine » présent par défaut dans de nombreuses configurations.

Au niveau flux réseau, l'attaquant doit être en mesure de recevoir les connexions initiées par l'autorité de certification vers le serveur référencé dans le champ cdc (“Client Domain Controller”). En théorie, ce champ contient le nom du serveur AD à qui envoyer la requête. Dans le cadre de l’exploitation, l’attaquant remplace le serveur AD par le nom d’une machine qu’il contrôle.

En pratique, il doit mettre en œuvre des services SMB, LSARPC et LDAP afin d'émuler le comportement d'un contrôleur de domaine légitime et de répondre aux différentes vérifications effectuées par AD CS afin d’aller jusqu’au bout du processus de réponse.

À ce jour, un Proof of Concept (PoC) publique, baptisé CertiGhost, est disponible. Il automatise l'ensemble de la chaîne d'exploitation afin d’obtenir à la fin un certificat valide émis au nom du contrôleur de domaine :

  • Crée un compte machine (ou réutilise un déjà existant)
  • Envoie une demande de certificat en utilisant un template Machine avec un champ “cdc” malveillant
  • Intercepte l’authentification de la CA et la rejoue sur le véritable contrôleur de domaine
  • Répond à la CA avec des données forgées pour usurper l’identité du DC
  • Récupère le certificat émis par la CA (format pfx)
Figure 1. Exemple de l'exécution du PoC CertiGhost (aniqfakhrul/CVE-2026-54121) dans un environnement de LAB

La journalisation, le nerf de la guerre

Pour détecter l’exploitation en temps réel, il est nécessaire d’avoir accès aux journaux générés par le service Certificate Service pour identifier les demandes de certificats qui peuvent contenir des valeurs malveillantes.

Pour cela, il est bon de rappeler les différentes journalisations à activer pour ne pas rater d’évènements suspects. La volumétrie est bien entendu à évaluer avant activation :

Certification Services CA

Il s’agit des journaux opérationnels de l'autorité de certification, utiles pour le diagnostic et l'investigation du traitement des requêtes.

Il est possible de les activer en accédant directement aux propriétés de la CA depuis le gestionnaire de serveur, ou bien via certsrv directement :

Figure 2. Aperçu de l'audit du service Certification Services

Certification Services (Security Audit)

Il s’agit des d’une fonctionnalité pour autoriser la journalisation (comprendre l’écriture) des évènements d’audit de sécurité générés par l’ADCS dans le journal sécurité de Windows,Security.evtx.

L’activité de ces audits en succès et échec permet d’activer l’enregistrement des journaux vu précédemment dans celui dédié à la sécurité de Microsoft Windows.

Il sera alors possible de retrouver les évènements courants tel que :

  • Evènement 4886 : Réception d'une demande de certificat
  • Evènement 4887 : Émission d'un certificat
  • Evènement 4888 : Refus d'une demande de certificat
Figure 3. Aperçu des journaux de sécurité de l'ADCS

Méthodes de détection

La méthode la plus pertinente pour identifier une tentative d'exploitation repose sur l'analyse de la valeur du champ « cdc » présent dans les attributs de la demande de certificat.

  • Dans un fonctionnement normal, ce champ correspond à un contrôleur de domaine utilisé par le client lors de la requête et doit donc référencer un hôte légitime de l'infrastructure Active Directory, par exemple « SRV-AD-01 ».
  • Lors d'une exploitation, un attaquant renseigne au contraire le nom d'un serveur qu'il contrôle afin de rediriger les vérifications effectuées par l'autorité de certification, par exemple « DESKTOP-UKJ57N ».

Cette vérification peut être réalisée en comparant la valeur observée dans les événements d'émission de certificats, évènement 4887, avec l'inventaire des contrôleurs de domaine autorisés.

Côté SOC, il est alors possible de définir une règle SIGMA basé sur les évènements Windows afin d’identifier la présence d’une valeur différente de votre liste de serveurs AD :

yaml
title: Potential Certighost Client Domain Controller Spoofing 

id: 1ff9d058-8aac-4f16-8177-83985fbdae9d 

status: stable 

description: | 

  Detects ADCS certificate issuance (Event ID 4887) where the certificate request 

  contains a CDC attribute that does not match an approved Domain Controller. 

references: 

  - https://gist.github.com/H0j3n/a5ef2609b5f2944ac2390a191a534c26 

  - https://github.com/aniqfakhrul/CVE-2026-54121 

author: OWN-CERT 

date: 2026-07-31 

logsource: 

  product: windows 

  service: security 

 

detection: 

  selection: 

    EventID: 4887 

    event_data.Attributes|contains: 'cdc:' 

 

  filter_legitimate_dc: 

    event_data.Attributes|contains: 

      - 'cdc:LAB-SRV-AD01.LABVULN.local' 

 

  condition: selection and not filter_legitimate_dc 

 

level: high 

tags: 

  - attack.credential-access 

  - attack.t1649 

Une alerte peut alors être générée lors de la détection d’une valeur non autorisée, ci-dessous l’adresse IP d’une machine utilisée par l’attaquant.  

Il est à noter que le requêteur, « GHOSTYNECLZQN$ », correspond à un nom aléatoire généré par le PoC : il n’est pas pertinent de chercher à le détecter car il pourrait être modifié par une autre valeur, par exemple ressemblant à un nom légitime.  

Figure 4. Aperçu d'une alerte EDR générée en LAB à la suite de la détection d'une tentative d'exploitation

Si vous vous retrouverez dans la situation où les journaux de la CA ne sont pas activés, ou bien ont été effacés, il est possible de réaliser une recherche manuelle directement dans la base de données de la CA au format ESE (.edb), localisée dans le dossier « C:\Windows\System32\Certlog ».

Figure 5. Aperçu du dossier CertLog contenant la base de données de la CA

Une recherche en utilisant une corrélation entre les tables « Requests », « RequestAttributes » et « Certificates » permet d’identifier les certificats suspects :

Table 

Contenu 

Requests 

Toutes les demandes de certificats (acceptées, refusées ou en attente) avec le RequestID, le demandeur, le template de certificat demandéla date, etc. 

RequestAttributes 

Les attributs associés à chaque demande : cdcccmrmd, SAN 

Certificates 

Les certificats effectivement émis : numéro de série, template, clé publique, dates de validité 

Ainsi, pour identifier un certificat potentiellement obtenu via CertiGhost, la logique est :

  1. Rechercher les lignes de RequestAttributesAttributeName = cdc.
  1. Récupérer le RequestID correspondant.
  1. Rejoindre avec la table « Requests » pour connaître : le demandeur (Requester), le template utilisé, la date

Une automatisation est possible en Python avec l’aide de quelques librairies dont pyesedb et permet d’identifier rapidement des anomalies sur les valeurs cdc utilisées :

python
python certighost_hunting.py LABVULN-LAB-SRV-PKI01-CA-1.edb 

 

================================================================================ 

Date UTC   : 2026-08-01T19:06:42.319000Z 

Requester  : LABVULN\GHOSTTUQGTJOQ$ 

CDC        : 192.168.100.190 

Template   :  

Request ID : 2 

================================================================================ 

Date UTC   : 2026-08-01T19:08:34.318000Z 

Requester  : LABVULN\GHOSTNIZLRRCI$ 

CDC        : 192.168.100.190 

Template   :  

Request ID : 3 

================================================================================ 

Date UTC   : 2026-08-01T19:09:47.038000Z 

Requester  : LABVULN\LAB-SRV-AD01$ 

CDC        : LAB-SRV-AD01.LABVULN.local 

Template   : 

Request ID : 4 

================================================================================ 

Date UTC   : 2026-08-01T19:10:29.806000Z 

Requester  : LABVULN\GHOSTXTSVKJNN$ 

CDC        : 192.168.100.190 

Template   : 

Request ID : 5 

================================================================================ 

Date UTC   : 2026-08-01T20:01:29.140000Z 

Requester  : LABVULN\GHOSTXPIKCIYR$ 

CDC        : 192.168.100.190 

Template   : 

Request ID : 6 

================================================================================ 

Date UTC   : 2026-08-01T20:06:02.174000Z 

Requester  : LABVULN\GHOSTAJIYXKJJ$ 

CDC        : 192.168.100.190 

Template   : 

Request ID : 7 

================================================================================ 

Date UTC   : 2026-08-02T18:04:45.240000Z 

Requester  : LABVULN\GHOSTYNECLZQN$ 

CDC        : 192.168.100.190 

Template   : 

Request ID : 8 

 

================================================================================ 

TOP CDC VALUES 

================================================================================ 

Rank  CDC value                                     Occurrences     Score 

-------------------------------------------------------------------------------- 

1     192.168.100.190                                         6     85.7% 

2     LAB-SRV-AD01.LABVULN.local                              1     14.3% 

-------------------------------------------------------------------------------- 

Total requests with cdc: 7 

Unique cdc values       : 2 

Bon, il s’agit d’un environnement de LAB avec peu de volumétrie (et beaucoup d’exploitations), donc il est facile de retrouver que l’attaquant utilise la machine possédant l’IP 192.168.100.190 pour mener son exploitation.

Annexe

Script Python utilisé

python
import sys 

from collections import Counter 

from datetime import datetime, timedelta, timezone 

 

import pyesedb 

 

 

def read_string(record, index): 

    try: 

        return record.get_value_data_as_string(index).rstrip("\x00") 

    except Exception: 

        value = record.get_value_data(index) 

        if isinstance(value, bytes): 

            return value.hex() 

        return str(value or "") 

 

 

def read_integer(record, index): 

    try: 

        return record.get_value_data_as_integer(index) 

    except Exception: 

        value = record.get_value_data(index) 

 

        if isinstance(value, bytes): 

            return int.from_bytes(value, "little") 

 

        return int(value) 

 

 

def get_row(table, row_index): 

    record = table.get_record(row_index) 

    row = {} 

 

    text_columns = { 

        "attributename", 

        "attributevalue", 

        "requestername", 

        "certificatetemplate", 

    } 

 

    for index in range(record.number_of_values): 

        name = record.get_column_name(index) 

        normalized = name.lstrip("$").lower() 

 

        if normalized == "requestid": 

            row[name] = read_integer(record, index) 

        elif normalized in text_columns: 

            row[name] = read_string(record, index) 

        else: 

            try: 

                row[name] = record.get_value_data(index) 

            except Exception: 

                row[name] = None 

 

    return row 

 

 

def get_value(row, name): 

    for key, value in row.items(): 

        if key.lstrip("$").lower() == name.lower(): 

            return value 

    return None 

 

 

def format_utc(value): 

    if value is None: 

        return "" 

    if isinstance(value, bytes) and len(value) == 8: 

        filetime = int.from_bytes(value, byteorder="little", signed=False) 

        date = datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta( 

            microseconds=filetime // 10 

        ) 

        return date.isoformat().replace("+00:00", "Z") 

 

    if isinstance(value, datetime): 

        if value.tzinfo is None: 

            value = value.replace(tzinfo=timezone.utc) 

        else: 

            value = value.astimezone(timezone.utc) 

        return value.isoformat().replace("+00:00", "Z") 

 

    return str(value) 

 

 

def get_table(database, name): 

    for index in range(database.number_of_tables): 

        table = database.get_table(index) 

        if table.name.lower() == name.lower(): 

            return table 

         

    raise RuntimeError(f"Table not found: {name}") 

 

 

def main(edb_path): 

    database = pyesedb.file() 

    database.open(edb_path) 

 

    cdc_counter = Counter() 

 

    try: 

        attributes = get_table(database, "RequestAttributes") 

        requests = get_table(database, "Requests") 

 

        requests_by_id = {} 

 

        for index in range(requests.number_of_records): 

            row = get_row(requests, index) 

            request_id = get_value(row, "RequestID") 

 

            if request_id is not None: 

                requests_by_id[request_id] = row 

 

        found = 0 

 

        for index in range(attributes.number_of_records): 

            row = get_row(attributes, index) 

 

            attribute_name = str( 

                get_value(row, "AttributeName") or "" 

            ).strip().rstrip(":").lower() 

 

            if attribute_name != "cdc": 

                continue 

 

            request_id = get_value(row, "RequestID") 

            cdc_value = str( 

                get_value(row, "AttributeValue") or "" 

            ).strip() 

 

            request = requests_by_id.get(request_id, {}) 

 

            requester = get_value(request, "RequesterName") or "" 

            template = get_value(request, "CertificateTemplate") or "" 

 

            date = ( 

                get_value(request, "SubmittedWhen") 

                or get_value(request, "RequestDate") 

                or get_value(request, "RequestTime") 

            ) 

 

            found += 1 

            cdc_counter[cdc_value] += 1 

 

            print("=" * 80) 

            print(f"Date UTC   : {format_utc(date)}") 

            print(f"Requester  : {requester}") 

            print(f"CDC        : {cdc_value}") 

            print(f"Template   : {template}") 

            print(f"Request ID : {request_id}") 

 

        if found == 0: 

            print("No request containing a cdc attribute was found.") 

            return 

 

        print("\n" + "=" * 80) 

        print("TOP CDC VALUES") 

        print("=" * 80) 

        print(f"{'Rank':<6}{'CDC value':<45}{'Occurrences':>12}{'Score':>10}") 

        print("-" * 80) 

 

        total = sum(cdc_counter.values()) 

 

        for rank, (cdc_value, occurrences) in enumerate( 

            cdc_counter.most_common(), 

            start=1, 

        ): 

            score = occurrences / total * 100 

 

            print( 

                f"{rank:<6}" 

                f"{cdc_value:<45}" 

                f"{occurrences:>12}" 

                f"{score:>9.1f}%" 

            ) 

 

        print("-" * 80) 

        print(f"Total requests with cdc: {total}") 

        print(f"Unique cdc values       : {len(cdc_counter)}") 

 

    finally: 

        database.close() 

 

 

if __name__ == "__main__": 

    if len(sys.argv) != 2: 

        print(f"Usage: python {sys.argv[0]} <CA_database.edb>") 

        sys.exit(1) 

 

    main(sys.argv[1]) 

Partager l'article :

Your OWN cyber expert.