diff --git a/cmd/api/src/analysis/ad/ntlm_integration_test.go b/cmd/api/src/analysis/ad/ntlm_integration_test.go index a272dc681..7fd19b632 100644 --- a/cmd/api/src/analysis/ad/ntlm_integration_test.go +++ b/cmd/api/src/analysis/ad/ntlm_integration_test.go @@ -33,6 +33,7 @@ import ( "github.com/specterops/bloodhound/dawgs/query" "github.com/specterops/bloodhound/graphschema" "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/graphschema/common" "github.com/specterops/bloodhound/src/test/integration" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -90,6 +91,66 @@ func TestPostNTLMRelayADCS(t *testing.T) { }) } +func TestNTLMRelayToADCSComposition(t *testing.T) { + testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) + + testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { + harness.NTLMCoerceAndRelayNTLMToADCS.Setup(testContext) + return nil + }, func(harness integration.HarnessDetails, db graph.Database) { + operation := analysis.NewPostRelationshipOperation(context.Background(), db, "NTLM Composition Test - CoerceAndRelayNTLMToADCS") + _, _, domains, authenticatedUsers, err := fetchNTLMPrereqs(db) + require.NoError(t, err) + + cache := ad2.NewADCSCache() + enterpriseCertAuthorities, err := ad2.FetchNodesByKind(context.Background(), db, ad.EnterpriseCA) + require.NoError(t, err) + certTemplates, err := ad2.FetchNodesByKind(context.Background(), db, ad.CertTemplate) + require.NoError(t, err) + err = cache.BuildCache(context.Background(), db, enterpriseCertAuthorities, certTemplates) + require.NoError(t, err) + + for _, domain := range domains { + innerDomain := domain + computerCache, err := fetchComputerCache(db, innerDomain) + require.NoError(t, err) + + err = ad2.PostCoerceAndRelayNTLMToADCS(cache, operation, authenticatedUsers, computerCache) + require.NoError(t, err) + } + + operation.Done() + + db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if edge, err := tx.Relationships().Filterf( + func() graph.Criteria { + return query.And( + query.Kind(query.Relationship(), ad.CoerceAndRelayNTLMToADCS), + query.Equals(query.StartProperty(common.Name.String()), "Authenticated Users Group"), + ) + }).First(); err != nil { + + t.Fatalf("error fetching NTLM to ADCS edge in integration test: %v", err) + } else { + composition, err := ad2.GetCoerceAndRelayNTLMtoADCSEdgeComposition(context.Background(), db, edge) + require.Nil(t, err) + + nodes := composition.AllNodes() + + require.Equal(t, 6, len(nodes)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.AuthenticatedUsersGroup)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.CertTemplate1)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.EnterpriseCA1)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.RootCA)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.Domain)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToADCS.NTAuthStore)) + } + return nil + }) + }) + +} + func TestPostNTLMRelaySMB(t *testing.T) { // TODO: Add some negative tests here testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) @@ -128,7 +189,7 @@ func TestPostNTLMRelaySMB(t *testing.T) { } else if protectedUsersForDomain.Contains(innerComputer.ID.Uint64()) && !ldapSigningForDomain.IsValidFunctionalLevel { continue } else if err = ad2.PostCoerceAndRelayNTLMToSMB(tx, outC, groupExpansions, innerComputer, authenticatedUserID, &compositionCounter); err != nil { - t.Logf("failed post processig for %s: %v", ad.CoerceAndRelayNTLMToSMB.String(), err) + t.Logf("failed post processing for %s: %v", ad.CoerceAndRelayNTLMToSMB.String(), err) } } return nil @@ -166,6 +227,104 @@ func TestPostNTLMRelaySMB(t *testing.T) { }) } +func TestNTLMRelayToSMBComposition(t *testing.T) { + testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema()) + + testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error { + harness.NTLMCoerceAndRelayNTLMToSMB.Setup(testContext) + return nil + }, func(harness integration.HarnessDetails, db graph.Database) { + operation := analysis.NewPostRelationshipOperation(context.Background(), db, "NTLM Composition Test - CoerceAndRelayNTLMToSMB") + + groupExpansions, computers, domains, authenticatedUsers, err := fetchNTLMPrereqs(db) + require.NoError(t, err) + + ldapSigningCache, err := ad2.FetchLDAPSigningCache(testContext.Context(), db) + require.NoError(t, err) + + protectedUsersCache, err := ad2.FetchProtectedUsersMappedToDomains(testContext.Context(), db, groupExpansions) + require.NoError(t, err) + + compositionCounter := analysis.NewCompositionCounter() + + 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 + domainSid, _ := innerDomain.Properties.Get(ad.DomainSID.String()).String() + + if authenticatedUserID, ok := authenticatedUsers[domainSid]; !ok { + t.Fatalf("authenticated user not found for %s", domainSid) + } else if protectedUsersForDomain, ok := protectedUsersCache[domainSid]; !ok { + continue + } else if ldapSigningForDomain, ok := ldapSigningCache[domainSid]; !ok { + continue + } else if protectedUsersForDomain.Contains(innerComputer.ID.Uint64()) && !ldapSigningForDomain.IsValidFunctionalLevel { + continue + } else if err = ad2.PostCoerceAndRelayNTLMToSMB(tx, outC, groupExpansions, innerComputer, authenticatedUserID, &compositionCounter); err != nil { + t.Logf("failed post processing for %s: %v", ad.CoerceAndRelayNTLMToSMB.String(), err) + } + } + return nil + }) + require.NoError(t, err) + } + + err = operation.Done() + require.NoError(t, err) + + db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if edge, err := tx.Relationships().Filterf( + func() graph.Criteria { + return query.And( + query.Kind(query.Relationship(), ad.CoerceAndRelayNTLMToSMB), + query.Equals(query.StartProperty(common.Name.String()), "Group2"), + ) + }).First(); err != nil { + t.Fatalf("error fetching NTLM to SMB edge in integration test: %v", err) + } else { + composition, err := ad2.GetCoerceAndRelayNTLMtoSMBComposition(context.Background(), db, edge) + require.Nil(t, err) + + nodes := composition.AllNodes() + + require.Equal(t, 4, len(nodes)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Computer8)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Computer9)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Group7)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Group8)) + } + return nil + }) + + db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if edge, err := tx.Relationships().Filterf( + func() graph.Criteria { + return query.And( + query.Kind(query.Relationship(), ad.CoerceAndRelayNTLMToSMB), + query.Equals(query.StartProperty(common.Name.String()), "Group1"), + ) + }).First(); err != nil { + t.Fatalf("error fetching NTLM to SMB edge in integration test: %v", err) + } else { + composition, err := ad2.GetCoerceAndRelayNTLMtoSMBComposition(context.Background(), db, edge) + require.Nil(t, err) + + nodes := composition.AllNodes() + + require.Equal(t, 2, len(nodes)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Computer1)) + require.True(t, nodes.Contains(harness.NTLMCoerceAndRelayNTLMToSMB.Computer2)) + // Question: No mapping to Group4 for the protected users requirement? + } + return nil + }) + + }) +} + func fetchComputerCache(db graph.Database, domain *graph.Node) (map[string]cardinality.Duplex[uint64], error) { cache := make(map[string]cardinality.Duplex[uint64]) if domainSid, err := domain.Properties.Get(ad.DomainSID.String()).String(); err != nil { diff --git a/packages/go/analysis/ad/filters.go b/packages/go/analysis/ad/filters.go index 667c8c87c..fee057408 100644 --- a/packages/go/analysis/ad/filters.go +++ b/packages/go/analysis/ad/filters.go @@ -120,7 +120,7 @@ func OutboundControlDescentFilter(ctx *ops.TraversalContext, segment *graph.Path return false } } else if relationship.Kind.Is(ad.MemberOf) && sawControlRel { - //If we've already seen a control rel, and we get to a MemberOf, we need to prevent a descent as well + // If we've already seen a control rel, and we get to a MemberOf, we need to prevent a descent as well shouldDescend = false return false } @@ -139,13 +139,13 @@ func BlocksInheritanceDescentFilter(ctx *ops.TraversalContext, segment *graph.Pa if !segment.Node.Kinds.ContainsOneOf(ad.OU) { return true } else if previousNode := segment.Trunk.Node; !previousNode.Kinds.ContainsOneOf(ad.OU) { - //If our previous node is not an OU, continue descent + // If our previous node is not an OU, continue descent return true } else if blocksInheritance, err := previousNode.Properties.Get(ad.BlocksInheritance.String()).Bool(); err != nil { - //If we get an error, we'll just default to unenforced + // If we get an error, we'll just default to unenforced return true } else if blocksInheritance { - //If our previous node blocks inheritance, we don't want to descend further, but we still want this node + // If our previous node blocks inheritance, we don't want to descend further, but we still want this node return false } else { return true diff --git a/packages/go/analysis/ad/ntlm.go b/packages/go/analysis/ad/ntlm.go index 6ca474efa..dc6a0b565 100644 --- a/packages/go/analysis/ad/ntlm.go +++ b/packages/go/analysis/ad/ntlm.go @@ -47,12 +47,12 @@ func PostNTLM(ctx context.Context, db graph.Database, groupExpansions impact.Pat adcsComputerCache = make(map[string]cardinality.Duplex[uint64]) operation = analysis.NewPostRelationshipOperation(ctx, db, "PostNTLM") authenticatedUsersCache = make(map[string]graph.ID) - //compositionChannel = make(chan analysis.CompositionInfo) + // compositionChannel = make(chan analysis.CompositionInfo) protectedUsersCache = make(map[string]cardinality.Duplex[uint64]) ) - //This is a POC on how to pipe composition info up through the operations - //go func() { + // This is a POC on how to pipe composition info up through the operations + // go func() { // count := 0 // edgeBuffer := make([]model.EdgeCompositionEdge, 0) // nodeBuffer := make([]model.EdgeCompositionNode, 0) @@ -73,7 +73,7 @@ func PostNTLM(ctx context.Context, db graph.Database, groupExpansions impact.Pat // break // } // } - //}() + // }() // 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 { @@ -448,7 +448,7 @@ func GetCoerceAndRelayNTLMtoSMBComposition(ctx context.Context, db graph.Databas return paths, nil } -// PostCoerceAndRelayNTLMtoSMB creates edges that allow a computer with unrolled admin access to one or more computers where SMB signing is disabled. +// PostCoerceAndRelayNTLMToSMB creates edges that allow a computer with unrolled admin access to one or more computers where SMB signing is disabled. // Comprised solely of adminTo and memberOf edges func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, expandedGroups impact.PathAggregator, computer *graph.Node, authenticatedUserID graph.ID, compositionCounter *analysis.CompositionCounter) error { if smbSigningEnabled, err := computer.Properties.Get(ad.SMBSigning.String()).Bool(); errors.Is(err, graph.ErrPropertyNotFound) { @@ -464,19 +464,19 @@ func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- analysis.Crea if firstDegreeAdmins, err := fetchFirstDegreeNodes(tx, computer, ad.AdminTo); err != nil { return err } else if firstDegreeAdmins.ContainingNodeKinds(ad.Computer).Len() > 0 { - //compositionID := compositionCounter.Get() + // compositionID := compositionCounter.Get() outC <- analysis.CreatePostRelationshipJob{ FromID: authenticatedUserID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToSMB, - //RelProperties: map[string]any{common.CompositionID.String(): compositionID}, + // RelProperties: map[string]any{common.CompositionID.String(): compositionID}, } // This is an example of how you would use the composition counter - //compositionC <- analysis.CompositionInfo{ + // compositionC <- analysis.CompositionInfo{ // CompositionID: compositionID, // EdgeIDs: nil, // NodeIDs: nil, - //} + // } } else { allAdminGroups := cardinality.NewBitmap64() for group := range firstDegreeAdmins.ContainingNodeKinds(ad.Group) { @@ -496,7 +496,7 @@ func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- analysis.Crea FromID: authenticatedUserID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToSMB, - //RelProperties: map[string]any{common.CompositionID.String(): compositionCounter.Get()}, + // RelProperties: map[string]any{common.CompositionID.String(): compositionCounter.Get()}, } } }