Compromission via l’AD CS : 2026, here we go again
Vulnérabilité
Compromission via l’AD CS : 2026, here we go again
OWN-CERT
-
6/8/2026
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 :
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 : cdc, ccm, rmd, 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 :
Rechercher les lignes de RequestAttributes où AttributeName = cdc.
Récupérer le RequestID correspondant.
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
--------------------------------------------------------------------------------
1192.168.100.190685.7%
2 LAB-SRV-AD01.LABVULN.local 114.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
defread_string(record, index):
try:
return record.get_value_data_as_string(index).rstrip("\x00")
except Exception:
value = record.get_value_data(index)
ifisinstance(value, bytes):
return value.hex()
returnstr(value or"")
defread_integer(record, index):
try:
return record.get_value_data_as_integer(index)
except Exception:
value = record.get_value_data(index)
ifisinstance(value, bytes):
returnint.from_bytes(value, "little")
returnint(value)
defget_row(table, row_index):
record = table.get_record(row_index)
row = {}
text_columns = {
"attributename",
"attributevalue",
"requestername",
"certificatetemplate",
}
for index inrange(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] = Nonereturn row
defget_value(row, name):
for key, value in row.items():
if key.lstrip("$").lower() == name.lower():
return value
returnNonedefformat_utc(value):
if value isNone:
return""ifisinstance(value, bytes) andlen(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")
ifisinstance(value, datetime):
if value.tzinfo isNone:
value = value.replace(tzinfo=timezone.utc)
else:
value = value.astimezone(timezone.utc)
return value.isoformat().replace("+00:00", "Z")
returnstr(value)
defget_table(database, name):
for index inrange(database.number_of_tables):
table = database.get_table(index)
if table.name.lower() == name.lower():
return table
raise RuntimeError(f"Table not found: {name}")
defmain(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 inrange(requests.number_of_records):
row = get_row(requests, index)
request_id = get_value(row, "RequestID")
if request_id isnotNone:
requests_by_id[request_id] = row
found = 0for index inrange(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] += 1print("=" * 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.")
returnprint("\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) inenumerate(
cdc_counter.most_common(),
start=1,
):
score = occurrences / total * 100print(
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__":
iflen(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <CA_database.edb>")
sys.exit(1)
main(sys.argv[1])