Skip to content

Commit fe0b538

Browse files
authored
Merge pull request #22 from jazofra/main
Enhance scan-all-computers with timeouts and configurability
2 parents 0d458a7 + 1d03ec4 commit fe0b538

11 files changed

Lines changed: 646 additions & 171 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,16 @@ Collect from a single SQL Server:
433433

434434
# Specifying domain controller IP (also used as DNS resolver)
435435
./mssqlhound --scan-all-computers --dc 10.0.0.1 --ldap-user "DOMAIN\username" --ldap-password "password"
436+
437+
# Scan additional candidate SQL ports on every blindly enumerated computer
438+
./mssqlhound --scan-all-computers --scan-all-computer-ports 1433,1434,14330
439+
440+
# Loosen the TCP reachability timeout for slow networks (default is 2 seconds)
441+
./mssqlhound --scan-all-computers --port-check-timeout 5
436442
```
437443

444+
When `--scan-all-computers` is set, SPN-discovered SQL Servers still honor their AD-advertised port or instance. The `--scan-all-computer-ports` list only applies to domain computers without an MSSQLSvc SPN. A per-server worker timeout ensures one wedged target (stuck in nested SQL/LDAP/DNS/SID lookups) cannot keep the worker pool open forever.
445+
438446
### DNS and Domain Controller Configuration
439447

440448
```bash
@@ -653,11 +661,13 @@ mssqlhound completion powershell | Out-String | Invoke-Expression
653661

654662
### Target Selection
655663

656-
| Flag | Description |
657-
|------|-------------|
658-
| `-t, --targets` | Targets (see Authentication above): single, comma-separated, or file path |
659-
| `-A, --scan-all-computers` | Scan all domain computers, not just those with SQL SPNs |
660-
| `--skip-private-address` | Skip private IP check when resolving domain computer addresses |
664+
| Flag | Default | Description |
665+
|------|---------|-------------|
666+
| `-t, --targets` | | Targets (see Authentication above): single, comma-separated, or file path |
667+
| `-A, --scan-all-computers` | false | Scan all domain computers, not just those with SQL SPNs. SPN-discovered SQL Servers still preserve their AD-advertised port or instance; blindly enumerated computers use the ports from `--scan-all-computer-ports` |
668+
| `--scan-all-computer-ports` | `1433` | Comma-separated TCP ports to scan on blindly enumerated domain computers when `--scan-all-computers` is set |
669+
| `--port-check-timeout` | `2` | TCP port reachability timeout (seconds) before skipping a target |
670+
| `--skip-private-address` | false | Skip private IP check when resolving domain computer addresses |
661671

662672
### Collection
663673

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# MSSQLHound Release Notes
2+
## Version 2.0.3 (June 8, 2026)
3+
- Add --scan-all-computer-ports and --port-check-timeout options
4+
- Fix LDAP bind fail fallback when channel binding required
25

36
## Version 2.0.2 (May 7, 2026)
47
- LDAP bind fail fallback to ADSI when channel binding required

cmd/mssqlhound/main.go

Lines changed: 101 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log/slog"
77
"net"
88
"os"
9+
"strconv"
910
"strings"
1011
"time"
1112

@@ -17,7 +18,7 @@ import (
1718
)
1819

1920
var (
20-
version = "2.0.0"
21+
version = "2.0.3"
2122

2223
// Shared connection options (persistent - inherited by subcommands)
2324
serverInstance string
@@ -39,23 +40,25 @@ var (
3940
proxyAddr string
4041

4142
// Collection-specific options (local to root command)
42-
tempDir string
43-
zipDir string
44-
fileSizeLimit string
43+
tempDir string
44+
zipDir string
45+
fileSizeLimit string
4546

4647
logPerTarget bool
4748

48-
domainEnumOnly bool
49-
skipLinkedServerEnum bool
50-
collectFromLinkedServers bool
51-
skipPrivateAddress bool
52-
scanAllComputers bool
53-
skipADNodeCreation bool
54-
disableNontraversableEdges bool
55-
disablePossibleEdges bool
56-
skipIPDedupe bool
49+
domainEnumOnly bool
50+
skipLinkedServerEnum bool
51+
collectFromLinkedServers bool
52+
skipPrivateAddress bool
53+
scanAllComputers bool
54+
skipADNodeCreation bool
55+
disableNontraversableEdges bool
56+
disablePossibleEdges bool
57+
skipIPDedupe bool
58+
scanAllComputerPorts string
5759

5860
linkedServerTimeout int
61+
portCheckTimeout int
5962
memoryThresholdPercent int
6063
workers int
6164

@@ -136,7 +139,9 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
136139
rootCmd.Flags().BoolVar(&disableNontraversableEdges, "disable-nontraversable-edges", false, "Disable non-traversable edges")
137140
rootCmd.Flags().BoolVar(&disablePossibleEdges, "disable-possible-edges", false, "Disable possible edges (makes them non-traversable in schema and edge data)")
138141
rootCmd.Flags().BoolVar(&skipIPDedupe, "skip-ip-dedupe", false, "Skip DNS-based target deduplication (keeps all targets even if they resolve to the same IP)")
142+
rootCmd.Flags().StringVar(&scanAllComputerPorts, "scan-all-computer-ports", "1433", "Comma-separated TCP ports to scan for --scan-all-computers targets")
139143
rootCmd.Flags().IntVar(&linkedServerTimeout, "linked-timeout", 300, "Linked server enumeration timeout (seconds)")
144+
rootCmd.Flags().IntVar(&portCheckTimeout, "port-check-timeout", 2, "TCP port reachability timeout before skipping a target (seconds)")
140145
rootCmd.Flags().IntVar(&memoryThresholdPercent, "memory-threshold", 90, "Stop when memory exceeds this percentage")
141146
rootCmd.Flags().IntVarP(&workers, "workers", "w", 0, "Number of concurrent workers (0 = sequential processing)")
142147

@@ -159,11 +164,11 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
159164
}
160165
for _, name := range []string{"scan-all-computers", "skip-private-address",
161166
"domain-enum-only", "skip-linked-servers", "collect-from-linked",
162-
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "skip-ip-dedupe"} {
167+
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "skip-ip-dedupe", "scan-all-computer-ports"} {
163168
rootCmd.Flags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck
164169
}
165170
for _, name := range []string{"linked-timeout", "workers", "file-size-limit",
166-
"memory-threshold", "size-update-interval"} {
171+
"port-check-timeout", "memory-threshold", "size-update-interval"} {
167172
rootCmd.Flags().SetAnnotation(name, "group", []string{"Performance"}) //nolint:errcheck
168173
}
169174
for _, name := range []string{"temp-dir", "zip-dir", "log-per-target"} {
@@ -385,6 +390,14 @@ func run(cmd *cobra.Command, args []string) error {
385390
return fmt.Errorf("--upload-schema-only and --upload-results-only are mutually exclusive")
386391
}
387392

393+
parsedScanAllComputerPorts, err := parsePortList(scanAllComputerPorts)
394+
if err != nil {
395+
return fmt.Errorf("invalid --scan-all-computer-ports: %w", err)
396+
}
397+
if portCheckTimeout <= 0 {
398+
return fmt.Errorf("--port-check-timeout must be greater than 0 seconds")
399+
}
400+
388401
// Determine what to upload: default is both schema and results
389402
uploadSchema := true
390403
uploadResults := true
@@ -396,49 +409,51 @@ func run(cmd *cobra.Command, args []string) error {
396409

397410
// Build configuration from flags
398411
config := &collector.Config{
399-
ServerInstance: serverInstance,
400-
ServerListFile: serverListFile,
401-
ServerList: serverList,
402-
UserID: userID,
403-
Password: password,
404-
NTHash: ntHash,
405-
UseKerberos: useKerberos,
406-
Krb5ConfigFile: krb5ConfigFile,
407-
Krb5CCacheFile: krb5CCacheFile,
408-
Krb5KeytabFile: krb5KeytabFile,
409-
Krb5Realm: krb5Realm,
410-
Domain: strings.ToUpper(domain),
411-
DC: dc,
412-
DNSResolver: dnsResolver,
413-
LDAPUser: effectiveLDAPUser,
414-
LDAPPassword: effectiveLDAPPassword,
415-
TempDir: tempDir,
416-
ZipDir: zipDir,
417-
FileSizeLimit: fileSizeLimit,
418-
Verbose: verbose,
419-
Debug: debug,
420-
DomainEnumOnly: domainEnumOnly,
421-
SkipLinkedServerEnum: skipLinkedServerEnum,
422-
CollectFromLinkedServers: collectFromLinkedServers,
423-
SkipPrivateAddress: skipPrivateAddress,
424-
ScanAllComputers: scanAllComputers,
425-
SkipADNodeCreation: skipADNodeCreation,
426-
DisableNontraversableEdges: disableNontraversableEdges,
427-
DisablePossibleEdges: disablePossibleEdges,
428-
SkipIPDedupe: skipIPDedupe,
429-
LinkedServerTimeout: linkedServerTimeout,
430-
MemoryThresholdPercent: memoryThresholdPercent,
431-
Workers: workers,
432-
ProxyAddr: proxyAddr,
433-
Logger: logger,
434-
LogPerTarget: logPerTarget,
435-
LogLevel: &logLevel,
436-
BloodHoundURL: bloodhoundURL,
437-
TokenID: tokenID,
438-
TokenKey: tokenKey,
439-
UploadSchema: uploadSchema,
440-
UploadResults: uploadResults,
441-
SkipCollection: skipCollection,
412+
ServerInstance: serverInstance,
413+
ServerListFile: serverListFile,
414+
ServerList: serverList,
415+
UserID: userID,
416+
Password: password,
417+
NTHash: ntHash,
418+
UseKerberos: useKerberos,
419+
Krb5ConfigFile: krb5ConfigFile,
420+
Krb5CCacheFile: krb5CCacheFile,
421+
Krb5KeytabFile: krb5KeytabFile,
422+
Krb5Realm: krb5Realm,
423+
Domain: strings.ToUpper(domain),
424+
DC: dc,
425+
DNSResolver: dnsResolver,
426+
LDAPUser: effectiveLDAPUser,
427+
LDAPPassword: effectiveLDAPPassword,
428+
TempDir: tempDir,
429+
ZipDir: zipDir,
430+
FileSizeLimit: fileSizeLimit,
431+
Verbose: verbose,
432+
Debug: debug,
433+
DomainEnumOnly: domainEnumOnly,
434+
SkipLinkedServerEnum: skipLinkedServerEnum,
435+
CollectFromLinkedServers: collectFromLinkedServers,
436+
SkipPrivateAddress: skipPrivateAddress,
437+
ScanAllComputers: scanAllComputers,
438+
ScanAllComputerPorts: parsedScanAllComputerPorts,
439+
SkipADNodeCreation: skipADNodeCreation,
440+
DisableNontraversableEdges: disableNontraversableEdges,
441+
DisablePossibleEdges: disablePossibleEdges,
442+
SkipIPDedupe: skipIPDedupe,
443+
LinkedServerTimeout: linkedServerTimeout,
444+
PortCheckTimeout: time.Duration(portCheckTimeout) * time.Second,
445+
MemoryThresholdPercent: memoryThresholdPercent,
446+
Workers: workers,
447+
ProxyAddr: proxyAddr,
448+
Logger: logger,
449+
LogPerTarget: logPerTarget,
450+
LogLevel: &logLevel,
451+
BloodHoundURL: bloodhoundURL,
452+
TokenID: tokenID,
453+
TokenKey: tokenKey,
454+
UploadSchema: uploadSchema,
455+
UploadResults: uploadResults,
456+
SkipCollection: skipCollection,
442457
}
443458

444459
if proxyAddr != "" {
@@ -475,6 +490,34 @@ func classifyTarget(target string) (string, string, string) {
475490
return target, "", ""
476491
}
477492

493+
func parsePortList(value string) ([]int, error) {
494+
parts := strings.Split(value, ",")
495+
ports := make([]int, 0, len(parts))
496+
seen := make(map[int]struct{}, len(parts))
497+
for _, part := range parts {
498+
part = strings.TrimSpace(part)
499+
if part == "" {
500+
return nil, fmt.Errorf("empty port")
501+
}
502+
port, err := strconv.Atoi(part)
503+
if err != nil {
504+
return nil, fmt.Errorf("%q is not a number", part)
505+
}
506+
if port < 1 || port > 65535 {
507+
return nil, fmt.Errorf("%d is outside 1-65535", port)
508+
}
509+
if _, exists := seen[port]; exists {
510+
continue
511+
}
512+
seen[port] = struct{}{}
513+
ports = append(ports, port)
514+
}
515+
if len(ports) == 0 {
516+
return nil, fmt.Errorf("at least one port is required")
517+
}
518+
return ports, nil
519+
}
520+
478521
// extractTargetCredentials parses user:password@target from a target string.
479522
// Splits on the last '@' (so UPN usernames like user@domain work) and the
480523
// first ':' in the credentials portion. Returns (user, password, cleanTarget, ok).

cmd/mssqlhound/main_test.go

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ func TestClassifyTarget(t *testing.T) {
1616
}
1717

1818
tests := []struct {
19-
name string
20-
input string
21-
wantInstance string
22-
wantListFile string
23-
wantList string
19+
name string
20+
input string
21+
wantInstance string
22+
wantListFile string
23+
wantList string
2424
}{
2525
{
2626
name: "empty input",
@@ -99,6 +99,47 @@ func TestClassifyTarget(t *testing.T) {
9999
}
100100
}
101101

102+
func TestParsePortList(t *testing.T) {
103+
tests := []struct {
104+
name string
105+
input string
106+
want []int
107+
wantErr bool
108+
}{
109+
{name: "default", input: "1433", want: []int{1433}},
110+
{name: "multiple", input: "1433,1444,51433", want: []int{1433, 1444, 51433}},
111+
{name: "spaces and duplicates", input: " 1433, 1444,1433 ", want: []int{1433, 1444}},
112+
{name: "empty", input: "", wantErr: true},
113+
{name: "empty item", input: "1433,,1444", wantErr: true},
114+
{name: "not numeric", input: "1433,abc", wantErr: true},
115+
{name: "zero", input: "0", wantErr: true},
116+
{name: "too high", input: "65536", wantErr: true},
117+
}
118+
119+
for _, tc := range tests {
120+
t.Run(tc.name, func(t *testing.T) {
121+
got, err := parsePortList(tc.input)
122+
if tc.wantErr {
123+
if err == nil {
124+
t.Fatal("expected error")
125+
}
126+
return
127+
}
128+
if err != nil {
129+
t.Fatalf("unexpected error: %v", err)
130+
}
131+
if len(got) != len(tc.want) {
132+
t.Fatalf("ports = %v, want %v", got, tc.want)
133+
}
134+
for i := range got {
135+
if got[i] != tc.want[i] {
136+
t.Fatalf("ports = %v, want %v", got, tc.want)
137+
}
138+
}
139+
})
140+
}
141+
}
142+
102143
func TestExtractTargetCredentials(t *testing.T) {
103144
tests := []struct {
104145
name string
@@ -227,12 +268,12 @@ func TestExtractTargetCredentials(t *testing.T) {
227268

228269
func TestParseBloodhoundUploadFlag(t *testing.T) {
229270
tests := []struct {
230-
name string
231-
input string
232-
wantID string
233-
wantKey string
234-
wantURL string
235-
wantErr string
271+
name string
272+
input string
273+
wantID string
274+
wantKey string
275+
wantURL string
276+
wantErr string
236277
}{
237278
{
238279
name: "valid full URL",

0 commit comments

Comments
 (0)