Dans la première partie de cet article, on a posé les bases : ce qu’est le compte krbtgt, pourquoi le Golden Ticket est une menace sérieuse, et une architecture Ansible théorique pour automatiser les rotations. Si vous n’avez pas lu cette partie, je vous encourage à commencer par là.

Ici on passe aux choses sérieuses. Le playbook de la partie 1 était volontairement simplifié pour illustrer le concept. En environnement de production réel, les choses se compliquent — le script interactif qui refuse d’être piloté, le fichier XML qui n’est pas trouvé, le double-hop Kerberos qui bloque tout, la forêt racine qui n’est pas celle qu’on croit. Autant d’obstacles que j’ai rencontrés et résolus, que je vous documente ici.


L’environnement réel

Mon infrastructure Active Directory se compose de deux domaines distincts dans la même forêt :

  • infradomaine.local — le domaine d’infrastructure, avec deux DCs (pdc-infra et dc-infra-02)
  • userdomaine.local — le domaine utilisateurs, avec deux DCs (pdc-users et dc-users-02)

Le nœud Ansible est une machine Linux (Ubuntu 24.04) qui orchestre l’ensemble.

srv-ansible (Ubuntu 24.04)
        │
        ├── WinRM/Kerberos (HTTPS:5986) ──► pdc-infra.infradomaine.local
        │                                         │
        │                                         └── Réplication AD ──► dc-infra-02.infradomaine.local
        │
        └── WinRM/CredSSP (HTTPS:5986) ──► pdc-users.userdomaine.local
                                                  │
                                                  └── Réplication AD ──► dc-users-02.userdomaine.local

Pourquoi deux transports différents ? On y reviendra — c’est l’un des points les plus intéressants de cette mise en œuvre.


Le bon script : New-KrbtgtKeys.ps1 de Jorge de Almeida Pinto

Dans la partie 1, je mentionnais “un script officiel Microsoft”. En pratique, le script que j’utilise — et que je recommande — est celui de Jorge de Almeida Pinto, MVP Enterprise Mobility & Security, disponible sur son dépôt GitHub :

👉 https://github.com/zjorz/Public-AD-Scripts

Un grand merci à Jorge pour ce travail remarquable. Son script (New-KrbtgtKeys.ps1, actuellement en v3.9) est bien plus complet que la version de base : gestion du mode simulation, support des RODCs, vérification de la réplication sur tous les DCs, mode de routine automatique avec tracking dans des attributs AD, notifications mail… C’est une référence dans la communauté AD.


Premier obstacle : un script interactif difficile à automatiser

Le script de Jorge est conçu pour être utilisé de façon interactive. À chaque exécution, il pose une série de questions :

Do you want to read information about the script? [YES | NO]:
Which mode of operation do you want to execute? [1-9 | 0]:
For the AD forest to be targeted, press [ENTER] for current:
For the AD domain to be targeted, press [ENTER] for current:
Which KrbTgt account do you want to target? [1 | 0]:
Do you really want to continue? [CONTINUE | STOP]:
What do you want to do? [CONTINUE | SKIP | STOP]:

Depuis la v3.9, un paramètre -modeOfOperation permet de bypasser la question du mode. Mais toutes les autres questions restent interactives. Depuis Ansible, il n’y a pas de terminal — on ne peut pas simplement taper des réponses.

La fausse bonne idée : Here-String PowerShell

Mon premier réflexe a été d’utiliser un here-string pour piper les réponses :

# Ça ne fonctionne pas
@"
no


1
CONTINUE
CONTINUE
"@ | & "C:\Scripts\krbtgt\New-KrbtgtKeys.ps1" -modeOfOperation resetModeKrbTgtProdAccountsResetOnce

Le problème : le script de Jorge utilise Read-Host et non [Console]::ReadLine(). Read-Host ne lit pas le stdin standard — il lit directement depuis le handle de la console. Le pipe PowerShell ne fonctionne pas dans ce cas.

La solution : redirection stdin via cmd.exe

La solution propre consiste à passer par cmd.exe avec une redirection stdin depuis un fichier temporaire :

# Créer le fichier de réponses
$answersFile = "C:\Scripts\krbtgt\krbtgt_answers.tmp"
$answersContent = "no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n"
[System.IO.File]::WriteAllText($answersFile, $answersContent, [System.Text.Encoding]::ASCII)

# Lancer le script avec stdin redirigé
$cmdArgs = "/c powershell.exe -ExecutionPolicy Bypass -File `"C:\Scripts\krbtgt\New-KrbtgtKeys.ps1`" " +
           "-modeOfOperation resetModeKrbTgtProdAccountsResetOnce " +
           "-skipDAMembershipCheck -skipElevationCheck < `"$answersFile`""
$output = & cmd.exe $cmdArgs 2>&1

# Nettoyage
Remove-Item $answersFile -Force -ErrorAction SilentlyContinue

La redirection < fichier opérée par cmd.exe fonctionne au niveau du système d’exploitation, contournant le comportement de Read-Host. Le script reçoit bien les réponses dans le bon ordre.

La séquence de réponses pour infradomaine.local (depuis le PDC de ce domaine) :

no          # Ne pas lire la documentation
            # [Entrée] → forêt courante (infradomaine.local)
            # [Entrée] → domaine courant (infradomaine.local)
1           # Scope : tous les RWDCs
CONTINUE    # Confirmer l'exécution du mode 6
CONTINUE    # Confirmer l'impact "MAJOR DOMAIN WIDE IMPACT"

Important : la question du mode (-modeOfOperation) est gérée par le paramètre en ligne de commande et ne nécessite pas de réponse dans le fichier. Ne l’incluez pas dans votre séquence, au risque de décaler toutes les réponses suivantes.


Deuxième obstacle : le fichier XML de configuration

Le script de Jorge supporte un fichier XML de configuration qui active des fonctionnalités avancées : routine de rotation automatique avec tracking dans l’AD, envoi de mail, intervalles configurables.

Dans les logs, tant que le XML n’est pas trouvé, on voit ceci :

Source Of Connection Parameters : Default Values In Script - No XML Config File Found
Use XML Config Settings         : FALSE
Reset Routine Enabled           : FALSE
Send Mail                       : FALSE

Le piège : le fichier XML doit porter exactement le même nom que le script, avec l’extension .xml. Pas config.xml, pas Reset-KrbTgt-Password-For-RWDCs-And-RODCs.xml (c’est le nom du fichier d’exemple sur GitHub) — mais New-KrbtgtKeys.xml, dans le même dossier que le script.

# Dans le script, ligne 6469 :
$script:scriptXMLConfigFilePath = Join-Path $currentScriptFolderPath `
    $currentScriptFileName.Replace(".ps1", ".xml")

Une fois le fichier correctement nommé et placé, les logs changent :

Source Of Connection Parameters : XML Config File 'C:\Scripts\krbtgt\New-KrbtgtKeys.xml'
Use XML Config Settings         : TRUE
Reset Routine Enabled           : TRUE
Send Mail                       : TRUE

Configuration du XML

Voici les paramètres clés que j’utilise. Le smtp est un relay interne sans authentification :

<?xml version="1.0" encoding="utf-8"?>
<resetKrbTgtPassword>
    <xmlReleaseVersion>v0.4</xmlReleaseVersion>
    <xmlReleaseDate>2026-01-15</xmlReleaseDate>

    <!-- Activer les paramètres XML -->
    <useXMLConfigFileSettings>TRUE</useXMLConfigFileSettings>

    <connectionTimeoutInMilliSeconds>500</connectionTimeoutInMilliSeconds>
    <goldenTicketMonitorWaitingIntervalBetweenRunsInSeconds>3600</goldenTicketMonitorWaitingIntervalBetweenRunsInSeconds>
    <goldenTicketMonitoringPeriodInSeconds>172800</goldenTicketMonitoringPeriodInSeconds>

    <!-- Routine de rotation automatique tous les 90 jours -->
    <resetRoutineEnabled>TRUE</resetRoutineEnabled>
    <resetRoutineFirstResetIntervalInDays>90</resetRoutineFirstResetIntervalInDays>
    <resetRoutineSecondResetIntervalInDays>1</resetRoutineSecondResetIntervalInDays>

    <!-- Attributs AD utilisés pour le tracking de la rotation -->
    <resetRoutineAttributeForResetState>extensionAttribute2</resetRoutineAttributeForResetState>
    <resetRoutineAttributeForResetDateAction1>extensionAttribute3</resetRoutineAttributeForResetDateAction1>
    <resetRoutineAttributeForResetDateAction2>extensionAttribute4</resetRoutineAttributeForResetDateAction2>

    <!-- Relay SMTP interne, sans authentification -->
    <sendMailEnabled>TRUE</sendMailEnabled>
    <smtpServer>smtp-relay.infradomaine.local</smtpServer>
    <smtpPort>25</smtpPort>
    <useSSL>FALSE</useSSL>
    <smtpCredsType>NO_AUTHN</smtpCredsType>
    <smtpCredsUserName>NO_AUTHN</smtpCredsUserName>
    <smtpCredsPassword>NO_AUTHN</smtpCredsPassword>

    <mailSubject>[krbtgt] Rotation - infradomaine.local</mailSubject>
    <mailPriority>High</mailPriority>
    <mailBody><!-- ... corps HTML ... --></mailBody>
    <mailFromSender>krbtgt-rotation@infradomaine.local</mailFromSender>
    <mailToRecipients>
        <mailToRecipient>admin@infradomaine.local</mailToRecipient>
    </mailToRecipients>
    <mailCcRecipients>
        <mailCcRecipient />
    </mailCcRecipients>
</resetKrbTgtPassword>

Note sur smtpServer : le script valide cette valeur et rejette les adresses IP. Il faut obligatoirement un nom DNS résolvable.


L’architecture finale : un wrapper PowerShell

Plutôt que de mettre toute la logique dans le playbook Ansible, j’ai opté pour un wrapper PowerShell sur chaque DC. Ansible appelle simplement ce wrapper, qui se charge du reste.

Avantages :

  • Le wrapper peut être testé directement sur le DC sans Ansible
  • La logique de gestion des logs et de l’envoi de mail est encapsulée côté Windows
  • Le playbook Ansible reste minimal et lisible
srv-ansible
    │
    │ win_powershell : lance le wrapper
    ▼
pdc-infra.infradomaine.local
    │
    │ cmd.exe + redirection stdin
    ▼
New-KrbtgtKeys.ps1
    │
    ├── Rotation du mot de passe krbtgt
    ├── Vérification réplication sur tous les DCs
    └── Envoi du log par mail via smtp-relay

Le wrapper PowerShell (version infradomaine.local)

# Invoke-KrbtgtRotation-Infra.ps1
# Wrapper rotation krbtgt infradomaine.local
# À déposer dans C:\Scripts\krbtgt\ sur le PDC

$ScriptPath = "C:\Scripts\krbtgt\New-KrbtgtKeys.ps1"
$LogDir     = "C:\Scripts\krbtgt"
$SmtpServer = "smtp-relay.infradomaine.local"
$SmtpPort   = 25
$MailFrom   = "krbtgt-rotation@infradomaine.local"
$MailTo     = "admin@infradomaine.local"
$Domain     = "infradomaine.local"

if (-not (Test-Path $ScriptPath)) {
    Write-Host "ERREUR : $ScriptPath introuvable." -ForegroundColor Red
    exit 1
}

Write-Host "=== Rotation krbtgt $Domain ===" -ForegroundColor Cyan
Write-Host "Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

# Réponses aux questions interactives :
# 1. "Read info?"          → no
# 2. "Forest?"             → [Entrée] (forêt courante = infradomaine.local)
# 3. "Domain?"             → [Entrée] (domaine courant = infradomaine.local)
# 4. "Scope?"              → 1 (tous les RWDCs)
# 5. "Continue mode 6?"    → CONTINUE
# 6. "Major impact?"       → CONTINUE
$answersFile    = "$LogDir\krbtgt_answers.tmp"
$answersContent = "no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n"
[System.IO.File]::WriteAllText($answersFile, $answersContent, [System.Text.Encoding]::ASCII)

$cmdArgs = "/c powershell.exe -ExecutionPolicy Bypass -File `"$ScriptPath`" " +
           "-modeOfOperation resetModeKrbTgtProdAccountsResetOnce " +
           "-skipDAMembershipCheck -skipElevationCheck < `"$answersFile`""
$output = & cmd.exe $cmdArgs 2>&1

Remove-Item $answersFile -Force -ErrorAction SilentlyContinue
Write-Host $output

# Récupérer le log le plus récent généré par le script
$LogFile = Get-ChildItem "$LogDir\*pdc-infra*New-KrbtgtKeys.log" |
    Sort-Object LastWriteTime -Descending |
    Select-Object -First 1

if (-not $LogFile) {
    Write-Host "Aucun log trouvé - rotation non due (planning 90 jours)." -ForegroundColor Yellow
    exit 0
}

$LogContent = Get-Content $LogFile.FullName -Raw -Encoding UTF8
$Success    = ($LogContent -match "Nr Of KrbTGT Account\(s\) With SUCCESSFUL Reset.*: 1")
$Failed     = ($LogContent -match "Nr Of KrbTGT Account\(s\) With FAILED Reset.*: [^0]")

if ($Failed)      { $Status = "ECHEC" }
elseif ($Success) { $Status = "SUCCES" }
else              { $Status = "INCONNU" }

Write-Host "Statut : $Status"

$Subject = "[$Status] Rotation krbtgt $Domain - $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
$Body    = @"
Rotation krbtgt - $Domain
Date    : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
Statut  : $Status
DC      : pdc-infra.infradomaine.local
Log     : $($LogFile.FullName)

========== LOG ==========
$LogContent
"@

try {
    Send-MailMessage -From $MailFrom -To $MailTo -Subject $Subject `
        -Body $Body -SmtpServer $SmtpServer -Port $SmtpPort -Encoding UTF8
    Write-Host "Mail envoyé à $MailTo" -ForegroundColor Green
} catch {
    Write-Host "ERREUR mail : $_" -ForegroundColor Red
}

exit $(if ($Failed) { 1 } else { 0 })

Troisième obstacle : le double domaine et la forêt racine

C’est le problème le plus intéressant de cette mise en œuvre.

Quand le script tourne sur pdc-users.userdomaine.local, il utilise [Entrée] pour la question de la forêt. On pourrait croire qu’il choisirait userdomaine.local comme forêt courante. Mais non — à cause de la relation d’approbation avec infradomaine.local, Windows présente infradomaine.local comme la forêt racine. Le script voit donc deux domaines dans cette forêt, listés sous les numéros 1 et 2.

Ce comportement varie. Dans certains contextes (session interactive, connexion directe), le script se comporte correctement. Depuis WinRM, la session est ouverte dans un contexte différent — j’ai rencontré des cas où la liste des domaines était vide (0 domaines trouvés), et d’autres où elle était correctement remplie.

La solution stable : laisser [Entrée] pour la forêt (ce qui sélectionne infradomaine.local comme forêt racine, accessible par la relation d’approbation), puis laisser [Entrée] pour le domaine afin de sélectionner le domaine courant de la machine (userdomaine.local).

# Séquence de réponses pour userdomaine.local (depuis pdc-users) :
# 1. "Read info?"          → no
# 2. "Forest?"             → [Entrée] (fondation.local via trust)
# 3. "Domain?"             → [Entrée] (userdomaine.local = domaine courant)
# 4. "Scope?"              → 1
# 5. "Continue mode 6?"    → CONTINUE
# 6. "Major impact?"       → CONTINUE
$answersContent = "no`r`n`r`n`r`n1`r`nCONTINUE`r`nCONTINUE`r`n"

Identique à la séquence pour infradomaine.local ! La différence est dans le contexte d’exécution : sur pdc-infra, le domaine courant est infradomaine.local ; sur pdc-users, c’est userdomaine.local.


Quatrième obstacle : le double-hop Kerberos

Avec le transport Kerberos sur WinRM, les credentials d'`ansible_svc@USERDOMAINE.LOCALsont utilisés pour la connexion initiale àpdc-users. Mais le script de Jorge doit ensuite contacter infradomaine.local` pour énumérer la forêt — une seconde authentification réseau.

Kerberos ne permet pas ce double-hop sans configuration explicite. Le script échoue avec :

The specified AD forest 'infradomaine.local' IS NOT accessible!
Custom credentials are needed...
Script is running in automated mode and because of that it cannot ask for credentials...

La solution : utiliser CredSSP (Credential Security Support Provider) pour la connexion WinRM vers pdc-users. CredSSP délègue les credentials de l’utilisateur Ansible jusqu’au DC, qui peut alors les utiliser pour les connexions réseau sortantes.

Côté DC (à exécuter une seule fois) :

Enable-WSManCredSSP -Role Server -Force

Côté Ansible (srv-ansible) :

pip install pywinrm[credssp] --break-system-packages

Dans l’inventory Ansible pour userdomaine :

vars:
  ansible_user: "ansible_svc@USERDOMAINE.LOCAL"
  ansible_password: "{{ vault_ansible_password_users }}"
  ansible_connection: winrm
  ansible_winrm_transport: credssp
  ansible_winrm_server_cert_validation: ignore
  ansible_port: 5986
  ansible_winrm_scheme: https

Note de sécurité : CredSSP délègue les credentials en clair jusqu’au serveur distant. À utiliser avec un compte dédié à faibles privilèges (uniquement “Admins du domaine” pour la rotation krbtgt), sur des connexions chiffrées (HTTPS/5986), et uniquement sur des DCs de confiance.


Les playbooks finaux

La structure finale est épurée. Pas de rôle complexe — juste deux playbooks qui appellent chacun le wrapper sur le bon DC.

Structure sur le nœud Ansible

/etc/ansible/krbtgt-rotation/
├── inventory/
│   ├── infradomaine.yml
│   └── userdomaine.yml
├── site_infra.yml
├── site_users.yml
└── Invoke-KrbtgtRotation-Users.ps1   # référence

Inventory infradomaine.yml

all:
  children:
    infra_dcs:
      hosts:
        PDC-INFRA:
          ansible_host: pdc-infra.infradomaine.local
        DC-INFRA-02:
          ansible_host: dc-infra-02.infradomaine.local
      vars:
        ansible_user: "ansible_svc@INFRADOMAINE.LOCAL"
        ansible_password: "{{ vault_ansible_password_infra }}"
        ansible_connection: winrm
        ansible_winrm_transport: kerberos
        ansible_winrm_server_cert_validation: ignore
        ansible_port: 5986
        ansible_winrm_scheme: https

Playbook site_infra.yml

---
- name: "Rotation krbtgt - infradomaine.local"
  hosts: PDC-INFRA
  gather_facts: false

  tasks:
    - name: "Lancement du wrapper de rotation krbtgt"
      ansible.windows.win_powershell:
        script: |
          Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
          & "C:\Scripts\krbtgt\Invoke-KrbtgtRotation-Infra.ps1"          
        executable: powershell.exe
      register: rotation_result

    - name: "Résultat"
      ansible.builtin.debug:
        msg: "{{ rotation_result.output }}"

Même structure pour site_users.yml, en ciblant PDC-USERS.

Commandes de lancement

cd /etc/ansible/krbtgt-rotation

# Rotation infradomaine
ansible-playbook site_infra.yml \
  -i inventory/infradomaine.yml \
  -e "@/etc/ansible/vault/secrets.yml"

# Rotation userdomaine
ansible-playbook site_users.yml \
  -i inventory/userdomaine.yml \
  -e "@/etc/ansible/vault/secrets.yml"

Automatisation avec cron

La rotation est déclenchée quotidiennement. C’est le script de Jorge — via le XML resetRoutineEnabled=TRUE et resetRoutineFirstResetIntervalInDays=90 — qui décide si la rotation est réellement due ou non. Si les 90 jours ne sont pas écoulés, le script retourne SKIPPED et aucune modification n’est effectuée.

# crontab -e (utilisateur ansible)

# Rotation krbtgt infradomaine.local - vérification quotidienne à 2h00
0 2 * * * cd /etc/ansible/krbtgt-rotation && \
  ansible-playbook site_infra.yml \
  -i inventory/infradomaine.yml \
  -e "@/etc/ansible/vault/secrets.yml" \
  >> /var/log/ansible-krbtgt-infra.log 2>&1

# Rotation krbtgt userdomaine.local - vérification quotidienne à 2h30
30 2 * * * cd /etc/ansible/krbtgt-rotation && \
  ansible-playbook site_users.yml \
  -i inventory/userdomaine.yml \
  -e "@/etc/ansible/vault/secrets.yml" \
  >> /var/log/ansible-krbtgt-users.log 2>&1

Le décalage de 30 minutes entre les deux évite les collisions éventuelles sur les ressources partagées (GAMMU, logs).


Ce que les logs vous disent

Un log de rotation réussie ressemble à ceci :

Source Of Connection Parameters : XML Config File 'C:\Scripts\krbtgt\New-KrbtgtKeys.xml' (Version: v0.4)
Use XML Config Settings         : TRUE
Reset Routine Enabled           : TRUE
Send Mail                       : TRUE

Nr Of KrbTGT Account(s) Processed In TOTAL  : 1
Nr Of KrbTGT Account(s) Candidate For Reset  : 1
Nr Of KrbTGT Account(s) With SUCCESSFUL Reset: 1
Nr Of KrbTGT Account(s) With FAILED Reset    : 0
Nr Of KrbTGT Account(s) With SKIPPED Reset   : 0
Nr Of KrbTGT Account(s) With ANOMALY DETECTED: 0

Un log de rotation non due (planning 90 jours non écoulé) :

Nr Of KrbTGT Account(s) With SKIPPED Reset   : 1

C’est un comportement normal et souhaité. Le script a vérifié les extensionAttribute2/3/4 sur le compte krbtgt, constaté que la rotation n’est pas encore due, et s’est arrêté proprement.

Une erreur de réplication non bloquante (peut arriver ponctuellement) :

Triggering Replicate Single Object On 'dc-infra-02.infradomaine.local' Failed...
Exception Message : Exception lors de l'appel de « SetInfo » avec « 0 » argument(s)

* The (new) password for Object [CN=krbtgt,...] now does exist in the AD database

La rotation a réussi malgré l’erreur de déclenchement forcé de la réplication. Le script vérifie ensuite que le nouvel attribut est bien présent sur le DC secondaire — et confirme que c’est le cas. La réplication AD normale prend le relai.


Bilan et points d’attention

Après plusieurs semaines de fonctionnement, voici ce que je retiens :

Ce qui fonctionne bien :

  • Le script de Jorge est robuste et bien documenté. Lisez les commentaires dans le source, ils valent le détour
  • La combinaison wrapper PowerShell + Ansible est solide et facile à maintenir
  • Le tracking via extensionAttribute est élégant — le script sait lui-même quand il doit tourner
  • Les notifications mail arrivent correctement, avec le log complet en corps de message

Ce qui demande de l’attention :

  • La séquence de réponses stdin est fragile : si une question change entre deux versions du script, toute la séquence est décalée. À vérifier après chaque mise à jour du script
  • Le fichier XML doit s’appeler exactement comme le script .ps1. C’est une contrainte non documentée de façon évidente
  • CredSSP sur WinRM expose les credentials — réservez ce transport aux comptes dédiés sur des canaux chiffrés
  • Testez toujours manuellement après avoir modifié le wrapper, avant de laisser le cron tourner seul

Ce que je ferai différemment à l’avenir :

  • Intégrer une vérification Ansible qui parse le log retourné et échoue explicitement si SUCCESSFUL Reset n’est pas dans les résultats
  • Ajouter une notification vers un système de ticketing pour tracer chaque rotation dans la CMDB

En résumé

La théorie c’est bien, la pratique c’est mieux. Entre le playbook épuré de la partie 1 et ce que tourne réellement en production, il y a la gestion d’un script interactif récalcitrant, un fichier XML aux contraintes de nommage silencieuses, un double-hop Kerberos à contourner, et une forêt racine qui n’est pas toujours celle qu’on attend.

Rien d’insurmontable — mais autant vous éviter de passer les mêmes heures de débogage.

La rotation krbtgt est maintenant planifiée, automatique, notifiée par mail, et traçable dans les logs Ansible. Le compte le plus important de votre Active Directory est enfin traité avec le sérieux qu’il mérite.


Un grand merci à Jorge de Almeida Pinto (Blog | GitHub) pour son travail sur New-KrbtgtKeys.ps1. C’est exactement le genre de contribution qui rend la communauté AD plus solide.