From f7502b175c9ff37f9773ad00221ba445fdf56fb9 Mon Sep 17 00:00:00 2001 From: mistahj67 <26472282+mistahj67@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:54:45 -0700 Subject: [PATCH 1/4] BED-3858 - Fix not ingesting tenant count during azure analysis (#328) Co-authored-by: Irshad Ahmed --- cmd/api/src/analysis/azure/queries.go | 16 +- cmd/api/src/analysis/azure/queries_test.go | 52 ++++++ cmd/api/src/test/integration/graph.go | 1 + cmd/api/src/test/integration/harnesses.go | 30 ++++ ...ness.json => AZInboundControlHarness.json} | 121 ++++++++------ .../harnesses/AZInboundControlHarness.svg | 1 + .../harnesses/AZManagementGroup.json | 156 ++++++++++++++++++ .../harnesses/AZManagementGroup.svg | 1 + ...membership.json => azgroupmembership.json} | 67 +++++--- .../harnesses/azgroupmembership.svg | 1 + .../harnesses/azinboundcontrolharness.svg | 18 -- .../integration/harnesses/azmembership.svg | 18 -- 12 files changed, 367 insertions(+), 115 deletions(-) create mode 100644 cmd/api/src/analysis/azure/queries_test.go rename cmd/api/src/test/integration/harnesses/{azinboundcontrolharness.json => AZInboundControlHarness.json} (77%) create mode 100644 cmd/api/src/test/integration/harnesses/AZInboundControlHarness.svg create mode 100644 cmd/api/src/test/integration/harnesses/AZManagementGroup.json create mode 100644 cmd/api/src/test/integration/harnesses/AZManagementGroup.svg rename cmd/api/src/test/integration/harnesses/{azmembership.json => azgroupmembership.json} (77%) create mode 100644 cmd/api/src/test/integration/harnesses/azgroupmembership.svg delete mode 100644 cmd/api/src/test/integration/harnesses/azinboundcontrolharness.svg delete mode 100644 cmd/api/src/test/integration/harnesses/azmembership.svg diff --git a/cmd/api/src/analysis/azure/queries.go b/cmd/api/src/analysis/azure/queries.go index 75bf58402..6c27548dd 100644 --- a/cmd/api/src/analysis/azure/queries.go +++ b/cmd/api/src/analysis/azure/queries.go @@ -1,17 +1,17 @@ // Copyright 2023 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 package azure @@ -21,7 +21,6 @@ import ( "fmt" "sync" - "github.com/specterops/bloodhound/src/model" "github.com/gofrs/uuid" "github.com/specterops/bloodhound/analysis" "github.com/specterops/bloodhound/dawgs/graph" @@ -30,6 +29,7 @@ import ( "github.com/specterops/bloodhound/graphschema/azure" "github.com/specterops/bloodhound/graphschema/common" "github.com/specterops/bloodhound/log" + "github.com/specterops/bloodhound/src/model" ) func GraphStats(ctx context.Context, db graph.Database) (model.AzureDataQualityStats, model.AzureDataQualityAggregation, error) { @@ -48,7 +48,7 @@ func GraphStats(ctx context.Context, db graph.Database) (model.AzureDataQualityS runID = newUUID.String() } - return stats, aggregation, db.ReadTransaction(ctx, func(tx graph.Transaction) error { + err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if tenants, err := ops.FetchNodes(tx.Nodes().Filterf(func() graph.Criteria { return query.Kind(query.Node(), azure.Tenant) })); err != nil { @@ -58,6 +58,8 @@ func GraphStats(ctx context.Context, db graph.Database) (model.AzureDataQualityS if tenantObjectID, err := tenant.Properties.Get(common.ObjectID.String()).String(); err != nil { log.Errorf("Tenant node %d does not have a valid %s property: %v", tenant.ID, common.ObjectID, err) } else { + aggregation.Tenants++ + var ( stat = model.AzureDataQualityStat{ RunID: runID, @@ -163,4 +165,6 @@ func GraphStats(ctx context.Context, db graph.Database) (model.AzureDataQualityS return nil }) + + return stats, aggregation, err } diff --git a/cmd/api/src/analysis/azure/queries_test.go b/cmd/api/src/analysis/azure/queries_test.go new file mode 100644 index 000000000..4afde925a --- /dev/null +++ b/cmd/api/src/analysis/azure/queries_test.go @@ -0,0 +1,52 @@ +// Copyright 2023 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 azure_test + +import ( + "context" + "github.com/specterops/bloodhound/dawgs/graph" + schema "github.com/specterops/bloodhound/graphschema" + azure2 "github.com/specterops/bloodhound/src/analysis/azure" + "github.com/specterops/bloodhound/src/test/integration" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestAnalysisAzure_GraphStats(t *testing.T) { + testCtx := integration.NewGraphTestContext(t, schema.DefaultGraphSchema()) + testCtx.DatabaseTest(func(harness integration.HarnessDetails, db graph.Database) { + + _, agg, err := azure2.GraphStats(context.TODO(), testCtx.Graph.Database) + require.Nil(t, err) + assert.NotZero(t, agg.Tenants) + assert.NotZero(t, agg.Users) + assert.NotZero(t, agg.Groups) + assert.NotZero(t, agg.Apps) + assert.NotZero(t, agg.ServicePrincipals) + assert.NotZero(t, agg.Devices) + assert.NotZero(t, agg.ManagementGroups) + assert.NotZero(t, agg.Subscriptions) + assert.NotZero(t, agg.ResourceGroups) + assert.NotZero(t, agg.VMs) + assert.NotZero(t, agg.KeyVaults) + assert.NotZero(t, agg.Relationships) + }) +} diff --git a/cmd/api/src/test/integration/graph.go b/cmd/api/src/test/integration/graph.go index b1459ba1c..a56fbd8e6 100644 --- a/cmd/api/src/test/integration/graph.go +++ b/cmd/api/src/test/integration/graph.go @@ -461,6 +461,7 @@ func (s *GraphTestContext) setupAzure() { s.Harness.AZMGRoleManagementReadWriteDirectoryHarness.Setup(s) s.Harness.AZMGServicePrincipalEndpointReadWriteAllHarness.Setup(s) s.Harness.AZInboundControlHarness.Setup(s) + s.Harness.AZManagementGroup.Setup(s) } func (s *GraphTestContext) setupActiveDirectory() { diff --git a/cmd/api/src/test/integration/harnesses.go b/cmd/api/src/test/integration/harnesses.go index 9b1194651..a19dc3075 100644 --- a/cmd/api/src/test/integration/harnesses.go +++ b/cmd/api/src/test/integration/harnesses.go @@ -753,16 +753,41 @@ type AZGroupMembershipHarness struct { func (s *AZGroupMembershipHarness) Setup(testCtx *GraphTestContext) { tenantID := RandomObjectID(testCtx.testCtx) + s.Tenant = testCtx.NewAzureTenant(tenantID) s.UserA = testCtx.NewAzureUser("UserA", "UserA", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) s.UserB = testCtx.NewAzureUser("UserB", "UserB", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) s.UserC = testCtx.NewAzureUser("UserC", "UserC", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) s.Group = testCtx.NewAzureGroup("Group", RandomObjectID(testCtx.testCtx), tenantID) + testCtx.NewRelationship(s.Tenant, s.Group, azure.Contains) + testCtx.NewRelationship(s.UserA, s.Group, azure.MemberOf) testCtx.NewRelationship(s.UserB, s.Group, azure.MemberOf) testCtx.NewRelationship(s.UserC, s.Group, azure.MemberOf) } +type AZManagementGroupHarness struct { + Tenant *graph.Node + UserA *graph.Node + UserB *graph.Node + UserC *graph.Node + Group *graph.Node +} + +func (s *AZManagementGroupHarness) Setup(testCtx *GraphTestContext) { + tenantID := RandomObjectID(testCtx.testCtx) + s.Tenant = testCtx.NewAzureTenant(tenantID) + s.UserA = testCtx.NewAzureUser("Batman", "Batman", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) + s.UserB = testCtx.NewAzureUser("Wonder Woman", "Wonder Woman", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) + s.UserC = testCtx.NewAzureUser("Flash", "Flash", "", RandomObjectID(testCtx.testCtx), "", tenantID, false) + s.Group = testCtx.NewAzureManagementGroup("Justice League", RandomObjectID(testCtx.testCtx), tenantID) + testCtx.NewRelationship(s.Tenant, s.Group, azure.Contains) + + testCtx.NewRelationship(s.UserA, s.Group, azure.ManagementGroup) + testCtx.NewRelationship(s.UserB, s.Group, azure.ManagementGroup) + testCtx.NewRelationship(s.UserC, s.Group, azure.ManagementGroup) +} + type AZEntityPanelHarness struct { Application *graph.Node Device *graph.Node @@ -1030,6 +1055,7 @@ func (s *AZMGServicePrincipalEndpointReadWriteAllHarness) Setup(testCtx *GraphTe } type AZInboundControlHarness struct { + AZTenant *graph.Node ControlledAZUser *graph.Node AZAppA *graph.Node AZGroupA *graph.Node @@ -1042,6 +1068,7 @@ type AZInboundControlHarness struct { func (s *AZInboundControlHarness) Setup(testCtx *GraphTestContext) { tenantID := RandomObjectID(testCtx.testCtx) + s.AZTenant = testCtx.NewAzureTenant(tenantID) s.ControlledAZUser = testCtx.NewAzureUser("Controlled AZUser", "Controlled AZUser", "", RandomObjectID(testCtx.testCtx), HarnessUserLicenses, tenantID, HarnessUserMFAEnabled) s.AZAppA = testCtx.NewAzureApplication("AZAppA", RandomObjectID(testCtx.testCtx), tenantID) s.AZGroupA = testCtx.NewAzureGroup("AZGroupA", RandomObjectID(testCtx.testCtx), tenantID) @@ -1051,6 +1078,8 @@ func (s *AZInboundControlHarness) Setup(testCtx *GraphTestContext) { s.AZServicePrincipalA = testCtx.NewAzureServicePrincipal("AZServicePrincipalA", RandomObjectID(testCtx.testCtx), tenantID) s.AZServicePrincipalB = testCtx.NewAzureServicePrincipal("AZServicePrincipalB", RandomObjectID(testCtx.testCtx), tenantID) + testCtx.NewRelationship(s.AZTenant, s.AZGroupA, azure.Contains) + testCtx.NewRelationship(s.AZUserA, s.AZGroupA, azure.MemberOf) testCtx.NewRelationship(s.AZServicePrincipalB, s.AZGroupB, azure.MemberOf) @@ -2198,6 +2227,7 @@ type HarnessDetails struct { Completeness CompletenessHarness AZBaseHarness AZBaseHarness AZGroupMembership AZGroupMembershipHarness + AZManagementGroup AZManagementGroupHarness AZEntityPanelHarness AZEntityPanelHarness AZMGApplicationReadWriteAllHarness AZMGApplicationReadWriteAllHarness AZMGAppRoleManagementReadWriteAllHarness AZMGAppRoleManagementReadWriteAllHarness diff --git a/cmd/api/src/test/integration/harnesses/azinboundcontrolharness.json b/cmd/api/src/test/integration/harnesses/AZInboundControlHarness.json similarity index 77% rename from cmd/api/src/test/integration/harnesses/azinboundcontrolharness.json rename to cmd/api/src/test/integration/harnesses/AZInboundControlHarness.json index b57cb1b59..8cfdb5e1f 100644 --- a/cmd/api/src/test/integration/harnesses/azinboundcontrolharness.json +++ b/cmd/api/src/test/integration/harnesses/AZInboundControlHarness.json @@ -54,10 +54,10 @@ }, "nodes": [ { - "id": "n7", + "id": "n0", "position": { "x": 1059.8976569191977, - "y": 1346.027889910619 + "y": 501.6907518623764 }, "caption": "Controlled AZUser", "labels": [], @@ -67,10 +67,10 @@ } }, { - "id": "n8", + "id": "n1", "position": { "x": 604.0284270522187, - "y": 894.3371380482427 + "y": 50 }, "caption": "AZGroup A", "labels": [], @@ -80,10 +80,10 @@ } }, { - "id": "n9", + "id": "n2", "position": { "x": 604.0284270522187, - "y": 1838.502464519107 + "y": 994.1653264708643 }, "caption": "AZGroup B", "labels": [], @@ -93,10 +93,10 @@ } }, { - "id": "n10", + "id": "n3", "position": { "x": 604.0284270522187, - "y": 1475.6758276195499 + "y": 631.3386895713072 }, "caption": "AZServicePrincipal A", "labels": [], @@ -106,10 +106,10 @@ } }, { - "id": "n11", + "id": "n4", "position": { "x": 604.0284270522187, - "y": 1229.2947464229803 + "y": 384.9576083747377 }, "caption": "AZUser B", "labels": [], @@ -119,10 +119,10 @@ } }, { - "id": "n12", + "id": "n5", "position": { "x": 75, - "y": 894.3371380482427 + "y": 50 }, "caption": "AZUser A", "labels": [], @@ -132,10 +132,10 @@ } }, { - "id": "n13", + "id": "n6", "position": { "x": 75, - "y": 1838.502464519107 + "y": 994.1653264708643 }, "caption": "AZServicePrincipal B", "labels": [], @@ -145,14 +145,27 @@ } }, { - "id": "n14", + "id": "n7", "position": { "x": 75, - "y": 1475.6758276195499 + "y": 631.3386895713072 }, "caption": "AZApp A", + "labels": [], + "properties": {}, "style": { "border-color": "#d33115" + } + }, + { + "id": "n8", + "position": { + "x": 379.0709828688383, + "y": 274.95744418338046 + }, + "caption": "AZTenant", + "style": { + "border-color": "#aea1ff" }, "labels": [], "properties": {} @@ -160,74 +173,84 @@ ], "relationships": [ { - "id": "n8", + "id": "n0", + "fromId": "n3", + "toId": "n0", "type": "AZResetPassword", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n10", - "toId": "n7" + } }, { - "id": "n9", + "id": "n1", + "fromId": "n4", + "toId": "n0", "type": "AZResetPassword", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n11", - "toId": "n7" + } }, { - "id": "n10", + "id": "n2", + "fromId": "n5", + "toId": "n1", "type": "AZMemberOf", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n12", - "toId": "n8" + } }, { - "id": "n11", + "id": "n3", + "fromId": "n6", + "toId": "n2", "type": "AZMemberOf", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n13", - "toId": "n9" + } }, { - "id": "n13", + "id": "n4", + "fromId": "n1", + "toId": "n0", "type": "AZResetPassword", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n8", - "toId": "n7" + } }, { - "id": "n16", + "id": "n5", + "fromId": "n2", + "toId": "n0", "type": "AZResetPassword", + "properties": {}, "style": { "arrow-color": "#68bc00" - }, - "properties": {}, - "fromId": "n9", - "toId": "n7" + } }, { - "id": "n17", + "id": "n6", + "fromId": "n7", + "toId": "n3", "type": "AZRunsAs", + "properties": {}, "style": { "arrow-color": "#d33115" + } + }, + { + "id": "n7", + "type": "AZContains", + "style": { + "arrow-color": "#68bc00" }, "properties": {}, - "fromId": "n14", - "toId": "n10" + "fromId": "n8", + "toId": "n1" } ] } \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/AZInboundControlHarness.svg b/cmd/api/src/test/integration/harnesses/AZInboundControlHarness.svg new file mode 100644 index 000000000..3c0d8c6ce --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/AZInboundControlHarness.svg @@ -0,0 +1 @@ +AZResetPasswordAZResetPasswordAZMemberOfAZMemberOfAZResetPasswordAZResetPasswordAZRunsAsAZContainsControlledAZUserAZGroupAAZGroupBAZServicePrincipalAAZUserBAZUserAAZServicePrincipalBAZAppAAZTenant \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/AZManagementGroup.json b/cmd/api/src/test/integration/harnesses/AZManagementGroup.json new file mode 100644 index 000000000..1a52ec979 --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/AZManagementGroup.json @@ -0,0 +1,156 @@ +{ + "nodes": [ + { + "id": "n0", + "position": { + "x": -55, + "y": 85.2904443080064 + }, + "caption": "UserA", + "labels": [ + "AZUser" + ], + "properties": {}, + "style": {} + }, + { + "id": "n1", + "position": { + "x": -55, + "y": 253.81479412258489 + }, + "caption": "UserB", + "labels": [ + "AZUser" + ], + "properties": {}, + "style": {} + }, + { + "id": "n2", + "position": { + "x": -55, + "y": 422.3391439371636 + }, + "caption": "UserC", + "labels": [ + "AZUser" + ], + "properties": {}, + "style": {} + }, + { + "id": "n3", + "position": { + "x": 449.99999999999994, + "y": 40.19237886466851 + }, + "caption": "Tenant", + "labels": [ + "AZTenant" + ], + "properties": {}, + "style": {} + }, + { + "id": "n4", + "position": { + "x": 300, + "y": 253.81479412258489 + }, + "caption": "Group", + "labels": [ + "AZGroup" + ], + "properties": {}, + "style": {} + } + ], + "relationships": [ + { + "id": "n0", + "fromId": "n2", + "toId": "n4", + "type": "AZManagementGroup", + "properties": {}, + "style": {} + }, + { + "id": "n1", + "fromId": "n1", + "toId": "n4", + "type": "AZManagementGroup", + "properties": {}, + "style": {} + }, + { + "id": "n2", + "fromId": "n0", + "toId": "n4", + "type": "AZManagementGroup", + "properties": {}, + "style": {} + }, + { + "id": "n3", + "fromId": "n3", + "toId": "n4", + "type": "AZContains", + "properties": {}, + "style": {} + } + ], + "style": { + "font-family": "sans-serif", + "background-color": "#ffffff", + "background-image": "", + "background-size": "100%", + "node-color": "#ffffff", + "border-width": 4, + "border-color": "#68bc00", + "radius": 50, + "node-padding": 5, + "node-margin": 2, + "outside-position": "auto", + "node-icon-image": "", + "node-background-image": "", + "icon-position": "inside", + "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": "#aea1ff", + "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" + } +} diff --git a/cmd/api/src/test/integration/harnesses/AZManagementGroup.svg b/cmd/api/src/test/integration/harnesses/AZManagementGroup.svg new file mode 100644 index 000000000..1a7ac2bbb --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/AZManagementGroup.svg @@ -0,0 +1 @@ +AZManagementGroupAZManagementGroupAZManagementGroupAZContainsUserAAZUserUserBAZUserUserCAZUserTenantAZTenantGroupAZGroup \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/azmembership.json b/cmd/api/src/test/integration/harnesses/azgroupmembership.json similarity index 77% rename from cmd/api/src/test/integration/harnesses/azmembership.json rename to cmd/api/src/test/integration/harnesses/azgroupmembership.json index e01123799..c281b8294 100644 --- a/cmd/api/src/test/integration/harnesses/azmembership.json +++ b/cmd/api/src/test/integration/harnesses/azgroupmembership.json @@ -54,36 +54,36 @@ }, "nodes": [ { - "id": "n0", + "id": "n1", "position": { - "x": 0, - "y": 0 + "x": 505.40943703856345, + "y": 89.79253144038367 }, "caption": "Group", - "style": {}, "labels": [ "AZGroup" ], - "properties": {} + "properties": {}, + "style": {} }, { - "id": "n1", + "id": "n2", "position": { - "x": -276.40943703856345, - "y": -159.58506288076748 + "x": 129, + "y": -4 }, "caption": "UserA", - "style": {}, "labels": [ "AZUser" ], - "properties": {} + "properties": {}, + "style": {} }, { - "id": "n2", + "id": "n3", "position": { - "x": -276.40943703856345, - "y": 0 + "x": 129, + "y": 155.58506288076748 }, "caption": "UserB", "labels": [ @@ -93,10 +93,10 @@ "style": {} }, { - "id": "n3", + "id": "n4", "position": { - "x": -276.40943703856345, - "y": 159.58506288076748 + "x": 129, + "y": 365.17012576153496 }, "caption": "UserC", "labels": [ @@ -104,32 +104,51 @@ ], "properties": {}, "style": {} + }, + { + "id": "n5", + "position": { + "x": 129, + "y": -185.5850628807675 + }, + "caption": "Tenant", + "style": {}, + "labels": [], + "properties": {} } ], "relationships": [ { "id": "n0", + "fromId": "n2", + "toId": "n1", "type": "AZMemberOf", - "style": {}, "properties": {}, - "fromId": "n1", - "toId": "n0" + "style": {} }, { "id": "n1", + "fromId": "n3", + "toId": "n1", "type": "AZMemberOf", - "style": {}, "properties": {}, - "fromId": "n2", - "toId": "n0" + "style": {} }, { "id": "n2", + "fromId": "n4", + "toId": "n1", "type": "AZMemberOf", + "properties": {}, + "style": {} + }, + { + "id": "n3", + "type": "AZContains", "style": {}, "properties": {}, - "fromId": "n3", - "toId": "n0" + "fromId": "n5", + "toId": "n1" } ] } \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/azgroupmembership.svg b/cmd/api/src/test/integration/harnesses/azgroupmembership.svg new file mode 100644 index 000000000..d0f558b8a --- /dev/null +++ b/cmd/api/src/test/integration/harnesses/azgroupmembership.svg @@ -0,0 +1 @@ +AZMemberOfAZMemberOfAZMemberOfAZContainsGroupAZGroupUserAAZUserUserBAZUserUserCAZUserTenant \ No newline at end of file diff --git a/cmd/api/src/test/integration/harnesses/azinboundcontrolharness.svg b/cmd/api/src/test/integration/harnesses/azinboundcontrolharness.svg deleted file mode 100644 index ecadd9568..000000000 --- a/cmd/api/src/test/integration/harnesses/azinboundcontrolharness.svg +++ /dev/null @@ -1,18 +0,0 @@ - -AZResetPasswordAZResetPasswordAZMemberOfAZMemberOfAZResetPasswordAZResetPasswordAZRunsAsControlledAZUserAZGroupAAZGroupBAZServicePrincipalAAZUserBAZUserAAZServicePrincipalBAZAppA diff --git a/cmd/api/src/test/integration/harnesses/azmembership.svg b/cmd/api/src/test/integration/harnesses/azmembership.svg deleted file mode 100644 index c34f86023..000000000 --- a/cmd/api/src/test/integration/harnesses/azmembership.svg +++ /dev/null @@ -1,18 +0,0 @@ - -AZMemberOfAZMemberOfAZMemberOfGroupAZGroupUserAAZUserUserBAZUserUserCAZUser From 343dda23986f6d8f7fb099604bc19ae830b5029b Mon Sep 17 00:00:00 2001 From: Rohan Vazarkar Date: Mon, 29 Jan 2024 16:20:58 -0500 Subject: [PATCH 2/4] ESC9a Edge Composition (#354) * feat: esc9a post * test: add esc9 test * chore: add harness files * fix: regen schema after merge * chore: fix small nits * chore: cleanup cert template new function * chore: add missing props * wip: 9a composition * fix: treat failure to grab properties as true * wip: esc9a composition * wip: esc9a composition * feat+chore: add depth controls to dawgs patterns * wip: esc9a composition * fix: do not drop the current segment if the next pattern is optional * wip: esc9a composition * fix: update other continuations to respect depth correctly * wip: edge comp * fix: swap * chore: remove unnecessary logs * feat: esc9a post * test: add esc9 test * chore: fix small nits * wip: 9a composition * wip: esc9a composition * wip: esc9a composition * feat+chore: add depth controls to dawgs patterns * wip: esc9a composition * fix: do not drop the current segment if the next pattern is optional * wip: esc9a composition * fix: update other continuations to respect depth correctly * wip: edge comp * fix: swap * chore: remove unnecessary logs * test: add test covering esc9a edge comp * chore: revert random re-ordering * chore: handle negative min/max depth on continuations --------- Co-authored-by: John Hopper --- .../src/analysis/ad/adcs_integration_test.go | 27 ++ .../Explore/EdgeInfo/EdgeInfoContent.tsx | 2 +- packages/go/analysis/ad/ad.go | 263 +++++++++++++++++- packages/go/dawgs/traversal/traversal.go | 115 ++++++-- .../HelpTexts/ADCSESC9a/ADCSESC9a.tsx | 2 + .../HelpTexts/ADCSESC9a/Composition.tsx | 54 ++++ 6 files changed, 427 insertions(+), 36 deletions(-) create mode 100644 packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/Composition.tsx diff --git a/cmd/api/src/analysis/ad/adcs_integration_test.go b/cmd/api/src/analysis/ad/adcs_integration_test.go index bda73f3ce..d6f856be2 100644 --- a/cmd/api/src/analysis/ad/adcs_integration_test.go +++ b/cmd/api/src/analysis/ad/adcs_integration_test.go @@ -639,6 +639,33 @@ func TestADCSESC9a(t *testing.T) { } return nil }) + + db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { + if results, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria { + return query.Kind(query.Relationship(), ad.ADCSESC9a) + })); err != nil { + t.Fatalf("error fetching esc9a edges in integration test; %v", err) + } else { + assert.Equal(t, 1, len(results)) + edge := results[0] + + if edgeComp, err := ad2.GetEdgeCompositionPath(context.Background(), db, edge); err != nil { + t.Fatalf("error getting edge composition for esc9: %v", err) + } else { + nodes := edgeComp.AllNodes().Slice() + assert.Contains(t, nodes, harness.ESC9AHarness.Attacker) + assert.Contains(t, nodes, harness.ESC9AHarness.Victim) + assert.Contains(t, nodes, harness.ESC9AHarness.Domain) + assert.Contains(t, nodes, harness.ESC9AHarness.NTAuthStore) + assert.Contains(t, nodes, harness.ESC9AHarness.RootCA) + assert.Contains(t, nodes, harness.ESC9AHarness.DC) + assert.Contains(t, nodes, harness.ESC9AHarness.EnterpriseCA) + assert.Contains(t, nodes, harness.ESC9AHarness.CertTemplate) + } + } + + return nil + }) }) } diff --git a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoContent.tsx b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoContent.tsx index bc0e4217b..2f46bf710 100644 --- a/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoContent.tsx +++ b/cmd/ui/src/views/Explore/EdgeInfo/EdgeInfoContent.tsx @@ -73,7 +73,7 @@ const EdgeInfoContent: FC<{ selectedEdge: NonNullable }> = ({ sele const sendOnChange = (selectedEdge.name === 'GoldenCert' || selectedEdge.name === 'ADCSESC1' || - selectedEdge.name === 'ADCSESC3') && + selectedEdge.name === 'ADCSESC3' || selectedEdge.name === 'ADCSESC9a') && section[0] === 'composition'; return ( diff --git a/packages/go/analysis/ad/ad.go b/packages/go/analysis/ad/ad.go index 1c0d029a5..cdc9dadea 100644 --- a/packages/go/analysis/ad/ad.go +++ b/packages/go/analysis/ad/ad.go @@ -549,6 +549,12 @@ func GetEdgeCompositionPath(ctx context.Context, db graph.Database, edge *graph. } else { pathSet = results } + } else if edge.Kind == ad.ADCSESC9a { + if results, err := GetADCSESC9aEdgeComposition(ctx, db, edge); err != nil { + return err + } else { + pathSet = results + } } return nil }) @@ -556,7 +562,7 @@ func GetEdgeCompositionPath(ctx context.Context, db graph.Database, edge *graph. func ADCSESC3Path1Pattern(domainId graph.ID, enterpriseCAs cardinality.Duplex[uint32]) traversal.PatternContinuation { return traversal.NewPattern(). - Outbound(query.And( + OutboundWithDepth(0, 0, query.And( query.Kind(query.Relationship(), ad.MemberOf), query.Kind(query.End(), ad.Group), )). @@ -591,7 +597,7 @@ func ADCSESC3Path1Pattern(domainId graph.ID, enterpriseCAs cardinality.Duplex[ui func ADCSESC3Path2Pattern(domainId graph.ID, enterpriseCAs, candidateTemplates cardinality.Duplex[uint32]) traversal.PatternContinuation { return traversal.NewPattern(). - Outbound(query.And( + OutboundWithDepth(0, 0, query.And( query.Kind(query.Relationship(), ad.MemberOf), query.Kind(query.End(), ad.Group), )). @@ -618,7 +624,7 @@ func ADCSESC3Path2Pattern(domainId graph.ID, enterpriseCAs, candidateTemplates c func ADCSESC3Path3Pattern() traversal.PatternContinuation { return traversal.NewPattern(). - Outbound(query.And( + OutboundWithDepth(0, 0, query.And( query.Kind(query.Relationship(), ad.MemberOf), query.Kind(query.End(), ad.Group), )). @@ -807,7 +813,7 @@ func getDelegatedEnrollmentAgentPath(ctx context.Context, startNode, certTemplat func ADCSESC1Path1Pattern(domainID graph.ID) traversal.PatternContinuation { return traversal.NewPattern(). - Outbound(query.And( + OutboundWithDepth(0, 0, query.And( query.Kind(query.Relationship(), ad.MemberOf), query.Kind(query.End(), ad.Group), )). @@ -846,7 +852,7 @@ func ADCSESC1Path1Pattern(domainID graph.ID) traversal.PatternContinuation { func ADCSESC1Path2Pattern(domainID graph.ID, enterpriseCAs cardinality.Duplex[uint32]) traversal.PatternContinuation { return traversal.NewPattern(). - Outbound(query.And( + OutboundWithDepth(0, 0, query.And( query.Kind(query.Relationship(), ad.MemberOf), query.Kind(query.End(), ad.Group), )). @@ -931,8 +937,6 @@ func GetADCSESC1EdgeComposition(ctx context.Context, db graph.Database, edge *gr // Render paths from the segments return paths, path1EnterpriseCAs.Each(func(value uint32) (bool, error) { for _, segment := range candidateSegments[graph.ID(value)] { - log.Infof("Found ESC1 Path: %s", graph.FormatPathSegment(segment)) - paths.AddPath(segment.Path()) } @@ -975,3 +979,248 @@ func getGoldenCertEdgeComposition(tx graph.Transaction, edge *graph.Relationship return finalPaths, nil } } + +func adcsESC9aPath1Pattern(domainID graph.ID) traversal.PatternContinuation { + return traversal.NewPattern(). + OutboundWithDepth( + 1, 1, + query.And( + query.KindIn(query.Relationship(), ad.GenericWrite, ad.GenericAll, ad.Owns, ad.WriteOwner, ad.WriteDACL), + query.KindIn(query.End(), ad.Computer, ad.User), + ), + ). + OutboundWithDepth( + 0, 0, + query.And( + query.Kind(query.Relationship(), ad.MemberOf), + query.Kind(query.End(), ad.Group), + ), + ). + Outbound( + query.And( + query.KindIn(query.Relationship(), ad.GenericAll, ad.Enroll, ad.AllExtendedRights), + query.Kind(query.End(), ad.CertTemplate), + query.Equals(query.EndProperty(ad.RequiresManagerApproval.String()), false), + query.Equals(query.EndProperty(ad.AuthenticationEnabled.String()), true), + query.Equals(query.EndProperty(ad.NoSecurityExtension.String()), true), + query.Equals(query.EndProperty(ad.EnrolleeSuppliesSubject.String()), false), + query.Or( + query.Equals(query.EndProperty(ad.SubjectAltRequireUPN.String()), true), + query.Equals(query.EndProperty(ad.SubjectAltRequireSPN.String()), true), + ), + query.Or( + query.Equals(query.EndProperty(ad.SchemaVersion.String()), 1), + query.And( + query.GreaterThan(query.EndProperty(ad.SchemaVersion.String()), 1), + query.Equals(query.EndProperty(ad.AuthorizedSignatures.String()), 0), + ), + ), + ), + ). + Outbound(query.And( + query.KindIn(query.Relationship(), ad.PublishedTo, ad.IssuedSignedBy), + query.Kind(query.End(), ad.EnterpriseCA), + )). + Outbound(query.And( + query.KindIn(query.Relationship(), ad.IssuedSignedBy, ad.EnterpriseCAFor), + query.Kind(query.End(), ad.RootCA), + )). + Outbound(query.And( + query.KindIn(query.Relationship(), ad.RootCAFor), + query.Equals(query.EndID(), domainID), + )) +} + +func adcsESC9APath2Pattern(caNodes []graph.ID, domainId graph.ID) traversal.PatternContinuation { + return traversal.NewPattern(). + OutboundWithDepth(0, 0, query.And( + query.Kind(query.Relationship(), ad.MemberOf), + query.Kind(query.End(), ad.Group), + )). + Outbound(query.And( + query.Kind(query.Relationship(), ad.Enroll), + query.InIDs(query.End(), caNodes...), + )). + Outbound(query.And( + query.KindIn(query.Relationship(), ad.TrustedForNTAuth), + query.Kind(query.End(), ad.NTAuthStore), + )). + Outbound(query.And( + query.KindIn(query.Relationship(), ad.NTAuthStoreFor), + query.Equals(query.EndID(), domainId), + )) +} + +func adcsESC9APath3Pattern(caIDs []graph.ID) traversal.PatternContinuation { + return traversal.NewPattern(). + Inbound( + query.KindIn(query.Relationship(), ad.DCFor, ad.TrustedBy), + ). + Inbound(query.And( + query.Kind(query.Relationship(), ad.CanAbuseWeakCertBinding), + query.InIDs(query.StartID(), caIDs...), + )) +} + +func GetADCSESC9aEdgeComposition(ctx context.Context, db graph.Database, edge *graph.Relationship) (graph.PathSet, error) { + /* + MATCH (n {objectid:'S-1-5-21-3933516454-2894985453-2515407000-500'})-[:ADCSESC9a]->(d:Domain {objectid:'S-1-5-21-3933516454-2894985453-2515407000'}) + OPTIONAL MATCH p1 = (n)-[:GenericAll|GenericWrite|Owns|WriteOwner|WriteDacl]->(m)-[:MemberOf*0..]->()-[:GenericAll|Enroll|AllExtendedRights]->(ct)-[:PublishedTo]->(ca)-[:IssuedSignedBy|EnterpriseCAFor|RootCAFor*1..]->(d) + WHERE ct.requiresmanagerapproval = false + AND ct.authenticationenabled = true + AND ct.nosecurityextension = true + AND ct.enrolleesuppliessubject = false + AND (ct.subjectaltrequireupn = true OR ct.subjectaltrequirespn = true) + AND ( + (ct.schemaversion > 1 AND ct.authorizedsignatures = 0) + OR ct.schemaversion = 1 + ) + AND ( + m:Computer + OR (m:User AND ct.subjectaltrequiredns = false AND ct.subjectaltrequiredomaindns = false) + ) + OPTIONAL MATCH p2 = (m)-[:MemberOf*0..]->()-[:Enroll]->(ca)-[:TrustedForNTAuth]->(nt)-[:NTAuthStoreFor]->(d) + OPTIONAL MATCH p3 = (ca)-[:CanAbuseWeakCertBinding|DCFor|TrustedBy*1..]->(d) + RETURN p1,p2,p3 + */ + + var ( + startNode *graph.Node + endNode *graph.Node + + traversalInst = traversal.New(db, analysis.MaximumDatabaseParallelWorkers) + paths = graph.PathSet{} + path1CandidateSegments = map[graph.ID][]*graph.PathSegment{} + victimCANodes = map[graph.ID][]graph.ID{} + path2CandidateSegments = map[graph.ID][]*graph.PathSegment{} + path3CandidateSegments = map[graph.ID][]*graph.PathSegment{} + p2canodes = make([]graph.ID, 0) + nodeMap = map[graph.ID]*graph.Node{} + lock = &sync.Mutex{} + ) + + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + if node, err := ops.FetchNode(tx, edge.StartID); err != nil { + return err + } else if eNode, err := ops.FetchNode(tx, edge.EndID); err != nil { + return err + } else { + startNode = node + endNode = eNode + return nil + } + }); err != nil { + return nil, err + } + + //Fully manifest p1 + if err := traversalInst.BreadthFirst(ctx, traversal.Plan{ + Root: startNode, + Driver: adcsESC9aPath1Pattern(edge.EndID).Do(func(terminal *graph.PathSegment) error { + victimNode := terminal.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Depth() == 1 + }) + + if victimNode.Kinds.ContainsOneOf(ad.User) { + certTemplate := terminal.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Node.Kinds.ContainsOneOf(ad.CertTemplate) + }) + + if !certTemplateValidForUserVictim(certTemplate) { + return nil + } + } + + caNode := terminal.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Node.Kinds.ContainsOneOf(ad.EnterpriseCA) + }) + + lock.Lock() + path1CandidateSegments[victimNode.ID] = append(path1CandidateSegments[victimNode.ID], terminal) + nodeMap[victimNode.ID] = victimNode + victimCANodes[victimNode.ID] = append(victimCANodes[victimNode.ID], caNode.ID) + lock.Unlock() + + return nil + }), + }); err != nil { + return nil, err + } + + for victim, p1CANodes := range victimCANodes { + if err := traversalInst.BreadthFirst(ctx, traversal.Plan{ + Root: nodeMap[victim], + Driver: adcsESC9APath2Pattern(p1CANodes, edge.EndID).Do(func(terminal *graph.PathSegment) error { + caNode := terminal.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Node.Kinds.ContainsOneOf(ad.EnterpriseCA) + }) + + lock.Lock() + path2CandidateSegments[caNode.ID] = append(path2CandidateSegments[caNode.ID], terminal) + p2canodes = append(p2canodes, caNode.ID) + lock.Unlock() + + return nil + }), + }); err != nil { + return nil, err + } + } + + if len(p2canodes) > 0 { + if err := traversalInst.BreadthFirst(ctx, traversal.Plan{ + Root: endNode, + Driver: adcsESC9APath3Pattern(p2canodes).Do(func(terminal *graph.PathSegment) error { + caNode := terminal.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Node.Kinds.ContainsOneOf(ad.EnterpriseCA) + }) + + lock.Lock() + path3CandidateSegments[caNode.ID] = append(path3CandidateSegments[caNode.ID], terminal) + lock.Unlock() + return nil + }), + }); err != nil { + return nil, err + } + } + + for _, p1paths := range path1CandidateSegments { + for _, p1path := range p1paths { + caNode := p1path.Search(func(nextSegment *graph.PathSegment) bool { + return nextSegment.Node.Kinds.ContainsOneOf(ad.EnterpriseCA) + }) + + if p2segments, ok := path2CandidateSegments[caNode.ID]; !ok { + continue + } else if p3segments, ok := path3CandidateSegments[caNode.ID]; !ok { + continue + } else { + paths.AddPath(p1path.Path()) + for _, p2 := range p2segments { + paths.AddPath(p2.Path()) + } + + for _, p3 := range p3segments { + paths.AddPath(p3.Path()) + } + } + } + } + + return paths, nil +} + +func certTemplateValidForUserVictim(certTemplate *graph.Node) bool { + if subjectAltRequireDNS, err := certTemplate.Properties.Get(ad.SubjectAltRequireDNS.String()).Bool(); err != nil { + return false + } else if subjectAltRequireDNS { + return false + } else if subjectAltRequireDomainDNS, err := certTemplate.Properties.Get(ad.SubjectAltRequireDomainDNS.String()).Bool(); err != nil { + return false + } else if subjectAltRequireDomainDNS { + return false + } else { + return true + } +} diff --git a/packages/go/dawgs/traversal/traversal.go b/packages/go/dawgs/traversal/traversal.go index 3ec0b72b4..dcfe83f10 100644 --- a/packages/go/dawgs/traversal/traversal.go +++ b/packages/go/dawgs/traversal/traversal.go @@ -49,7 +49,9 @@ type PatternMatchDelegate = func(terminal *graph.PathSegment) error // The return value of the Do(...) function may be passed directly to a Traversal via a Plan as the Plan.Driver field. type PatternContinuation interface { Outbound(criteria ...graph.Criteria) PatternContinuation + OutboundWithDepth(min, max int, criteria ...graph.Criteria) PatternContinuation Inbound(criteria ...graph.Criteria) PatternContinuation + InboundWithDepth(min, max int, criteria ...graph.Criteria) PatternContinuation Do(delegate PatternMatchDelegate) Driver } @@ -57,6 +59,8 @@ type PatternContinuation interface { type expansion struct { criteria []graph.Criteria direction graph.Direction + minDepth int + maxDepth int } func (s expansion) PrepareCriteria(segment *graph.PathSegment) (graph.Criteria, error) { @@ -84,6 +88,7 @@ func (s expansion) PrepareCriteria(segment *graph.PathSegment) (graph.Criteria, type patternTag struct { patternIdx int + depth int } func popSegmentPatternTag(segment *graph.PathSegment) *patternTag { @@ -95,6 +100,7 @@ func popSegmentPatternTag(segment *graph.PathSegment) *patternTag { } else { tag = &patternTag{ patternIdx: 0, + depth: 0, } } @@ -112,26 +118,62 @@ func (s *pattern) Do(delegate PatternMatchDelegate) Driver { return s.Driver } -// Outbound specifies the next outbound expansion step for this pattern. -func (s *pattern) Outbound(criteria ...graph.Criteria) PatternContinuation { +// OutboundWithDepth specifies the next outbound expansion step for this pattern with depth parameters. +func (s *pattern) OutboundWithDepth(min, max int, criteria ...graph.Criteria) PatternContinuation { + if min < 0 { + min = 1 + log.Warnf("Negative mindepth not allowed. Setting min depth for expansion to 1") + } + + if max < 0 { + max = 0 + log.Warnf("Negative maxdepth not allowed. Setting max depth for expansion to 0") + } + s.expansions = append(s.expansions, expansion{ criteria: criteria, direction: graph.DirectionOutbound, + minDepth: min, + maxDepth: max, }) return s } -// Inbound specifies the next inbound expansion step for this pattern. -func (s *pattern) Inbound(criteria ...graph.Criteria) PatternContinuation { +// Outbound specifies the next outbound expansion step for this pattern. By default, this expansion will use a minimum +// depth of 1 to make the expansion required and a maximum depth of 0 to expand indefinitely. +func (s *pattern) Outbound(criteria ...graph.Criteria) PatternContinuation { + return s.OutboundWithDepth(1, 0, criteria...) +} + +// InboundWithDepth specifies the next inbound expansion step for this pattern with depth parameters. +func (s *pattern) InboundWithDepth(min, max int, criteria ...graph.Criteria) PatternContinuation { + if min < 0 { + min = 1 + log.Warnf("Negative mindepth not allowed. Setting min depth for expansion to 1") + } + + if max < 0 { + max = 0 + log.Warnf("Negative maxdepth not allowed. Setting max depth for expansion to 0") + } + s.expansions = append(s.expansions, expansion{ criteria: criteria, direction: graph.DirectionInbound, + minDepth: min, + maxDepth: max, }) return s } +// Inbound specifies the next inbound expansion step for this pattern. By default, this expansion will use a minimum +// depth of 1 to make the expansion required and a maximum depth of 0 to expand indefinitely. +func (s *pattern) Inbound(criteria ...graph.Criteria) PatternContinuation { + return s.InboundWithDepth(1, 0, criteria...) +} + // NewPattern returns a new PatternContinuation for building a new pattern. func NewPattern() PatternContinuation { return &pattern{} @@ -152,9 +194,9 @@ func (s *pattern) Driver(ctx context.Context, tx graph.Transaction, segment *gra for next := range cursor.Chan() { nextSegment := segment.Descend(next.Node, next.Relationship) nextSegment.Tag = &patternTag{ - // Use the tag's patternIdx here since this is the reference that will see the increment when - // the current expansion is exhausted + // Use the tag's patternIdx and depth since this is a continuation of the expansions patternIdx: tag.patternIdx, + depth: tag.depth + 1, } nextSegments = append(nextSegments, nextSegment) @@ -168,34 +210,51 @@ func (s *pattern) Driver(ctx context.Context, tx graph.Transaction, segment *gra if fetchDirection, err := currentExpansion.direction.Reverse(); err != nil { return nil, err } else { - // Perform the current expansion. - if criteria, err := currentExpansion.PrepareCriteria(segment); err != nil { - return nil, err - } else if err := tx.Relationships().Filter(criteria).FetchDirection(fetchDirection, fetchFunc); err != nil { - return nil, err - } - - // No further expansions means this pattern segment is complete. Increment the pattern index to select the - // next pattern expansion. - tag.patternIdx++ - - // Perform the next expansion if there is one. - if tag.patternIdx < len(s.expansions) { - nextExpansion := s.expansions[tag.patternIdx] - - // Expand the next segments - if criteria, err := nextExpansion.PrepareCriteria(segment); err != nil { + // If no max depth was set or if a max depth was set expand the current step further + if currentExpansion.maxDepth == 0 || tag.depth < currentExpansion.maxDepth { + // Perform the current expansion. + if criteria, err := currentExpansion.PrepareCriteria(segment); err != nil { return nil, err } else if err := tx.Relationships().Filter(criteria).FetchDirection(fetchDirection, fetchFunc); err != nil { return nil, err } - } else if len(nextSegments) == 0 { - // If there are no expanded segments and there are no remaining expansions, this is a terminal segment. - // Hand it off to the delegate and handle any returned error. - if err := s.delegate(segment); err != nil { - return nil, err + } + + // Check first if this current segment was fetched using the current expansion (i.e. non-optional) + if tag.depth > 0 && currentExpansion.minDepth == 0 || tag.depth >= currentExpansion.minDepth { + // No further expansions means this pattern segment is complete. Increment the pattern index to select the + // next pattern expansion. Additionally, set the depth back to zero for the tag since we are leaving the + // current expansion. + tag.patternIdx++ + tag.depth = 0 + + // Perform the next expansion if there is one. + if tag.patternIdx < len(s.expansions) { + nextExpansion := s.expansions[tag.patternIdx] + + // Expand the next segments + if criteria, err := nextExpansion.PrepareCriteria(segment); err != nil { + return nil, err + } else if err := tx.Relationships().Filter(criteria).FetchDirection(fetchDirection, fetchFunc); err != nil { + return nil, err + } + + // If the next expansion is optional, make sure to preserve the current traversal branch + if nextExpansion.minDepth == 0 { + // Reattach the tag to the segment before adding it to the returned segments for the next expansion + segment.Tag = tag + nextSegments = append(nextSegments, segment) + } + } else if len(nextSegments) == 0 { + // If there are no expanded segments and there are no remaining expansions, this is a terminal segment. + // Hand it off to the delegate and handle any returned error. + if err := s.delegate(segment); err != nil { + return nil, err + } } } + + // If the above condition does not match then this current expansion is non-terminal and non-continuable } // Return any collected segments diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/ADCSESC9a.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/ADCSESC9a.tsx index 9de2b72c5..7dcde37b4 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/ADCSESC9a.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/ADCSESC9a.tsx @@ -19,6 +19,7 @@ import WindowsAbuse from './WindowsAbuse'; import LinuxAbuse from './LinuxAbuse'; import Opsec from './Opsec'; import References from './References'; +import Composition from "./Composition"; const ADCSESC9a = { general: General, @@ -26,6 +27,7 @@ const ADCSESC9a = { linuxAbuse: LinuxAbuse, opsec: Opsec, references: References, + composition: Composition }; export default ADCSESC9a; diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/Composition.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/Composition.tsx new file mode 100644 index 000000000..4a24ff5ea --- /dev/null +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC9a/Composition.tsx @@ -0,0 +1,54 @@ +// 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 + +import { FC } from 'react'; +import { Alert, Box, Skeleton, Typography } from '@mui/material'; +import { apiClient } from '../../../utils/api'; +import { EdgeInfoProps } from '..'; +import { useQuery } from 'react-query'; +import VirtualizedNodeList, { VirtualizedNodeListItem } from '../../VirtualizedNodeList'; + +const Composition: FC = ({ sourceDBId, targetDBId, edgeName }) => { + const { data, isLoading, isError } = useQuery(['edgeComposition', sourceDBId, targetDBId, edgeName], ({ signal }) => + apiClient.getEdgeComposition(sourceDBId!, targetDBId!, edgeName!).then((result) => result.data) + ); + + const nodesArray: VirtualizedNodeListItem[] = Object.values(data?.data.nodes || {}).map((node) => ({ + name: node.label, + objectId: node.objectId, + kind: node.kind, + })); + + return ( + <> + + The relationship represents the effective outcome of the configuration and relationships between several + different objects. All objects involved in the creation of this relationship are listed here: + + + {isLoading ? ( + + ) : isError ? ( + Couldn't load edge composition + ) : ( + + )} + + + ); +}; + +export default Composition; From f55cef724680e4e778d5c7638ac3f7cc3cb0ac37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20B=C3=BClow=20Knudsen?= <12843299+JonasBK@users.noreply.github.com> Date: Tue, 30 Jan 2024 10:37:51 +0100 Subject: [PATCH 3/4] docs: Add to ESC3 abuse info (#350) --- .../components/HelpTexts/ADCSESC3/General.tsx | 17 ++++++++--------- .../HelpTexts/ADCSESC3/LinuxAbuse.tsx | 15 +++++++++++++++ .../HelpTexts/ADCSESC3/WindowsAbuse.tsx | 15 +++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/General.tsx index 25356805b..73d474ef4 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/General.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/General.tsx @@ -27,15 +27,14 @@ const General: FC = ({ sourceName, sourceType, targetName }) => { domain {targetName}. - The principal has permission to enroll on a certificate template with the Certificate Request Agent EKU - (OID 1.3.6.1.4.1.311.20.2.1), allowing them to obtain an enrollment agent certificate. They also have - permission to enroll for a certificate template that permits enrollment by enrollment agents and can be - used for authentication. Additionally, they also have enrollment permissions for an enterprise CA with - the necessary templates published. This enterprise CA is trusted for NT authentication in the forest, - along with the CA certificate chain up to the root CA certificate. This setup lets the principal enroll - certificates for any AD forest user or computer, enabling authentication and impersonation of any AD - forest user or computer without their credentials, unless the target user or computer is protected by - enrollment agent restrictions on the enterprise CA. + The principal has permission to enroll on a certificate allowing them to obtain an enrollment agent + certificate. They also have permission to enroll for a certificate template that permits enrollment by + enrollment agents and can be used for authentication. Additionally, they also have enrollment + permissions for an enterprise CA with the necessary templates published. This enterprise CA is trusted + for NT authentication in the forest, along with the CA certificate chain up to the root CA certificate. + This setup lets the principal enroll certificates for any AD forest user or computer, enabling + authentication and impersonation of any AD forest user or computer without their credentials, unless the + target user or computer is protected by enrollment agent restrictions on the enterprise CA. ); diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/LinuxAbuse.tsx index 5b1438442..5a555d739 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/LinuxAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/LinuxAbuse.tsx @@ -32,6 +32,15 @@ const LinuxAbuse: FC = () => { "certipy req -u 'user@corp.local' -p 'password' -dc-ip 'DC_IP' -target 'ca_host' -ca 'ca_name' -template 'vulnerable template'" } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: @@ -44,6 +53,12 @@ const LinuxAbuse: FC = () => { "certipy req -u 'user@corp.local' -p 'password' -dc-ip 'DC_IP' -target 'ca_host' -ca 'ca_name' -template 'User' -on-behalf-of 'contoso\\administrator' -pfx 'user.pfx'" } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the target principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. + Choose another target with the given attribute set. + Step 3: diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/WindowsAbuse.tsx index fc9c11c59..e6bd09040 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/WindowsAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC3/WindowsAbuse.tsx @@ -30,6 +30,15 @@ const WindowsAbuse: FC = () => { {'Certify.exe request /ca:CORPDC01.CORP.LOCAL\\CORP-CORPDC01-CA /template:Vuln-EnrollmentAgent'} + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: @@ -55,6 +64,12 @@ const WindowsAbuse: FC = () => { Save the certificate as itadminenrollment.pem and the private key as{' '} itadminenrollment.key. + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the target principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. + Choose another target with the given attribute set. + Step 4: From 712e035b1f2b28cef67d61016d933cf944812552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20B=C3=BClow=20Knudsen?= <12843299+JonasBK@users.noreply.github.com> Date: Tue, 30 Jan 2024 10:56:02 +0100 Subject: [PATCH 4/4] docs: add note in ESC6 abuse info (#356) --- .../src/components/HelpTexts/ADCSESC6a/LinuxAbuse.tsx | 9 +++++++++ .../src/components/HelpTexts/ADCSESC6a/WindowsAbuse.tsx | 9 +++++++++ .../src/components/HelpTexts/ADCSESC6b/LinuxAbuse.tsx | 9 +++++++++ .../src/components/HelpTexts/ADCSESC6b/WindowsAbuse.tsx | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/LinuxAbuse.tsx index 874627307..83ce889f6 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/LinuxAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/LinuxAbuse.tsx @@ -30,6 +30,15 @@ const LinuxAbuse: FC = () => { 'certipy req -u john@corp.local -p Passw0rd -ca corp-DC-CA -target ca.corp.local -template ESC6 -upn administrator@corp.local' } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: Request a ticket granting ticket (TGT) from the domain, specifying the certificate created in Step 1 and the IP of a domain controller: diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/WindowsAbuse.tsx index 12e36054d..296249a25 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/WindowsAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6a/WindowsAbuse.tsx @@ -30,6 +30,15 @@ const WindowsAbuse: FC = () => { '.\\Certify.exe request /ca:rootdomaindc.forestroot.com\\forestroot-RootDomainDC-CA /template:ESC6 /altname:forestroot\\ForestRootDA' } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: Convert the emitted certificate to PFX format: diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/LinuxAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/LinuxAbuse.tsx index 275e0334c..2c91fa3bf 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/LinuxAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/LinuxAbuse.tsx @@ -33,6 +33,15 @@ const LinuxAbuse: FC = () => { 'certipy req -u john@corp.local -p Passw0rd -ca corp-DC-CA -target ca.corp.local -template ESC6 -upn administrator@corp.local' } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/WindowsAbuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/WindowsAbuse.tsx index a9b6f1bef..0d81e85a1 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/WindowsAbuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/ADCSESC6b/WindowsAbuse.tsx @@ -33,6 +33,15 @@ const WindowsAbuse: FC = () => { '.\\Certify.exe request /ca:rootdomaindc.forestroot.com\\forestroot-RootDomainDC-CA /template:ESC6 /altname:forestroot\\ForestRootDA' } + + If the enrollment fails with an error message stating that the Email or DNS name is unavailable and + cannot be added to the Subject or Subject Alternate name, then it is because the enrollee principal does + not have their 'mail' or 'dNSHostName' attribute set, which is required by the certificate template. The + 'mail' attribute can be set on both user and computer objects but the 'dNSHostName' attribute can only + be set on computer objects. Computers have validated write permission to their own 'dNSHostName' + attribute by default, but neither users nor computers can write to their own 'mail' attribute by + default. + Step 2: