Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 307 additions & 0 deletions packages/go/analysis/ad/adcs_forest_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@
package ad_test

import (
"context"
"testing"

"github.com/specterops/bloodhound/cmd/api/src/test/integration"
adAnalysis "github.com/specterops/bloodhound/packages/go/analysis/ad"
"github.com/specterops/bloodhound/packages/go/analysis/post"
"github.com/specterops/bloodhound/packages/go/graphschema"
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
"github.com/specterops/bloodhound/packages/go/graphschema/common"
"github.com/specterops/dawgs/graph"
"github.com/specterops/dawgs/ops"
"github.com/specterops/dawgs/query"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -200,6 +204,309 @@ func TestADCSNTAuthStoreChainRequiresExactPattern(t *testing.T) {
)
}

func TestADCSESC1CompositionScopesHostsToExactEnterpriseCA(t *testing.T) {
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())

var (
domainSID = integration.RandomDomainSID()
foreignDomainSID = integration.RandomDomainSID()
unknownDomainSID = integration.RandomDomainSID()
principal *graph.Node
domain *graph.Node
validEnterpriseCA *graph.Node
unhostedCA *graph.Node
disabledHostedCA *graph.Node
crossForestCA *graph.Node
unknownHostDomainCA *graph.Node
validHost *graph.Node
disabledValidCAHost *graph.Node
crossForestValidCAHost *graph.Node
unknownDomainValidCAHost *graph.Node
disabledHost *graph.Node
crossForestHost *graph.Node
unknownDomainHost *graph.Node
)

testContext.DatabaseTestWithSetup(
func(harness *integration.HarnessDetails) error {
domain = testContext.NewActiveDirectoryDomain("Domain", domainSID, false, true)
testContext.NewActiveDirectoryDomain("ForeignDomain", foreignDomainSID, false, true)
rootCA := testContext.NewActiveDirectoryRootCA("RootCA", domainSID)
ntAuthStore := testContext.NewActiveDirectoryNTAuthStore("NTAuthStore", domainSID)
certificateTemplate := testContext.NewActiveDirectoryCertTemplate("CertificateTemplate", domainSID, integration.CertTemplateData{
AuthenticationEnabled: true,
AuthorizedSignatures: 0,
EnrolleeSuppliesSubject: true,
RequiresManagerApproval: false,
SchemaVersion: 1,
})
principal = testContext.NewActiveDirectoryGroup("Principal", domainSID)

validEnterpriseCA = testContext.NewActiveDirectoryEnterpriseCA("ValidEnterpriseCA", domainSID)
unhostedCA = testContext.NewActiveDirectoryEnterpriseCA("UnhostedCA", domainSID)
disabledHostedCA = testContext.NewActiveDirectoryEnterpriseCA("DisabledHostedCA", domainSID)
crossForestCA = testContext.NewActiveDirectoryEnterpriseCA("CrossForestCA", domainSID)
unknownHostDomainCA = testContext.NewActiveDirectoryEnterpriseCA("UnknownHostDomainCA", domainSID)

testContext.NewRelationship(rootCA, domain, ad.RootCAFor)
testContext.NewRelationship(ntAuthStore, domain, ad.NTAuthStoreFor)
testContext.NewRelationship(principal, certificateTemplate, ad.Enroll)

for _, enterpriseCA := range []*graph.Node{
validEnterpriseCA,
unhostedCA,
disabledHostedCA,
crossForestCA,
unknownHostDomainCA,
} {
testContext.NewRelationship(enterpriseCA, rootCA, ad.IssuedSignedBy)
testContext.NewRelationship(enterpriseCA, ntAuthStore, ad.TrustedForNTAuth)
testContext.NewRelationship(certificateTemplate, enterpriseCA, ad.PublishedTo)
testContext.NewRelationship(principal, enterpriseCA, ad.Enroll)
}

validHost = addEnabledHostingComputer(testContext, "ValidHost", domainSID, validEnterpriseCA)
disabledValidCAHost = testContext.NewActiveDirectoryComputer("DisabledValidCAHost", domainSID)
disabledValidCAHost.Properties.Set(common.Enabled.String(), false)
testContext.UpdateNode(disabledValidCAHost)
testContext.NewRelationship(disabledValidCAHost, validEnterpriseCA, ad.HostsCAService)
crossForestValidCAHost = addEnabledHostingComputer(testContext, "CrossForestValidCAHost", foreignDomainSID, validEnterpriseCA)
unknownDomainValidCAHost = addEnabledHostingComputer(testContext, "UnknownDomainValidCAHost", unknownDomainSID, validEnterpriseCA)

disabledHost = testContext.NewActiveDirectoryComputer("DisabledHost", domainSID)
disabledHost.Properties.Set(common.Enabled.String(), false)
testContext.UpdateNode(disabledHost)
testContext.NewRelationship(disabledHost, disabledHostedCA, ad.HostsCAService)
crossForestHost = addEnabledHostingComputer(testContext, "CrossForestHost", foreignDomainSID, crossForestCA)
unknownDomainHost = addEnabledHostingComputer(testContext, "UnknownDomainHost", unknownDomainSID, unknownHostDomainCA)
return nil
},
func(harness integration.HarnessDetails, db graph.Database) {
localGroupData, cache, err := FetchADCSPrereqs(db)
require.NoError(t, err)

chainedDomains := cache.GetECAHostedChainedDomains()
require.Contains(t, chainedDomains, validEnterpriseCA.ID.Uint64())
assert.NotContains(t, chainedDomains, unhostedCA.ID.Uint64())
assert.NotContains(t, chainedDomains, disabledHostedCA.ID.Uint64())
assert.NotContains(t, chainedDomains, crossForestCA.ID.Uint64())
assert.NotContains(t, chainedDomains, unknownHostDomainCA.ID.Uint64())

edgeOperation := post.NewPostRelationshipOperation(t.Context(), db, "ADCS ESC1 exact host CA scoping")
for _, certificateChains := range chainedDomains {
certificateChains := certificateChains
require.NoError(t, edgeOperation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
return adAnalysis.PostADCSESC1(ctx, tx, outC, localGroupData, certificateChains, cache)
}))
}
require.NoError(t, edgeOperation.Done())

var edge *graph.Relationship
require.NoError(t, db.ReadTransaction(t.Context(), func(tx graph.Transaction) error {
edge, err = tx.Relationships().Filterf(func() graph.Criteria {
return query.And(
query.Kind(query.Relationship(), ad.ADCSESC1),
query.Equals(query.StartID(), principal.ID),
query.Equals(query.EndID(), domain.ID),
)
}).First()
return err
}))

composition, err := adAnalysis.GetADCSESC1EdgeComposition(t.Context(), db, edge)
require.NoError(t, err)
require.NotEmpty(t, composition)
require.True(t, composition.AllNodes().Contains(validEnterpriseCA))
for _, invalidNode := range []*graph.Node{
unhostedCA,
disabledHostedCA,
crossForestCA,
unknownHostDomainCA,
disabledValidCAHost,
crossForestValidCAHost,
unknownDomainValidCAHost,
disabledHost,
crossForestHost,
unknownDomainHost,
} {
require.False(t, composition.AllNodes().Contains(invalidNode))
}

var validHostPathFound bool
for _, path := range composition.Paths() {
path.Walk(func(start, end *graph.Node, relationship *graph.Relationship) bool {
if relationship.Kind.Is(ad.HostsCAService) {
require.Equal(t, validHost.ID, start.ID)
require.Equal(t, validEnterpriseCA.ID, end.ID)
validHostPathFound = true
}
return true
})
}
require.True(t, validHostPathFound)
},
)
}

func TestGoldenCertCreationAndCompositionUseOnlyQualifyingHosts(t *testing.T) {
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())

var (
domainSID = integration.RandomDomainSID()
foreignDomainSID = integration.RandomDomainSID()
unknownDomainSID = integration.RandomDomainSID()
domain *graph.Node
enterpriseCA *graph.Node
validHost *graph.Node
disabledHost *graph.Node
crossForestHost *graph.Node
unknownDomainHost *graph.Node
)

testContext.DatabaseTestWithSetup(
func(harness *integration.HarnessDetails) error {
domain = testContext.NewActiveDirectoryDomain("Domain", domainSID, false, true)
testContext.NewActiveDirectoryDomain("ForeignDomain", foreignDomainSID, false, true)
rootCA := testContext.NewActiveDirectoryRootCA("RootCA", domainSID)
enterpriseCA = testContext.NewActiveDirectoryEnterpriseCA("EnterpriseCA", domainSID)

testContext.NewRelationship(enterpriseCA, rootCA, ad.IssuedSignedBy)
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domain, domainSID)

validHost = addEnabledHostingComputer(testContext, "ValidHost", domainSID, enterpriseCA)
disabledHost = testContext.NewActiveDirectoryComputer("DisabledHost", domainSID)
disabledHost.Properties.Set(common.Enabled.String(), false)
testContext.UpdateNode(disabledHost)
testContext.NewRelationship(disabledHost, enterpriseCA, ad.HostsCAService)
crossForestHost = addEnabledHostingComputer(testContext, "CrossForestHost", foreignDomainSID, enterpriseCA)
unknownDomainHost = addEnabledHostingComputer(testContext, "UnknownDomainHost", unknownDomainSID, enterpriseCA)

return nil
},
func(harness integration.HarnessDetails, db graph.Database) {
_, cache, err := FetchADCSPrereqs(db)
require.NoError(t, err)

chainedDomains := cache.GetECAHostedChainedDomains()
require.Contains(t, chainedDomains, enterpriseCA.ID.Uint64())

edgeOperation := post.NewPostRelationshipOperation(t.Context(), db, "GoldenCert exact host scoping")
require.NoError(t, edgeOperation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
return adAnalysis.PostGoldenCert(ctx, tx, outC, chainedDomains[enterpriseCA.ID.Uint64()])
}))
require.NoError(t, edgeOperation.Done())

var goldenCertEdge *graph.Relationship
require.NoError(t, db.ReadTransaction(t.Context(), func(tx graph.Transaction) error {
edges, err := ops.FetchRelationships(tx.Relationships().Filter(query.And(
query.Kind(query.Relationship(), ad.GoldenCert),
query.Equals(query.EndID(), domain.ID),
)))
if err != nil {
return err
}

require.Len(t, edges, 1)
goldenCertEdge = edges[0]
assert.Equal(t, validHost.ID, goldenCertEdge.StartID)
return nil
}))

composition, err := adAnalysis.GetEdgeCompositionPath(t.Context(), db, goldenCertEdge)
require.NoError(t, err)
require.NotEmpty(t, composition)
require.True(t, composition.AllNodes().Contains(validHost))
for _, invalidHost := range []*graph.Node{disabledHost, crossForestHost, unknownDomainHost} {
require.False(t, composition.AllNodes().Contains(invalidHost))
}

var qualifyingHostPathFound bool
for _, path := range composition.Paths() {
path.Walk(func(start, end *graph.Node, relationship *graph.Relationship) bool {
if relationship.Kind.Is(ad.HostsCAService) {
require.Equal(t, validHost.ID, start.ID)
require.Equal(t, enterpriseCA.ID, end.ID)
qualifyingHostPathFound = true
}
return true
})
}
require.True(t, qualifyingHostPathFound)
},
)
}

func TestADCSHostEligibilityFallsBackWhenEnterpriseCAForestIsUnresolved(t *testing.T) {
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())

var (
domainSID = integration.RandomDomainSID()
unresolvedCADomainSID = integration.RandomDomainSID()
principal *graph.Node
domain *graph.Node
enterpriseCA *graph.Node
host *graph.Node
)

testContext.DatabaseTestWithSetup(
func(harness *integration.HarnessDetails) error {
domain = testContext.NewActiveDirectoryDomain("Domain", domainSID, false, true)
rootCA := testContext.NewActiveDirectoryRootCA("RootCA", domainSID)
ntAuthStore := testContext.NewActiveDirectoryNTAuthStore("NTAuthStore", domainSID)
certificateTemplate := testContext.NewActiveDirectoryCertTemplate("CertificateTemplate", domainSID, integration.CertTemplateData{
AuthenticationEnabled: true,
AuthorizedSignatures: 0,
EnrolleeSuppliesSubject: true,
RequiresManagerApproval: false,
SchemaVersion: 1,
})
principal = testContext.NewActiveDirectoryGroup("Principal", domainSID)
enterpriseCA = testContext.NewActiveDirectoryEnterpriseCA("EnterpriseCA", unresolvedCADomainSID)

testContext.NewRelationship(rootCA, domain, ad.RootCAFor)
testContext.NewRelationship(ntAuthStore, domain, ad.NTAuthStoreFor)
testContext.NewRelationship(enterpriseCA, rootCA, ad.IssuedSignedBy)
testContext.NewRelationship(enterpriseCA, ntAuthStore, ad.TrustedForNTAuth)
testContext.NewRelationship(certificateTemplate, enterpriseCA, ad.PublishedTo)
testContext.NewRelationship(principal, certificateTemplate, ad.Enroll)
testContext.NewRelationship(principal, enterpriseCA, ad.Enroll)
host = addEnabledHostingComputer(testContext, "Host", domainSID, enterpriseCA)
return nil
},
func(harness integration.HarnessDetails, db graph.Database) {
localGroupData, cache, err := FetchADCSPrereqs(db)
require.NoError(t, err)

chainedDomains := cache.GetECAHostedChainedDomains()
require.Contains(t, chainedDomains, enterpriseCA.ID.Uint64())

edgeOperation := post.NewPostRelationshipOperation(t.Context(), db, "ADCS unresolved CA forest fallback")
require.NoError(t, edgeOperation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error {
return adAnalysis.PostADCSESC1(ctx, tx, outC, localGroupData, chainedDomains[enterpriseCA.ID.Uint64()], cache)
}))
require.NoError(t, edgeOperation.Done())

var edge *graph.Relationship
require.NoError(t, db.ReadTransaction(t.Context(), func(tx graph.Transaction) error {
edge, err = tx.Relationships().Filterf(func() graph.Criteria {
return query.And(
query.Kind(query.Relationship(), ad.ADCSESC1),
query.Equals(query.StartID(), principal.ID),
query.Equals(query.EndID(), domain.ID),
)
}).First()
return err
}))

composition, err := adAnalysis.GetADCSESC1EdgeComposition(t.Context(), db, edge)
require.NoError(t, err)
require.NotEmpty(t, composition)
require.True(t, composition.AllNodes().Contains(enterpriseCA))
require.True(t, composition.AllNodes().Contains(host))
},
)
}

// TestADCSForestScoping_UsesHostForestECAAndKeepsCrossForestDomain models
// shared ADCS across two forests: one computer hosts the CA service for an
// EnterpriseCA in its own forest and for a copied EnterpriseCA in another forest.
Expand Down
Loading
Loading