Table of Contents

Dieser Artikel ist der Praktische Part zur Theorie – für die Antwort auf die Frage "Wozu das ganze?" also bitte dort vorbei schauen.

Damit ich nicht als jemand dastehe der sagt "man müsste mal" und dann nichts tut, folgt hier ein (für meine Verhältnisse) "schneller" Proof of Concept wie man in Realität Passwort Rotation handhaben könnte. Als kurze Erinnerung die Komponenten die wir für eine Lösung brauchen:

  1. Wir setzen Initialkennwörter, die unsere PAM Lösung kennt
  2. Die PAM Lösung kann von sich aus Kennwörter rotieren wenn notwendig / erwünscht
  3. Benutzer, wo das Kennwort out of Sync mit der PAM Lösung ist, können wieder aufgegleist werden

Ich werde für jeden der einzelnen Punkte ein PoC Snippet in PowerShell bereit stellen – die individuelle Implementierung hängt von den Feinheiten der PAM Lösung und den Anforderungen ihrer Organisation ab.

Die bessere Lösung ist sicherlich, notwendige Rechte auf eine Azure Function auszulagern, die entsprechend nur Passwörter Rotiert – es hat aber nicht jeder Zugang zu Azure Resourcen, die hier beschriebene Lösung ist rein PowerShell.

Für volle Transparenz, der hier beschriebene Ansatz ist besser als Global Admin / Privileged Auth Admin auf einer App Registration, hat aber ein großes Manko:
Die PAM Lösung würde sich in meinem PoC über den weniger sicheren OIDC ROPC Auth Flow als Benutzer Authentifizeren, um das "eigene" Kennwort zu rotieren. Das ist notwendig, um interaktive Prompts zu vermeiden – OIDC will aber eigentlich nicht, dass sich Systeme unmittelbar als Benutzer Authentifizieren
Ergebnis:

  • Das Script Funktioniert nur, wenn Conditional Access kein MFA oder Compliant Device fordert (In der Regel über App + IP Exclusion – natürlich nicht Optimal)
  • Alternativ müsste man das Script anpassen um einen Interaktiven Flow zu nutzen (Connect-MgGraph oder ähnliches) und Beispielsweise mit Hilfe von AutoIT den Login Prozess navigieren

Mit dieser Wand an caveats stellt sich die Frage, warum ich überhaupt diesen Weg wähle: ganz einfach, weil ich die meisten Snippets schon habe, und diese nur zusammen führen muss.
^ Dieser Satz wurde vor meiner ROPC Detour geschrieben 🤡


Setzen des Benutzerkennworts auf einen bekannten Wert

Vorne Weg müssen wir klären, wie die PAM Lösung an das initiale Kennwort kommt. Ich möchte keine Kennwörter übertragen, wo keine Übertragung notwendig ist – in meinem schnellen Beispiel wird ein ausreichend privilegierter Administrator (oder die Identity Governance Lösung) beim Anlegen der Benutzer ein Kennwort setzen, dass auch die PAM Lösung errechnen kann.

Allen Benutzern das selbe Kennwort zu setzen kommt aus hoffentlich bekannten Gründen nicht in Frage. Die mir persönlich offensichtlichsten Lösungen sind, den Benutzernamen mit einem geteilten Saltwert zu Hashen oder mit dem gleichen Key zu verschlüsseln.
So kommen beide Seiten immer mit gegebenem Benutzer zum selben Wert, den wir entsprechend als Kennwort nutzen können. Der Wert ist für jeden Benutzer unterschiedlich und es kann kein System erraten werden, selbst wenn irgenwann ein einzelnes Kennwort abfließt.

Den geteilten Hash würde ich dann nutzen, wenn ich einen symmetrischen Wert austauschen muss bzw. Abrufe. Es gibt in Windows eingebaute Hashmethoden, die für Kennwörter gebaut sind.
Verschlüsseln würde ich, wenn es eine Schnittstelle gibt, mit der ich Cryptographische Funktionen Auslagern kann – Beispielsweise ein HSM mit entsprechender API – so etwas habe ich aber nicht zur Verfügung, entsprechend bleibe ich bei Hashing. Das sind aber keine begründeten Erfahrungswerte, sondern einfach ein Gefühl.


Um also unsere Kennwörter zu generieren nutzen wir folgende Funktion:

Da in unserem Fall der Hash dem Kennwort gleichzusetzen ist, ist die Funktion etwas gehärtet – der Hash und der Salt sind Secure Strings, Klartext wird so schnell wie möglich aus dem Speicher gelöscht – siehe die immens interessante Arbeit von Mark Kraus


function New-Pbkdf2Hash {
  param (
    [Parameter(Mandatory = $true, Position = 0)]
    [ValidateNotNull()]
    [string]$toHash,

    [Parameter(Mandatory = $true, Position = 1)]
    [ValidateNotNull()]
    [Securestring]$salt,

    [Parameter(Mandatory = $false)]
    [ValidateSet("SHA256","SHA3_256","SHA384","SHA3_384","SHA512","SHA3_512")]
    [string]$hashAlg = "SHA512",

    [Parameter(Mandatory = $false)]
    [ValidateSet("lower","upper")]
    [string]$standardCase, # Falls wir aufgrund von Händischen Einträgen / uneinheitlicher Datenquelle Case Sensitivity umgehen wollen

    # Für Nutzung als Kennwort Speicher ist der Empfohlene Wert 210.000 Iterationen, aber dann dauert die Kalkulation schon ein paar Sekunden 
    # Das Kennwort wird in unserem Fall nicht gespeichert, sondern nur der initiale Wert generiert
    [Parameter(Mandatory = $false)]
    [int]$iterations = 1000, 

    [Parameter(Mandatory = $false)]
    [int]$hashLength = 60
  )

  switch ($standardCase) {
    "lower" { $toHash = $toHash.ToLower() }
    "upper" { $toHash = $toHash.ToUpper() }
  }

  try {
    # Generieren des Hashes / Passwort und Konvertieren zu Securestring aus Byte Array
    $saltBytes = [Text.Encoding]::UTF8.GetBytes([System.Net.NetworkCredential]::new("", $salt).Password)
    # Die dedizierte Pbkdf2 Methode ist nur im vollen .NET Verfügbar, nicht .NET Framework
    $hashResult = [System.Security.Cryptography.Rfc2898DeriveBytes]::new($toHash, $saltBytes, $iterations, $hashAlg)
    $hashString =  ConvertTo-Securestring "$( [System.Convert]::ToBase64String($hashResult.GetBytes($hashLength)) )" -AsPlainText -Force
  }
  catch {
    Throw $_.Exception.Message
  }
  finally {
    # Entfernen kritischer Werte aus dem Arbeitsspeicher
    $sensitiveVars = @("saltBytes","hashResult")
    Remove-Variable $sensitiveVars
    [gc]::Collect()
  }

  return $hashString
}


Jetzt wo wir unsere Funktion zum generieren der Passwörter haben, müssen wir uns auf die Parameter der Hashfunktion einigen. Für meinen PoC gehe ich davon aus, dass Periodisch der Hash zwischen PAM und Entra ID / Identity Governance Admins abgestimmt wird.
Ich speichere den Wert auf den entsprechenden Hosts mit
ConvertTo-SecureString "<DiesIstUnserGeteilterWert>" -AsPlainText -Force | ConvertFrom-SecureString | Out-File .\encryptedHash.txt

So ist zumindest minimale Sicherheit gegeben. Achtung – Denke daran, dass der Export mit dem gleichen Konto erfolgen muss, das später bei der Ausführung genutzt wird – sonst schlägt die Entschlüsselung fehl.

Der Saltwert sollte idealerweise an einem gemeinsam zugänglichen sicheren Ort gespeichert werden und regelmäßig rotiert werden – die meisten PAM Lösungen haben auch Secret Management APIs, die sich für diese Methode anbieten.


Der Identity Provider / Entra Admin sollte jetzt als bei der Anlage von Benutzern das Kennwort wie folgt vorgeben:

Connect-MgGraph -Scopes 'User.ReadWrite.All'

# Importieren des Salt
$salt = Get-Content .\encryptedHash.txt | ConvertTo-SecureString

$upn = "max.mustermann@test.de"

# Wir gehen von Handarbeit auf beiden Seiten aus, also einigen wir uns auf Lowercase
$sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower

# Wir müssen sicherstellen, dass der Benutzer das Passwort beim nächsten Login nicht ändern muss, sonst funktioniert der ROPC Flow nicht
# Daher forceChangePasswordNextSignIn = $false
$params = @{
    displayName       = "Max Mustermann"
    passwordProfile   = @{ 
        Password                      = [System.Net.NetworkCredential]::new("", $sharedPassword).Password 
        forceChangePasswordNextSignIn = $false
    }
    accountEnabled    = $true
    mailNickName      = "max.mustermann"
    userPrincipalName = $upn
    #...
}

New-Mguser @params
# Invoke-MgGraphRequest POST "/v1.0/users" -body $params

Remove-Variable "params"
[gc]::collect()

Wenn alles geklappt hat, bekommen wir eine Antwort mit dem neu erstellten Benutzerobjekt, aber das interessiert uns gerade nicht so.

Die PAM Lösung kann jetzt das Passwort Rotieren, insofern ihr der Salt bekannt ist und der Benutzer im Vault angelegt wurde.


Kennwort Rotation

Voraussetzungen

  1. Entra ID App Registration mit Delegate "Directory.AccessAsUser.All" Rechten und Zugehörig:
    1. Tenant ID
    2. Client ID
    3. Thumbprint des Zertifikats auf dem PAM Server oder Secret
    4. Falls Anwendbar: Conditional Access Ausnahme für die Enterprise App (Reminder: wenn man keine Public Clients in der Registration aktiviert, kann nur unser Server mit dem Zertifikat die App nutzen)
  2. Vom PAM Server ausgehende Verbindung zu Microsoft 365 Adressen (mindestens ID 56)
  3. Microsoft.Graph.Authentication PowerShell Modul auf dem PAM Server

Wenn wir uns aber hier als Benutzer Anmelden, und die App erlaubt mit allen Rechten des Benutzers mit der Graph API zu interagieren, warum Benutzen wir dann nicht einfach dieses Konto um Kennwörter zu setzen?
Dann müssten wir vorab prüfen / wissen, welche Rechte all unsere Konten haben, um dann ein einzelnes Konto als Service Account zu nutzen.
Ich bevorzuge es, eine allgemeine Lösung zu implementieren, die auch Funktioniert, wenn man nicht zufällig ein hochprivilegiertes Konto im Vault hat. Außerdem sollten diese Benutzer keine stehenden Rechte haben, sondern über PIM Just in in Time aktivieren – und zwar mit Interaktivem MFA.


Ausführung

Achtung: Wir Nutzen die Funktion von Oben für die Hash bzw. Kennwortgeneration und die Graph ROPC Authentifizierung
Es werden die selben Variablen weiter genutzt.

$sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower
$newPassword = "<Neues_Passwort>" # Wie dieses Kennwort generiert wird hängt von der PAM Lösung ab

# Für den Verbindugnsaufbau benötigen wir Benutzername und Passwort des Users, dem wir das Kennwort rotieren wollen
[PSCredential]$userCred = New-Object System.Management.Automation.PSCredential ($upn, $sharedPassword)
Connect-ROPCGraph -userCredentials $userCred -tenantId $conf.tenantID -clientId $conf.clientID -certificateThumbprint $conf.thumb

# Wir Setzen unser neues Passwort
$params = @{
    currentPassword = $usercred.GetNetworkCredential().Password
    newPassword     = $newPassword 
}
$res = Invoke-MgGraphRequest POST "/v1.0/me/changePassword" -Body $params

# Alternative mit Microsoft.Graph.Users.Actions Modul:
# Update-MgUserPassword -UserId $userCred.Username -BodyParameter $params

Remove-Variable "params", "newPassword"
[gc]::collect()

Nach erfolgreicher Ausführung erhalten wir keine Antwort, aber beim nächsten Login müssen wir das neue Passwort nutzen.


Wiederherstellen des PAM Management (Reconciliation)

Und wieder, wenn man eine dedizierte Azure Function nutzt, würde dies kein Thema sein – aber ich habe schon zu reinem PowerShell committed. Vielleicht irgendwann mal wenn es jemand interessiert 🤷

In dieser Variante muss (die Liste) der problematischen Benutzer einem Entra ID Administrator (User Administrator wenn es sich um reguläre Konten handelt, Privileged Authentication Administrator / Global Admin wenn es sich um privilegierte Konten handelt) geliefert werden, der dann folgendes Ausführt:

$toReconcile = @("UPN1", "UPN2") # Oder aus CSV Laden
Connect-MgGraph -Scopes 'User.ReadWrite.All'

# Wir laden wieder unser "Shared Secret" als Salt Wert
$salt = Get-Content .\encryptedHash.txt | ConvertTo-SecureString

foreach ($upn in $toReconcile) {
    $sharedPassword = New-Pbkdf2Hash -toHash $upn -salt $salt -standardCase lower

    $params = @{
        passwordProfile = @{ 
            Password                      = [System.Net.NetworkCredential]::new("", $sharedPassword).Password 
            forceChangePasswordNextSignIn = $false
        }
    }

    Invoke-MgGraphRequest PATCH "/v1.0/users/$upn" -Body $params
    # Update-MgUser -UserId $upn -PasswordProfile @{forceChangePasswordNextSignIn = $false; password = [System.Net.NetworkCredential]::new("", $sharedPassword).Password}

    Remove-Variable "params", "sharedPassword"
    [gc]::collect()
}

! Es sollte immer sicher gestellt werden, dass möglichst bald nach dem initialen Setzen dass Kennwort durch die PAM Lösung rotiert wird

Fazit

Wenn man wirklich wert auf least privilege legt, bekommt man oft nicht die einfachste Lösung. Und ich sage auch ganz offen, dass es nicht allzu realistisch ist, dass dieser PoC in jeder Umgebung eingesetzt wird.
Ich hoffe mehr, dass ein PAM-Anbieter oder Berater auf diesen Artikel aufmerksam wird und die Möglichkeit erkennt, sich der Verbesserung der Applikation anzunehmen. Oder irgend ein anderer Entra ID Administrator fragt sich, ob man wirklich mit Privileged Authentication Administrator um sich werfen muss, und kann sich fundiert beschweren.

Und vielleicht bekommen wir irgendwann einfach ein eingebautes Modul, oder einen Wizard, oder was auch immer – damit wir nicht mehr unnötig große Sicherheitsrisiken in unsere Umgebungen installieren.

Oder wir bekommen granularere Application Permission Scopes von Microsoft.


Ich werde keine Kommentare moderieren und möchte Ihre E-Mail-Adresse nicht; bitte beteiligen Sie sich an der Diskussion über meinen Zugehörigen LinkedIn Post.


Wenn sie an den Dingen interessiert sind die ich tue folgen sie mir auf LinkedIn.

Zuletzt bearbeitet: 17. Februar 2024