Skip to content

Commit fa69c5f

Browse files
committed
fix(postprocess): Fix multi-forest ADCS false positives BED-5572
1 parent 2c568f4 commit fa69c5f

3 files changed

Lines changed: 406 additions & 9 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright 2024 Specter Ops, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
//go:build integration
18+
19+
package ad_test
20+
21+
import (
22+
"testing"
23+
24+
"github.com/specterops/bloodhound/cmd/api/src/test/integration"
25+
"github.com/specterops/bloodhound/packages/go/graphschema"
26+
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
27+
"github.com/specterops/bloodhound/packages/go/graphschema/common"
28+
"github.com/specterops/dawgs/graph"
29+
"github.com/stretchr/testify/assert"
30+
"github.com/stretchr/testify/require"
31+
)
32+
33+
// addEnabledHostingComputer creates an enabled computer in the given domain and
34+
// links it to the CA via HostsCAService (mirrors the integration package's
35+
// unexported addHostingComputer).
36+
func addEnabledHostingComputer(testCtx *integration.GraphTestContext, name, domainSID string, enterpriseCA *graph.Node) {
37+
computer := testCtx.NewActiveDirectoryComputer(name, domainSID)
38+
computer.Properties.Set(common.Enabled.String(), true)
39+
testCtx.UpdateNode(computer)
40+
testCtx.NewRelationship(computer, enterpriseCA, ad.HostsCAService)
41+
}
42+
43+
// linkEnterpriseCAToDomain adds the per-domain edges that make a domain "chain
44+
// valid" (RootCAFor ∩ TrustedForNTAuth) for the CA. The per-CA EnterpriseCAFor
45+
// edge is created once by the caller; each domain gets its own NTAuthStore so the
46+
// TrustedForNTAuth edges don't collide.
47+
func linkEnterpriseCAToDomain(testCtx *integration.GraphTestContext, enterpriseCA, rootCA *graph.Node, domain *graph.Node, domainSID string) {
48+
ntAuthStore := testCtx.NewActiveDirectoryNTAuthStore("NTAuthStore-"+domainSID, domainSID)
49+
50+
// RootCAFor path: domain <-RootCAFor- rootCA <-EnterpriseCAFor- enterpriseCA
51+
testCtx.NewRelationship(rootCA, domain, ad.RootCAFor)
52+
53+
// TrustedForNTAuth path: domain <-NTAuthStoreFor- ntAuthStore <-TrustedForNTAuth- enterpriseCA
54+
testCtx.NewRelationship(ntAuthStore, domain, ad.NTAuthStoreFor)
55+
testCtx.NewRelationship(enterpriseCA, ntAuthStore, ad.TrustedForNTAuth)
56+
}
57+
58+
// TestADCSForestScoping_FiltersForeignForestDomain models shared ADCS across two
59+
// forests: a CA hosted in forest A whose cert chain also reaches forest B's
60+
// domain. The CA's hosting computer is in forest A, so the CA is retained but its
61+
// chained-domain set must be scoped to forest A only.
62+
func TestADCSForestScoping_FiltersForeignForestDomain(t *testing.T) {
63+
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
64+
65+
var (
66+
domainASID = integration.RandomDomainSID()
67+
domainBSID = integration.RandomDomainSID()
68+
69+
enterpriseCAID graph.ID
70+
domainAID graph.ID
71+
domainBID graph.ID
72+
)
73+
74+
testContext.DatabaseTestWithSetup(
75+
func(harness *integration.HarnessDetails) error {
76+
domainA := testContext.NewActiveDirectoryDomain("ForestA-Domain", domainASID, false, true)
77+
domainB := testContext.NewActiveDirectoryDomain("ForestB-Domain", domainBSID, false, true)
78+
79+
// The CA and its root live in forest A.
80+
enterpriseCA := testContext.NewActiveDirectoryEnterpriseCA("SharedECA", domainASID)
81+
rootCA := testContext.NewActiveDirectoryRootCA("SharedRootCA", domainASID)
82+
83+
// The CA chains up to its root once; the per-domain edges are added below.
84+
testContext.NewRelationship(enterpriseCA, rootCA, ad.EnterpriseCAFor)
85+
86+
// Valid cert chain to forest A (the CA's own forest)...
87+
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainA, domainASID)
88+
// ...and a cross-forest chain into forest B (shared ADCS).
89+
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainB, domainBSID)
90+
91+
// Hosting computer lives in the CA's own forest.
92+
addEnabledHostingComputer(testContext, "HostA", domainASID, enterpriseCA)
93+
94+
enterpriseCAID = enterpriseCA.ID
95+
domainAID = domainA.ID
96+
domainBID = domainB.ID
97+
return nil
98+
},
99+
func(harness integration.HarnessDetails, db graph.Database) {
100+
_, cache, err := FetchADCSPrereqs(db)
101+
require.NoError(t, err)
102+
103+
chainedDomains := cache.GetECAHostedChainedDomains()
104+
105+
require.Contains(t, chainedDomains, enterpriseCAID.Uint64(), "CA with an in-forest host should be retained")
106+
chains := chainedDomains[enterpriseCAID.Uint64()]
107+
assert.True(t, chains.Domains.Contains(domainAID.Uint64()), "in-forest domain should survive")
108+
assert.False(t, chains.Domains.Contains(domainBID.Uint64()), "foreign-forest domain should be filtered out")
109+
},
110+
)
111+
}
112+
113+
// TestADCSForestScoping_DropsCAWithOnlyCrossForestHost models a CA whose only
114+
// HostsCAService computer was matched across a forest boundary. With no hosting
115+
// computer in the CA's own forest, the CA should be dropped entirely.
116+
func TestADCSForestScoping_DropsCAWithOnlyCrossForestHost(t *testing.T) {
117+
testContext := integration.NewGraphTestContext(t, graphschema.DefaultGraphSchema())
118+
119+
var (
120+
domainASID = integration.RandomDomainSID()
121+
domainBSID = integration.RandomDomainSID()
122+
123+
enterpriseCAID graph.ID
124+
)
125+
126+
testContext.DatabaseTestWithSetup(
127+
func(harness *integration.HarnessDetails) error {
128+
domainA := testContext.NewActiveDirectoryDomain("ForestA-Domain", domainASID, false, true)
129+
domainB := testContext.NewActiveDirectoryDomain("ForestB-Domain", domainBSID, false, true)
130+
131+
enterpriseCA := testContext.NewActiveDirectoryEnterpriseCA("SharedECA", domainASID)
132+
rootCA := testContext.NewActiveDirectoryRootCA("SharedRootCA", domainASID)
133+
134+
// The CA chains up to its root once; the per-domain edges are added below.
135+
testContext.NewRelationship(enterpriseCA, rootCA, ad.EnterpriseCAFor)
136+
137+
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainA, domainASID)
138+
linkEnterpriseCAToDomain(testContext, enterpriseCA, rootCA, domainB, domainBSID)
139+
140+
// Only hosting computer lives in forest B (cross-forest from the CA).
141+
addEnabledHostingComputer(testContext, "HostB", domainBSID, enterpriseCA)
142+
143+
enterpriseCAID = enterpriseCA.ID
144+
return nil
145+
},
146+
func(harness integration.HarnessDetails, db graph.Database) {
147+
_, cache, err := FetchADCSPrereqs(db)
148+
require.NoError(t, err)
149+
150+
chainedDomains := cache.GetECAHostedChainedDomains()
151+
152+
assert.NotContains(t, chainedDomains, enterpriseCAID.Uint64(), "CA with no in-forest hosting computer should be skipped")
153+
},
154+
)
155+
}

packages/go/analysis/ad/adcscache.go

Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"fmt"
2222
"log/slog"
23+
"strings"
2324
"sync"
2425

2526
"github.com/specterops/bloodhound/packages/go/analysis/ad/wellknown"
@@ -138,6 +139,8 @@ type ADCSCache struct {
138139
authStoreForChainValid map[graph.ID]cardinality.Duplex[uint64] //Auth stores with a valid chain to the domain, key is domain ID
139140
rootCAForChainValid map[graph.ID]cardinality.Duplex[uint64] //Root CA with a valid chain to the domain, key is domain ID
140141
hasHostingComputer map[graph.ID]bool
142+
ecaForestDomains map[graph.ID]cardinality.Duplex[uint64] // enterprise CA ID → domain IDs in the CA's own forest (SameForestTrust closure). Absent when the forest can't be resolved, which disables forest filtering for that CA.
143+
hasInForestHostingComputer map[graph.ID]bool // enterprise CA ID → whether the CA has an enabled hosting computer inside its own forest
141144

142145
// ESC4-specific caches: principals with specific rights on cert templates, pre-computed to avoid per-ECA DB queries
143146
certTemplateGenericWriters map[graph.ID]CachedPrincipalSet // principals with GenericWrite on a cert template
@@ -158,6 +161,8 @@ func NewADCSCache() *ADCSCache {
158161
authStoreForChainValid: make(map[graph.ID]cardinality.Duplex[uint64]),
159162
rootCAForChainValid: make(map[graph.ID]cardinality.Duplex[uint64]),
160163
hasHostingComputer: make(map[graph.ID]bool),
164+
ecaForestDomains: make(map[graph.ID]cardinality.Duplex[uint64]),
165+
hasInForestHostingComputer: make(map[graph.ID]bool),
161166
certTemplateEnrollers: make(map[graph.ID]CachedPrincipalSet),
162167
certTemplateControllers: make(map[graph.ID]CachedPrincipalSet),
163168
enterpriseCAEnrollers: make(map[graph.ID]CachedPrincipalSet),
@@ -275,6 +280,15 @@ func (s *ADCSCache) BuildCache(ctx context.Context, db graph.Database, enterpris
275280

276281
certTemplateMeasure()
277282

283+
// Index domains by SID so a CA or computer can be mapped to its forest via
284+
// its domainsid. SIDs are upper-cased to match collected node identifiers.
285+
domainsBySID := make(map[string]*graph.Node, len(s.domains))
286+
for _, domain := range s.domains {
287+
if sid, err := domain.Properties.Get(common.ObjectID.String()).String(); err == nil && sid != "" {
288+
domainsBySID[strings.ToUpper(sid)] = domain
289+
}
290+
}
291+
278292
ecaMeasure := measure.ContextMeasure(
279293
ctx,
280294
slog.LevelInfo,
@@ -327,18 +341,48 @@ func (s *ADCSCache) BuildCache(ctx context.Context, db graph.Database, enterpris
327341
attr.Error(err),
328342
)
329343
} else {
330-
hasHostingComputer := false
344+
// Resolve the CA's own forest; fall back to forest-agnostic behavior
345+
// when it can't be determined.
346+
forestDomains, forestKnown, err := resolveEnterpriseCAForest(tx, eca, domainsBySID)
347+
if err != nil {
348+
slog.WarnContext(
349+
ctx,
350+
"Error resolving forest for enterprise ca",
351+
slog.Uint64("enterprise_ca", uint64(eca.ID)),
352+
attr.Error(err),
353+
)
354+
}
355+
356+
var (
357+
hasHostingComputer = false
358+
hostInForest = false
359+
)
331360

332361
for _, computer := range hostingComputers.Slice() {
333362
if enabled, err := computer.Properties.Get(common.Enabled.String()).Bool(); err != nil {
334363
continue
335-
} else if enabled {
336-
hasHostingComputer = true
337-
break
364+
} else if !enabled {
365+
continue
366+
}
367+
368+
hasHostingComputer = true
369+
370+
// Only count a host that lives in the CA's forest; a shared CA can
371+
// be linked to a computer in another forest.
372+
if forestKnown {
373+
if computerSID, err := computer.Properties.Get(ad.DomainSID.String()).String(); err == nil && computerSID != "" {
374+
if computerDomain, ok := domainsBySID[strings.ToUpper(computerSID)]; ok && forestDomains.Contains(computerDomain.ID.Uint64()) {
375+
hostInForest = true
376+
}
377+
}
338378
}
339379
}
340-
s.hasHostingComputer[eca.ID] = hasHostingComputer
341380

381+
s.hasHostingComputer[eca.ID] = hasHostingComputer
382+
if forestKnown {
383+
s.ecaForestDomains[eca.ID] = forestDomains
384+
s.hasInForestHostingComputer[eca.ID] = hostInForest
385+
}
342386
}
343387
}
344388

@@ -455,6 +499,34 @@ func (s *ADCSCache) BuildCache(ctx context.Context, db graph.Database, enterpris
455499
return err
456500
}
457501

502+
// resolveEnterpriseCAForest returns the domain IDs in the CA's forest (the
503+
// SameForestTrust closure of the CA's domain, resolved from its domainsid). ok is
504+
// false when the forest can't be resolved, so callers fall back to forest-agnostic
505+
// behavior rather than dropping the CA.
506+
func resolveEnterpriseCAForest(tx graph.Transaction, eca *graph.Node, domainsBySID map[string]*graph.Node) (cardinality.Duplex[uint64], bool, error) {
507+
domainSID, err := eca.Properties.Get(ad.DomainSID.String()).String()
508+
if err != nil || domainSID == "" {
509+
return nil, false, nil
510+
}
511+
512+
caDomain, ok := domainsBySID[strings.ToUpper(domainSID)]
513+
if !ok {
514+
return nil, false, nil
515+
}
516+
517+
forestNodes, err := FetchNodesWithSameForestTrustRelationship(tx, caDomain)
518+
if err != nil {
519+
return nil, false, err
520+
}
521+
522+
forestDomains := graph.NodeSetToDuplex(forestNodes)
523+
// Always include the CA's own domain (the closure is just the seed when there
524+
// are no SameForestTrust edges).
525+
forestDomains.Add(caDomain.ID.Uint64())
526+
527+
return forestDomains, true, nil
528+
}
529+
458530
func (s *ADCSCache) GetECAHostedChainedDomains() map[uint64]*EnterpriseCAChainedDomains {
459531
s.mutex.RLock()
460532
defer s.mutex.RUnlock()
@@ -464,15 +536,29 @@ func (s *ADCSCache) GetECAHostedChainedDomains() map[uint64]*EnterpriseCAChained
464536
for _, enterpriseCA := range s.enterpriseCertAuthorities {
465537
innerEnterpriseCA := enterpriseCA
466538

539+
forestDomains, forestKnown := s.ecaForestDomains[innerEnterpriseCA.ID]
540+
541+
// Require an enabled hosting computer; when the forest is known, require it
542+
// in-forest. Drops CAs whose only host was matched across a forest boundary.
543+
if forestKnown {
544+
if !s.hasInForestHostingComputer[innerEnterpriseCA.ID] {
545+
continue
546+
}
547+
} else if !s.hasHostingComputer[innerEnterpriseCA.ID] {
548+
continue
549+
}
550+
467551
targetDomains := NewEnterpriseCAChainedDomains(enterpriseCA)
468552
for _, domain := range s.domains {
469553
innerDomain := domain
470554

471-
if hasHost, ok := s.hasHostingComputer[innerEnterpriseCA.ID]; !ok {
472-
continue
473-
} else if !hasHost {
555+
// Skip domains outside the CA's forest so the ESC fan-out never reaches
556+
// a foreign-forest domain.
557+
if forestKnown && !forestDomains.Contains(innerDomain.ID.Uint64()) {
474558
continue
475-
} else if _, ok := s.rootCAForChainValid[innerDomain.ID]; !ok {
559+
}
560+
561+
if _, ok := s.rootCAForChainValid[innerDomain.ID]; !ok {
476562
continue
477563
} else if _, ok := s.authStoreForChainValid[innerDomain.ID]; !ok {
478564
continue
@@ -498,10 +584,18 @@ func (s *ADCSCache) GetChainedDomains() map[uint64]*EnterpriseCAChainedDomains {
498584
for _, enterpriseCA := range s.enterpriseCertAuthorities {
499585
innerEnterpriseCA := enterpriseCA
500586

587+
forestDomains, forestKnown := s.ecaForestDomains[innerEnterpriseCA.ID]
588+
501589
targetDomains := NewEnterpriseCAChainedDomains(enterpriseCA)
502590
for _, domain := range s.domains {
503591
innerDomain := domain
504592

593+
// Skip domains outside the CA's forest, keeping EnrollOnBehalfOf linkage
594+
// from crossing a forest boundary.
595+
if forestKnown && !forestDomains.Contains(innerDomain.ID.Uint64()) {
596+
continue
597+
}
598+
505599
if _, ok := s.rootCAForChainValid[innerDomain.ID]; !ok {
506600
continue
507601
} else if _, ok := s.authStoreForChainValid[innerDomain.ID]; !ok {

0 commit comments

Comments
 (0)