Skip to content

Commit

Permalink
BED-5033 added SMB & ADCS composition tests
Browse files Browse the repository at this point in the history
  • Loading branch information
mvlipka committed Feb 25, 2025
1 parent 57bb582 commit 871c85d
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 15 deletions.
161 changes: 160 additions & 1 deletion cmd/api/src/analysis/ad/ntlm_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions packages/go/analysis/ad/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
20 changes: 10 additions & 10 deletions packages/go/analysis/ad/ntlm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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()},
}
}
}
Expand Down

0 comments on commit 871c85d

Please sign in to comment.