diff --git a/deployments/kubehound/kubegraph/kubehound-db-init.groovy b/deployments/kubehound/kubegraph/kubehound-db-init.groovy index 682fe96f6..bc0d893aa 100644 --- a/deployments/kubehound/kubegraph/kubehound-db-init.groovy +++ b/deployments/kubehound/kubegraph/kubehound-db-init.groovy @@ -81,9 +81,6 @@ mgmt.addConnection(tokenBruteforce, permissionSet, identity); tokenList = mgmt.makeEdgeLabel('TOKEN_LIST').multiplicity(MULTI).make(); mgmt.addConnection(tokenList, permissionSet, identity); -tokenVarLog = mgmt.makeEdgeLabel('TOKEN_VAR_LOG_SYMLINK').multiplicity(ONE2MANY).make(); -mgmt.addConnection(tokenVarLog, container, volume); - nsenter = mgmt.makeEdgeLabel('CE_NSENTER').multiplicity(MANY2ONE).make(); mgmt.addConnection(nsenter, container, node); @@ -99,6 +96,9 @@ mgmt.addConnection(privMount, container, node); sysPtrace = mgmt.makeEdgeLabel('CE_SYS_PTRACE').multiplicity(MANY2ONE).make(); mgmt.addConnection(sysPtrace, container, node); +varLogSymLink = mgmt.makeEdgeLabel('CE_VAR_LOG_SYMLINK').multiplicity(MULTI).make(); +mgmt.addConnection(varLogSymLink, container, node); + endpointExploit = mgmt.makeEdgeLabel('ENDPOINT_EXPLOIT').multiplicity(MULTI).make(); mgmt.addConnection(endpointExploit, endpoint, container); diff --git a/docs/reference/attacks/TOKEN_VAR_LOG_SYMLINK.md b/docs/reference/attacks/CE_VAR_LOG_SYMLINK.md similarity index 89% rename from docs/reference/attacks/TOKEN_VAR_LOG_SYMLINK.md rename to docs/reference/attacks/CE_VAR_LOG_SYMLINK.md index dbe7e811a..dfac1c73b 100644 --- a/docs/reference/attacks/TOKEN_VAR_LOG_SYMLINK.md +++ b/docs/reference/attacks/CE_VAR_LOG_SYMLINK.md @@ -1,25 +1,25 @@ --- -title: TOKEN_VAR_LOG_SYMLINK +title: CE_VAR_LOG_SYMLINK --- -# TOKEN_VAR_LOG_SYMLINK +# CE_VAR_LOG_SYMLINK | Source | Destination | MITRE | | ----------------------------------------- | ------------------------------------- |----------------------------------| | [Container](../entities/container.md) | [Node](../entities/node.md) | [Escape to Host, T1611](https://attack.mitre.org/techniques/T1611/) | -Steal all K8s API tokens from a node via an exposed `/var/log` mount. +Arbitrary file reads on the host from a node via an exposed `/var/log` mount. ## Details -A pod running as root and with a mount point to the node’s `/var/log` directory can expose the entire contents of its host filesystem to any user who has access to its logs, enabling an attacker to steal all K8s API tokens present on the K8s node. See [Kubernetes Pod Escape Using Log Mounts](https://blog.aquasec.com/kubernetes-security-pod-escape-log-mounts) for a more detailed explanation of the technique. +A pod running as root and with a mount point to the node’s `/var/log` directory can expose the entire contents of its host filesystem to any user who has access to its logs, enabling an attacker to read arbitrary files on the host node. See [Kubernetes Pod Escape Using Log Mounts](https://blog.aquasec.com/kubernetes-security-pod-escape-log-mounts) for a more detailed explanation of the technique. ## Prerequisites @@ -115,7 +115,7 @@ Avoid running containers as the `root` user. Enforce running as an unprivileged ## Calculation -+ [TokenVarLogSymlink](https://github.com/DataDog/KubeHound/tree/main/pkg/kubehound/graph/edge/token_var_log_symlink.go) ++ [EscapeVarLogSymlink](https://github.com/DataDog/KubeHound/tree/main/pkg/kubehound/graph/edge/escape_var_log_symlink.go) ## References: diff --git a/docs/reference/attacks/index.md b/docs/reference/attacks/index.md index 211bf6d96..76a5a615f 100644 --- a/docs/reference/attacks/index.md +++ b/docs/reference/attacks/index.md @@ -30,6 +30,6 @@ hide: | [TOKEN_BRUTEFORCE](./TOKEN_BRUTEFORCE.md) | Brute-force secret name of service account token | Steal Application Access Token | Credential Access | | [TOKEN_LIST](./TOKEN_LIST.md) | Access service account token secrets | Steal Application Access Token | Credential Access | | [TOKEN_STEAL](./TOKEN_STEAL.md) | Steal service account token from volume | Unsecured Credentials | Credential Access | -| [TOKEN_VAR_LOG_SYMLINK](./TOKEN_VAR_LOG_SYMLINK.md) | Steal service account token from volume | Unsecured Credentials | Credential Access | +| [CE_VAR_LOG_SYMLINK](./CE_VAR_LOG_SYMLINK.md) | Read file from sensitive host mount | Escape to host | Privilege escalation | | [VOLUME_ACCESS](./VOLUME_ACCESS.md) | Access host volume | Container and Resource Discovery | Discovery | | [VOLUME_DISCOVER](./VOLUME_DISCOVER.md) | Enumerate mounted volumes | Container and Resource Discovery | Discovery | diff --git a/pkg/kubehound/graph/edge/escape_var_log_symlink.go b/pkg/kubehound/graph/edge/escape_var_log_symlink.go new file mode 100644 index 000000000..1d316fb4b --- /dev/null +++ b/pkg/kubehound/graph/edge/escape_var_log_symlink.go @@ -0,0 +1,128 @@ +package edge + +import ( + "context" + "fmt" + + "github.com/DataDog/KubeHound/pkg/kubehound/graph/adapter" + "github.com/DataDog/KubeHound/pkg/kubehound/graph/types" + "github.com/DataDog/KubeHound/pkg/kubehound/models/converter" + "github.com/DataDog/KubeHound/pkg/kubehound/models/shared" + "github.com/DataDog/KubeHound/pkg/kubehound/storage/cache" + "github.com/DataDog/KubeHound/pkg/kubehound/storage/storedb" + "github.com/DataDog/KubeHound/pkg/kubehound/store/collections" + gremlin "github.com/apache/tinkerpop/gremlin-go/v3/driver" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func init() { + Register(&EscapeVarLogSymlink{}, RegisterGraphDependency) +} + +type EscapeVarLogSymlink struct { + BaseContainerEscape +} + +// The mongodb query returns a list of permissionSet +type permissionSetIDEscapeGroup struct { + PermissionSetID primitive.ObjectID `bson:"_id" json:"permission_set"` +} + +func (e *EscapeVarLogSymlink) Label() string { + return "CE_VAR_LOG_SYMLINK" +} + +// List of needed edges to run the traversal query +func (e *EscapeVarLogSymlink) Dependencies() []string { + return []string{"PERMISSION_DISCOVER", "IDENTITY_ASSUME", "VOLUME_DISCOVER", "VOLUME_ACCESS"} +} + +func (e *EscapeVarLogSymlink) Name() string { + return "ContainerEscapeVarLogSymlink" +} + +func (e *EscapeVarLogSymlink) Processor(ctx context.Context, oic *converter.ObjectIDConverter, entry any) (any, error) { + typed, ok := entry.(*permissionSetIDEscapeGroup) + if !ok { + return nil, fmt.Errorf("Invalid type passed to processor: %T", entry) + } + + permissionSetVertexID, err := oic.GraphID(ctx, typed.PermissionSetID.Hex()) + if err != nil { + return nil, fmt.Errorf("%s edge IN id convert: %w", e.Label(), err) + } + + return permissionSetVertexID, nil +} + +func (e *EscapeVarLogSymlink) Traversal() types.EdgeTraversal { + return func(source *gremlin.GraphTraversalSource, inserts []any) *gremlin.GraphTraversal { + g := source.GetGraphTraversal() + // reduce the graph to only these permission sets + g.V(inserts...).HasLabel("PermissionSet"). + // get identity vertices + InE("PERMISSION_DISCOVER").OutV(). + // get container vertices + InE("IDENTITY_ASSUME").OutV(). + // save container vertices as "c" so we can link to it to the node via CE_VAR_LOG_SYMLINK + HasLabel("Container").As("c"). + // Get all the volumes + OutE("VOLUME_DISCOVER").InV(). + Has("type", shared.VolumeTypeHost). + // filter only the volumes that are "affected" by this attacks ("/", "/var", "/var/log"). + Has("sourcePath", P.Within("/", "/var", "/var/log")). + // get the node related to that volume mount + InE("VOLUME_ACCESS").OutV(). + HasLabel("Node").As("n"). + AddE("CE_VAR_LOG_SYMLINK").From("c").To("n"). + Barrier().Limit(0) + + return g + } +} + +func (e *EscapeVarLogSymlink) Stream(ctx context.Context, store storedb.Provider, _ cache.CacheReader, + callback types.ProcessEntryCallback, complete types.CompleteQueryCallback) error { + + permissionSets := adapter.MongoDB(store).Collection(collections.PermissionSetName) + pipeline := []bson.M{ + { + "$match": bson.M{ + "rules": bson.M{ + "$elemMatch": bson.M{ + "$and": bson.A{ + bson.M{"$or": bson.A{ + bson.M{"apigroups": ""}, + bson.M{"apigroups": "*"}, + }}, + bson.M{"$or": bson.A{ + bson.M{"resources": "pods/log"}, + bson.M{"resources": "pods/*"}, + bson.M{"resources": "*"}, + }}, + bson.M{"$or": bson.A{ + bson.M{"verbs": "get"}, + bson.M{"verbs": "*"}, + }}, + bson.M{"resourcenames": nil}, // TODO: handle resource scope + }, + }, + }, + }, + }, + { + "$project": bson.M{ + "_id": 1, + }, + }, + } + + cur, err := permissionSets.Aggregate(ctx, pipeline) + if err != nil { + return err + } + defer cur.Close(ctx) + + return adapter.MongoCursorHandler[permissionSetIDEscapeGroup](ctx, cur, callback, complete) +} diff --git a/test/setup/test-cluster/attacks/TOKEN_VAR_LOG_SYMLINK.yaml b/test/setup/test-cluster/attacks/CE_VAR_LOG_SYMLINK.yaml similarity index 93% rename from test/setup/test-cluster/attacks/TOKEN_VAR_LOG_SYMLINK.yaml rename to test/setup/test-cluster/attacks/CE_VAR_LOG_SYMLINK.yaml index 142a51a1c..30351157f 100644 --- a/test/setup/test-cluster/attacks/TOKEN_VAR_LOG_SYMLINK.yaml +++ b/test/setup/test-cluster/attacks/CE_VAR_LOG_SYMLINK.yaml @@ -1,4 +1,4 @@ -# TOKEN_VAR_LOG_SYMLINK edge +# CE_VAR_LOG_SYMLINK edge apiVersion: v1 kind: ServiceAccount metadata: @@ -26,7 +26,7 @@ roleRef: name: read-logs subjects: - kind: ServiceAccount - name: varrlog-sa + name: varlog-sa namespace: default --- apiVersion: v1 @@ -38,7 +38,7 @@ metadata: app: kubehound-edge-test spec: containers: - - name: varlog-pod + - name: varlog-container image: ubuntu volumeMounts: - mountPath: /host/var/log diff --git a/test/system/graph_dsl_test.go b/test/system/graph_dsl_test.go index 2b2c183cf..651bf9d9b 100644 --- a/test/system/graph_dsl_test.go +++ b/test/system/graph_dsl_test.go @@ -111,6 +111,7 @@ func (suite *DslTestSuite) TestTraversalSource_escapes() { "path[modload-pod, CE_MODULE_LOAD, Node]", "path[kube-proxy, CE_MODULE_LOAD, Node]", "path[kube-proxy, CE_PRIV_MOUNT, Node]", + "path[varlog-container, CE_VAR_LOG_SYMLINK, Node]", } suite.ElementsMatch(escapes, expected) @@ -167,7 +168,7 @@ func (suite *DslTestSuite) TestTraversalSource_hostMounts() { func (suite *DslTestSuite) TestTraversalSource_identities() { ids := suite.testScriptArray("kh.identities().has('namespace', 'default').values('name')") expected := []string{ - "varrlog-sa", "rolebind-sa", "tokenget-sa", "pod-create-sa", + "varlog-sa", "rolebind-sa", "tokenget-sa", "pod-create-sa", "impersonate-sa", "pod-exec-sa", "tokenlist-sa", "pod-patch-sa", } @@ -177,7 +178,7 @@ func (suite *DslTestSuite) TestTraversalSource_identities() { func (suite *DslTestSuite) TestTraversalSource_sas() { ids := suite.testScriptArray("kh.sas().has('namespace', 'default').values('name')") expected := []string{ - "varrlog-sa", "rolebind-sa", "tokenget-sa", "pod-create-sa", + "varlog-sa", "rolebind-sa", "tokenget-sa", "pod-create-sa", "impersonate-sa", "pod-exec-sa", "tokenlist-sa", "pod-patch-sa", } diff --git a/test/system/graph_edge_test.go b/test/system/graph_edge_test.go index 166a884d0..23c579751 100644 --- a/test/system/graph_edge_test.go +++ b/test/system/graph_edge_test.go @@ -364,7 +364,7 @@ func (suite *EdgeTestSuite) TestEdge_PERMISSION_DISCOVER() { "path[map[name:[tokenlist-sa]], map[], map[name:[list-secrets::pod-list-secrets]", "path[map[name:[pod-exec-sa]], map[], map[name:[exec-pods::pod-exec-pods]", "path[map[name:[impersonate-sa]], map[], map[name:[impersonate::pod-impersonate]", - "path[map[name:[varrlog-sa]], map[], map[name:[read-logs::pod-read-logs]", + "path[map[name:[varlog-sa]], map[], map[name:[read-logs::pod-read-logs]", } suite.Subset(paths, expected) @@ -684,6 +684,14 @@ func (suite *EdgeTestSuite) Test_NoEdgeCase() { suite.Equal(len(results), 0) } +func (suite *EdgeTestSuite) Test_CE_VAR_LOG_SYMLINK() { + containers := map[string]bool{ + "varlog-container": true, + } + + suite._testContainerEscape("CE_VAR_LOG_SYMLINK", DefaultContainerEscapeNodes, containers) +} + func TestEdgeTestSuite(t *testing.T) { suite.Run(t, new(EdgeTestSuite)) } diff --git a/test/system/graph_vertex_test.go b/test/system/graph_vertex_test.go index f784d7579..36440ee00 100644 --- a/test/system/graph_vertex_test.go +++ b/test/system/graph_vertex_test.go @@ -249,7 +249,7 @@ func (suite *VertexTestSuite) TestVertexPod() { suite.Equal(expectedPods, resultsMap) } -func (suite *VertexTestSuite) TestVertexPerrmissionSet() { +func (suite *VertexTestSuite) TestVertexPermissionSet() { results, err := suite.g.V(). HasLabel(vertex.PermissionSetLabel). Has("namespace", "default"). @@ -297,7 +297,7 @@ func (suite *VertexTestSuite) TestVertexCritical() { func (suite *VertexTestSuite) TestVertexVolume() { results, err := suite.g.V().HasLabel(vertex.VolumeLabel).ElementMap().ToList() suite.NoError(err) - suite.Equal(51, len(results)) + suite.Equal(52, len(results)) results, err = suite.g.V().HasLabel(vertex.VolumeLabel).Has("sourcePath", "/proc/sys/kernel").Has("name", "nodeproc").ElementMap().ToList() suite.NoError(err) diff --git a/test/system/vertex.gen.go b/test/system/vertex.gen.go index e36c224e2..8c168718c 100644 --- a/test/system/vertex.gen.go +++ b/test/system/vertex.gen.go @@ -1,5 +1,5 @@ // PLEASE DO NOT EDIT -// THIS HAS BEEN GENERATED AUTOMATICALLY on 2023-09-19 16:48 +// THIS HAS BEEN GENERATED AUTOMATICALLY on 2023-10-06 15:45 // // Generate it with "go generate ./..." // @@ -688,9 +688,9 @@ var expectedContainers = map[string]graph.Container{ // Node: "", Compromised: 0, }, - "varlog-pod": { + "varlog-container": { StoreID: "", - Name: "varlog-pod", + Name: "varlog-container", Image: "ubuntu", Command: []string{}, Args: []string{},