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 +}