Skip to content

Commit c2c75ab

Browse files
committed
LDAP obfuscation with ldapx, --simple-bind support & other improvements.
1 parent 19c0f48 commit c2c75ab

10 files changed

Lines changed: 195 additions & 76 deletions

File tree

cmd/flashingestor/ingestion.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ type IngestionManager struct {
6868
ldapsToLdapFallback bool // whether to fallback from LDAPS to LDAP on connection failure
6969
appendForestDomains bool // whether to append to existing forest domains file
7070
initialDomain string // the initial domain that started the ingestion
71+
ldapxFilter string // LDAP filter obfuscation middleware chain
72+
ldapxAttrs string // LDAP attributes obfuscation middleware chain
73+
ldapxBaseDN string // LDAP baseDN obfuscation middleware chain
7174
}
7275

7376
// JobManager methods
@@ -116,14 +119,8 @@ func (m *IngestionManager) testLDAPConnection(
116119
) (string, error) {
117120
var err error
118121

119-
creds := m.auth.Creds()
120-
if creds.Username == "" && creds.Password == "" {
121-
// Switch to SimpleBind only to allow for anonymous binds
122-
m.ldapAuthOptions.SimpleBind = true
123-
}
124-
125122
// Test the connection
126-
conn, err := ldapauth.ConnectTo(ctx, creds, target, ldapOptions)
123+
conn, err := ldapauth.ConnectTo(ctx, m.auth.Creds(), target, ldapOptions)
127124
if err != nil {
128125
return "", fmt.Errorf("LDAP connection failed: %w", err)
129126
}
@@ -290,6 +287,7 @@ func (m *IngestionManager) start(
290287
continue
291288
}
292289
m.ingestDomain(ctx, req.DomainName, req.BaseDN, req.DomainController)
290+
m.logger.Log0("-")
293291
}
294292

295293
// All domains have been processed, write forest structure and log final summary
@@ -303,6 +301,7 @@ func (m *IngestionManager) start(
303301
} else {
304302
m.logger.Log0("🫠 [red]No domains were ingested.[-]")
305303
}
304+
m.logger.Log0("-")
306305
}()
307306
}
308307

@@ -532,7 +531,6 @@ func (m *IngestionManager) notifyIngestionSkipped(domainName string) {
532531
}
533532

534533
func (m *IngestionManager) ingestDomain(ctx context.Context, domainName, baseDN, domainController string) {
535-
m.logger.Log0("-")
536534
m.uiApp.AddDomainTab(domainName)
537535
m.uiApp.SwitchToDomainTab(domainName)
538536
m.uiApp.InsertIngestHeader(domainName)
@@ -729,12 +727,39 @@ func (m *IngestionManager) ingestDomain(ctx context.Context, domainName, baseDN,
729727

730728
wg.Add(1)
731729
spinner.SetRunning(domainName, i, true)
730+
731+
// Log original query
732732
m.logger.Log2(
733733
"🎡 Collecting \"[blue]%s[-]\" for domain \"[blue]%s[-]\":\n [blue]Filter[-]: %s\n [blue]Attributes[-]: %s\n [blue]BaseDN[-]: %s",
734734
job.Name, domainName, job.Filter,
735735
strings.Join(job.Attributes, ","), job.BaseDN,
736736
)
737737

738+
// Apply LDAP obfuscation if configured
739+
obfuscatedJob := job
740+
hasObfuscation := false
741+
if m.ldapxFilter != "" {
742+
obfuscatedJob.Filter = applyFilterObfuscation(job.Filter, m.ldapxFilter)
743+
hasObfuscation = true
744+
}
745+
if m.ldapxAttrs != "" {
746+
obfuscatedJob.Attributes = applyAttrListObfuscation(job.Attributes, m.ldapxAttrs)
747+
hasObfuscation = true
748+
}
749+
if m.ldapxBaseDN != "" {
750+
obfuscatedJob.BaseDN = applyBaseDNObfuscation(job.BaseDN, m.ldapxBaseDN)
751+
hasObfuscation = true
752+
}
753+
754+
// Log obfuscated query if obfuscation was applied
755+
if hasObfuscation {
756+
m.logger.Log2(
757+
"🎡 Obfuscated \"[purple]%s[-]\" for domain \"[purple]%s[-]\":\n [purple]Filter[-]: %s\n [purple]Attributes[-]: %s\n [purple]BaseDN[-]: %s",
758+
obfuscatedJob.Name, domainName, obfuscatedJob.Filter,
759+
strings.Join(obfuscatedJob.Attributes, ","), obfuscatedJob.BaseDN,
760+
)
761+
}
762+
738763
go func(j gildap.QueryJob, jobIndex int) {
739764
m.runJob(
740765
ctx, m.auth.Creds(), target, ldapOptions,
@@ -745,7 +770,7 @@ func (m *IngestionManager) ingestDomain(ctx context.Context, domainName, baseDN,
745770
"🎡 Finished \"[blue]%s[-]\" for domain \"[blue]%s[-]\"",
746771
j.Name, domainName,
747772
)
748-
}(job, i)
773+
}(obfuscatedJob, i)
749774
}
750775

751776
wg.Wait()

cmd/flashingestor/main.go

Lines changed: 109 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
)
2020

2121
var (
22-
version = "0.3.1"
22+
version = "0.3.2"
2323
)
2424

2525
// Application entry point
@@ -84,20 +84,24 @@ func main() {
8484
}
8585

8686
logger.Log0("🔍 [blue]Custom DNS resolver[-]: \"" + customDNS + "\"")
87+
} else {
88+
logger.Log0("🔍 [blue]Using system DNS resolver: consider specifying a DNS server explicitly.[-]")
8789
}
8890

8991
logger.Log0("⭕ [blue]LDAP scheme[-]: " + cfg.LdapAuthOptions.Scheme)
9092

91-
// Check if we have authentication credentials
92-
disableIngest := false
93-
if cfg.ChosenAuthIngest == "" {
94-
disableIngest = true
95-
logger.Log0("🫠 [red]No authentication credentials detected for ingestion. Ingestion will be disabled for this session.[-]")
96-
}
97-
98-
disableRemote := cfg.ChosenAuthIngest == "" && cfg.ChosenAuthRemote == ""
99-
if disableRemote {
100-
logger.Log0("🫠 [red]No authentication credentials detected for remote collection. Remote collection will be disabled for this session.[-]")
93+
// Log LDAP obfuscation settings
94+
if cfg.LdapxFilter != "" || cfg.LdapxAttrs != "" || cfg.LdapxBaseDN != "" {
95+
logger.Log0("🎭 [blue]LDAP obfuscation (ldapx) enabled:[-]")
96+
if cfg.LdapxFilter != "" {
97+
logger.Log0(" [blue]Filter chain[-]: %s", cfg.LdapxFilter)
98+
}
99+
if cfg.LdapxAttrs != "" {
100+
logger.Log0(" [blue]Attrs chain[-]: %s", cfg.LdapxAttrs)
101+
}
102+
if cfg.LdapxBaseDN != "" {
103+
logger.Log0(" [blue]BaseDN chain[-]: %s", cfg.LdapxBaseDN)
104+
}
101105
}
102106

103107
bhInst := &bloodhound.BH{}
@@ -112,21 +116,98 @@ func main() {
112116
if cfg.ChosenAuthIngest == "" {
113117
logger.Log0("🔗 [blue]Auth method (ingestion)[-]: None")
114118
} else {
119+
// Auto-enable SimpleBind for anonymous authentication
120+
if cfg.ChosenAuthIngest == "Anonymous" {
121+
cfg.LdapAuthOptions.SimpleBind = true
122+
}
123+
115124
authMethodIngestStr := cfg.ChosenAuthIngest
116-
if cfg.IngestAuth.Kerberos() {
117-
authMethodIngestStr += " [blue](over Kerberos)[-]"
125+
126+
// For ingestion, SimpleBind takes precedence over all methods
127+
if cfg.LdapAuthOptions.SimpleBind {
128+
authMethodIngestStr += " [blue](SimpleBind)[-]"
129+
} else if cfg.IngestAuth.Kerberos() {
130+
// Certificate with Kerberos uses PKINIT
131+
// other credential types (password, NTHash, CCacche, AES Key) used with -k
132+
// should just be labeled "Kerberos"
133+
if cfg.ChosenAuthIngest == "CertPFX" || cfg.ChosenAuthIngest == "CertPEM" {
134+
authMethodIngestStr += " [blue](PKINIT/Kerberos)[-]"
135+
} else {
136+
authMethodIngestStr += " [blue](Kerberos)[-]"
137+
}
138+
} else if cfg.ChosenAuthIngest == "CertPFX" || cfg.ChosenAuthIngest == "CertPEM" {
139+
// Certificate without Kerberos can be said to use SChannel
140+
authMethodIngestStr += " [blue](SChannel)[-]"
141+
} else {
142+
// Otherwise, Password/NTHash uses NTLM
143+
authMethodIngestStr += " [blue](NTLM)[-]"
118144
}
119145

120146
logger.Log0("🔗 [blue]Auth method (ingestion)[-]: " + authMethodIngestStr)
121147
}
122148

123-
if cfg.RuntimeOptions.GetRecurseTrusts() {
124-
ingestNoCrossDomain := !slices.Contains([]string{"Password", "NTHash", "Anonymous"}, cfg.ChosenAuthIngest) || cfg.IngestAuth.Kerberos()
125-
if ingestNoCrossDomain {
126-
// Kerberos cross-realm auth should be feasible to implement,
127-
// but I don't know how yet :)
128-
logger.Log0("🫠 [yellow]RecurseTrusts disabled (not supported for this auth method)[-]")
129-
cfg.RuntimeOptions.SetRecurseTrusts(false)
149+
if cfg.ChosenAuthRemote == "Anonymous" {
150+
// Temporarily disabled until I can decide
151+
cfg.ChosenAuthRemote = ""
152+
}
153+
154+
if cfg.ChosenAuthRemote == "" {
155+
logger.Log0("🔗 [blue]Auth method (remote collection)[-]: None")
156+
} else {
157+
authMethodRemoteStr := cfg.ChosenAuthRemote
158+
if cfg.ChosenAuthRemote == "CertPFX" || cfg.ChosenAuthRemote == "CertPEM" {
159+
// Certificates for remote collection always use PKINIT
160+
authMethodRemoteStr += " [blue](PKINIT/Kerberos)[-]"
161+
} else if cfg.RemoteAuth.Kerberos() {
162+
// Kerberos Ticket / AESKey
163+
authMethodRemoteStr += " [blue](Kerberos)[-]"
164+
} else {
165+
// Password / NTHash uses NTLM
166+
authMethodRemoteStr += " [blue](NTLM)[-]"
167+
}
168+
169+
logger.Log0("🔗 [blue]Auth method (remote collection)[-]: " + authMethodRemoteStr)
170+
}
171+
172+
// Check if we have proper authentication credentials
173+
// to determine whether to disable methods
174+
disableIngest := false
175+
if cfg.ChosenAuthIngest == "" {
176+
disableIngest = true
177+
logger.Log0("🫠 [red]No authentication credentials detected for ingestion. Ingestion will be disabled for this session.[-]")
178+
}
179+
180+
disableRemote := cfg.ChosenAuthRemote == ""
181+
if disableRemote {
182+
logger.Log0("🫠 [red]No authentication credentials detected for remote collection. Remote collection will be disabled for this session.[-]")
183+
}
184+
185+
// Check if we should disable recurse_trusts and search_forest
186+
// when using an auth method that doesn't support cross-domain authentication
187+
ingestNoCrossDomain := !slices.Contains([]string{"Password", "NTHash", "Anonymous"}, cfg.ChosenAuthIngest) || cfg.IngestAuth.Kerberos() || (cfg.LdapAuthOptions.SimpleBind && cfg.ChosenAuthIngest != "Anonymous")
188+
189+
if cfg.RuntimeOptions.GetRecurseTrusts() && ingestNoCrossDomain {
190+
logger.Log0("🫠 [yellow]RecurseTrusts disabled (not supported for this auth method)[-]")
191+
cfg.RuntimeOptions.SetRecurseTrusts(false)
192+
}
193+
194+
if cfg.RuntimeOptions.GetSearchForest() && ingestNoCrossDomain {
195+
logger.Log0("🫠 [yellow]SearchForest disabled (not supported for this auth method)[-]")
196+
cfg.RuntimeOptions.SetSearchForest(false)
197+
}
198+
199+
initialDomainRemote := strings.ToUpper(cfg.RemoteAuth.Creds().Domain)
200+
remoteNoCrossDomain := initialDomainRemote != "." && (!slices.Contains([]string{"Password", "NTHash"}, cfg.ChosenAuthRemote) || cfg.RemoteAuth.Kerberos())
201+
if remoteNoCrossDomain {
202+
logger.Log0("🫠 [yellow]Remote collection methods will be limited to domain '" + initialDomainRemote + "' (cross-domain authentication not supported for this auth method)[-]")
203+
}
204+
205+
// Temporary restriction until a better solution is implemented
206+
// TODO: Allow for NTHash too?
207+
if cfg.RuntimeOptions.IsMethodEnabled("certservices") {
208+
if cfg.ChosenAuthRemote != "Password" {
209+
logger.Log0("🫠 [yellow]CertServices disabled (not supported for this auth method)[-]")
210+
cfg.RuntimeOptions.DisableMethod("certservices")
130211
}
131212
}
132213

@@ -146,42 +227,19 @@ func main() {
146227
searchForest: cfg.RuntimeOptions.GetSearchForest(),
147228
ldapsToLdapFallback: cfg.RuntimeOptions.GetLdapsToLdapFallback(),
148229
appendForestDomains: cfg.RuntimeOptions.GetAppendForestDomains(),
230+
ldapxFilter: cfg.LdapxFilter,
231+
ldapxAttrs: cfg.LdapxAttrs,
232+
ldapxBaseDN: cfg.LdapxBaseDN,
149233
}
150234

151235
conversionMgr := newConversionManager(bhInst, uiApp, logger)
152236

153-
if cfg.ChosenAuthRemote == "" {
154-
logger.Log0("🔗 [blue]Auth method (remote collection)[-]: None")
155-
} else {
156-
authMethodRemoteStr := cfg.ChosenAuthRemote
157-
if cfg.RemoteAuth.Kerberos() {
158-
authMethodRemoteStr += " [blue](over Kerberos)[-]"
159-
}
160-
161-
logger.Log0("🔗 [blue]Auth method (remote collection)[-]: " + authMethodRemoteStr)
162-
}
163-
164-
initialDomainRemote := strings.ToUpper(cfg.RemoteAuth.Creds().Domain)
165-
remoteNoCrossDomain := initialDomainRemote != "." && (!slices.Contains([]string{"Password", "NTHash", "Anonymous"}, cfg.ChosenAuthRemote) || cfg.RemoteAuth.Kerberos())
166-
if remoteNoCrossDomain {
167-
logger.Log0("🫠 [yellow]Remote collection methods will be limited to domain '" + initialDomainRemote + "' (cross-domain authentication not supported for this auth method)[-]")
168-
}
169-
170237
remoteMgr := newRemoteCollectionManager(
171238
bhInst,
172239
uiApp,
173240
logger,
174241
)
175242

176-
// Temporary restriction until a better solution is implemented
177-
// TODO: Allow for NTHash too?
178-
if cfg.RuntimeOptions.IsMethodEnabled("certservices") {
179-
if cfg.ChosenAuthRemote != "Password" {
180-
logger.Log0("🫠 [yellow]CertServices disabled (not supported for this auth method)[-]")
181-
cfg.RuntimeOptions.DisableMethod("certservices")
182-
}
183-
}
184-
185243
var initialDomain, initialBaseDN, initialDC string
186244
if !disableIngest {
187245
initialDomain = strings.ToUpper(cfg.IngestAuth.Creds().Domain)
@@ -194,7 +252,11 @@ func main() {
194252
logger.Log0("🔗 [blue]Inferred BaseDN[-]: \"%s\"", initialBaseDN)
195253

196254
initialDC = cfg.DomainController
197-
logger.Log0("🔗 [blue]Initial DC[-]: \"%s\"", initialDC)
255+
if initialDC == "" {
256+
logger.Log0("🔗 [blue]Initial DC[-]: (auto-discovered)")
257+
} else {
258+
logger.Log0("🔗 [blue]Initial DC[-]: \"%s\"", initialDC)
259+
}
198260
}
199261
}
200262
logger.Log0("-")

config/config.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type Config struct {
3434
ChosenAuthIngest string
3535
ChosenAuthRemote string
3636
Resolver *CustomResolver
37+
LdapxFilter string
38+
LdapxAttrs string
39+
LdapxBaseDN string
3740
}
3841

3942
const DEFAULT_REMOTE_METHOD_TIMEOUT = 4 * time.Second
@@ -118,6 +121,11 @@ func ParseFlags() (*Config, error) {
118121
pflag.DurationVar(&config.RemoteComputerTimeout, "computer-timeout", DEFAULT_REMOTE_COMPUTER_TIMEOUT, "Timeout per computer for remote collection")
119122
pflag.DurationVar(&config.RemoteMethodTimeout, "method-timeout", DEFAULT_REMOTE_METHOD_TIMEOUT, "Timeout per method of remote collection")
120123

124+
// LDAP obfuscation flags
125+
pflag.StringVarP(&config.LdapxFilter, "ldapx-filter", "f", "", "LDAP filter obfuscation middleware chain (e.g., 'OGDR', read the docs for details)")
126+
pflag.StringVarP(&config.LdapxAttrs, "ldapx-attrs", "a", "", "LDAP attributes obfuscation middleware chain (e.g., 'Owp', read the docs for details)")
127+
pflag.StringVarP(&config.LdapxBaseDN, "ldapx-basedn", "b", "", "LDAP baseDN obfuscation middleware chain (e.g., 'OX', read the docs for details)")
128+
121129
// Register adauth flags for ingestion
122130
standardAuthOptions := &adauth.Options{}
123131
registerIngestionAuthFlags(standardAuthOptions, pflag.CommandLine)
@@ -187,14 +195,19 @@ func ParseFlags() (*Config, error) {
187195
if err != nil {
188196
return nil, err
189197
}
198+
199+
// Validate SimpleBind is only used with Password or Anonymous
200+
if config.LdapAuthOptions.SimpleBind && chosenAuthIngest != "" && chosenAuthIngest != "Password" && chosenAuthIngest != "Anonymous" {
201+
return nil, fmt.Errorf("--simple-bind can only be used with password or anonymous authentication (got %s)", chosenAuthIngest)
202+
}
203+
190204
if ingestAuth != nil {
191205
ingestAuth.SetDC(config.DomainController)
192206
config.IngestAuth = ingestAuth
193207
}
194208

195-
// Password should be required for remote collection
196-
// that's why "isEmptyPassword" is always false here
197-
chosenAuthRemote, remoteAuth, err := ParseCredential(remoteAuthOptions, false)
209+
isEmptyPasswordV2 := remoteAuthOptions.Password == "" && pflag.CommandLine.Changed("remote-password")
210+
chosenAuthRemote, remoteAuth, err := ParseCredential(remoteAuthOptions, isEmptyPasswordV2)
198211
if err != nil {
199212
return nil, err
200213
}
@@ -247,7 +260,7 @@ func registerLdapFlags(opts *ldapauth.Options, flagset *pflag.FlagSet) {
247260
flagset.DurationVar(&opts.Timeout, "timeout", DEFAULT_LDAP_TIMEOUT, "LDAP connection timeout")
248261
flagset.BoolVar(&opts.Verify, "verify", false, "Verify LDAP TLS certificate")
249262
flagset.BoolVar(&opts.StartTLS, "start-tls", false, "Negotiate StartTLS before authenticating on regular LDAP connection")
250-
//flagset.BoolVar(&opts.SimpleBind, "simple-bind", false, "Use simple bind instead of NTLM/Kerberos/mTLS (password required)")
263+
flagset.BoolVar(&opts.SimpleBind, "simple-bind", false, "Use simple bind instead of NTLM/Kerberos (ingestion only, requires password)")
251264
}
252265

253266
// setupDNSResolver creates and configures a custom DNS resolver with caching.

0 commit comments

Comments
 (0)