diff --git a/go.mod b/go.mod index 8378c26ad9..8738db1684 100644 --- a/go.mod +++ b/go.mod @@ -94,7 +94,6 @@ require ( github.com/projectdiscovery/dsl v0.8.20 github.com/projectdiscovery/fasttemplate v0.0.2 github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c - github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb github.com/projectdiscovery/goflags v0.1.75 github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d diff --git a/go.sum b/go.sum index f63bf5e22b..7e908a08f1 100644 --- a/go.sum +++ b/go.sum @@ -849,8 +849,6 @@ github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxys github.com/projectdiscovery/freeport v0.0.7/go.mod h1:cOhWKvNBe9xM6dFJ3RrrLvJ5vXx2NQ36SecuwjenV2k= github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c h1:s+lLAlrOrgwlPZQ9DFqNw+kia2nteKnJZ2Ek313yoUc= github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c/go.mod h1:rN35/D3lVx2YDeENFFz06uj8j3XIqK1Ym9XcISF5fzg= -github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG906Drtbpz4DwU5mhGIeOhRcktDH4cGQitGUMAsg= -github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY= github.com/projectdiscovery/goflags v0.1.75 h1:njEBnyueQaFa2ptWxbyl9zX0OClNdlN2AzZveNHiBOs= github.com/projectdiscovery/goflags v0.1.75/go.mod h1:7nAP1r2Dqgn/rwmOE3EWbZWUCEJKNIhVSBGpuzJAIns= github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 h1:hYd1zQA/dxO2ASyQ6Re73TcJkW1LjLQvt4+86Hxefz8= diff --git a/pkg/js/generated/ts/dcerpc.ts b/pkg/js/generated/ts/dcerpc.ts index ab3bcba500..919956eb27 100755 --- a/pkg/js/generated/ts/dcerpc.ts +++ b/pkg/js/generated/ts/dcerpc.ts @@ -135,6 +135,67 @@ export class Client { public SamrEnumerateUsers(): DomainUser[] | null { return null; } + + /** + * EnumServices lists Win32 services on the target via SVCCTL + * (nmap: smb-enum-services). + * @example + * ```javascript + * const c = new dcerpc.Client('dc01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); + * const services = c.EnumServices(); + * for (const s of services) { + * if (s.State === 'RUNNING') { log(s.Name + ' => ' + s.DisplayName); } + * } + * ``` + */ + public EnumServices(): ServiceEntry[] | null { + return null; + } + + /** + * EnumSessions lists SMB sessions known to the server via SRVSVC + * (nmap: smb-enum-sessions). + * @example + * ```javascript + * const c = new dcerpc.Client('fs01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); + * const sessions = c.EnumSessions(); + * for (const s of sessions) { + * log(s.Username + '@' + s.Client + ' active=' + s.Active + 's'); + * } + * ``` + */ + public EnumSessions(): SessionEntry[] | null { + return null; + } + + /** + * EnumProcesses lists running processes via the Terminal Services Legacy API + * (nmap smb-enum-processes analogue). + * @example + * ```javascript + * const c = new dcerpc.Client('dc01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); + * const procs = c.EnumProcesses(); + * for (const p of procs) { + * log(p.PID + ' ' + p.Name + ' session=' + p.SessionID); + * } + * ``` + */ + public EnumProcesses(): ProcessEntry[] | null { + return null; + } + + /** + * EnumLoggedOnUsers lists users known to the workstation service (WKSSVC). + * @example + * ```javascript + * const c = new dcerpc.Client('ws01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); + * const users = c.EnumLoggedOnUsers(); + * for (const u of users) { log(u.LogonDomain + '\\' + u.Username); } + * ``` + */ + public EnumLoggedOnUsers(): LoggedOnUser[] | null { + return null; + } /** @@ -295,6 +356,48 @@ export interface FileEntry { IsDir?: boolean, } +/** + * ServiceEntry is a flat SVCCTL service record (nmap smb-enum-services). + */ +export interface ServiceEntry { + Name?: string, + DisplayName?: string, + State?: string, + StateCode?: number, + Controls?: number, +} + +/** + * SessionEntry is a flat SRVSVC session record (nmap smb-enum-sessions). + */ +export interface SessionEntry { + Client?: string, + Username?: string, + Active?: number, + Idle?: number, +} + +/** + * ProcessEntry is a running process (nmap smb-enum-processes analogue via WinStation). + */ +export interface ProcessEntry { + Name?: string, + PID?: number, + SessionID?: number, + WorkingSetSize?: number, + SID?: string, +} + +/** + * LoggedOnUser is a WKSSVC workstation user record. + */ +export interface LoggedOnUser { + Username?: string, + LogonDomain?: string, + OthDomains?: string, + LogonServer?: string, +} + /** diff --git a/pkg/js/generated/ts/smb.ts b/pkg/js/generated/ts/smb.ts index c9b769cb5d..7fadfa24ee 100644 --- a/pkg/js/generated/ts/smb.ts +++ b/pkg/js/generated/ts/smb.ts @@ -2,8 +2,8 @@ /** * SMBClient is a client for SMB servers. - * Internally client uses github.com/zmap/zgrab2/lib/smb/smb driver. - * github.com/projectdiscovery/go-smb2 driver + * Unauthenticated discovery uses zgrab2 / fingerprintx. + * Authenticated share I/O uses goimpacket via smbsession. * @example * ```javascript * const smb = require('nuclei/smb'); @@ -66,10 +66,69 @@ export class SMBClient { * } * ``` */ - public ListShares(host: string, port: number, user: string): string[] | null { + public ListShares(host: string, port: number, user: string, password: string): string[] | null { + return null; + } + + /** + * ListDir lists files and directories under path on the given share + * (nmap smb-ls). path may be empty or "." for the share root. + * user may be "DOMAIN\\user" or "user@domain". + * @example + * ```javascript + * const smb = require('nuclei/smb'); + * const client = new smb.SMBClient(); + * const entries = client.ListDir('acme.com', 445, 'user', 'pass', 'backup', '.'); + * for (const e of entries) { log(e.Name + (e.IsDir ? '/' : '')); } + * ``` + */ + public ListDir(host: string, port: number, user: string, password: string, share: string, dir: string): ShareEntry[] | null { + return null; + } + + /** + * ReadFile reads a file from share/path into a string, capped at 10 MiB. + * @example + * ```javascript + * const smb = require('nuclei/smb'); + * const client = new smb.SMBClient(); + * const body = client.ReadFile('acme.com', 445, 'user', 'pass', 'backup', 'creds.txt'); + * log(body); + * ``` + */ + public ReadFile(host: string, port: number, user: string, password: string, share: string, filePath: string): string | null { + return null; + } + + /** + * ListTree recursively lists files under path on share up to a fixed depth + * and entry budget. Entry names are share-relative paths. + * @example + * ```javascript + * const smb = require('nuclei/smb'); + * const client = new smb.SMBClient(); + * const tree = client.ListTree('acme.com', 445, 'user', 'pass', 'backup', '.'); + * for (const e of tree) { log(e.Name); } + * ``` + */ + public ListTree(host: string, port: number, user: string, password: string, share: string, dir: string): ShareEntry[] | null { + return null; + } + + /** + * ListProtocols discovers which SMB dialects/capabilities the server + * exposes (nmap smb-protocols). + * @example + * ```javascript + * const smb = require('nuclei/smb'); + * const client = new smb.SMBClient(); + * const info = client.ListProtocols('acme.com', 445); + * log(to_json(info)); + * ``` + */ + public ListProtocols(host: string, port: number): ProtocolInfo | null { return null; } - /** * DetectSMBGhost tries to detect SMBGhost vulnerability @@ -88,6 +147,31 @@ export class SMBClient { } +/** + * ShareEntry is a single file or directory on an SMB share. + */ +export interface ShareEntry { + Name?: string, + Size?: number, + IsDir?: boolean, + ModTime?: string, +} + +/** + * ProtocolInfo summarises dialects and capabilities discovered during + * SMB negotiation (nmap smb-protocols style). + */ +export interface ProtocolInfo { + SMB1Supported?: boolean, + SMB2Supported?: boolean, + Version?: string, + Dialect?: string, + HasNTLM?: boolean, + NativeOS?: string, + NTLM?: string, + GroupName?: string, +} + /** diff --git a/pkg/js/libs/dcerpc/dcerpc.go b/pkg/js/libs/dcerpc/dcerpc.go index 9183d758fa..06f6d06378 100644 --- a/pkg/js/libs/dcerpc/dcerpc.go +++ b/pkg/js/libs/dcerpc/dcerpc.go @@ -1,7 +1,24 @@ // Package dcerpc exposes a small subset of the Mzack9999/goimpacket DCE/RPC // stack to nuclei javascript templates. It is the entry point for AD attack // templates that need to talk EPMAPPER / SAMR / LSARPC / SVCCTL / TSCH / WINREG -// to a domain controller or member server. +// / SRVSVC to a domain controller or member server. +// +// Capability map overlapping nmap SMB scripts (issue #4707): +// +// smb-enum-users → Client.SamrEnumerateUsers +// smb-enum-services → Client.EnumServices +// smb-enum-sessions → Client.EnumSessions +// smb-enum-processes → Client.EnumProcesses (WinStation LegacyAPI) +// smb-psexec → Client.SmbExec (also nuclei/goexec, nuclei/scmr) +// smb-ls / cat → Client.SmbLs / SmbCat (prefer nuclei/smb for new templates) +// (logged-on users) → Client.EnumLoggedOnUsers (WKSSVC; not in nmap list) +// +// Not implemented here (by design — do not add): +// +// smb-flood — DoS; harmful, no scanner value (nmap categories: dos) +// smb-mbenum — Master Browser / mailslots; obsolete NetBIOS surface +// smb-print-text — writes to printer spooler; intrusive niche +// smb-protocols / unauth discovery — nuclei/smb // // All host arguments are validated against the per-execution network policy // before any traffic is sent. The actual TCP dial is performed via goimpacket's @@ -28,6 +45,7 @@ import ( gpsmbexec "github.com/Mzack9999/goimpacket/pkg/smbexec" "github.com/projectdiscovery/goja" + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smbsession" "github.com/projectdiscovery/nuclei/v3/pkg/js/utils" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" ) @@ -399,11 +417,12 @@ func (c *Client) SmbListShares() ([]string, error) { if err := c.connect(); err != nil { return nil, err } - return c.smb.ListShares() + return smbsession.FromClient(c.smb).ListShares() } // SmbCat reads the contents of a single file from the given share. The path // is interpreted relative to the share root (use forward slashes). +// Prefer nuclei/smb.ReadFile for new templates; this remains for dcerpc sessions. // // @example // ```javascript @@ -417,13 +436,11 @@ func (c *Client) SmbCat(share, file string) (string, error) { if err := c.connect(); err != nil { return "", err } - if err := c.smb.UseShare(share); err != nil { - return "", fmt.Errorf("use share %s: %w", share, err) - } - return c.smb.Cat(file) + return smbsession.FromClient(c.smb).ReadFile(share, file, smbsession.DefaultMaxReadBytes) } // SmbLs lists files under dir on the given share. dir = "" lists the root. +// Prefer nuclei/smb.ListDir for new templates. // // @example // ```javascript @@ -431,29 +448,14 @@ func (c *Client) SmbCat(share, file string) (string, error) { // const entries = c.SmbLs('backup', ''); // for (const e of entries) { log(e.Name + (e.IsDir ? '/' : '')); } // ``` -type FileEntry struct { - Name string `json:"name"` - Size int64 `json:"size"` - IsDir bool `json:"is_dir"` -} +type FileEntry = smbsession.Entry func (c *Client) SmbLs(share, dir string) ([]FileEntry, error) { c.nj.Require(share != "", "share cannot be empty") if err := c.connect(); err != nil { return nil, err } - if err := c.smb.UseShare(share); err != nil { - return nil, fmt.Errorf("use share %s: %w", share, err) - } - infos, err := c.smb.Ls(dir) - if err != nil { - return nil, err - } - out := make([]FileEntry, 0, len(infos)) - for _, fi := range infos { - out = append(out, FileEntry{Name: fi.Name(), Size: fi.Size(), IsDir: fi.IsDir()}) - } - return out, nil + return smbsession.FromClient(c.smb).ListDir(share, dir) } // LsaLookupSids resolves an array of SIDs to (domain, name, type) triples diff --git a/pkg/js/libs/dcerpc/enum.go b/pkg/js/libs/dcerpc/enum.go new file mode 100644 index 0000000000..8b063fd82d --- /dev/null +++ b/pkg/js/libs/dcerpc/enum.go @@ -0,0 +1,250 @@ +package dcerpc + +import ( + "fmt" + + gpsrvsvc "github.com/Mzack9999/goimpacket/pkg/dcerpc/srvsvc" + gpsvcctl "github.com/Mzack9999/goimpacket/pkg/dcerpc/svcctl" + winstation "github.com/Mzack9999/goimpacket/pkg/dcerpc/tsts" + gpwkssvc "github.com/Mzack9999/goimpacket/pkg/dcerpc/wkssvc" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" +) + +// ServiceEntry is a flat SVCCTL service record suitable for JS templates +// (nmap smb-enum-services). +type ServiceEntry struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + State string `json:"state"` + StateCode uint32 `json:"state_code"` + Controls uint32 `json:"controls,omitempty"` +} + +// SessionEntry is a flat SRVSVC session record (nmap smb-enum-sessions). +type SessionEntry struct { + Client string `json:"client"` + Username string `json:"username"` + Active uint32 `json:"active_seconds"` + Idle uint32 `json:"idle_seconds"` +} + +// ProcessEntry is a running process from the Terminal Services Legacy API +// (nmap smb-enum-processes analogue; nmap uses winreg perf counters, we use WinStation). +type ProcessEntry struct { + Name string `json:"name"` + PID uint32 `json:"pid"` + SessionID uint32 `json:"session_id"` + WorkingSetSize uint32 `json:"working_set_size,omitempty"` + SID string `json:"sid,omitempty"` +} + +// LoggedOnUser is a WKSSVC workstation user record (interactive / network logons +// known to the target). Useful companion to SamrEnumerateUsers / EnumSessions. +type LoggedOnUser struct { + Username string `json:"username"` + LogonDomain string `json:"logon_domain"` + OthDomains string `json:"other_domains,omitempty"` + LogonServer string `json:"logon_server,omitempty"` +} + +// EnumServices lists Win32 services on the target via SVCCTL +// (nmap: smb-enum-services). Requires an authenticated session with rights +// to open the Service Control Manager. +// +// @example +// ```javascript +// const dcerpc = require('nuclei/dcerpc'); +// const c = new dcerpc.Client('dc01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); +// const services = c.EnumServices(); +// +// for (const s of services) { +// if (s.State === 'RUNNING') { log(s.Name + ' => ' + s.DisplayName); } +// } +// +// ``` +func (c *Client) EnumServices() ([]ServiceEntry, error) { + if !protocolstate.IsHostAllowed(c.nj.ExecutionId(), c.Host) { + return nil, protocolstate.ErrHostDenied.Msgf(c.Host) + } + rpc, err := c.rpcOverNamedPipe("svcctl", gpsvcctl.UUID, gpsvcctl.MajorVersion, gpsvcctl.MinorVersion) + if err != nil { + return nil, err + } + defer func() { + _ = rpc.Transport.Close() + }() + + sc, err := gpsvcctl.NewServiceController(rpc) + if err != nil { + return nil, fmt.Errorf("svcctl open scm: %w", err) + } + defer sc.Close() + + const serviceWin32 = gpsvcctl.SERVICE_WIN32_OWN_PROCESS | gpsvcctl.SERVICE_WIN32_SHARE_PROCESS + raw, err := sc.EnumServicesStatus(serviceWin32, gpsvcctl.SERVICE_STATE_ALL) + if err != nil { + return nil, err + } + return mapServiceEntries(raw), nil +} + +// EnumSessions lists SMB sessions known to the server via SRVSVC +// (nmap: smb-enum-sessions). Often requires administrative rights. +// +// @example +// ```javascript +// const dcerpc = require('nuclei/dcerpc'); +// const c = new dcerpc.Client('fs01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); +// const sessions = c.EnumSessions(); +// +// for (const s of sessions) { +// log(s.Username + '@' + s.Client + ' active=' + s.Active + 's'); +// } +// +// ``` +func (c *Client) EnumSessions() ([]SessionEntry, error) { + if !protocolstate.IsHostAllowed(c.nj.ExecutionId(), c.Host) { + return nil, protocolstate.ErrHostDenied.Msgf(c.Host) + } + rpc, err := c.rpcOverNamedPipe("srvsvc", gpsrvsvc.UUID, gpsrvsvc.MajorVersion, gpsrvsvc.MinorVersion) + if err != nil { + return nil, err + } + defer func() { + _ = rpc.Transport.Close() + }() + + raw, err := gpsrvsvc.NetrSessionEnum(rpc) + if err != nil { + return nil, err + } + return mapSessionEntries(raw), nil +} + +// EnumProcesses lists running processes via the Terminal Services Legacy API +// (Ctx_WinStation_API_service). This is the goimpacket-backed analogue of +// nmap smb-enum-processes (which reads winreg performance counters). Requires +// rights to open the WinStation server handle; TermService should be running. +// +// @example +// ```javascript +// const dcerpc = require('nuclei/dcerpc'); +// const c = new dcerpc.Client('dc01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); +// const procs = c.EnumProcesses(); +// +// for (const p of procs) { +// log(p.PID + ' ' + p.Name + ' session=' + p.SessionID); +// } +// +// ``` +func (c *Client) EnumProcesses() ([]ProcessEntry, error) { + if !protocolstate.IsHostAllowed(c.nj.ExecutionId(), c.Host) { + return nil, protocolstate.ErrHostDenied.Msgf(c.Host) + } + rpc, err := c.rpcOverNamedPipe(winstation.PipeCtxWinStation, winstation.LegacyAPIUUID, winstation.MajorVersion, winstation.MinorVersion) + if err != nil { + return nil, err + } + defer func() { + _ = rpc.Transport.Close() + }() + + legacy := winstation.NewLegacyClient(rpc) + handle, err := legacy.OpenServer() + if err != nil { + return nil, fmt.Errorf("winstation OpenServer: %w", err) + } + defer func() { + _ = legacy.CloseServer(handle) + }() + + raw, err := legacy.GetAllProcesses(handle) + if err != nil { + return nil, err + } + return mapProcessEntries(raw), nil +} + +// EnumLoggedOnUsers lists users currently known to the workstation service +// via WKSSVC NetrWkstaUserEnum. Complements SamrEnumerateUsers (domain DB) +// and EnumSessions (SMB sessions). +// +// @example +// ```javascript +// const dcerpc = require('nuclei/dcerpc'); +// const c = new dcerpc.Client('ws01.acme.local', 'acme.local', 'admin', 'P@ssw0rd'); +// const users = c.EnumLoggedOnUsers(); +// for (const u of users) { log(u.LogonDomain + '\\' + u.Username); } +// ``` +func (c *Client) EnumLoggedOnUsers() ([]LoggedOnUser, error) { + if !protocolstate.IsHostAllowed(c.nj.ExecutionId(), c.Host) { + return nil, protocolstate.ErrHostDenied.Msgf(c.Host) + } + rpc, err := c.rpcOverNamedPipe("wkssvc", gpwkssvc.UUID, gpwkssvc.MajorVersion, gpwkssvc.MinorVersion) + if err != nil { + return nil, err + } + defer func() { + _ = rpc.Transport.Close() + }() + + raw, err := gpwkssvc.NetrWkstaUserEnum(rpc) + if err != nil { + return nil, err + } + return mapLoggedOnUsers(raw), nil +} + +func mapServiceEntries(raw []gpsvcctl.EnumServiceEntry) []ServiceEntry { + out := make([]ServiceEntry, 0, len(raw)) + for _, e := range raw { + out = append(out, ServiceEntry{ + Name: e.ServiceName, + DisplayName: e.DisplayName, + State: gpsvcctl.GetServiceState(e.Status.CurrentState), + StateCode: e.Status.CurrentState, + Controls: e.Status.ControlsAccepted, + }) + } + return out +} + +func mapSessionEntries(raw []gpsrvsvc.SessionInfo10) []SessionEntry { + out := make([]SessionEntry, 0, len(raw)) + for _, e := range raw { + out = append(out, SessionEntry{ + Client: e.Cname, + Username: e.Username, + Active: e.ActiveTime, + Idle: e.IdleTime, + }) + } + return out +} + +func mapProcessEntries(raw []winstation.ProcessInfo) []ProcessEntry { + out := make([]ProcessEntry, 0, len(raw)) + for _, e := range raw { + out = append(out, ProcessEntry{ + Name: e.ImageName, + PID: e.UniqueProcessId, + SessionID: e.SessionId, + WorkingSetSize: e.WorkingSetSize, + SID: e.SID, + }) + } + return out +} + +func mapLoggedOnUsers(raw []gpwkssvc.WkstaUserInfo1) []LoggedOnUser { + out := make([]LoggedOnUser, 0, len(raw)) + for _, e := range raw { + out = append(out, LoggedOnUser{ + Username: e.Username, + LogonDomain: e.LogonDomain, + OthDomains: e.OthDomains, + LogonServer: e.LogonServer, + }) + } + return out +} diff --git a/pkg/js/libs/dcerpc/enum_policy_test.go b/pkg/js/libs/dcerpc/enum_policy_test.go new file mode 100644 index 0000000000..0e54c77dbb --- /dev/null +++ b/pkg/js/libs/dcerpc/enum_policy_test.go @@ -0,0 +1,57 @@ +package dcerpc + +import ( + "testing" + + "github.com/projectdiscovery/goja" + "github.com/projectdiscovery/nuclei/v3/pkg/js/utils" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +const enumDeniedHost = "203.0.113.51" + +func newDeniedEnumClient(t *testing.T, executionID string) *Client { + t.Helper() + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: executionID, + ExcludeTargets: []string{enumDeniedHost}, + })) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + runtime := goja.New() + runtime.SetContextValue("executionId", executionID) + return &Client{ + Host: enumDeniedHost, + nj: utils.NewNucleiJS(runtime), + } +} + +func TestEnumServicesDeniesHostBeforeDial(t *testing.T) { + c := newDeniedEnumClient(t, "dcerpc-enum-services-deny") + _, err := c.EnumServices() + require.Error(t, err) + require.Contains(t, err.Error(), enumDeniedHost) +} + +func TestEnumSessionsDeniesHostBeforeDial(t *testing.T) { + c := newDeniedEnumClient(t, "dcerpc-enum-sessions-deny") + _, err := c.EnumSessions() + require.Error(t, err) + require.Contains(t, err.Error(), enumDeniedHost) +} + +func TestEnumProcessesDeniesHostBeforeDial(t *testing.T) { + c := newDeniedEnumClient(t, "dcerpc-enum-processes-deny") + _, err := c.EnumProcesses() + require.Error(t, err) + require.Contains(t, err.Error(), enumDeniedHost) +} + +func TestEnumLoggedOnUsersDeniesHostBeforeDial(t *testing.T) { + c := newDeniedEnumClient(t, "dcerpc-enum-loggedon-deny") + _, err := c.EnumLoggedOnUsers() + require.Error(t, err) + require.Contains(t, err.Error(), enumDeniedHost) +} diff --git a/pkg/js/libs/dcerpc/enum_test.go b/pkg/js/libs/dcerpc/enum_test.go new file mode 100644 index 0000000000..0ab2878b4f --- /dev/null +++ b/pkg/js/libs/dcerpc/enum_test.go @@ -0,0 +1,78 @@ +package dcerpc + +import ( + "testing" + + gpsrvsvc "github.com/Mzack9999/goimpacket/pkg/dcerpc/srvsvc" + gpsvcctl "github.com/Mzack9999/goimpacket/pkg/dcerpc/svcctl" + winstation "github.com/Mzack9999/goimpacket/pkg/dcerpc/tsts" + gpwkssvc "github.com/Mzack9999/goimpacket/pkg/dcerpc/wkssvc" + "github.com/stretchr/testify/require" +) + +func TestMapServiceEntries(t *testing.T) { + raw := []gpsvcctl.EnumServiceEntry{ + { + ServiceName: "Spooler", + DisplayName: "Print Spooler", + Status: gpsvcctl.ServiceStatus{CurrentState: gpsvcctl.SERVICE_RUNNING, ControlsAccepted: 1}, + }, + { + ServiceName: "StoppedSvc", + DisplayName: "Stopped", + Status: gpsvcctl.ServiceStatus{CurrentState: gpsvcctl.SERVICE_STOPPED}, + }, + } + got := mapServiceEntries(raw) + require.Len(t, got, 2) + require.Equal(t, "Spooler", got[0].Name) + require.Equal(t, "Print Spooler", got[0].DisplayName) + require.Equal(t, "RUNNING", got[0].State) + require.Equal(t, uint32(gpsvcctl.SERVICE_RUNNING), got[0].StateCode) + require.Equal(t, "STOPPED", got[1].State) +} + +func TestMapSessionEntries(t *testing.T) { + raw := []gpsrvsvc.SessionInfo10{ + {Cname: `\\client1`, Username: "alice", ActiveTime: 10, IdleTime: 2}, + {Cname: `\\client2`, Username: "bob", ActiveTime: 0, IdleTime: 99}, + } + got := mapSessionEntries(raw) + require.Len(t, got, 2) + require.Equal(t, `\\client1`, got[0].Client) + require.Equal(t, "alice", got[0].Username) + require.Equal(t, uint32(10), got[0].Active) + require.Equal(t, uint32(2), got[0].Idle) + require.Equal(t, "bob", got[1].Username) +} + +func TestMapProcessEntries(t *testing.T) { + raw := []winstation.ProcessInfo{ + {ImageName: "lsass.exe", UniqueProcessId: 628, SessionId: 0, WorkingSetSize: 1024, SID: "S-1-5-18"}, + {ImageName: "explorer.exe", UniqueProcessId: 1200, SessionId: 1}, + } + got := mapProcessEntries(raw) + require.Len(t, got, 2) + require.Equal(t, "lsass.exe", got[0].Name) + require.Equal(t, uint32(628), got[0].PID) + require.Equal(t, "S-1-5-18", got[0].SID) + require.Equal(t, uint32(1), got[1].SessionID) +} + +func TestMapLoggedOnUsers(t *testing.T) { + raw := []gpwkssvc.WkstaUserInfo1{ + {Username: "alice", LogonDomain: "CORP", LogonServer: "DC01"}, + } + got := mapLoggedOnUsers(raw) + require.Len(t, got, 1) + require.Equal(t, "alice", got[0].Username) + require.Equal(t, "CORP", got[0].LogonDomain) + require.Equal(t, "DC01", got[0].LogonServer) +} + +func TestMapServiceEntriesEmpty(t *testing.T) { + require.Empty(t, mapServiceEntries(nil)) + require.Empty(t, mapSessionEntries(nil)) + require.Empty(t, mapProcessEntries(nil)) + require.Empty(t, mapLoggedOnUsers(nil)) +} diff --git a/pkg/js/libs/dcerpc/transport_init.go b/pkg/js/libs/dcerpc/transport_init.go index 92ad27786f..e10b0b5765 100644 --- a/pkg/js/libs/dcerpc/transport_init.go +++ b/pkg/js/libs/dcerpc/transport_init.go @@ -1,75 +1,14 @@ package dcerpc import ( - "context" - "fmt" - "net" + gptr "github.com/Mzack9999/goimpacket/pkg/transport" - gptransport "github.com/Mzack9999/goimpacket/pkg/transport" - - "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/gptransport" ) -// init wires the goimpacket package-level dial hook as a strict tripwire. -// Every TCP connection inside goimpacket is supposed to go through a -// per-Client *gptransport.Dialer built by NewExecDialer below, which captures -// the executionId of the calling JS runtime. If something inside goimpacket -// bypasses that and reaches this global hook we refuse to dial - we will not -// silently pick a random execution's dialer. -func init() { - gptransport.SetDial(func(ctx context.Context, network, address string) (net.Conn, error) { - execID := executionIDFromCtx(ctx) - if execID == "" { - return nil, fmt.Errorf("goimpacket: refusing to dial %s/%s without an executionId-bound dialer; wrap the call site with a *gptransport.Dialer built via NewExecDialer", network, address) - } - return dialWithExec(ctx, execID, network, address) - }) -} - -// NewExecDialer returns a *gptransport.Dialer whose DialFn is bound to the -// given executionId. Every connection made through the returned dialer is -// validated against the execution's network policy and routed through the -// matching fastdialer. Pass it into goimpacket constructors such as -// smb.NewClientWithDialer or dcerpc.DialTCPWithDialer to guarantee the -// connection cannot leak across executions. -func NewExecDialer(execID string) *gptransport.Dialer { - if execID == "" { - return &gptransport.Dialer{} - } - return &gptransport.Dialer{ - DialFn: func(ctx context.Context, network, address string) (net.Conn, error) { - return dialWithExec(ctx, execID, network, address) - }, - } -} - -// dialWithExec performs the actual fastdialer dial after enforcing the -// per-execution host policy. -func dialWithExec(ctx context.Context, execID, network, address string) (net.Conn, error) { - host, _, err := net.SplitHostPort(address) - if err != nil { - return nil, fmt.Errorf("invalid address %q: %w", address, err) - } - if !protocolstate.IsHostAllowed(execID, host) { - return nil, protocolstate.ErrHostDenied.Msgf(host) - } - dialer := protocolstate.GetDialersWithId(execID) - if dialer == nil || dialer.Fastdialer == nil { - return nil, fmt.Errorf("goimpacket: no fastdialer registered for executionId %q", execID) - } - return dialer.Fastdialer.Dial(ctx, network, address) -} - -// executionIDFromCtx pulls the executionId set by nuclei on its goja runtime -// or scan context. Returns "" when the context carries no id. -func executionIDFromCtx(ctx context.Context) string { - if ctx == nil { - return "" - } - if v := ctx.Value("executionId"); v != nil { - if id, ok := v.(string); ok { - return id - } - } - return "" -} +// NewExecDialer is kept on the dcerpc package for JS/bindgen compatibility and +// existing Go call sites (krbroast, secretsdump). Implementation lives in +// gptransport so smb / file / dcerpc share one dialer. +func NewExecDialer(execID string) *gptr.Dialer { + return gptransport.NewExecDialer(execID) +} \ No newline at end of file diff --git a/pkg/js/libs/dcerpc/wmiexec.go b/pkg/js/libs/dcerpc/wmiexec.go index 846f9348ac..d040c262b1 100644 --- a/pkg/js/libs/dcerpc/wmiexec.go +++ b/pkg/js/libs/dcerpc/wmiexec.go @@ -8,6 +8,7 @@ import ( gpwmiexec "github.com/Mzack9999/goimpacket/pkg/wmiexec" "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/gptransport" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" ) @@ -78,7 +79,7 @@ func (c *Client) WmiExec(command, share string) (*WmiExecResult, error) { type execDialerAdapter struct{ execID string } func (e *execDialerAdapter) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - return dialWithExec(ctx, e.execID, network, address) + return gptransport.DialWithExec(ctx, e.execID, network, address) } // Compile-time guard that execDialerAdapter satisfies dcerpc.Dialer. diff --git a/pkg/js/libs/gptransport/dialer.go b/pkg/js/libs/gptransport/dialer.go new file mode 100644 index 0000000000..356bf56ed8 --- /dev/null +++ b/pkg/js/libs/gptransport/dialer.go @@ -0,0 +1,72 @@ +// Package gptransport binds Mzack9999/goimpacket TCP dials to nuclei's +// per-execution fastdialer and network policy. +// +// Import this package (directly or via smbsession / dcerpc) so init() installs +// the global tripwire: any goimpacket dial without an execution-bound Dialer +// fails closed instead of leaking across scans. +package gptransport + +import ( + "context" + "fmt" + "net" + + gptr "github.com/Mzack9999/goimpacket/pkg/transport" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" +) + +func init() { + gptr.SetDial(func(ctx context.Context, network, address string) (net.Conn, error) { + execID := ExecutionIDFromCtx(ctx) + if execID == "" { + return nil, fmt.Errorf("goimpacket: refusing to dial %s/%s without an executionId-bound dialer; wrap the call site with a *gptransport.Dialer built via NewExecDialer", network, address) + } + return DialWithExec(ctx, execID, network, address) + }) +} + +// NewExecDialer returns a *gptr.Dialer whose DialFn is bound to the +// given executionId. Every connection made through the returned dialer is +// validated against the execution's network policy and routed through the +// matching fastdialer. +func NewExecDialer(execID string) *gptr.Dialer { + if execID == "" { + return &gptr.Dialer{} + } + return &gptr.Dialer{ + DialFn: func(ctx context.Context, network, address string) (net.Conn, error) { + return DialWithExec(ctx, execID, network, address) + }, + } +} + +// DialWithExec performs the fastdialer dial after enforcing host policy. +func DialWithExec(ctx context.Context, execID, network, address string) (net.Conn, error) { + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", address, err) + } + if !protocolstate.IsHostAllowed(execID, host) { + return nil, protocolstate.ErrHostDenied.Msgf(host) + } + dialer := protocolstate.GetDialersWithId(execID) + if dialer == nil || dialer.Fastdialer == nil { + return nil, fmt.Errorf("goimpacket: no fastdialer registered for executionId %q", execID) + } + return dialer.Fastdialer.Dial(ctx, network, address) +} + +// ExecutionIDFromCtx pulls the executionId set by nuclei on its goja runtime +// or scan context. Returns "" when the context carries no id. +func ExecutionIDFromCtx(ctx context.Context) string { + if ctx == nil { + return "" + } + if v := ctx.Value("executionId"); v != nil { + if id, ok := v.(string); ok { + return id + } + } + return "" +} diff --git a/pkg/js/libs/gptransport/dialer_test.go b/pkg/js/libs/gptransport/dialer_test.go new file mode 100644 index 0000000000..d5254a0da4 --- /dev/null +++ b/pkg/js/libs/gptransport/dialer_test.go @@ -0,0 +1,35 @@ +package gptransport_test + +import ( + "context" + "testing" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/gptransport" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestDialWithExecDeniesExcludedHost(t *testing.T) { + execID := "gptransport-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: execID, + ExcludeTargets: []string{"203.0.113.50"}, + })) + t.Cleanup(func() { protocolstate.Close(execID) }) + + _, err := gptransport.DialWithExec(context.Background(), execID, "tcp", "203.0.113.50:445") + require.Error(t, err) + require.Contains(t, err.Error(), "203.0.113.50") +} + +func TestExecutionIDFromCtx(t *testing.T) { + require.Equal(t, "", gptransport.ExecutionIDFromCtx(context.Background())) + ctx := context.WithValue(context.Background(), "executionId", "abc") //nolint:staticcheck + require.Equal(t, "abc", gptransport.ExecutionIDFromCtx(ctx)) +} + +func TestNewExecDialerEmptyID(t *testing.T) { + d := gptransport.NewExecDialer("") + require.NotNil(t, d) +} diff --git a/pkg/js/libs/smb/memo.smb.go b/pkg/js/libs/smb/memo.smb.go index a8c6876271..3d319c1d3e 100644 --- a/pkg/js/libs/smb/memo.smb.go +++ b/pkg/js/libs/smb/memo.smb.go @@ -4,11 +4,9 @@ package smb import ( "context" "errors" - "fmt" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" - "github.com/zmap/zgrab2/lib/smb/smb" ) @@ -29,7 +27,7 @@ func memoizedconnectSMBInfoMode(ctx context.Context, executionId string, host st } func memoizedlistShares(ctx context.Context, executionId string, host string, port int, user string, password string) ([]string, error) { - hash := "listShares" + ":" + fmt.Sprint(executionId) + ":" + fmt.Sprint(host) + ":" + fmt.Sprint(port) + ":" + fmt.Sprint(user) + ":" + fmt.Sprint(password) + hash := smbMemoKey("listShares", executionId, host, fmt.Sprint(port), user, password) v, err, _ := protocolstate.Memoizer.Do(hash, func() (interface{}, error) { return listShares(ctx, executionId, host, port, user, password) @@ -43,3 +41,51 @@ func memoizedlistShares(ctx context.Context, executionId string, host string, po return []string{}, errors.New("could not convert cached result") } + +func memoizedlistDir(ctx context.Context, executionId string, host string, port int, user string, password string, share string, dir string) ([]ShareEntry, error) { + hash := smbMemoKey("listDir", executionId, host, fmt.Sprint(port), user, password, share, dir) + + v, err, _ := protocolstate.Memoizer.Do(hash, func() (interface{}, error) { + return listDir(ctx, executionId, host, port, user, password, share, dir) + }) + if err != nil { + return []ShareEntry{}, err + } + if value, ok := v.([]ShareEntry); ok { + return value, nil + } + + return []ShareEntry{}, errors.New("could not convert cached result") +} + +func memoizedreadFile(ctx context.Context, executionId string, host string, port int, user string, password string, share string, filePath string) (string, error) { + hash := smbMemoKey("readFile", executionId, host, fmt.Sprint(port), user, password, share, filePath) + + v, err, _ := protocolstate.Memoizer.Do(hash, func() (interface{}, error) { + return readFile(ctx, executionId, host, port, user, password, share, filePath) + }) + if err != nil { + return "", err + } + if value, ok := v.(string); ok { + return value, nil + } + + return "", errors.New("could not convert cached result") +} + +func memoizedlistTree(ctx context.Context, executionId string, host string, port int, user string, password string, share string, dir string) ([]ShareEntry, error) { + hash := smbMemoKey("listTree", executionId, host, fmt.Sprint(port), user, password, share, dir) + + v, err, _ := protocolstate.Memoizer.Do(hash, func() (interface{}, error) { + return listTree(ctx, executionId, host, port, user, password, share, dir) + }) + if err != nil { + return []ShareEntry{}, err + } + if value, ok := v.([]ShareEntry); ok { + return value, nil + } + + return []ShareEntry{}, errors.New("could not convert cached result") +} diff --git a/pkg/js/libs/smb/memo_key.go b/pkg/js/libs/smb/memo_key.go new file mode 100644 index 0000000000..4ec8f25156 --- /dev/null +++ b/pkg/js/libs/smb/memo_key.go @@ -0,0 +1,21 @@ +package smb + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" +) + +// smbMemoKey builds an unambiguous memoizer key from structured fields. +// Fields are JSON-encoded (so values containing ":" cannot collide) and hashed +// so credentials are not retained as plaintext in the memoizer key space. +func smbMemoKey(operation string, fields ...string) string { + payload, err := json.Marshal(append([]string{operation}, fields...)) + if err != nil { + // Extremely unlikely for []string; fall back to a distinct failure key. + sum := sha256.Sum256([]byte(operation)) + return operation + ":err:" + hex.EncodeToString(sum[:]) + } + sum := sha256.Sum256(payload) + return operation + ":" + hex.EncodeToString(sum[:]) +} diff --git a/pkg/js/libs/smb/memo_key_test.go b/pkg/js/libs/smb/memo_key_test.go new file mode 100644 index 0000000000..621a0d8ee3 --- /dev/null +++ b/pkg/js/libs/smb/memo_key_test.go @@ -0,0 +1,16 @@ +package smb + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSmbMemoKeyUnambiguousAndHashed(t *testing.T) { + a := smbMemoKey("listDir", "exec", "host", "445", "user:a", "pass", "share", "dir") + b := smbMemoKey("listDir", "exec", "host", "445", "user", "a:pass", "share", "dir") + require.NotEqual(t, a, b, "colon in credentials must not collide keys") + require.Contains(t, a, "listDir:") + require.NotContains(t, a, "pass") + require.NotContains(t, a, "user:a") +} diff --git a/pkg/js/libs/smb/smb.go b/pkg/js/libs/smb/smb.go index 803074f2b0..ac85a20f79 100644 --- a/pkg/js/libs/smb/smb.go +++ b/pkg/js/libs/smb/smb.go @@ -1,3 +1,29 @@ +// Package smb exposes the nuclei JavaScript `nuclei/smb` module. +// +// Capability map (nmap SMB scripts → JS API): +// +// smb-protocols → SMBClient.ListProtocols / ConnectSMBInfoMode +// smb-ls → SMBClient.ListDir / ListTree +// (file read/cat) → SMBClient.ReadFile +// share enum → SMBClient.ListShares +// SMBGhost check → SMBClient.DetectSMBGhost +// +// Authenticated share I/O is backed by pkg/js/libs/smbsession (goimpacket) so +// nuclei/smb and nuclei/dcerpc.SmbLs/SmbCat share one stack. Unauthenticated +// discovery still uses zgrab2 / fingerprintx. +// +// RPC-heavy nmap scripts (enum-users/services/sessions/processes, psexec) live under +// `nuclei/dcerpc` / `nuclei/goexec` — see that package's docs. Do not duplicate +// them here; share filesystem and unauthenticated discovery stay in this module. +// +// Permanently out of scope for nuclei (also documented on dcerpc): +// smb-flood (DoS), smb-mbenum (obsolete browser), smb-print-text (printer write). +// +// Sandbox rules applied to every share operation: +// - protocolstate.IsHostAllowed before dial +// - fastdialer only (via gptransport) +// - share-relative paths; ".." escapes rejected +// - ReadFile capped (default 10 MiB); ListTree depth/entry capped package smb import ( @@ -7,21 +33,40 @@ import ( "time" "github.com/praetorian-inc/fingerprintx/pkg/plugins" - "github.com/projectdiscovery/go-smb2" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" "github.com/zmap/zgrab2/lib/smb/smb" ) type ( // SMBClient is a client for SMB servers. - // Internally client uses github.com/zmap/zgrab2/lib/smb/smb driver. - // github.com/projectdiscovery/go-smb2 driver + // Unauthenticated discovery uses zgrab2 / fingerprintx. + // Authenticated share I/O uses goimpacket via pkg/js/libs/smbsession. // @example // ```javascript // const smb = require('nuclei/smb'); // const client = new smb.SMBClient(); // ``` SMBClient struct{} + + // ProtocolInfo summarises dialects and capabilities discovered during + // SMB negotiation (nmap smb-protocols style). + // @example + // ```javascript + // const smb = require('nuclei/smb'); + // const client = new smb.SMBClient(); + // const info = client.ListProtocols('acme.com', 445); + // log(to_json(info)); + // ``` + ProtocolInfo struct { + SMB1Supported bool `json:"smb1_supported"` + SMB2Supported bool `json:"smb2_supported"` + Version string `json:"version,omitempty"` + Dialect string `json:"dialect,omitempty"` + HasNTLM bool `json:"has_ntlm"` + NativeOS string `json:"native_os,omitempty"` + NTLM string `json:"ntlm,omitempty"` + GroupName string `json:"group_name,omitempty"` + } ) // ConnectSMBInfoMode tries to connect to provided host and port @@ -121,42 +166,116 @@ func (c *SMBClient) ListShares(ctx context.Context, host string, port int, user, return memoizedlistShares(ctx, executionId, host, port, user, password) } -// @memo -func listShares(ctx context.Context, executionId string, host string, port int, user string, password string) ([]string, error) { - if !protocolstate.IsHostAllowed(executionId, host) { - // host is not valid according to network policy - return nil, protocolstate.ErrHostDenied.Msgf(host) - } - dialer := protocolstate.GetDialersWithId(executionId) - if dialer == nil { - return nil, fmt.Errorf("dialers not initialized for %s", executionId) - } +// ListDir lists files and directories under path on the given share +// (nmap smb-ls). path may be empty or "." for the share root. +// user may be "DOMAIN\\user" or "user@domain". +// @example +// ```javascript +// const smb = require('nuclei/smb'); +// const client = new smb.SMBClient(); +// const entries = client.ListDir('acme.com', 445, 'user', 'pass', 'backup', '.'); +// for (const e of entries) { log(e.Name + (e.IsDir ? '/' : '')); } +// ``` +func (c *SMBClient) ListDir(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) { + executionId := ctx.Value("executionId").(string) + return memoizedlistDir(ctx, executionId, host, port, user, password, share, dir) +} - conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port)) +// ReadFile reads a file from share/path into a string, capped at 10 MiB +// (nmap smb-ls / smb-cat style content fetch). +// @example +// ```javascript +// const smb = require('nuclei/smb'); +// const client = new smb.SMBClient(); +// const body = client.ReadFile('acme.com', 445, 'user', 'pass', 'backup', 'creds.txt'); +// log(body); +// ``` +func (c *SMBClient) ReadFile(ctx context.Context, host string, port int, user, password, share, filePath string) (string, error) { + executionId := ctx.Value("executionId").(string) + return memoizedreadFile(ctx, executionId, host, port, user, password, share, filePath) +} + +// ListTree recursively lists files under path on share up to a fixed depth +// and entry budget. Entry names are share-relative paths. +// @example +// ```javascript +// const smb = require('nuclei/smb'); +// const client = new smb.SMBClient(); +// const tree = client.ListTree('acme.com', 445, 'user', 'pass', 'backup', '.'); +// for (const e of tree) { log(e.Name); } +// ``` +func (c *SMBClient) ListTree(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) { + executionId := ctx.Value("executionId").(string) + return memoizedlistTree(ctx, executionId, host, port, user, password, share, dir) +} + +// ListProtocols discovers which SMB dialects/capabilities the server +// exposes (nmap smb-protocols). +// @example +// ```javascript +// const smb = require('nuclei/smb'); +// const client = new smb.SMBClient(); +// const info = client.ListProtocols('acme.com', 445); +// log(to_json(info)); +// ``` +func (c *SMBClient) ListProtocols(ctx context.Context, host string, port int) (*ProtocolInfo, error) { + log, err := c.ConnectSMBInfoMode(ctx, host, port) if err != nil { return nil, err } - defer func() { - _ = conn.Close() - }() + return protocolInfoFromLog(log), nil +} - d := &smb2.Dialer{ - Initiator: &smb2.NTLMInitiator{ - User: user, - Password: password, - }, +func protocolInfoFromLog(log *smb.SMBLog) *ProtocolInfo { + if log == nil { + return &ProtocolInfo{} } - s, err := d.Dial(conn) - if err != nil { - return nil, err + info := &ProtocolInfo{ + SMB1Supported: log.SupportV1, + HasNTLM: log.HasNTLM, + NativeOS: log.NativeOs, + NTLM: log.NTLM, + GroupName: log.GroupName, } - defer func() { - _ = s.Logoff() - }() + if log.Version != nil { + info.Version = log.Version.VerString + if log.Version.Major >= 2 { + info.SMB2Supported = true + } + if log.Version.Major == 1 { + info.SMB1Supported = true + } + } + if log.NegotiationLog != nil { + info.Dialect = dialectName(log.NegotiationLog.DialectRevision) + if log.NegotiationLog.DialectRevision != 0 && log.NegotiationLog.DialectRevision != smb.DialectSmb2_ALL { + info.SMB2Supported = true + } + } + // ConnectSMBInfoMode returns a usable log when SMBv2/v3 negotiated. + if !info.SMB1Supported && !info.SMB2Supported && info.Version != "" { + info.SMB2Supported = true + } + return info +} - names, err := s.ListSharenames() - if err != nil { - return nil, err +func dialectName(rev uint16) string { + switch rev { + case smb.DialectSmb_2_0_2: + return "SMB 2.0.2" + case smb.DialectSmb_2_1: + return "SMB 2.1" + case smb.DialectSmb_3_0: + return "SMB 3.0" + case smb.DialectSmb_3_0_2: + return "SMB 3.0.2" + case smb.DialectSmb_3_1_1: + return "SMB 3.1.1" + case smb.DialectSmb2_ALL: + return "SMB 2+/wildcard" + case 0: + return "" + default: + return fmt.Sprintf("0x%04x", rev) } - return names, nil } diff --git a/pkg/js/libs/smb/smb_path.go b/pkg/js/libs/smb/smb_path.go new file mode 100644 index 0000000000..529ecf7e93 --- /dev/null +++ b/pkg/js/libs/smb/smb_path.go @@ -0,0 +1,15 @@ +package smb + +import "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smbsession" + +func parseNTLMIdentity(user string) (domain, username string) { + return smbsession.ParseIdentity(user) +} + +func normalizeSharePath(p string) (string, error) { + return smbsession.NormalizeSharePath(p) +} + +func requireShareName(share string) error { + return smbsession.RequireShareName(share) +} diff --git a/pkg/js/libs/smb/smb_policy_test.go b/pkg/js/libs/smb/smb_policy_test.go new file mode 100644 index 0000000000..7d2cce5adc --- /dev/null +++ b/pkg/js/libs/smb/smb_policy_test.go @@ -0,0 +1,90 @@ +package smb + +import ( + "context" + "testing" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +const deniedHost = "203.0.113.50" // TEST-NET-3; no DNS needed for policy checks + +func TestListDirDeniesHostBeforeDial(t *testing.T) { + executionID := "smb-listdir-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: executionID, + ExcludeTargets: []string{deniedHost}, + })) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ListDir(ctx, deniedHost, 445, "user", "pass", "share", ".") + require.Error(t, err) + require.Contains(t, err.Error(), deniedHost) +} + +func TestReadFileDeniesHostBeforeDial(t *testing.T) { + executionID := "smb-readfile-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: executionID, + ExcludeTargets: []string{deniedHost}, + })) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ReadFile(ctx, deniedHost, 445, "user", "pass", "share", "a.txt") + require.Error(t, err) + require.Contains(t, err.Error(), deniedHost) +} + +func TestListTreeDeniesHostBeforeDial(t *testing.T) { + executionID := "smb-listtree-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: executionID, + ExcludeTargets: []string{deniedHost}, + })) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ListTree(ctx, deniedHost, 445, "user", "pass", "share", ".") + require.Error(t, err) + require.Contains(t, err.Error(), deniedHost) +} + +func TestListSharesDeniesHostBeforeDial(t *testing.T) { + executionID := "smb-listshares-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: executionID, + ExcludeTargets: []string{deniedHost}, + })) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ListShares(ctx, deniedHost, 445, "user", "pass") + require.Error(t, err) + require.Contains(t, err.Error(), deniedHost) +} + +func TestListDirRejectsEmptyShare(t *testing.T) { + executionID := "smb-empty-share" + require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID})) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ListDir(ctx, "127.0.0.1", 445, "user", "pass", "", ".") + require.Error(t, err) + require.Contains(t, err.Error(), "share name cannot be empty") +} + +func TestListDirRejectsShareWithSeparator(t *testing.T) { + executionID := "smb-bad-share" + require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID})) + t.Cleanup(func() { protocolstate.Close(executionID) }) + + ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck + _, err := (&SMBClient{}).ListDir(ctx, "127.0.0.1", 445, "user", "pass", `evil\share`, ".") + require.Error(t, err) + require.Contains(t, err.Error(), "path separators") +} diff --git a/pkg/js/libs/smb/smb_share.go b/pkg/js/libs/smb/smb_share.go new file mode 100644 index 0000000000..7beed80cc1 --- /dev/null +++ b/pkg/js/libs/smb/smb_share.go @@ -0,0 +1,59 @@ +package smb + +import ( + "context" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smbsession" +) + +// ShareEntry is a single file or directory on an SMB share. +type ShareEntry = smbsession.Entry + +// @memo +func listShares(ctx context.Context, executionId string, host string, port int, user string, password string) ([]string, error) { + sess, err := smbsession.Dial(ctx, executionId, host, port, smbsession.Creds{User: user, Password: password}) + if err != nil { + return nil, err + } + defer sess.Close() + return sess.ListShares() +} + +// @memo +func listDir(ctx context.Context, executionId string, host string, port int, user string, password string, share string, dir string) ([]ShareEntry, error) { + if err := smbsession.RequireShareName(share); err != nil { + return nil, err + } + sess, err := smbsession.Dial(ctx, executionId, host, port, smbsession.Creds{User: user, Password: password}) + if err != nil { + return nil, err + } + defer sess.Close() + return sess.ListDir(share, dir) +} + +// @memo +func readFile(ctx context.Context, executionId string, host string, port int, user string, password string, share string, filePath string) (string, error) { + if err := smbsession.RequireShareName(share); err != nil { + return "", err + } + sess, err := smbsession.Dial(ctx, executionId, host, port, smbsession.Creds{User: user, Password: password}) + if err != nil { + return "", err + } + defer sess.Close() + return sess.ReadFile(share, filePath, smbsession.DefaultMaxReadBytes) +} + +// @memo +func listTree(ctx context.Context, executionId string, host string, port int, user string, password string, share string, dir string) ([]ShareEntry, error) { + if err := smbsession.RequireShareName(share); err != nil { + return nil, err + } + sess, err := smbsession.Dial(ctx, executionId, host, port, smbsession.Creds{User: user, Password: password}) + if err != nil { + return nil, err + } + defer sess.Close() + return sess.ListTree(share, dir, smbsession.DefaultMaxTreeDepth, smbsession.DefaultMaxTreeEntries) +} diff --git a/pkg/js/libs/smb/smb_share_test.go b/pkg/js/libs/smb/smb_share_test.go new file mode 100644 index 0000000000..b75d0dd7a7 --- /dev/null +++ b/pkg/js/libs/smb/smb_share_test.go @@ -0,0 +1,51 @@ +package smb + +import ( + "testing" + + "github.com/stretchr/testify/require" + zgrabsmb "github.com/zmap/zgrab2/lib/smb/smb" +) + +func TestParseNTLMIdentity(t *testing.T) { + domain, user := parseNTLMIdentity(`CORP\alice`) + require.Equal(t, "CORP", domain) + require.Equal(t, "alice", user) +} + +func TestNormalizeSharePath(t *testing.T) { + got, err := normalizeSharePath(`docs\a.txt`) + require.NoError(t, err) + require.Equal(t, "docs/a.txt", got) + _, err = normalizeSharePath("../x") + require.Error(t, err) +} + +func TestRequireShareName(t *testing.T) { + require.Error(t, requireShareName("")) + require.Error(t, requireShareName("a/b")) + require.NoError(t, requireShareName("backup")) +} + +func TestProtocolInfoFromLog(t *testing.T) { + info := protocolInfoFromLog(&zgrabsmb.SMBLog{ + SupportV1: true, + HasNTLM: true, + Version: &zgrabsmb.SMBVersions{ + Major: 3, + Minor: 1, + VerString: "SMB 3.1.1", + }, + NegotiationLog: &zgrabsmb.NegotiationLog{ + DialectRevision: zgrabsmb.DialectSmb_3_1_1, + }, + }) + require.True(t, info.SMB1Supported) + require.True(t, info.SMB2Supported) + require.Equal(t, "SMB 3.1.1", info.Dialect) +} + +func TestDialectName(t *testing.T) { + require.Equal(t, "SMB 2.1", dialectName(zgrabsmb.DialectSmb_2_1)) + require.Equal(t, "", dialectName(0)) +} diff --git a/pkg/js/libs/smbsession/path.go b/pkg/js/libs/smbsession/path.go new file mode 100644 index 0000000000..9777191f8f --- /dev/null +++ b/pkg/js/libs/smbsession/path.go @@ -0,0 +1,63 @@ +package smbsession + +import ( + "fmt" + "path" + "strings" +) + +// ParseIdentity splits DOMAIN\user, domain/user, or user@domain into domain and username. +func ParseIdentity(user string) (domain, username string) { + user = strings.TrimSpace(user) + if user == "" { + return "", "" + } + if i := strings.IndexByte(user, '\\'); i >= 0 { + return user[:i], user[i+1:] + } + if i := strings.IndexByte(user, '/'); i >= 0 { + return user[:i], user[i+1:] + } + if i := strings.LastIndexByte(user, '@'); i > 0 { + return user[i+1:], user[:i] + } + return "", user +} + +// NormalizeSharePath converts an SMB share-relative path to a clean form +// (forward slashes, no leading slash, "." for share root). Rejects ".." escapes. +func NormalizeSharePath(p string) (string, error) { + p = strings.TrimSpace(p) + p = strings.ReplaceAll(p, `\`, `/`) + p = strings.Trim(p, `/`) + if p == "" || p == "." { + return ".", nil + } + if strings.ContainsRune(p, 0) { + return "", fmt.Errorf("share path contains NUL") + } + clean := path.Clean(p) + clean = strings.TrimPrefix(clean, "/") + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("share path escapes share root: %q", p) + } + if clean == "." { + return ".", nil + } + return clean, nil +} + +// RequireShareName validates a share name (no path separators). +func RequireShareName(share string) error { + share = strings.TrimSpace(share) + if share == "" { + return fmt.Errorf("share name cannot be empty") + } + if strings.ContainsAny(share, `/\`) { + return fmt.Errorf("share name must not contain path separators: %q", share) + } + if strings.ContainsRune(share, 0) { + return fmt.Errorf("share name contains NUL") + } + return nil +} diff --git a/pkg/js/libs/smbsession/path_test.go b/pkg/js/libs/smbsession/path_test.go new file mode 100644 index 0000000000..1cfd2f8710 --- /dev/null +++ b/pkg/js/libs/smbsession/path_test.go @@ -0,0 +1,40 @@ +package smbsession + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseIdentity(t *testing.T) { + d, u := ParseIdentity(`CORP\alice`) + require.Equal(t, "CORP", d) + require.Equal(t, "alice", u) + + d, u = ParseIdentity("alice@corp.local") + require.Equal(t, "corp.local", d) + require.Equal(t, "alice", u) + + d, u = ParseIdentity("alice") + require.Equal(t, "", d) + require.Equal(t, "alice", u) +} + +func TestNormalizeSharePath(t *testing.T) { + got, err := NormalizeSharePath(`docs\a.txt`) + require.NoError(t, err) + require.Equal(t, "docs/a.txt", got) + + _, err = NormalizeSharePath("../etc") + require.Error(t, err) + + got, err = NormalizeSharePath("") + require.NoError(t, err) + require.Equal(t, ".", got) +} + +func TestRequireShareName(t *testing.T) { + require.Error(t, RequireShareName("")) + require.Error(t, RequireShareName("a/b")) + require.NoError(t, RequireShareName("C$")) +} diff --git a/pkg/js/libs/smbsession/session.go b/pkg/js/libs/smbsession/session.go new file mode 100644 index 0000000000..6f49a7e17e --- /dev/null +++ b/pkg/js/libs/smbsession/session.go @@ -0,0 +1,318 @@ +package smbsession + +import ( + "context" + "fmt" + "io" + "os" + "path" + "strings" + "time" + + gpsession "github.com/Mzack9999/goimpacket/pkg/session" + gpsmb "github.com/Mzack9999/goimpacket/pkg/smb" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/gptransport" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" +) + +const ( + // DefaultMaxReadBytes caps remote file reads into memory. + DefaultMaxReadBytes = 10 << 20 // 10 MiB + // DefaultMaxTreeDepth caps recursive tree walks. + DefaultMaxTreeDepth = 8 + // DefaultMaxTreeEntries caps how many tree entries are collected. + DefaultMaxTreeEntries = 1024 +) + +// Creds holds NTLM (password or hash) credentials for Dial. +type Creds struct { + User string + Password string + Domain string + Hash string // optional NT hash; when set, preferred over password +} + +// Entry is a share-relative file or directory record. +type Entry struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"is_dir"` + ModTime string `json:"mod_time,omitempty"` +} + +// shareBackend is the subset of goimpacket SMB client used for share I/O. +// Tests substitute a fake implementation. +type shareBackend interface { + UseShare(name string) error + Ls(dir string) ([]os.FileInfo, error) + Cat(file string) (string, error) + ListShares() ([]string, error) +} + +// shareOpener is an optional extension for streaming reads with a byte cap. +type shareOpener interface { + Open(file string) (io.ReadCloser, error) +} + +// Session wraps an authenticated goimpacket SMB client. +type Session struct { + client *gpsmb.Client + backend shareBackend // when set (tests), used instead of client +} + +// Dial connects and authenticates to host:port using the execution's dialer. +func Dial(ctx context.Context, executionID, host string, port int, creds Creds) (*Session, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return nil, err + } + if !protocolstate.IsHostAllowed(executionID, host) { + return nil, protocolstate.ErrHostDenied.Msgf(host) + } + if port <= 0 { + port = 445 + } + domain, user := ParseIdentity(creds.User) + if creds.Domain != "" { + domain = creds.Domain + } + if user == "" { + user = strings.TrimSpace(creds.User) + } + + gpCreds := &gpsession.Credentials{ + Domain: domain, + Username: user, + Password: creds.Password, + Hash: creds.Hash, + } + target := gpsession.Target{Host: host, Port: port} + client := gpsmb.NewClientWithDialer(target, gpCreds, gptransport.NewExecDialer(executionID)) + + errCh := make(chan error, 1) + go func() { + errCh <- client.Connect() + }() + select { + case <-ctx.Done(): + client.Close() + // Drain connect result so the goroutine can exit; ignore the outcome + // because cancellation already won the race. + <-errCh + return nil, ctx.Err() + case err := <-errCh: + if err != nil { + return nil, err + } + return &Session{client: client}, nil + } +} + +// FromClient wraps an already-connected goimpacket SMB client (e.g. dcerpc's). +func FromClient(client *gpsmb.Client) *Session { + return &Session{client: client} +} + +// Close tears down the SMB session. Safe on nil. +func (s *Session) Close() { + if s == nil || s.client == nil { + return + } + s.client.Close() +} + +// Native returns the underlying goimpacket client for advanced callers. +func (s *Session) Native() *gpsmb.Client { + if s == nil { + return nil + } + return s.client +} + +func (s *Session) ops() shareBackend { + if s == nil { + return nil + } + if s.backend != nil { + return s.backend + } + if s.client == nil { + return nil + } + return s.client +} + +// ListShares enumerates share names. +func (s *Session) ListShares() ([]string, error) { + ops := s.ops() + if ops == nil { + return nil, fmt.Errorf("smb session not connected") + } + return ops.ListShares() +} + +// ListDir lists one directory on share (share-relative path). +func (s *Session) ListDir(share, dir string) ([]Entry, error) { + ops := s.ops() + if ops == nil { + return nil, fmt.Errorf("smb session not connected") + } + return listDir(ops, share, dir) +} + +// ReadFile reads a file from share, capped at maxBytes (default DefaultMaxReadBytes). +func (s *Session) ReadFile(share, filePath string, maxBytes int64) (string, error) { + ops := s.ops() + if ops == nil { + return "", fmt.Errorf("smb session not connected") + } + return readFile(ops, share, filePath, maxBytes) +} + +// ListTree walks share directories up to maxDepth / maxEntries. +func (s *Session) ListTree(share, root string, maxDepth, maxEntries int) ([]Entry, error) { + ops := s.ops() + if ops == nil { + return nil, fmt.Errorf("smb session not connected") + } + return listTree(ops, share, root, maxDepth, maxEntries) +} + +func listDir(ops shareBackend, share, dir string) ([]Entry, error) { + if err := RequireShareName(share); err != nil { + return nil, err + } + normalized, err := NormalizeSharePath(dir) + if err != nil { + return nil, err + } + if err := ops.UseShare(share); err != nil { + return nil, fmt.Errorf("mount share %q: %w", share, err) + } + infos, err := ops.Ls(normalized) + if err != nil { + return nil, err + } + out := make([]Entry, 0, len(infos)) + for _, fi := range infos { + name := fi.Name() + if name == "." || name == ".." { + continue + } + out = append(out, fileInfoToEntry(fi)) + } + return out, nil +} + +func readFile(ops shareBackend, share, filePath string, maxBytes int64) (string, error) { + if err := RequireShareName(share); err != nil { + return "", err + } + if maxBytes <= 0 { + maxBytes = DefaultMaxReadBytes + } + normalized, err := NormalizeSharePath(filePath) + if err != nil { + return "", err + } + if normalized == "." { + return "", fmt.Errorf("file path cannot be empty") + } + if err := ops.UseShare(share); err != nil { + return "", fmt.Errorf("mount share %q: %w", share, err) + } + // Prefer streaming Open+LimitReader when the backend supports it (tests / + // future goimpacket Open). Fall back to Cat for the stock client. + if opener, ok := ops.(shareOpener); ok { + f, err := opener.Open(normalized) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + limited := io.LimitReader(f, maxBytes+1) + body, err := io.ReadAll(limited) + if err != nil { + return "", err + } + if int64(len(body)) > maxBytes { + return "", fmt.Errorf("file %q exceeds max read size of %d bytes", normalized, maxBytes) + } + return string(body), nil + } + body, err := ops.Cat(normalized) + if err != nil { + return "", err + } + if int64(len(body)) > maxBytes { + return "", fmt.Errorf("file %q exceeds max read size of %d bytes", normalized, maxBytes) + } + return body, nil +} + +func listTree(ops shareBackend, share, root string, maxDepth, maxEntries int) ([]Entry, error) { + if err := RequireShareName(share); err != nil { + return nil, err + } + if maxDepth <= 0 { + maxDepth = DefaultMaxTreeDepth + } + if maxEntries <= 0 { + maxEntries = DefaultMaxTreeEntries + } + normalized, err := NormalizeSharePath(root) + if err != nil { + return nil, err + } + if err := ops.UseShare(share); err != nil { + return nil, fmt.Errorf("mount share %q: %w", share, err) + } + + var out []Entry + var walk func(rel string, depth int) error + walk = func(rel string, depth int) error { + if len(out) >= maxEntries { + return fmt.Errorf("tree listing exceeded max entries (%d)", maxEntries) + } + infos, err := ops.Ls(rel) + if err != nil { + return err + } + for _, fi := range infos { + name := fi.Name() + if name == "." || name == ".." { + continue + } + childRel := name + if rel != "." && rel != "" { + childRel = path.Join(rel, name) + } + entry := fileInfoToEntry(fi) + entry.Name = childRel + out = append(out, entry) + if len(out) >= maxEntries { + return fmt.Errorf("tree listing exceeded max entries (%d)", maxEntries) + } + if fi.IsDir() && depth < maxDepth { + if err := walk(childRel, depth+1); err != nil { + return err + } + } + } + return nil + } + if err := walk(normalized, 1); err != nil { + return out, err + } + return out, nil +} + +func fileInfoToEntry(fi os.FileInfo) Entry { + entry := Entry{Name: fi.Name(), Size: fi.Size(), IsDir: fi.IsDir()} + if mt := fi.ModTime(); !mt.IsZero() { + entry.ModTime = mt.UTC().Format(time.RFC3339) + } + return entry +} diff --git a/pkg/js/libs/smbsession/session_test.go b/pkg/js/libs/smbsession/session_test.go new file mode 100644 index 0000000000..c7da4dbefb --- /dev/null +++ b/pkg/js/libs/smbsession/session_test.go @@ -0,0 +1,262 @@ +package smbsession + +import ( + "context" + "io" + "io/fs" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestDialDeniesExcludedHost(t *testing.T) { + execID := "smbsession-dial-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: execID, + ExcludeTargets: []string{"203.0.113.50"}, + })) + t.Cleanup(func() { protocolstate.Close(execID) }) + + _, err := Dial(context.Background(), execID, "203.0.113.50", 445, Creds{User: "u", Password: "p"}) + require.Error(t, err) + require.Contains(t, err.Error(), "203.0.113.50") +} + +func TestSessionNilNotConnected(t *testing.T) { + var s *Session + _, err := s.ListDir("share", ".") + require.Error(t, err) + require.Contains(t, err.Error(), "not connected") + + _, err = (&Session{}).ReadFile("share", "a.txt", 10) + require.Error(t, err) + require.Contains(t, err.Error(), "not connected") +} + +func TestListDirOnFakeBackend(t *testing.T) { + fake := newFakeBackend(map[string][]fakeNode{ + ".": { + {name: "a.txt", size: 3, content: "abc"}, + {name: "docs", isDir: true}, + {name: ".", isDir: true}, + {name: "..", isDir: true}, + }, + }) + sess := &Session{backend: fake} + entries, err := sess.ListDir("backup", ".") + require.NoError(t, err) + require.Len(t, entries, 2) + require.Equal(t, "a.txt", entries[0].Name) + require.False(t, entries[0].IsDir) + require.Equal(t, "docs", entries[1].Name) + require.True(t, entries[1].IsDir) + require.Equal(t, "backup", fake.mounted) +} + +func TestListDirRejectsEscapeAndBadShare(t *testing.T) { + sess := &Session{backend: newFakeBackend(nil)} + _, err := sess.ListDir("backup", "../etc") + require.Error(t, err) + _, err = sess.ListDir("", ".") + require.Error(t, err) + require.Contains(t, err.Error(), "share name cannot be empty") +} + +func TestReadFileOnFakeBackend(t *testing.T) { + fake := newFakeBackend(map[string][]fakeNode{ + ".": {{name: "secret.txt", size: 5, content: "hello"}}, + }) + sess := &Session{backend: fake} + body, err := sess.ReadFile("backup", "secret.txt", 1024) + require.NoError(t, err) + require.Equal(t, "hello", body) +} + +func TestReadFileRejectsOversize(t *testing.T) { + fake := newFakeBackend(map[string][]fakeNode{ + ".": {{name: "big.bin", size: 100, content: strings.Repeat("x", 100)}}, + }) + sess := &Session{backend: fake} + _, err := sess.ReadFile("backup", "big.bin", 10) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds max read size") +} + +func TestReadFileRejectsEmptyPath(t *testing.T) { + sess := &Session{backend: newFakeBackend(nil)} + _, err := sess.ReadFile("backup", ".", 10) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot be empty") +} + +func TestListTreeOnFakeBackend(t *testing.T) { + fake := newFakeBackend(map[string][]fakeNode{ + ".": { + {name: "root.txt", size: 1, content: "r"}, + {name: "docs", isDir: true}, + }, + "docs": { + {name: "nested.txt", size: 1, content: "n"}, + {name: "deep", isDir: true}, + }, + "docs/deep": { + {name: "leaf.txt", size: 1, content: "l"}, + }, + }) + sess := &Session{backend: fake} + entries, err := sess.ListTree("backup", ".", 3, 100) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + } + require.Contains(t, names, "root.txt") + require.Contains(t, names, "docs") + require.Contains(t, names, "docs/nested.txt") + require.Contains(t, names, "docs/deep/leaf.txt") +} + +func TestListTreeRespectsMaxEntries(t *testing.T) { + nodes := make([]fakeNode, 0, 5) + for i := 0; i < 5; i++ { + nodes = append(nodes, fakeNode{name: string(rune('a'+i)) + ".txt", size: 1, content: "x"}) + } + sess := &Session{backend: newFakeBackend(map[string][]fakeNode{".": nodes})} + _, err := sess.ListTree("backup", ".", 1, 3) + require.Error(t, err) + require.Contains(t, err.Error(), "max entries") +} + +func TestListTreeRespectsMaxDepth(t *testing.T) { + fake := newFakeBackend(map[string][]fakeNode{ + ".": {{name: "docs", isDir: true}}, + "docs": {{name: "deep", isDir: true}}, + "docs/deep": {{name: "leaf.txt", size: 1, content: "l"}}, + }) + sess := &Session{backend: fake} + entries, err := sess.ListTree("backup", ".", 1, 100) // depth 1: only top-level + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + } + require.Contains(t, names, "docs") + require.NotContains(t, names, "docs/deep") + require.NotContains(t, names, "docs/deep/leaf.txt") +} + +func TestListSharesOnFakeBackend(t *testing.T) { + fake := newFakeBackend(nil) + fake.shares = []string{"IPC$", "backup"} + sess := &Session{backend: fake} + names, err := sess.ListShares() + require.NoError(t, err) + require.Equal(t, []string{"IPC$", "backup"}, names) +} + +type fakeNode struct { + name string + size int64 + isDir bool + content string +} + +type fakeBackend struct { + dirs map[string][]fakeNode + shares []string + mounted string +} + +func newFakeBackend(dirs map[string][]fakeNode) *fakeBackend { + if dirs == nil { + dirs = map[string][]fakeNode{} + } + return &fakeBackend{dirs: dirs} +} + +func (f *fakeBackend) UseShare(name string) error { + f.mounted = name + return nil +} + +func (f *fakeBackend) ListShares() ([]string, error) { + return append([]string(nil), f.shares...), nil +} + +func (f *fakeBackend) Ls(dirname string) ([]os.FileInfo, error) { + dirname = path.Clean(strings.Trim(strings.ReplaceAll(dirname, `\`, `/`), "/")) + if dirname == "" { + dirname = "." + } + nodes, ok := f.dirs[dirname] + if !ok { + return nil, &os.PathError{Op: "readdir", Path: dirname, Err: fs.ErrNotExist} + } + out := make([]os.FileInfo, 0, len(nodes)) + for _, n := range nodes { + out = append(out, n.info()) + } + return out, nil +} + +func (f *fakeBackend) Cat(name string) (string, error) { + rc, err := f.Open(name) + if err != nil { + return "", err + } + defer func() { _ = rc.Close() }() + body, err := io.ReadAll(rc) + if err != nil { + return "", err + } + return string(body), nil +} + +func (f *fakeBackend) Open(name string) (io.ReadCloser, error) { + name = path.Clean(strings.Trim(strings.ReplaceAll(name, `\`, `/`), "/")) + dir, base := path.Split(name) + dir = strings.Trim(dir, "/") + if dir == "" { + dir = "." + } + nodes, ok := f.dirs[dir] + if !ok { + return nil, &os.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} + } + for _, n := range nodes { + if n.name == base { + if n.isDir { + return nil, &os.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + return io.NopCloser(strings.NewReader(n.content)), nil + } + } + return nil, &os.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} +} + +func (n fakeNode) info() os.FileInfo { + return fakeFileInfo{n: n, mod: time.Unix(1, 0).UTC()} +} + +type fakeFileInfo struct { + n fakeNode + mod time.Time +} + +func (f fakeFileInfo) Name() string { return f.n.name } +func (f fakeFileInfo) Size() int64 { return f.n.size } +func (f fakeFileInfo) Mode() os.FileMode { + if f.n.isDir { + return fs.ModeDir | 0755 + } + return 0644 +} +func (f fakeFileInfo) ModTime() time.Time { return f.mod } +func (f fakeFileInfo) IsDir() bool { return f.n.isDir } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/pkg/protocols/file/file.go b/pkg/protocols/file/file.go index ef3113c251..bbec89a5b8 100644 --- a/pkg/protocols/file/file.go +++ b/pkg/protocols/file/file.go @@ -1,3 +1,20 @@ +// Package file implements the nuclei file protocol for local and remote SMB paths. +// +// Local paths use os.Open / directory walks as before. Remote targets use the +// shared smbsession stack (issue #6142 / #4707): +// +// nuclei -t file-template.yaml -target '\\fs01\share\secret.txt' +// nuclei -t file-template.yaml -target 'smb://user:pass@fs01/share/' +// +// Template auth (when not embedded in the URL): +// +// file: +// - extensions: [all] +// smb-user: auditor +// smb-password: secret +// # smb-domain: CORP +// # smb-hash: +// # smb-port: 445 package file import ( @@ -70,6 +87,25 @@ type Request struct { // NoRecursive specifies whether to not do recursive checks if folders are provided. NoRecursive bool `yaml:"no-recursive,omitempty" json:"no-recursive,omitempty" jsonschema:"title=do not perform recursion,description=Specifies whether to not do recursive checks if folders are provided"` + // description: | + // SMBUser authenticates to remote SMB shares when the file input is a UNC + // or smb:// path (issue #6142). Guest/anon: empty password. + // examples: + // - value: "\"auditor\"" + SMBUser string `yaml:"smb-user,omitempty" json:"smb-user,omitempty" jsonschema:"title=SMB username,description=Username for SMB file targets"` + // description: | + // SMBPassword is the password for SMB file targets. + SMBPassword string `yaml:"smb-password,omitempty" json:"smb-password,omitempty" jsonschema:"title=SMB password,description=Password for SMB file targets"` + // description: | + // SMBDomain is the optional NTLM domain / workgroup. + SMBDomain string `yaml:"smb-domain,omitempty" json:"smb-domain,omitempty" jsonschema:"title=SMB domain,description=Domain for SMB authentication"` + // description: | + // SMBHash enables pass-the-hash (overrides smb-password when set). + SMBHash string `yaml:"smb-hash,omitempty" json:"smb-hash,omitempty" jsonschema:"title=SMB NT hash,description=NT hash for SMB pass-the-hash"` + // description: | + // SMBPort overrides the default SMB port (445) for UNC targets. + SMBPort int `yaml:"smb-port,omitempty" json:"smb-port,omitempty" jsonschema:"title=SMB port,description=TCP port for SMB (default 445)"` + allExtensions bool } diff --git a/pkg/protocols/file/find.go b/pkg/protocols/file/find.go index 916696f2f9..eb2433e172 100644 --- a/pkg/protocols/file/find.go +++ b/pkg/protocols/file/find.go @@ -1,6 +1,7 @@ package file import ( + "context" "io" "io/fs" "os" @@ -16,9 +17,20 @@ import ( // getInputPaths parses the specified input paths and returns a compiled // list of finished absolute paths to the files evaluating any allowlist, denylist, // glob, file or folders, etc. -func (request *Request) getInputPaths(target string, callback func(string)) error { +func (request *Request) getInputPaths(ctx context.Context, target string, callback func(string)) error { processed := make(map[string]struct{}) + // Remote SMB targets (UNC / smb://) — issue #6142 bridge. + if IsSMBPath(target) { + return request.enumerateSMBInputs(ctx, target, func(path string) { + if _, ok := processed[path]; ok { + return + } + processed[path] = struct{}{} + callback(path) + }) + } + // Template input includes a wildcard if strings.Contains(target, "*") && !request.NoRecursive { if err := request.findGlobPathMatches(target, processed, callback); err != nil { diff --git a/pkg/protocols/file/find_test.go b/pkg/protocols/file/find_test.go index 011467c847..81ec22c1e5 100644 --- a/pkg/protocols/file/find_test.go +++ b/pkg/protocols/file/find_test.go @@ -1,6 +1,7 @@ package file import ( + "context" "os" "path/filepath" "testing" @@ -52,7 +53,7 @@ func TestFindInputPaths(t *testing.T) { } expected := []string{"config.yaml", "final.yaml", "test.js"} got := []string{} - err = request.getInputPaths(tempDir+"/*", func(item string) { + err = request.getInputPaths(context.Background(), tempDir+"/*", func(item string) { base := filepath.Base(item) got = append(got, base) }) @@ -60,7 +61,7 @@ func TestFindInputPaths(t *testing.T) { require.ElementsMatch(t, expected, got, "could not get correct file matches for glob") got = []string{} - err = request.getInputPaths(tempDir, func(item string) { + err = request.getInputPaths(context.Background(), tempDir, func(item string) { base := filepath.Base(item) got = append(got, base) }) diff --git a/pkg/protocols/file/request.go b/pkg/protocols/file/request.go index b715a4bfc9..a8e1fa1030 100644 --- a/pkg/protocols/file/request.go +++ b/pkg/protocols/file/request.go @@ -57,10 +57,36 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, metadata, if input.MetaInput.Input == "" { return errors.New("input cannot be empty file or folder expected") } - err = request.getInputPaths(input.MetaInput.Input, func(filePath string) { + err = request.getInputPaths(input.Context(), input.MetaInput.Input, func(filePath string) { wg.Add() go func(filePath string) { defer wg.Done() + + if IsSMBPath(filePath) { + request.options.Progress.AddToTotal(1) + body, err := request.readSMBFile(input.Context(), filePath) + if err != nil { + gologger.Error().Msgf("%s\n", err) + request.options.Progress.IncrementFailedRequestsBy(1) + return + } + reader := strings.NewReader(body) + event, fileMatches, err := request.processReader(reader, filePath, input, int64(len(body)), previous) + if err != nil { + if errors.Is(err, errEmptyResult) { + request.options.Progress.IncrementRequests() + return + } + gologger.Error().Msgf("%s\n", err) + request.options.Progress.IncrementFailedRequestsBy(1) + return + } + dumpResponse(event, request.options, fileMatches, filePath) + callback(event) + request.options.Progress.IncrementRequests() + return + } + fi, err := os.Open(filePath) if err != nil { gologger.Error().Msgf("%s\n", err) diff --git a/pkg/protocols/file/smb_bridge.go b/pkg/protocols/file/smb_bridge.go new file mode 100644 index 0000000000..9c3a76eb0a --- /dev/null +++ b/pkg/protocols/file/smb_bridge.go @@ -0,0 +1,149 @@ +package file + +import ( + "context" + "fmt" + "strings" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smbsession" +) + +func (request *Request) resolveSMBCreds(target *SMBTarget) smbsession.Creds { + creds := smbsession.Creds{ + User: request.SMBUser, + Password: request.SMBPassword, + Domain: request.SMBDomain, + Hash: request.SMBHash, + } + if target.User != "" { + creds.User = target.User + } + if target.Password != "" { + creds.Password = target.Password + } + if target.Domain != "" { + creds.Domain = target.Domain + } + if creds.Domain == "" && creds.User != "" { + d, u := smbsession.ParseIdentity(creds.User) + if d != "" { + creds.Domain = d + creds.User = u + } + } + return creds +} + +func (request *Request) smbPort(target *SMBTarget) int { + if request.SMBPort > 0 { + return request.SMBPort + } + if target.Port > 0 { + return target.Port + } + return 445 +} + +func (request *Request) executionID() string { + if request.options != nil && request.options.Options != nil { + return request.options.Options.ExecutionId + } + return "" +} + +// enumerateSMBInputs expands an SMB target into concrete file display paths. +// +// Example template usage: +// +// id: smb-secrets-scan +// file: +// - extensions: [all] +// smb-user: auditor +// smb-password: secret +// # nuclei -t tmpl.yaml -target 'smb://fs01/backup/' +// # or -target '\\fs01\backup\creds.txt' +func (request *Request) enumerateSMBInputs(ctx context.Context, input string, callback func(string)) error { + target, err := ParseSMBTarget(input) + if err != nil { + return err + } + + // Single-file targets: no dial needed for expansion (readSMBFile dials later). + if !isDirectorySMBTarget(input) { + callback(target.Display()) + return nil + } + + execID := request.executionID() + if execID == "" { + return fmt.Errorf("smb file target requires an initialized execution id") + } + sess, err := smbsession.Dial(ctx, execID, target.Host, request.smbPort(target), request.resolveSMBCreds(target)) + if err != nil { + return err + } + defer sess.Close() + + if request.NoRecursive { + entries, err := sess.ListDir(target.Share, target.Path) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir { + continue + } + child := *target + child.Path = e.Name + callback(child.Display()) + } + return nil + } + + entries, err := sess.ListTree(target.Share, target.Path, smbsession.DefaultMaxTreeDepth, smbsession.DefaultMaxTreeEntries) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir { + continue + } + child := *target + child.Path = e.Name + callback(child.Display()) + } + return nil +} + +func (request *Request) readSMBFile(ctx context.Context, displayPath string) (string, error) { + target, err := ParseSMBTarget(displayPath) + if err != nil { + return "", err + } + if target.Path == "." || isDirectorySMBTarget(displayPath) { + return "", fmt.Errorf("smb path is a directory, not a file: %s", displayPath) + } + execID := request.executionID() + if execID == "" { + return "", fmt.Errorf("smb file target requires an initialized execution id") + } + sess, err := smbsession.Dial(ctx, execID, target.Host, request.smbPort(target), request.resolveSMBCreds(target)) + if err != nil { + return "", err + } + defer sess.Close() + maxBytes := request.maxSize + if maxBytes <= 0 { + maxBytes = smbsession.DefaultMaxReadBytes + } + return sess.ReadFile(target.Share, target.Path, maxBytes) +} + +// isDirectorySMBTarget reports whether the path refers to a share root / dir listing. +func isDirectorySMBTarget(input string) bool { + t, err := ParseSMBTarget(input) + if err != nil { + return false + } + return t.Path == "." || strings.HasSuffix(strings.TrimSpace(input), "/") || strings.HasSuffix(strings.TrimSpace(input), `\`) +} diff --git a/pkg/protocols/file/smb_bridge_test.go b/pkg/protocols/file/smb_bridge_test.go new file mode 100644 index 0000000000..0fcfc51cbc --- /dev/null +++ b/pkg/protocols/file/smb_bridge_test.go @@ -0,0 +1,114 @@ +package file + +import ( + "context" + "testing" + + "github.com/projectdiscovery/nuclei/v3/pkg/protocols" + "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate" + "github.com/projectdiscovery/nuclei/v3/pkg/types" + "github.com/stretchr/testify/require" +) + +func TestGetInputPathsSMBDeniesHost(t *testing.T) { + execID := "file-smb-getpaths-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: execID, + ExcludeTargets: []string{"203.0.113.52"}, + })) + t.Cleanup(func() { protocolstate.Close(execID) }) + + req := &Request{ + SMBUser: "auditor", + SMBPassword: "secret", + options: &protocols.ExecutorOptions{ + Options: &types.Options{ExecutionId: execID}, + }, + } + // Directory target dials during enumeration so host policy applies here. + err := req.getInputPaths(context.Background(), `\\203.0.113.52\backup\`, func(string) { + t.Fatal("callback must not run when host is denied") + }) + require.Error(t, err) + require.Contains(t, err.Error(), "203.0.113.52") +} + +func TestGetInputPathsSMBRequiresExecutionID(t *testing.T) { + req := &Request{SMBUser: "u", SMBPassword: "p"} + err := req.getInputPaths(context.Background(), `\\fs01\backup\`, func(string) {}) + require.Error(t, err) + require.Contains(t, err.Error(), "execution id") +} + +func TestGetInputPathsSMBSingleFileNoDial(t *testing.T) { + req := &Request{} // no execution id / options + var got []string + err := req.getInputPaths(context.Background(), `\\fs01\backup\a.txt`, func(path string) { + got = append(got, path) + }) + require.NoError(t, err) + require.Equal(t, []string{`\\fs01\backup\a.txt`}, got) +} + +func TestReadSMBFileRejectsDirectory(t *testing.T) { + execID := "file-smb-read-dir" + require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID})) + t.Cleanup(func() { protocolstate.Close(execID) }) + + req := &Request{ + options: &protocols.ExecutorOptions{ + Options: &types.Options{ExecutionId: execID}, + }, + } + _, err := req.readSMBFile(context.Background(), `\\fs01\backup`) + require.Error(t, err) + require.Contains(t, err.Error(), "directory") +} + +func TestReadSMBFileRequiresExecutionID(t *testing.T) { + req := &Request{} + _, err := req.readSMBFile(context.Background(), `\\fs01\backup\a.txt`) + require.Error(t, err) + require.Contains(t, err.Error(), "execution id") +} + +func TestReadSMBFileDeniesHost(t *testing.T) { + execID := "file-smb-read-deny" + require.NoError(t, protocolstate.Init(&types.Options{ + ExecutionId: execID, + ExcludeTargets: []string{"203.0.113.53"}, + })) + t.Cleanup(func() { protocolstate.Close(execID) }) + + req := &Request{ + SMBUser: "u", + SMBPassword: "p", + options: &protocols.ExecutorOptions{ + Options: &types.Options{ExecutionId: execID}, + }, + } + _, err := req.readSMBFile(context.Background(), `\\203.0.113.53\backup\a.txt`) + require.Error(t, err) + require.Contains(t, err.Error(), "203.0.113.53") +} + +func TestEnumerateSMBInputsSingleFileCallback(t *testing.T) { + target, err := ParseSMBTarget(`\\fs01\backup\docs\a.txt`) + require.NoError(t, err) + require.Equal(t, `\\fs01\backup\docs\a.txt`, target.Display()) + require.False(t, isDirectorySMBTarget(target.Display())) +} + +func TestIsDirectorySMBTargetSubdirTrailingSlash(t *testing.T) { + require.True(t, isDirectorySMBTarget(`\\fs01\backup\docs\`)) + require.True(t, isDirectorySMBTarget(`smb://fs01/backup/docs/`)) + require.False(t, isDirectorySMBTarget(`\\fs01\backup\docs\a.txt`)) +} + +func TestSMBPortOverride(t *testing.T) { + req := &Request{SMBPort: 1445} + require.Equal(t, 1445, req.smbPort(&SMBTarget{Port: 445})) + req = &Request{} + require.Equal(t, 445, req.smbPort(&SMBTarget{})) + require.Equal(t, 139, req.smbPort(&SMBTarget{Port: 139})) +} diff --git a/pkg/protocols/file/smb_path.go b/pkg/protocols/file/smb_path.go new file mode 100644 index 0000000000..90c8515530 --- /dev/null +++ b/pkg/protocols/file/smb_path.go @@ -0,0 +1,163 @@ +package file + +import ( + "fmt" + "net/url" + "strings" + + "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/smbsession" +) + +// SMBTarget is a parsed UNC or smb:// path suitable for the file protocol bridge. +// +// Accepted forms (issue #6142): +// +// \\host\share\path\to\file +// //host/share/path/to/file +// smb://host/share/path/to/file +// smb://user:pass@host/share/path +// smb://domain;user:pass@host/share/path +type SMBTarget struct { + Host string + Port int + Share string + Path string // share-relative; "." for share root / directory listing + User string + Password string + Domain string +} + +// IsSMBPath reports whether input looks like a remote SMB target rather than a local path. +func IsSMBPath(input string) bool { + input = strings.TrimSpace(input) + if input == "" { + return false + } + lower := strings.ToLower(input) + if strings.HasPrefix(lower, "smb://") { + return true + } + if strings.HasPrefix(input, `\\`) || strings.HasPrefix(input, "//") { + return true + } + return false +} + +// ParseSMBTarget parses an SMB UNC or smb:// URL into an SMBTarget. +func ParseSMBTarget(input string) (*SMBTarget, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("empty SMB path") + } + lower := strings.ToLower(input) + if strings.HasPrefix(lower, "smb://") { + return parseSMBURL(input) + } + if strings.HasPrefix(input, `\\`) || strings.HasPrefix(input, "//") { + return parseUNC(input) + } + return nil, fmt.Errorf("not an SMB path: %q", input) +} + +func parseSMBURL(raw string) (*SMBTarget, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("parse smb url: %w", err) + } + if u.Host == "" { + return nil, fmt.Errorf("smb url missing host") + } + host := u.Hostname() + port := 445 + if p := u.Port(); p != "" { + var n int + if _, err := fmt.Sscanf(p, "%d", &n); err == nil && n > 0 { + port = n + } + } + user, pass, domain := "", "", "" + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + // domain;user form in username + if i := strings.IndexByte(user, ';'); i >= 0 { + domain = user[:i] + user = user[i+1:] + } + } + share, rel, err := splitSharePath(u.Path) + if err != nil { + return nil, err + } + return &SMBTarget{ + Host: host, + Port: port, + Share: share, + Path: rel, + User: user, + Password: pass, + Domain: domain, + }, nil +} + +func parseUNC(raw string) (*SMBTarget, error) { + s := strings.ReplaceAll(raw, `\`, `/`) + s = strings.TrimPrefix(s, "//") + parts := strings.Split(s, "/") + cleaned := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + cleaned = append(cleaned, p) + } + } + if len(cleaned) < 2 { + return nil, fmt.Errorf("UNC path requires \\\\host\\share: %q", raw) + } + host := cleaned[0] + share := cleaned[1] + rel := "." + if len(cleaned) > 2 { + rel = strings.Join(cleaned[2:], "/") + } + if err := smbsession.RequireShareName(share); err != nil { + return nil, err + } + normalized, err := smbsession.NormalizeSharePath(rel) + if err != nil { + return nil, err + } + return &SMBTarget{Host: host, Port: 445, Share: share, Path: normalized}, nil +} + +func splitSharePath(urlPath string) (share, rel string, err error) { + urlPath = strings.Trim(urlPath, "/") + if urlPath == "" { + return "", "", fmt.Errorf("smb url missing share name") + } + parts := strings.SplitN(urlPath, "/", 2) + share = parts[0] + if err := smbsession.RequireShareName(share); err != nil { + return "", "", err + } + rel = "." + if len(parts) == 2 { + rel = parts[1] + } + normalized, err := smbsession.NormalizeSharePath(rel) + if err != nil { + return "", "", err + } + return share, normalized, nil +} + +// Display returns a stable UNC-like string for events/logs (never embeds password). +func (t *SMBTarget) Display() string { + if t == nil { + return "" + } + if t.Path == "" || t.Path == "." { + return fmt.Sprintf(`\\%s\%s`, t.Host, t.Share) + } + p := strings.ReplaceAll(t.Path, `/`, `\`) + return fmt.Sprintf(`\\%s\%s\%s`, t.Host, t.Share, p) +} diff --git a/pkg/protocols/file/smb_path_test.go b/pkg/protocols/file/smb_path_test.go new file mode 100644 index 0000000000..334e3af29e --- /dev/null +++ b/pkg/protocols/file/smb_path_test.go @@ -0,0 +1,92 @@ +package file + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsSMBPath(t *testing.T) { + require.True(t, IsSMBPath(`\\fs01\backup\a.txt`)) + require.True(t, IsSMBPath(`//fs01/backup/a.txt`)) + require.True(t, IsSMBPath(`smb://fs01/backup/a.txt`)) + require.True(t, IsSMBPath(`SMB://user:pass@fs01/share/`)) + require.False(t, IsSMBPath(`/tmp/a.txt`)) + require.False(t, IsSMBPath(`C:\Windows\a.txt`)) + require.False(t, IsSMBPath(``)) +} + +func TestParseSMBTargetUNC(t *testing.T) { + got, err := ParseSMBTarget(`\\fs01\backup\docs\creds.txt`) + require.NoError(t, err) + require.Equal(t, "fs01", got.Host) + require.Equal(t, "backup", got.Share) + require.Equal(t, "docs/creds.txt", got.Path) + require.Equal(t, 445, got.Port) + require.Equal(t, `\\fs01\backup\docs\creds.txt`, got.Display()) +} + +func TestParseSMBTargetUNCShareRoot(t *testing.T) { + got, err := ParseSMBTarget(`\\fs01\backup`) + require.NoError(t, err) + require.Equal(t, "backup", got.Share) + require.Equal(t, ".", got.Path) + require.Equal(t, `\\fs01\backup`, got.Display()) +} + +func TestParseSMBTargetURL(t *testing.T) { + got, err := ParseSMBTarget(`smb://auditor:secret@fs01:1445/backup/a.txt`) + require.NoError(t, err) + require.Equal(t, "fs01", got.Host) + require.Equal(t, 1445, got.Port) + require.Equal(t, "backup", got.Share) + require.Equal(t, "a.txt", got.Path) + require.Equal(t, "auditor", got.User) + require.Equal(t, "secret", got.Password) +} + +func TestParseSMBTargetURLWithDomain(t *testing.T) { + got, err := ParseSMBTarget(`smb://CORP;alice:p@fs01/share/x`) + require.NoError(t, err) + require.Equal(t, "CORP", got.Domain) + require.Equal(t, "alice", got.User) + require.Equal(t, "p", got.Password) + require.Equal(t, "share", got.Share) + require.Equal(t, "x", got.Path) +} + +func TestParseSMBTargetRejectsEscape(t *testing.T) { + _, err := ParseSMBTarget(`\\fs01\backup\..\..\Windows\win.ini`) + require.Error(t, err) +} + +func TestParseSMBTargetRejectsBadShare(t *testing.T) { + _, err := ParseSMBTarget(`smb://fs01/bad/share/x`) + // share is "bad", path is "share/x" — valid + require.NoError(t, err) + _, err = ParseSMBTarget(`\\fs01\`) + require.Error(t, err) +} + +func TestResolveSMBCredsPreferURL(t *testing.T) { + req := &Request{SMBUser: "tmpl", SMBPassword: "tmpl-pass", SMBDomain: "TMPL"} + target := &SMBTarget{User: "urluser", Password: "urlpass", Domain: "URLDOM"} + creds := req.resolveSMBCreds(target) + require.Equal(t, "urluser", creds.User) + require.Equal(t, "urlpass", creds.Password) + require.Equal(t, "URLDOM", creds.Domain) +} + +func TestResolveSMBCredsParseIdentity(t *testing.T) { + req := &Request{SMBUser: `CORP\bob`, SMBPassword: "x"} + creds := req.resolveSMBCreds(&SMBTarget{}) + require.Equal(t, "CORP", creds.Domain) + require.Equal(t, "bob", creds.User) +} + +func TestIsDirectorySMBTarget(t *testing.T) { + require.True(t, isDirectorySMBTarget(`\\fs01\backup`)) + require.True(t, isDirectorySMBTarget(`smb://fs01/backup/`)) + require.True(t, isDirectorySMBTarget(`\\fs01\backup\docs\`)) + require.False(t, isDirectorySMBTarget(`\\fs01\backup\a.txt`)) +}