Skip to content
Open
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
95 changes: 95 additions & 0 deletions .claude/plans/data-access-edges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Plan: Optional Read / Write / Delete database edges

## Context
Today the only edge that terminates on an `MSSQL_Database` node representing
elevated access is `MSSQL_ControlDB` (traversable, created from `CONTROL` on the
database or the `db_owner` fixed role). Meatbag wants to also surface principals
that can **read, write, or delete** data in a database — a weaker but still
security-relevant capability. These new edges must be **non-traversable** (they
are informational, not privilege-escalation paths) and their creation must be
**opt-in** via a new flag so existing output is unchanged by default.

## Decisions (confirmed with user)
- **Three edges**, all `→ MSSQL_Database`, all non-traversable:
- `MSSQL_ReadDB` ← `SELECT` on the database
- `MSSQL_WriteDB` ← `INSERT` or `UPDATE` on the database
- `MSSQL_DeleteDB` ← `DELETE` on the database
- **Opt-in flag** `--enable-data-access-edges` (default `false`).
- **Sources**: explicit DB-scoped grants (`sys.database_permissions`, class
`DATABASE`) **and** the `db_datareader` / `db_datawriter` fixed roles.
(`db_datareader` → Read; `db_datawriter` → Write **and** Delete.)

## Implementation

### 1. Register the three edge kinds — `internal/bloodhound/writer.go`
- Add `ReadDB`, `WriteDB`, `DeleteDB` fields to the `EdgeKinds` struct and its
literal, with values `"MSSQL_ReadDB"`, `"MSSQL_WriteDB"`, `"MSSQL_DeleteDB"`.

### 2. Mark them non-traversable + add properties — `internal/bloodhound/edges.go`
- Add the three kinds to the `case` list in `IsTraversableEdge` (return `false`).
- Add three entries to `edgePropertyGenerators` (mirror the `ControlDB` /
`Connect` generator style: General / WindowsAbuse / LinuxAbuse / Opsec /
References). Abuse text = connect as `ctx.SourceName` to `ctx.SQLServerName`,
`USE <db>;` then `SELECT` / `INSERT`+`UPDATE` / `DELETE` example statements.

### 3. Schema + seed data (so BloodHound registers the kinds)
- `internal/bloodhound/schema.json`: add 3 `relationship_kinds` entries with
`"is_traversable": false` (one per new kind).
- `internal/bloodhound/seed_data.json`: add one self-loop edge per new kind
(matches the existing one-edge-per-kind convention).

### 4. Config plumbing
- `internal/collector/collector.go` `Config`: add `EnableDataAccessEdges bool`.
- `cmd/mssqlhound/main.go`: add package var `enableDataAccessEdges`, register
`rootCmd.Flags().BoolVar(... "enable-data-access-edges", false, ...)`, add it
to the `"Collection"` group annotation slice, and set
`EnableDataAccessEdges: enableDataAccessEdges` in the `Config` literal in `run`.

### 5. Emit the edges
All new edge creation is guarded by `if c.config.EnableDataAccessEdges`.

- **Explicit grants** — `createDatabasePermissionEdges`
(`internal/collector/collector.go`, ~line 5910). Add `case "SELECT"`,
`case "INSERT"`, `case "UPDATE"`, `case "DELETE"` (only when
`perm.ClassDesc == "DATABASE"`), each creating the corresponding edge from
`principal.ObjectIdentifier` → `db.ObjectIdentifier` via `c.createEdge`,
reusing `c.getDatabasePrincipalType(principal.TypeDescription)` for
`SourceType`. `INSERT` and `UPDATE` both emit `WriteDB` (edge-dedup in the
writer's `seenEdges` collapses the duplicate when both are granted).
- **Fixed roles** — `createFixedRoleEdges` DB-role loop
(`internal/collector/collector.go`, ~line 5192). Add `case "db_datareader":`
(emit `ReadDB`) and `case "db_datawriter":` (emit `WriteDB` + `DeleteDB`),
using `IsFixedRole: true` in the `EdgeContext`, following the `db_owner`
pattern.

Because `createEdge` already returns `nil` for non-traversable edges when
`DisableNontraversableEdges` is set, the new edges also correctly disappear
under `--disable-nontraversable-edges`.

### 6. Tests — `internal/collector/edge_unit_test.go` + `edge_test_data_test.go`
- Add `buildDataAccessTestData()` with principals holding `SELECT` / `INSERT` /
`UPDATE` / `DELETE` DATABASE grants and `db_datareader` / `db_datawriter`
fixed roles.
- Add `readDBTestCases` / `writeDBTestCases` / `deleteDBTestCases` and a
`TestDataAccessEdges` that runs `runEdgeCreation`. **Note**: the default test
config does not set `EnableDataAccessEdges`; add a variant helper (or extend
`runEdgeCreation`) so the test enables the flag, and add one negative test
asserting the edges are absent when the flag is off.
- Append the new case slices into the aggregate `all` slice in
`edge_test_data_test.go` (the `allEdgeTestCases` builder ~line 696).

### 7. Docs — `README.md`
- Add the flag row to the **Collection** flag table (~line 674) and a short
usage example near **Possible Edge Options** (~line 579).
- Add the three edges to the edge index (~line 100) and the edge-properties
table (~line 1290) as "No unique edge properties".

## Verification
- `go test ./...` (project rule 10) — new `TestDataAccessEdges` plus existing
suite must pass.
- `go build ./cmd/mssqlhound` → binary in repo root; run
`./mssqlhound --help` and confirm `--enable-data-access-edges` appears in the
Collection group.
- Sanity-check schema validity: the collector's schema-upload path reads
`bloodhound.SchemaJSON`; a malformed JSON edit would fail `go test`'s
`json.Unmarshal` in schema-dependent tests.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Run MSSQLHound with a few common collection patterns:
- [`MSSQL_Control`](#mssql_control)
- [`MSSQL_ControlDB`](#mssql_controldb)
- [`MSSQL_ControlServer`](#mssql_controlserver)
- [`MSSQL_DeleteDB`](#mssql_deletedb)
- [`MSSQL_ExecuteAs`](#mssql_executeas)
- [`MSSQL_ExecuteAsOwner`](#mssql_executeasowner)
- [`MSSQL_ExecuteOnHost`](#mssql_executeonhost)
Expand All @@ -130,8 +131,10 @@ Run MSSQLHound with a few common collection patterns:
- [`MSSQL_LinkedTo`](#mssql_linkedto)
- [`MSSQL_MemberOf`](#mssql_memberof)
- [`MSSQL_Owns`](#mssql_owns)
- [`MSSQL_ReadDB`](#mssql_readdb)
- [`MSSQL_ServiceAccountFor`](#mssql_serviceaccountfor)
- [`MSSQL_TakeOwnership`](#mssql_takeownership)
- [`MSSQL_WriteDB`](#mssql_writedb)

# Overview
Collects BloodHound OpenGraph compatible data from one or more MSSQL servers into individual temporary files, then zips them in the current directory
Expand Down Expand Up @@ -583,8 +586,14 @@ export BLOODHOUND_TOKEN_KEY=<token-key>
# Disable possible edges (stricter pathfinding, fewer false positives)
./mssqlhound -t sql.contoso.com --disable-possible-edges

# Skip AD node creation (still emits AD-touching edges in ad_edges.json)
./mssqlhound -t sql.contoso.com --skip-ad-nodes
# Skip AD node creation (still emits AD-touching edges in ad_edges.json)
./mssqlhound -t sql.contoso.com --skip-ad-nodes

# Add non-traversable data-access edges (read/write/delete) to databases
# Draws MSSQL_ReadDB (SELECT), MSSQL_WriteDB (INSERT/UPDATE), and MSSQL_DeleteDB
# (DELETE) edges from principals with explicit DATABASE-scoped grants or the
# db_datareader / db_datawriter fixed roles. Off by default.
./mssqlhound -t sql.contoso.com --enable-data-access-edges
```

### Linked Server Options
Expand Down Expand Up @@ -681,6 +690,7 @@ mssqlhound completion powershell | Out-String | Invoke-Expression
| `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes; AD-touching edges are still emitted to `ad_edges.json` |
| `--disable-nontraversable-edges` | false | Disable non-traversable edges |
| `--disable-possible-edges` | false | Disable possible edges (makes them non-traversable in schema and edge data) |
| `--enable-data-access-edges` | false | Create non-traversable `MSSQL_ReadDB`/`MSSQL_WriteDB`/`MSSQL_DeleteDB` edges for principals that can read, write, or delete data in a database |
| `-w, --workers` | 0 | Number of concurrent workers (0 = sequential processing) |

### Output / Storage
Expand Down Expand Up @@ -1292,6 +1302,8 @@ All edges based on permissions may contain the `With Grant` property, which mean
| **`MSSQL_ControlDB`** | • No unique edge properties |
<a id="mssql_controlserver"></a>
| **`MSSQL_ControlServer`** | • No unique edge properties |
<a id="mssql_deletedb"></a>
| **`MSSQL_DeleteDB`** | • No unique edge properties |
<a id="mssql_executeas"></a>
| **`MSSQL_ExecuteAs`** | • No unique edge properties |
<a id="mssql_executeonhost"></a>
Expand Down Expand Up @@ -1320,10 +1332,14 @@ All edges based on permissions may contain the `With Grant` property, which mean
| **`MSSQL_MemberOf`** | • No unique edge properties |
<a id="mssql_owns"></a>
| **`MSSQL_Owns`** | • No unique edge properties |
<a id="mssql_readdb"></a>
| **`MSSQL_ReadDB`** | • No unique edge properties |
<a id="mssql_serviceaccountfor"></a>
| **`MSSQL_ServiceAccountFor`** | • No unique edge properties |
<a id="mssql_takeownership"></a>
| **`MSSQL_TakeOwnership`** | • No unique edge properties |
<a id="mssql_writedb"></a>
| **`MSSQL_WriteDB`** | • No unique edge properties |

# Credits

Expand Down
5 changes: 4 additions & 1 deletion cmd/mssqlhound/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ var (
skipADNodeCreation bool
disableNontraversableEdges bool
disablePossibleEdges bool
enableDataAccessEdges bool
skipIPDedupe bool
scanAllComputerPorts string

Expand Down Expand Up @@ -138,6 +139,7 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
rootCmd.Flags().BoolVar(&skipADNodeCreation, "skip-ad-nodes", false, "Skip creating User, Group, Computer nodes")
rootCmd.Flags().BoolVar(&disableNontraversableEdges, "disable-nontraversable-edges", false, "Disable non-traversable edges")
rootCmd.Flags().BoolVar(&disablePossibleEdges, "disable-possible-edges", false, "Disable possible edges (makes them non-traversable in schema and edge data)")
rootCmd.Flags().BoolVar(&enableDataAccessEdges, "enable-data-access-edges", false, "Create non-traversable ReadDB/WriteDB/DeleteDB edges for principals that can read, write, or delete data in a database")
rootCmd.Flags().BoolVar(&skipIPDedupe, "skip-ip-dedupe", false, "Skip DNS-based target deduplication (keeps all targets even if they resolve to the same IP)")
rootCmd.Flags().StringVar(&scanAllComputerPorts, "scan-all-computer-ports", "1433", "Comma-separated TCP ports to scan for --scan-all-computers targets")
rootCmd.Flags().IntVar(&linkedServerTimeout, "linked-timeout", 300, "Linked server enumeration timeout (seconds)")
Expand All @@ -164,7 +166,7 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
}
for _, name := range []string{"scan-all-computers", "skip-private-address",
"domain-enum-only", "skip-linked-servers", "collect-from-linked",
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "skip-ip-dedupe", "scan-all-computer-ports"} {
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "enable-data-access-edges", "skip-ip-dedupe", "scan-all-computer-ports"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck
}
for _, name := range []string{"linked-timeout", "workers", "file-size-limit",
Expand Down Expand Up @@ -439,6 +441,7 @@ func run(cmd *cobra.Command, args []string) error {
SkipADNodeCreation: skipADNodeCreation,
DisableNontraversableEdges: disableNontraversableEdges,
DisablePossibleEdges: disablePossibleEdges,
EnableDataAccessEdges: enableDataAccessEdges,
SkipIPDedupe: skipIPDedupe,
LinkedServerTimeout: linkedServerTimeout,
PortCheckTimeout: time.Duration(portCheckTimeout) * time.Second,
Expand Down
58 changes: 57 additions & 1 deletion internal/bloodhound/edges.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ func IsTraversableEdge(kind string) bool {
EdgeKinds.AlterDBRole,
EdgeKinds.AlterServerRole,
EdgeKinds.ImpersonateDBUser,
EdgeKinds.ImpersonateLogin:
EdgeKinds.ImpersonateLogin,
EdgeKinds.ReadDB,
EdgeKinds.WriteDB,
EdgeKinds.DeleteDB:
return false
default:
return true
Expand Down Expand Up @@ -255,6 +258,59 @@ var edgePropertyGenerators = map[string]func(*EdgeContext) EdgeProperties{
}
},

EdgeKinds.ReadDB: func(ctx *EdgeContext) EdgeProperties {
abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and read data from the " + ctx.TargetName + " database:\n" +
"USE " + ctx.TargetName + "; \n" +
"-- List readable tables \n" +
"SELECT s.name AS SchemaName, t.name AS TableName FROM sys.tables t JOIN sys.schemas s ON t.schema_id = s.schema_id; \n" +
"-- Read data \n" +
"SELECT * FROM [schema].[table]; "
return EdgeProperties{
General: "The source " + ctx.SourceType + " has SELECT permission on the " + ctx.TargetName + " database (granted directly or through the db_datareader fixed role). This allows reading data from all tables and views in the database, which may expose sensitive information such as credentials, personal data, or business secrets. This is a non-traversable, informational edge.",
WindowsAbuse: abuse,
LinuxAbuse: abuse,
Opsec: "SELECT statements are not logged by SQL Server's default trace. Data access auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" +
"Reading data is generally low-risk from a detection standpoint unless dedicated database activity monitoring is in place.",
References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" +
"- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles",
}
},

EdgeKinds.WriteDB: func(ctx *EdgeContext) EdgeProperties {
abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and modify data in the " + ctx.TargetName + " database:\n" +
"USE " + ctx.TargetName + "; \n" +
"-- Insert data \n" +
"INSERT INTO [schema].[table] (column1) VALUES ('value'); \n" +
"-- Update data \n" +
"UPDATE [schema].[table] SET column1 = 'value' WHERE <condition>; "
return EdgeProperties{
General: "The source " + ctx.SourceType + " has INSERT and/or UPDATE permission on the " + ctx.TargetName + " database (granted directly or through the db_datawriter fixed role). This allows modifying data in tables, which could be abused to tamper with application data, escalate privileges within an application, or plant malicious content. This is a non-traversable, informational edge.",
WindowsAbuse: abuse,
LinuxAbuse: abuse,
Opsec: "INSERT and UPDATE statements are not logged by SQL Server's default trace. Data modification auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" +
"Triggers on the target tables may generate side effects or log entries.",
References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" +
"- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles",
}
},

EdgeKinds.DeleteDB: func(ctx *EdgeContext) EdgeProperties {
abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and delete data from the " + ctx.TargetName + " database:\n" +
"USE " + ctx.TargetName + "; \n" +
"-- Delete data \n" +
"DELETE FROM [schema].[table] WHERE <condition>; \n" +
"WARNING: Deleting data may cause data loss and application outages. Do not run destructive statements against production systems without authorization."
return EdgeProperties{
General: "The source " + ctx.SourceType + " has DELETE permission on the " + ctx.TargetName + " database (granted directly or through the db_datawriter fixed role). This allows deleting rows from tables, which could be abused to destroy data, cause application outages, or remove evidence of prior activity. This is a non-traversable, informational edge.",
WindowsAbuse: abuse,
LinuxAbuse: abuse,
Opsec: "DELETE statements are not logged by SQL Server's default trace. Data modification auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" +
"Deleting data is destructive and may be noticed through application errors or data integrity checks even without dedicated monitoring.",
References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" +
"- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles",
}
},

EdgeKinds.Impersonate: func(ctx *EdgeContext) EdgeProperties {
var windowsAbuse, linuxAbuse, opsec string
if ctx.DatabaseName != "" {
Expand Down
15 changes: 15 additions & 0 deletions internal/bloodhound/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@
"name": "MSSQL_TakeOwnership",
"description": "",
"is_traversable": false
},
{
"name": "MSSQL_ReadDB",
"description": "",
"is_traversable": false
},
{
"name": "MSSQL_WriteDB",
"description": "",
"is_traversable": false
},
{
"name": "MSSQL_DeleteDB",
"description": "",
"is_traversable": false
}
],
"environments": [
Expand Down
5 changes: 4 additions & 1 deletion internal/bloodhound/seed_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
{ "kind": "MSSQL_Control", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ControlDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ControlServer", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_DeleteDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ExecuteAs", "start": { "value": "dbuser-6cfe3d9a-9c2e-4d73-bc70-8a53f8bb5d61" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ExecuteAsOwner", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ExecuteOnHost", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
Expand All @@ -46,8 +47,10 @@
{ "kind": "MSSQL_LinkedTo", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_MemberOf", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_Owns", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ReadDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_ServiceAccountFor", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_TakeOwnership", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }
{ "kind": "MSSQL_TakeOwnership", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } },
{ "kind": "MSSQL_WriteDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }
]
}
}
Loading