From 0a6a170e7cbc30e06f5414a4a9dd441177f7d6c5 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Fri, 13 Dec 2024 09:15:53 -0600 Subject: [PATCH 1/3] BED-5036 implement post processing for CoerceAndRelayNTLMToSMB --- packages/cue/bh/ad/ad.cue | 20 ++- packages/go/analysis/ad/ntlm.go | 140 ++++++++++++++++++ packages/go/graphschema/ad/ad.go | 14 +- .../bh-shared-ui/src/graphSchema.ts | 8 + 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 packages/go/analysis/ad/ntlm.go diff --git a/packages/cue/bh/ad/ad.cue b/packages/cue/bh/ad/ad.cue index 4367d76551..06eb67b6a9 100644 --- a/packages/cue/bh/ad/ad.cue +++ b/packages/cue/bh/ad/ad.cue @@ -735,6 +735,13 @@ MinPwdLength: types.#StringEnum & { representation: "minpwdlength" } +SmbSigning: types.#StringEnum & { + symbol: "SmbSigning" + schema: "ad" + name: "SMB Signing" + representation: "smbsigning" +} + Properties: [ AdminCount, CASecurityCollected, @@ -836,7 +843,8 @@ Properties: [ MinPwdAge, MaxPwdAge, LockoutDuration, - LockoutObservationWindow + LockoutObservationWindow, + SmbSigning ] // Kinds @@ -1298,6 +1306,11 @@ SyncedToEntraUser: types.#Kind & { schema: "active_directory" } +CoerceAndRelayNTLMToSMB: types.#Kind & { + symbol: "CoerceAndRelayNTLMToSMB" + schema: "active_directory" +} + // Relationship Kinds RelationshipKinds: [ Owns, @@ -1370,6 +1383,7 @@ RelationshipKinds: [ ADCSESC10b, ADCSESC13, SyncedToEntraUser, + CoerceAndRelayNTLMToSMB, ] // ACL Relationships @@ -1452,6 +1466,7 @@ PathfindingRelationships: [ ADCSESC13, DCFor, SyncedToEntraUser, + CoerceAndRelayNTLMToSMB, ] EdgeCompositionRelationships: [ @@ -1465,5 +1480,6 @@ EdgeCompositionRelationships: [ ADCSESC9b, ADCSESC10a, ADCSESC10b, - ADCSESC13 + ADCSESC13, + CoerceAndRelayNTLMToSMB ] diff --git a/packages/go/analysis/ad/ntlm.go b/packages/go/analysis/ad/ntlm.go new file mode 100644 index 0000000000..93820afc3b --- /dev/null +++ b/packages/go/analysis/ad/ntlm.go @@ -0,0 +1,140 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://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. +// +// SPDX-License-Identifier: Apache-2.0 + +package ad + +import ( + "context" + "errors" + + "github.com/specterops/bloodhound/analysis" + "github.com/specterops/bloodhound/analysis/impact" + "github.com/specterops/bloodhound/dawgs/cardinality" + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/specterops/bloodhound/dawgs/ops" + "github.com/specterops/bloodhound/dawgs/query" + "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/graphschema/common" + "github.com/specterops/bloodhound/log" +) + +// PostNTLM is the initial function used to execute our NTLM analysis +func PostNTLM(ctx context.Context, db graph.Database, groupExpansions impact.PathAggregator) (*analysis.AtomicPostProcessingStats, error) { + operation := analysis.NewPostRelationshipOperation(ctx, db, "PostNTLM") + + // TODO: after adding all of our new NTLM edges, benchmark performance between submitting multiple readers per computer or single reader per computer + err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + + authenticatedUsersCache := make(map[string]graph.ID) + + // Fetch all nodes where the node is a Group and is an Authenticated User + if err := tx.Nodes().Filter( + query.And( + query.Kind(query.Node(), ad.Group), + query.StringEndsWith(query.NodeProperty(common.ObjectID.String()), AuthenticatedUsersSuffix)), + ).Fetch(func(cursor graph.Cursor[*graph.Node]) error { + for authenticatedUser := range cursor.Chan() { + if domain, err := authenticatedUser.Properties.Get(ad.Domain.String()).String(); err != nil { + continue + } else { + authenticatedUsersCache[domain] = authenticatedUser.ID + } + } + + return cursor.Error() + }, + ); err != nil { + return err + } else { + // Fetch all nodes where the type is Computer + return tx.Nodes().Filter(query.Kind(query.Node(), ad.Computer)).Fetch(func(cursor graph.Cursor[*graph.Node]) error { + for computer := range cursor.Chan() { + innerComputer := computer + + if domain, err := innerComputer.Properties.Get(ad.Domain.String()).String(); err != nil { + continue + } else { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return PostCoerceAndRelayNtlmToSmb(tx, outC, groupExpansions, innerComputer, domain, authenticatedUsersCache) + }); err != nil { + log.Warnf("Post processing failed for %s: %v", ad.CoerceAndRelayNTLMToSMB, err) + // Additional analysis may occur if one of our analysis errors + continue + } + } + } + + return cursor.Error() + }) + } + }) + if err != nil { + return nil, err + } + + return &operation.Stats, operation.Done() +} + +// PostCoerceAndRelayNtlmToSmb creates edges that allow a computer with unrolled admin access to one or more computers where SMB signing is disabled. +// Comprised solely oof adminTo and memberOf edges +func PostCoerceAndRelayNtlmToSmb(tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, expandedGroups impact.PathAggregator, computer *graph.Node, domain string, authenticaedUserNodes map[string]graph.ID) error { + if authenticatedUserID, ok := authenticaedUserNodes[domain]; !ok { + return nil + } else if smbSigningEnabled, err := computer.Properties.Get(ad.SmbSigning.String()).Bool(); err != nil { + if errors.Is(err, graph.ErrPropertyNotFound) { + return nil + } else { + return err + } + } else if !smbSigningEnabled { + + // Fetch the admins with edges to the provided computer + if firstDegreeAdmins, err := fetchFirstDegreeNodes(tx, computer, ad.AdminTo); err != nil { + return err + } else { + if firstDegreeAdmins.ContainingNodeKinds(ad.Computer).Len() > 0 { + outC <- analysis.CreatePostRelationshipJob{ + FromID: authenticatedUserID, + ToID: computer.ID, + Kind: ad.CoerceAndRelayNTLMToSMB, + } + } else { + allAdmins := cardinality.NewBitmap64() + for group := range firstDegreeAdmins.ContainingNodeKinds(ad.Group) { + allAdmins.And(expandedGroups.Cardinality(group.Uint64())) + } + + // Fetch nodes where the node id is in our allAdmins bitmap and are of type Computer + if computerIds, err := ops.FetchNodeIDs(tx.Nodes().Filter( + query.And( + query.InIDs(query.Node(), graph.DuplexToGraphIDs(allAdmins)...), + query.Kind(query.Node(), ad.Computer), + ), + )); err != nil { + return err + } else if len(computerIds) > 0 { + outC <- analysis.CreatePostRelationshipJob{ + FromID: authenticatedUserID, + ToID: computer.ID, + Kind: ad.CoerceAndRelayNTLMToSMB, + } + } + } + } + } + + return nil +} diff --git a/packages/go/graphschema/ad/ad.go b/packages/go/graphschema/ad/ad.go index 5dad6bea8d..28aec9c9aa 100644 --- a/packages/go/graphschema/ad/ad.go +++ b/packages/go/graphschema/ad/ad.go @@ -112,6 +112,7 @@ var ( ADCSESC10b = graph.StringKind("ADCSESC10b") ADCSESC13 = graph.StringKind("ADCSESC13") SyncedToEntraUser = graph.StringKind("SyncedToEntraUser") + CoerceAndRelayNTLMToSMB = graph.StringKind("CoerceAndRelayNTLMToSMB") ) type Property string @@ -218,10 +219,11 @@ const ( MaxPwdAge Property = "maxpwdage" LockoutDuration Property = "lockoutduration" LockoutObservationWindow Property = "lockoutobservationwindow" + SmbSigning Property = "smbsigning" ) func AllProperties() []Property { - return []Property{AdminCount, CASecurityCollected, CAName, CertChain, CertName, CertThumbprint, CertThumbprints, HasEnrollmentAgentRestrictions, EnrollmentAgentRestrictionsCollected, IsUserSpecifiesSanEnabled, IsUserSpecifiesSanEnabledCollected, RoleSeparationEnabled, RoleSeparationEnabledCollected, HasBasicConstraints, BasicConstraintPathLength, UnresolvedPublishedTemplates, DNSHostname, CrossCertificatePair, DistinguishedName, DomainFQDN, DomainSID, Sensitive, HighValue, BlocksInheritance, IsACL, IsACLProtected, IsDeleted, Enforced, Department, HasCrossCertificatePair, HasSPN, UnconstrainedDelegation, LastLogon, LastLogonTimestamp, IsPrimaryGroup, HasLAPS, DontRequirePreAuth, LogonType, HasURA, PasswordNeverExpires, PasswordNotRequired, FunctionalLevel, TrustType, SidFiltering, TrustedToAuth, SamAccountName, CertificateMappingMethodsRaw, CertificateMappingMethods, StrongCertificateBindingEnforcementRaw, StrongCertificateBindingEnforcement, EKUs, SubjectAltRequireUPN, SubjectAltRequireDNS, SubjectAltRequireDomainDNS, SubjectAltRequireEmail, SubjectAltRequireSPN, SubjectRequireEmail, AuthorizedSignatures, ApplicationPolicies, IssuancePolicies, SchemaVersion, RequiresManagerApproval, AuthenticationEnabled, SchannelAuthenticationEnabled, EnrolleeSuppliesSubject, CertificateApplicationPolicy, CertificateNameFlag, EffectiveEKUs, EnrollmentFlag, Flags, NoSecurityExtension, RenewalPeriod, ValidityPeriod, OID, HomeDirectory, CertificatePolicy, CertTemplateOID, GroupLinkID, ObjectGUID, ExpirePasswordsOnSmartCardOnlyAccounts, MachineAccountQuota, SupportedKerberosEncryptionTypes, TGTDelegationEnabled, PasswordStoredUsingReversibleEncryption, SmartcardRequired, UseDESKeyOnly, LogonScriptEnabled, LockedOut, UserCannotChangePassword, PasswordExpired, DSHeuristics, UserAccountControl, TrustAttributes, MinPwdLength, PwdProperties, PwdHistoryLength, LockoutThreshold, MinPwdAge, MaxPwdAge, LockoutDuration, LockoutObservationWindow} + return []Property{AdminCount, CASecurityCollected, CAName, CertChain, CertName, CertThumbprint, CertThumbprints, HasEnrollmentAgentRestrictions, EnrollmentAgentRestrictionsCollected, IsUserSpecifiesSanEnabled, IsUserSpecifiesSanEnabledCollected, RoleSeparationEnabled, RoleSeparationEnabledCollected, HasBasicConstraints, BasicConstraintPathLength, UnresolvedPublishedTemplates, DNSHostname, CrossCertificatePair, DistinguishedName, DomainFQDN, DomainSID, Sensitive, HighValue, BlocksInheritance, IsACL, IsACLProtected, IsDeleted, Enforced, Department, HasCrossCertificatePair, HasSPN, UnconstrainedDelegation, LastLogon, LastLogonTimestamp, IsPrimaryGroup, HasLAPS, DontRequirePreAuth, LogonType, HasURA, PasswordNeverExpires, PasswordNotRequired, FunctionalLevel, TrustType, SidFiltering, TrustedToAuth, SamAccountName, CertificateMappingMethodsRaw, CertificateMappingMethods, StrongCertificateBindingEnforcementRaw, StrongCertificateBindingEnforcement, EKUs, SubjectAltRequireUPN, SubjectAltRequireDNS, SubjectAltRequireDomainDNS, SubjectAltRequireEmail, SubjectAltRequireSPN, SubjectRequireEmail, AuthorizedSignatures, ApplicationPolicies, IssuancePolicies, SchemaVersion, RequiresManagerApproval, AuthenticationEnabled, SchannelAuthenticationEnabled, EnrolleeSuppliesSubject, CertificateApplicationPolicy, CertificateNameFlag, EffectiveEKUs, EnrollmentFlag, Flags, NoSecurityExtension, RenewalPeriod, ValidityPeriod, OID, HomeDirectory, CertificatePolicy, CertTemplateOID, GroupLinkID, ObjectGUID, ExpirePasswordsOnSmartCardOnlyAccounts, MachineAccountQuota, SupportedKerberosEncryptionTypes, TGTDelegationEnabled, PasswordStoredUsingReversibleEncryption, SmartcardRequired, UseDESKeyOnly, LogonScriptEnabled, LockedOut, UserCannotChangePassword, PasswordExpired, DSHeuristics, UserAccountControl, TrustAttributes, MinPwdLength, PwdProperties, PwdHistoryLength, LockoutThreshold, MinPwdAge, MaxPwdAge, LockoutDuration, LockoutObservationWindow, SmbSigning} } func ParseProperty(source string) (Property, error) { switch source { @@ -427,6 +429,8 @@ func ParseProperty(source string) (Property, error) { return LockoutDuration, nil case "lockoutobservationwindow": return LockoutObservationWindow, nil + case "smbsigning": + return SmbSigning, nil default: return "", errors.New("Invalid enumeration value: " + source) } @@ -635,6 +639,8 @@ func (s Property) String() string { return string(LockoutDuration) case LockoutObservationWindow: return string(LockoutObservationWindow) + case SmbSigning: + return string(SmbSigning) default: return "Invalid enumeration case: " + string(s) } @@ -843,6 +849,8 @@ func (s Property) Name() string { return "Lockout Duration" case LockoutObservationWindow: return "Lockout Observation Window" + case SmbSigning: + return "SMB Signing" default: return "Invalid enumeration case: " + string(s) } @@ -859,13 +867,13 @@ func Nodes() []graph.Kind { return []graph.Kind{Entity, User, Computer, Group, GPO, OU, Container, Domain, LocalGroup, LocalUser, AIACA, RootCA, EnterpriseCA, NTAuthStore, CertTemplate, IssuancePolicy} } func Relationships() []graph.Kind { - return []graph.Kind{Owns, GenericAll, GenericWrite, WriteOwner, WriteDACL, MemberOf, ForceChangePassword, AllExtendedRights, AddMember, HasSession, Contains, GPLink, AllowedToDelegate, CoerceToTGT, GetChanges, GetChangesAll, GetChangesInFilteredSet, TrustedBy, AllowedToAct, AdminTo, CanPSRemote, CanRDP, ExecuteDCOM, HasSIDHistory, AddSelf, DCSync, ReadLAPSPassword, ReadGMSAPassword, DumpSMSAPassword, SQLAdmin, AddAllowedToAct, WriteSPN, AddKeyCredentialLink, LocalToComputer, MemberOfLocalGroup, RemoteInteractiveLogonRight, SyncLAPSPassword, WriteAccountRestrictions, WriteGPLink, RootCAFor, DCFor, PublishedTo, ManageCertificates, ManageCA, DelegatedEnrollmentAgent, Enroll, HostsCAService, WritePKIEnrollmentFlag, WritePKINameFlag, NTAuthStoreFor, TrustedForNTAuth, EnterpriseCAFor, IssuedSignedBy, GoldenCert, EnrollOnBehalfOf, OIDGroupLink, ExtendedByPolicy, ADCSESC1, ADCSESC3, ADCSESC4, ADCSESC5, ADCSESC6a, ADCSESC6b, ADCSESC7, ADCSESC9a, ADCSESC9b, ADCSESC10a, ADCSESC10b, ADCSESC13, SyncedToEntraUser} + return []graph.Kind{Owns, GenericAll, GenericWrite, WriteOwner, WriteDACL, MemberOf, ForceChangePassword, AllExtendedRights, AddMember, HasSession, Contains, GPLink, AllowedToDelegate, CoerceToTGT, GetChanges, GetChangesAll, GetChangesInFilteredSet, TrustedBy, AllowedToAct, AdminTo, CanPSRemote, CanRDP, ExecuteDCOM, HasSIDHistory, AddSelf, DCSync, ReadLAPSPassword, ReadGMSAPassword, DumpSMSAPassword, SQLAdmin, AddAllowedToAct, WriteSPN, AddKeyCredentialLink, LocalToComputer, MemberOfLocalGroup, RemoteInteractiveLogonRight, SyncLAPSPassword, WriteAccountRestrictions, WriteGPLink, RootCAFor, DCFor, PublishedTo, ManageCertificates, ManageCA, DelegatedEnrollmentAgent, Enroll, HostsCAService, WritePKIEnrollmentFlag, WritePKINameFlag, NTAuthStoreFor, TrustedForNTAuth, EnterpriseCAFor, IssuedSignedBy, GoldenCert, EnrollOnBehalfOf, OIDGroupLink, ExtendedByPolicy, ADCSESC1, ADCSESC3, ADCSESC4, ADCSESC5, ADCSESC6a, ADCSESC6b, ADCSESC7, ADCSESC9a, ADCSESC9b, ADCSESC10a, ADCSESC10b, ADCSESC13, SyncedToEntraUser, CoerceAndRelayNTLMToSMB} } func ACLRelationships() []graph.Kind { return []graph.Kind{AllExtendedRights, ForceChangePassword, AddMember, AddAllowedToAct, GenericAll, WriteDACL, WriteOwner, GenericWrite, ReadLAPSPassword, ReadGMSAPassword, Owns, AddSelf, WriteSPN, AddKeyCredentialLink, GetChanges, GetChangesAll, GetChangesInFilteredSet, WriteAccountRestrictions, WriteGPLink, SyncLAPSPassword, DCSync, ManageCertificates, ManageCA, Enroll, WritePKIEnrollmentFlag, WritePKINameFlag} } func PathfindingRelationships() []graph.Kind { - return []graph.Kind{Owns, GenericAll, GenericWrite, WriteOwner, WriteDACL, MemberOf, ForceChangePassword, AllExtendedRights, AddMember, HasSession, Contains, GPLink, AllowedToDelegate, CoerceToTGT, TrustedBy, AllowedToAct, AdminTo, CanPSRemote, CanRDP, ExecuteDCOM, HasSIDHistory, AddSelf, DCSync, ReadLAPSPassword, ReadGMSAPassword, DumpSMSAPassword, SQLAdmin, AddAllowedToAct, WriteSPN, AddKeyCredentialLink, SyncLAPSPassword, WriteAccountRestrictions, WriteGPLink, GoldenCert, ADCSESC1, ADCSESC3, ADCSESC4, ADCSESC5, ADCSESC6a, ADCSESC6b, ADCSESC7, ADCSESC9a, ADCSESC9b, ADCSESC10a, ADCSESC10b, ADCSESC13, DCFor, SyncedToEntraUser} + return []graph.Kind{Owns, GenericAll, GenericWrite, WriteOwner, WriteDACL, MemberOf, ForceChangePassword, AllExtendedRights, AddMember, HasSession, Contains, GPLink, AllowedToDelegate, CoerceToTGT, TrustedBy, AllowedToAct, AdminTo, CanPSRemote, CanRDP, ExecuteDCOM, HasSIDHistory, AddSelf, DCSync, ReadLAPSPassword, ReadGMSAPassword, DumpSMSAPassword, SQLAdmin, AddAllowedToAct, WriteSPN, AddKeyCredentialLink, SyncLAPSPassword, WriteAccountRestrictions, WriteGPLink, GoldenCert, ADCSESC1, ADCSESC3, ADCSESC4, ADCSESC5, ADCSESC6a, ADCSESC6b, ADCSESC7, ADCSESC9a, ADCSESC9b, ADCSESC10a, ADCSESC10b, ADCSESC13, DCFor, SyncedToEntraUser, CoerceAndRelayNTLMToSMB} } func IsACLKind(s graph.Kind) bool { for _, acl := range ACLRelationships() { diff --git a/packages/javascript/bh-shared-ui/src/graphSchema.ts b/packages/javascript/bh-shared-ui/src/graphSchema.ts index 9789f621a6..2df1a0ec18 100644 --- a/packages/javascript/bh-shared-ui/src/graphSchema.ts +++ b/packages/javascript/bh-shared-ui/src/graphSchema.ts @@ -141,6 +141,7 @@ export enum ActiveDirectoryRelationshipKind { ADCSESC10b = 'ADCSESC10b', ADCSESC13 = 'ADCSESC13', SyncedToEntraUser = 'SyncedToEntraUser', + CoerceAndRelayNTLMToSMB = 'CoerceAndRelayNTLMToSMB', } export function ActiveDirectoryRelationshipKindToDisplay(value: ActiveDirectoryRelationshipKind): string | undefined { switch (value) { @@ -284,6 +285,8 @@ export function ActiveDirectoryRelationshipKindToDisplay(value: ActiveDirectoryR return 'ADCSESC13'; case ActiveDirectoryRelationshipKind.SyncedToEntraUser: return 'SyncedToEntraUser'; + case ActiveDirectoryRelationshipKind.CoerceAndRelayNTLMToSMB: + return 'CoerceAndRelayNTLMToSMB'; default: return undefined; } @@ -301,6 +304,7 @@ export const EdgeCompositionRelationships = [ 'ADCSESC10a', 'ADCSESC10b', 'ADCSESC13', + 'CoerceAndRelayNTLMToSMB', ]; export enum ActiveDirectoryKindProperties { AdminCount = 'admincount', @@ -404,6 +408,7 @@ export enum ActiveDirectoryKindProperties { MaxPwdAge = 'maxpwdage', LockoutDuration = 'lockoutduration', LockoutObservationWindow = 'lockoutobservationwindow', + SmbSigning = 'smbsigning', } export function ActiveDirectoryKindPropertiesToDisplay(value: ActiveDirectoryKindProperties): string | undefined { switch (value) { @@ -609,6 +614,8 @@ export function ActiveDirectoryKindPropertiesToDisplay(value: ActiveDirectoryKin return 'Lockout Duration'; case ActiveDirectoryKindProperties.LockoutObservationWindow: return 'Lockout Observation Window'; + case ActiveDirectoryKindProperties.SmbSigning: + return 'SMB Signing'; default: return undefined; } @@ -663,6 +670,7 @@ export function ActiveDirectoryPathfindingEdges(): ActiveDirectoryRelationshipKi ActiveDirectoryRelationshipKind.ADCSESC13, ActiveDirectoryRelationshipKind.DCFor, ActiveDirectoryRelationshipKind.SyncedToEntraUser, + ActiveDirectoryRelationshipKind.CoerceAndRelayNTLMToSMB, ]; } export enum AzureNodeKind { From e33b88dd7df635c1aa384d6ae1813ff7dbc9a799 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Fri, 13 Dec 2024 11:46:45 -0600 Subject: [PATCH 2/3] BED-5036 initial integration test pass --- .../src/analysis/ad/ntlm_integration_test.go | 105 +++++++++++++ cmd/api/src/test/integration/harnesses.go | 22 +++ .../harnesses/CoerceAndRelayNTLMToSMB.json | 148 ++++++++++++++++++ .../harnesses/CoerceAndRelayNTLMToSMB.svg | 18 +++ packages/go/analysis/ad/ntlm.go | 45 +++--- 5 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 cmd/api/src/analysis/ad/ntlm_integration_test.go create mode 100644 cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json create mode 100644 cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.svg diff --git a/cmd/api/src/analysis/ad/ntlm_integration_test.go b/cmd/api/src/analysis/ad/ntlm_integration_test.go new file mode 100644 index 0000000000..02b517bedc --- /dev/null +++ b/cmd/api/src/analysis/ad/ntlm_integration_test.go @@ -0,0 +1,105 @@ +// Copyright 2024 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://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. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package ad_test + +import ( + "context" + "testing" + + "github.com/specterops/bloodhound/analysis" + ad2 "github.com/specterops/bloodhound/analysis/ad" + "github.com/specterops/bloodhound/analysis/impact" + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/specterops/bloodhound/dawgs/ops" + "github.com/specterops/bloodhound/dawgs/query" + "github.com/specterops/bloodhound/graphschema" + "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/src/test/integration" + "github.com/stretchr/testify/require" +) + +func TestPostNtlm(t *testing.T) { + testContex := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) + + testContex.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { + harness.NtlmCoerceAndRelayNtlmToSmb.Setup(testContex) + return nil + }, func(harness integration.HarnessDetails, db graph.Database) { + operation := analysis.NewPostRelationshipOperation(context.Background(), db, "NTLM Post Process Test - CoerceAndRelayNtlmToSmb") + + groupExpansions, computers, domains, authenticatedUsers, err := fetchNtlmPrereqs(db) + require.NoError(t, err) + + for _, domain := range domains { + innerDomain := domain + + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + for _, computer := range computers { + innerComputer := computer + + if err = ad2.PostCoerceAndRelayNtlmToSmb(tx, outC, groupExpansions, innerComputer, innerDomain.ID.String(), authenticatedUsers); err != nil { + t.Logf("failed post processig for %s: %v", ad.CoerceAndRelayNTLMToSMB.String(), err) + } + } + return nil + }) + require.NoError(t, err) + } + + db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if results, err := ops.FetchStartNodes(tx.Relationships().Filterf(func() graph.Criteria { + return query.Kind(query.Relationship(), ad.CoerceAndRelayNTLMToSMB) + })); err != nil { + t.Fatalf("error fetching ntlm to smb edges in integration test; %v", err) + } else { + require.Equal(t, 1, len(results)) + + require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.DomainAdminsUser)) + require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.AuthenticatedUsers)) + require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.ServerAdmins)) + + } + return nil + }) + + err = operation.Done() + require.NoError(t, err) + }) +} + +func fetchNtlmPrereqs(db graph.Database) (expansions impact.PathAggregator, computers []*graph.Node, domains []*graph.Node, authenticatedUsers map[string]graph.ID, err error) { + cache := make(map[string]graph.ID) + if expansions, err = ad2.ExpandAllRDPLocalGroups(context.Background(), db); err != nil { + return nil, nil, nil, cache, err + } else if computers, err = ad2.FetchNodesByKind(context.Background(), db, ad.Computer); err != nil { + return nil, nil, nil, cache, err + } else if err = db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if cache, err = ad2.FetchAuthUsersMappedToDomains(tx); err != nil { + return err + } + return nil + }); err != nil { + return nil, nil, nil, cache, err + } else if domains, err = ad2.FetchNodesByKind(context.Background(), db, ad.Domain); err != nil { + return nil, nil, nil, cache, err + } else { + return expansions, computers, domains, cache, nil + } +} diff --git a/cmd/api/src/test/integration/harnesses.go b/cmd/api/src/test/integration/harnesses.go index 637aebc7c0..21dbcb8ee3 100644 --- a/cmd/api/src/test/integration/harnesses.go +++ b/cmd/api/src/test/integration/harnesses.go @@ -8402,6 +8402,27 @@ func (s *ESC10bHarnessDC2) Setup(graphTestContext *GraphTestContext) { graphTestContext.UpdateNode(s.DC1) } +type NtlmCoerceAndRelayNtlmToSmb struct { + AuthenticatedUsers *graph.Node + DomainAdminsUser *graph.Node + ServerAdmins *graph.Node + computer3 *graph.Node + computer8 *graph.Node +} + +func (s *NtlmCoerceAndRelayNtlmToSmb) Setup(graphTestContext *GraphTestContext) { + domainSid := RandomDomainSID() + s.AuthenticatedUsers = graphTestContext.NewActiveDirectoryUser("Authenticated Users", domainSid) + s.DomainAdminsUser = graphTestContext.NewActiveDirectoryUser("Domain Admins User", domainSid) + s.ServerAdmins = graphTestContext.NewActiveDirectoryDomain("Server Admins", domainSid, false, true) + s.computer3 = graphTestContext.NewActiveDirectoryComputer("computer3", domainSid) + s.computer8 = graphTestContext.NewActiveDirectoryComputer("computer8", domainSid) + graphTestContext.NewRelationship(s.computer3, s.ServerAdmins, ad.MemberOf) + graphTestContext.NewRelationship(s.ServerAdmins, s.computer8, ad.AdminTo) + graphTestContext.NewRelationship(s.AuthenticatedUsers, s.computer8, ad.CoerceAndRelayNTLMToSMB) + graphTestContext.NewRelationship(s.computer8, s.DomainAdminsUser, ad.HasSession) +} + type HarnessDetails struct { RDP RDPHarness RDPB RDPHarness2 @@ -8500,4 +8521,5 @@ type HarnessDetails struct { DCSyncHarness DCSyncHarness SyncLAPSPasswordHarness SyncLAPSPasswordHarness HybridAttackPaths HybridAttackPaths + NtlmCoerceAndRelayNtlmToSmb NtlmCoerceAndRelayNtlmToSmb } diff --git a/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json new file mode 100644 index 0000000000..02f12df119 --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json @@ -0,0 +1,148 @@ +{ + "style": { + "font-family": "sans-serif", + "background-color": "#ffffff", + "background-image": "", + "background-size": "100%", + "node-color": "#ffffff", + "border-width": 4, + "border-color": "#000000", + "radius": 50, + "node-padding": 5, + "node-margin": 2, + "outside-position": "auto", + "node-icon-image": "", + "node-background-image": "", + "icon-position": "outside", + "icon-size": 64, + "caption-position": "inside", + "caption-max-width": 200, + "caption-color": "#000000", + "caption-font-size": 50, + "caption-font-weight": "normal", + "label-position": "inside", + "label-display": "pill", + "label-color": "#000000", + "label-background-color": "#ffffff", + "label-border-color": "#000000", + "label-border-width": 4, + "label-font-size": 40, + "label-padding": 5, + "label-margin": 4, + "directionality": "directed", + "detail-position": "inline", + "detail-orientation": "parallel", + "arrow-width": 5, + "arrow-color": "#000000", + "margin-start": 5, + "margin-end": 5, + "margin-peer": 20, + "attachment-start": "normal", + "attachment-end": "normal", + "relationship-icon-image": "", + "type-color": "#000000", + "type-background-color": "#ffffff", + "type-border-color": "#000000", + "type-border-width": 0, + "type-font-size": 16, + "type-padding": 5, + "property-position": "outside", + "property-alignment": "colon", + "property-color": "#000000", + "property-font-size": 16, + "property-font-weight": "normal" + }, + "nodes": [ + { + "id": "n0", + "position": { + "x": 0, + "y": 0 + }, + "caption": "computer3", + "style": {}, + "labels": [], + "properties": {} + }, + { + "id": "n1", + "position": { + "x": 284.5, + "y": 0 + }, + "caption": "Server Admins", + "style": {}, + "labels": [], + "properties": {} + }, + { + "id": "n2", + "position": { + "x": 485.67187924757275, + "y": -201.17187924757275 + }, + "caption": "computer8", + "style": {}, + "labels": [], + "properties": { + "smb_signing": "false" + } + }, + { + "id": "n3", + "position": { + "x": 0, + "y": -201.17187924757275 + }, + "caption": "Authenticated Users", + "style": {}, + "labels": [], + "properties": {} + }, + { + "id": "n4", + "position": { + "x": 665.8359396237863, + "y": -381.3359396237863 + }, + "caption": "Domain Admins User", + "style": {}, + "labels": [], + "properties": {} + } + ], + "relationships": [ + { + "id": "n0", + "type": "MemberOf", + "style": {}, + "properties": {}, + "fromId": "n0", + "toId": "n1" + }, + { + "id": "n1", + "type": "AdminTo", + "style": {}, + "properties": {}, + "fromId": "n1", + "toId": "n2" + }, + { + "id": "n2", + "type": "CoerceAndRelayNTLMToSMB", + "style": {}, + "properties": {}, + "fromId": "n3", + "toId": "n2" + }, + { + "id": "n3", + "type": "HasSession", + "style": {}, + "properties": {}, + "fromId": "n2", + "toId": "n4" + } + ] +} \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.svg b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.svg new file mode 100644 index 0000000000..acbab20923 --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.svg @@ -0,0 +1,18 @@ + +MemberOfAdminToCoerceAndRelayNTLMToSMBHasSessioncomputer3ServerAdminscomputer8smb_signing:falseAuthenticatedUsersDomainAdminsUser diff --git a/packages/go/analysis/ad/ntlm.go b/packages/go/analysis/ad/ntlm.go index 93820afc3b..ee0107d338 100644 --- a/packages/go/analysis/ad/ntlm.go +++ b/packages/go/analysis/ad/ntlm.go @@ -38,25 +38,8 @@ func PostNTLM(ctx context.Context, db graph.Database, groupExpansions impact.Pat // TODO: after adding all of our new NTLM edges, benchmark performance between submitting multiple readers per computer or single reader per computer err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { - authenticatedUsersCache := make(map[string]graph.ID) - // Fetch all nodes where the node is a Group and is an Authenticated User - if err := tx.Nodes().Filter( - query.And( - query.Kind(query.Node(), ad.Group), - query.StringEndsWith(query.NodeProperty(common.ObjectID.String()), AuthenticatedUsersSuffix)), - ).Fetch(func(cursor graph.Cursor[*graph.Node]) error { - for authenticatedUser := range cursor.Chan() { - if domain, err := authenticatedUser.Properties.Get(ad.Domain.String()).String(); err != nil { - continue - } else { - authenticatedUsersCache[domain] = authenticatedUser.ID - } - } - - return cursor.Error() - }, - ); err != nil { + if authenticatedUsersCache, err := FetchAuthUsersMappedToDomains(tx); err != nil { return err } else { // Fetch all nodes where the type is Computer @@ -89,7 +72,7 @@ func PostNTLM(ctx context.Context, db graph.Database, groupExpansions impact.Pat } // PostCoerceAndRelayNtlmToSmb creates edges that allow a computer with unrolled admin access to one or more computers where SMB signing is disabled. -// Comprised solely oof adminTo and memberOf edges +// Comprised solely of adminTo and memberOf edges func PostCoerceAndRelayNtlmToSmb(tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, expandedGroups impact.PathAggregator, computer *graph.Node, domain string, authenticaedUserNodes map[string]graph.ID) error { if authenticatedUserID, ok := authenticaedUserNodes[domain]; !ok { return nil @@ -138,3 +121,27 @@ func PostCoerceAndRelayNtlmToSmb(tx graph.Transaction, outC chan<- analysis.Crea return nil } + +// FetchAuthUsersMappedToDomains Fetch all nodes where the node is a Group and is an Authenticated User +func FetchAuthUsersMappedToDomains(tx graph.Transaction) (map[string]graph.ID, error) { + authenticatedUsers := make(map[string]graph.ID) + + err := tx.Nodes().Filter( + query.And( + query.Kind(query.Node(), ad.Group), + query.StringEndsWith(query.NodeProperty(common.ObjectID.String()), AuthenticatedUsersSuffix)), + ).Fetch(func(cursor graph.Cursor[*graph.Node]) error { + for authenticatedUser := range cursor.Chan() { + if domain, err := authenticatedUser.Properties.Get(ad.Domain.String()).String(); err != nil { + continue + } else { + authenticatedUsers[domain] = authenticatedUser.ID + } + } + + return cursor.Error() + }, + ) + + return authenticatedUsers, err +} From a16581891aefa6de2a8f8570b9516974d79e6170 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Fri, 13 Dec 2024 14:15:30 -0600 Subject: [PATCH 3/3] BED-5036 integration test written, working on race condition for test harness --- cmd/api/src/analysis/ad/ntlm_integration_test.go | 8 +++++--- cmd/api/src/test/integration/harnesses.go | 16 +++++++++++++++- .../harnesses/CoerceAndRelayNTLMToSMB.json | 4 +++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cmd/api/src/analysis/ad/ntlm_integration_test.go b/cmd/api/src/analysis/ad/ntlm_integration_test.go index 02b517bedc..ab713a9206 100644 --- a/cmd/api/src/analysis/ad/ntlm_integration_test.go +++ b/cmd/api/src/analysis/ad/ntlm_integration_test.go @@ -32,6 +32,7 @@ import ( "github.com/specterops/bloodhound/graphschema" "github.com/specterops/bloodhound/graphschema/ad" "github.com/specterops/bloodhound/src/test/integration" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,9 +72,10 @@ func TestPostNtlm(t *testing.T) { } else { require.Equal(t, 1, len(results)) - require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.DomainAdminsUser)) - require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.AuthenticatedUsers)) - require.True(t, results.Contains(harness.NtlmCoerceAndRelayNtlmToSmb.ServerAdmins)) + objectId, err := results[0].Properties.Get("objectid").String() + require.NoError(t, err) + + assert.Equal(t, "authenticated-users-S-1-5-11", objectId) } return nil diff --git a/cmd/api/src/test/integration/harnesses.go b/cmd/api/src/test/integration/harnesses.go index 21dbcb8ee3..2e45b5f08d 100644 --- a/cmd/api/src/test/integration/harnesses.go +++ b/cmd/api/src/test/integration/harnesses.go @@ -8412,11 +8412,25 @@ type NtlmCoerceAndRelayNtlmToSmb struct { func (s *NtlmCoerceAndRelayNtlmToSmb) Setup(graphTestContext *GraphTestContext) { domainSid := RandomDomainSID() - s.AuthenticatedUsers = graphTestContext.NewActiveDirectoryUser("Authenticated Users", domainSid) + s.AuthenticatedUsers = graphTestContext.NewActiveDirectoryGroup("Authenticated Users", domainSid) + s.AuthenticatedUsers.Properties.Set("objectid", fmt.Sprintf("authenticated-users%s", adAnalysis.AuthenticatedUsersSuffix)) + s.AuthenticatedUsers.Properties.Set("Domain", domainSid) + graphTestContext.UpdateNode(s.AuthenticatedUsers) + s.DomainAdminsUser = graphTestContext.NewActiveDirectoryUser("Domain Admins User", domainSid) + s.ServerAdmins = graphTestContext.NewActiveDirectoryDomain("Server Admins", domainSid, false, true) + s.ServerAdmins.Properties.Set("objectid", fmt.Sprintf("server-admins%s", adAnalysis.AuthenticatedUsersSuffix)) + s.ServerAdmins.Properties.Set("Domain", domainSid) + graphTestContext.UpdateNode(s.ServerAdmins) + + s.DomainAdminsUser.Properties.Set("objectid", fmt.Sprintf("domainadminuser-users%s", adAnalysis.AuthenticatedUsersSuffix)) s.computer3 = graphTestContext.NewActiveDirectoryComputer("computer3", domainSid) + s.computer8 = graphTestContext.NewActiveDirectoryComputer("computer8", domainSid) + s.computer8.Properties.Set("smb_signing", "false") + graphTestContext.UpdateNode(s.computer8) + graphTestContext.NewRelationship(s.computer3, s.ServerAdmins, ad.MemberOf) graphTestContext.NewRelationship(s.ServerAdmins, s.computer8, ad.AdminTo) graphTestContext.NewRelationship(s.AuthenticatedUsers, s.computer8, ad.CoerceAndRelayNTLMToSMB) diff --git a/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json index 02f12df119..7b37bf3018 100644 --- a/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json +++ b/cmd/api/src/test/integration/harnesses/CoerceAndRelayNTLMToSMB.json @@ -97,7 +97,9 @@ "caption": "Authenticated Users", "style": {}, "labels": [], - "properties": {} + "properties": { + "objectid": "authenticatedusers-S-1-5-11" + } }, { "id": "n4",