diff options
author | Bryan McNulty <bryanmcnulty@protonmail.com> | 2025-04-16 12:11:58 -0500 |
---|---|---|
committer | Bryan McNulty <bryanmcnulty@protonmail.com> | 2025-04-16 12:11:58 -0500 |
commit | 55eb4275fb760ac7a3ce1444f5ae0ded9e2ff91c (patch) | |
tree | edf4ec3b814fb10ccdbf759a62819a865d3e8141 /pkg | |
parent | a827b67d47cba7b02ea9599fe6bb88ffb3a6967d (diff) | |
download | goexec-55eb4275fb760ac7a3ce1444f5ae0ded9e2ff91c.tar.gz goexec-55eb4275fb760ac7a3ce1444f5ae0ded9e2ff91c.zip |
rewrote everything lol
Diffstat (limited to 'pkg')
32 files changed, 2322 insertions, 0 deletions
diff --git a/pkg/goexec/auth.go b/pkg/goexec/auth.go new file mode 100644 index 0000000..e2e9c3e --- /dev/null +++ b/pkg/goexec/auth.go @@ -0,0 +1,11 @@ +package goexec + +import ( + "github.com/RedTeamPentesting/adauth" +) + +// AuthOptions holds Windows / Active Directory authentication parameters +type AuthOptions struct { + Target *adauth.Target + Credential *adauth.Credential +} diff --git a/pkg/goexec/clean.go b/pkg/goexec/clean.go new file mode 100644 index 0000000..a853dc4 --- /dev/null +++ b/pkg/goexec/clean.go @@ -0,0 +1,31 @@ +package goexec + +import ( + "context" + "github.com/rs/zerolog" +) + +type CleanProvider interface { + Clean(ctx context.Context) (err error) +} + +type Cleaner struct { + workers []func(ctx context.Context) error +} + +func (c *Cleaner) AddCleaner(worker func(ctx context.Context) error) { + c.workers = append(c.workers, worker) +} + +func (c *Cleaner) Clean(ctx context.Context) (err error) { + log := zerolog.Ctx(ctx).With(). + Str("component", "cleaner").Logger() + + for _, worker := range c.workers { + if err = worker(log.WithContext(ctx)); err != nil { + + log.Warn().Err(err).Msg("Clean worker failed") + } + } + return +} diff --git a/pkg/goexec/client.go b/pkg/goexec/client.go new file mode 100644 index 0000000..bee0399 --- /dev/null +++ b/pkg/goexec/client.go @@ -0,0 +1,26 @@ +package goexec + +import "context" + +// Client represents an application layer network client +type Client interface { + + // Connect establishes a connection to the remote server + Connect(ctx context.Context) error + + // Close terminates the active connection and frees allocated resources + Close(ctx context.Context) error +} + +// ClientOptions represents configuration options for a Client +type ClientOptions struct { + + // Proxy specifies the URI of the proxy server to route client requests through + Proxy string `json:"proxy,omitempty" yaml:"proxy,omitempty"` + + // Host specifies the hostname or IP address that the client should connect to + Host string `json:"host" yaml:"host"` + + // Port specifies the network port on Host that the client will connect to + Port uint16 `json:"port" yaml:"port"` +} diff --git a/pkg/goexec/dce/client.go b/pkg/goexec/dce/client.go new file mode 100644 index 0000000..e8dadae --- /dev/null +++ b/pkg/goexec/dce/client.go @@ -0,0 +1,89 @@ +package dce + +import ( + "context" + "fmt" + "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/oiweiwei/go-msrpc/msrpc/epm/epm/v3" + "github.com/rs/zerolog" +) + +type Client struct { + Options + + conn dcerpc.Conn + hostname string +} + +func NewClient() *Client { + return new(Client) +} + +func (c *Client) String() string { + return ClientName +} + +func (c *Client) Reconnect(ctx context.Context, opts ...dcerpc.Option) (err error) { + c.DcerpcOptions = append(c.DcerpcOptions, opts...) + + return c.Connect(ctx) +} + +func (c *Client) Dce() (dce dcerpc.Conn) { + return c.conn +} + +func (c *Client) Logger(ctx context.Context) (log zerolog.Logger) { + return zerolog.Ctx(ctx).With(). + Str("client", c.String()).Logger() +} + +func (c *Client) Connect(ctx context.Context) (err error) { + + log := c.Logger(ctx) + ctx = log.WithContext(ctx) + + var do, eo []dcerpc.Option + + do = append(do, c.DcerpcOptions...) + do = append(do, c.authOptions...) + + if !c.NoSign { + do = append(do, dcerpc.WithSign()) + eo = append(eo, dcerpc.WithSign()) + } + if !c.NoSeal { + do = append(do, dcerpc.WithSeal(), dcerpc.WithSecurityLevel(dcerpc.AuthLevelPktPrivacy)) + eo = append(eo, dcerpc.WithSeal(), dcerpc.WithSecurityLevel(dcerpc.AuthLevelPktPrivacy)) + } + + if !c.NoLog { + do = append(do, dcerpc.WithLogger(log)) + eo = append(eo, dcerpc.WithLogger(log)) + } + + if !c.NoEpm { + log.Debug().Msg("Using endpoint mapper") + + eo = append(eo, c.epmOptions...) + eo = append(eo, c.authOptions...) + + do = append(do, epm.EndpointMapper(ctx, c.Host, eo...)) + } + + for _, e := range c.stringBindings { + do = append(do, dcerpc.WithEndpoint(e.String())) + } + + if c.conn, err = dcerpc.Dial(ctx, c.Host, do...); err != nil { + + log.Error().Err(err).Msgf("Failed to connect to %s endpoint", c.String()) + return fmt.Errorf("dial %s: %w", c.String(), err) + } + + return +} + +func (c *Client) Close(ctx context.Context) (err error) { + return c.conn.Close(ctx) +} diff --git a/pkg/goexec/dce/default.go b/pkg/goexec/dce/default.go new file mode 100644 index 0000000..d3a912f --- /dev/null +++ b/pkg/goexec/dce/default.go @@ -0,0 +1,5 @@ +package dce + +const ( + ClientName = "DCE" +) diff --git a/pkg/goexec/dce/options.go b/pkg/goexec/dce/options.go new file mode 100644 index 0000000..e13ef43 --- /dev/null +++ b/pkg/goexec/dce/options.go @@ -0,0 +1,108 @@ +package dce + +import ( + "context" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/RedTeamPentesting/adauth/dcerpcauth" + "github.com/oiweiwei/go-msrpc/dcerpc" + "net" +) + +type Options struct { + goexec.ClientOptions + goexec.AuthOptions + + // NoSign disables packet signing by omitting dcerpc.WithSign() + NoSign bool `json:"no_sign" yaml:"no_sign"` + + // NoSeal disables packet stub encryption by omitting dcerpc.WithSeal() + NoSeal bool `json:"no_seal" yaml:"no_seal"` + + // NoLog disables logging by omitting dcerpc.WithLogger(...) + NoLog bool `json:"no_log" yaml:"no_log"` + + // NoEpm disables DCE endpoint mapper communications + NoEpm bool `json:"no_epm" yaml:"no_epm"` + + // Endpoint stores the explicit DCE string binding to use + Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` + + // Filter stores the filter for returned endpoints from an endpoint mapper + Filter string `json:"filter,omitempty" yaml:"filter,omitempty"` + + netDialer goexec.Dialer + dialer dcerpc.Dialer + authOptions []dcerpc.Option + DcerpcOptions []dcerpc.Option + epmOptions []dcerpc.Option + stringBindings []*dcerpc.StringBinding +} + +func (c *Client) Parse(ctx context.Context) (err error) { + + // Reset internals + { + c.netDialer = nil + c.dialer = nil + c.stringBindings = []*dcerpc.StringBinding{} + c.authOptions = []dcerpc.Option{} + c.DcerpcOptions = []dcerpc.Option{} + c.epmOptions = []dcerpc.Option{ + dcerpc.WithSign(), // Require signing for EPM + } + } + + if !c.NoSeal { + // Enable encryption + c.DcerpcOptions = append(c.DcerpcOptions, dcerpc.WithSeal(), dcerpc.WithSecurityLevel(dcerpc.AuthLevelPktPrivacy)) + c.epmOptions = append(c.epmOptions, dcerpc.WithSeal(), dcerpc.WithSecurityLevel(dcerpc.AuthLevelPktPrivacy)) + } + if !c.NoSign { + // Enable signing + c.DcerpcOptions = append(c.DcerpcOptions, dcerpc.WithSign()) + //c.epmOptions = append(c.epmOptions, dcerpc.WithSign()) + } + + // Parse DCERPC endpoint + if c.Endpoint != "" { + sb, err := dcerpc.ParseStringBinding(c.Endpoint) + if err != nil { + return err + } + c.stringBindings = append(c.stringBindings, sb) + } + + // Parse EPM filter + if c.Filter != "" { + sb, err := dcerpc.ParseStringBinding(c.Filter) + if err != nil { + return err + } + c.stringBindings = append(c.stringBindings, sb) + } + + if c.Proxy == "" { + c.netDialer = &net.Dialer{} // FUTURE: additional dial c + + } else { + // Parse proxy URL + d, err := goexec.ParseProxyURI(c.Proxy) + if err != nil { + return err + } + var ok bool + if c.dialer, ok = d.(dcerpc.Dialer); !ok { + return fmt.Errorf("cannot cast %T to dcerpc.Dialer", d) + } + } + + // Parse authentication parameters + if c.authOptions, err = dcerpcauth.AuthenticationOptions(ctx, c.Credential, c.Target, &dcerpcauth.Options{}); err != nil { + return fmt.Errorf("parse auth c: %w", err) + } + + c.Host = c.Target.AddressWithoutPort() + + return +} diff --git a/pkg/goexec/dcom/dcom.go b/pkg/goexec/dcom/dcom.go new file mode 100644 index 0000000..e66a382 --- /dev/null +++ b/pkg/goexec/dcom/dcom.go @@ -0,0 +1,34 @@ +package dcomexec + +import ( + guuid "github.com/google/uuid" + "github.com/oiweiwei/go-msrpc/midl/uuid" + "github.com/oiweiwei/go-msrpc/msrpc/dcom" + "github.com/oiweiwei/go-msrpc/msrpc/dtyp" +) + +const ( + LcEnglishUs uint32 = 0x409 +) + +var ( + ShellWindowsUuid = uuid.MustParse("9BA05972-F6A8-11CF-A442-00A0C90A8F39") + Mmc20Uuid = uuid.MustParse("49B2791A-B1AE-4C90-9B8E-E860BA07F889") + + RandCid = dcom.CID(*dtyp.GUIDFromUUID(uuid.MustParse(guuid.NewString()))) + IDispatchIID = &dcom.IID{ + Data1: 0x20400, + Data2: 0x0, + Data3: 0x0, + Data4: []byte{0xc0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x46}, + } + ComVersion = &dcom.COMVersion{ + MajorVersion: 5, + MinorVersion: 7, + } + MmcClsid = dcom.ClassID(*dtyp.GUIDFromUUID(Mmc20Uuid)) + ORPCThis = &dcom.ORPCThis{ + Version: ComVersion, + CID: &RandCid, + } +) diff --git a/pkg/goexec/dcom/mmc.go b/pkg/goexec/dcom/mmc.go new file mode 100644 index 0000000..9c92af3 --- /dev/null +++ b/pkg/goexec/dcom/mmc.go @@ -0,0 +1,50 @@ +package dcomexec + +import ( + "context" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/rs/zerolog" +) + +const ( + MethodMmc = "MMC" // MMC20.Application::Document.ActiveView.ExecuteShellCommand +) + +type DcomMmc struct { + DcomExec + + WorkingDirectory string + WindowState string +} + +// Execute will perform command execution via the MMC20.Application DCOM object. +func (m *DcomMmc) Execute(ctx context.Context, in *goexec.ExecutionInput) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodMmc). + Logger() + + method := "Document.ActiveView.ExecuteShellCommand" + + var args = in.Arguments + if args == "" { + args = " " // the process arguments can't be a blank string + } + + // Arguments must be passed in reverse order + if _, err := callComMethod(ctx, + m.dispatchClient, + method, + stringToVariant(m.WindowState), + stringToVariant(in.Arguments), + stringToVariant(m.WorkingDirectory), + stringToVariant(in.Executable)); err != nil { + + log.Error().Err(err).Msg("Failed to call method") + return fmt.Errorf("call %q: %w", method, err) + } + log.Info().Msg("Method call successful") + return +} diff --git a/pkg/goexec/dcom/module.go b/pkg/goexec/dcom/module.go new file mode 100644 index 0000000..47dc7ca --- /dev/null +++ b/pkg/goexec/dcom/module.go @@ -0,0 +1,111 @@ +package dcomexec + +import ( + "context" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec/dce" + "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/oiweiwei/go-msrpc/msrpc/dcom" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/iremotescmactivator/v0" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/oaut/idispatch/v0" + "github.com/rs/zerolog" +) + +const ( + ModuleName = "DCOM" +) + +type DcomExec struct { + client *dce.Client + dispatchClient idispatch.DispatchClient +} + +func (m *DcomExec) Init(ctx context.Context, c *dce.Client) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName).Logger() + + m.client = c + + if m.client.Dce() == nil { + return errors.New("DCE connection not initialized") + } + + opts := []dcerpc.Option{ + dcerpc.WithSign(), + } + + inst := &dcom.InstantiationInfoData{ + ClassID: &MmcClsid, + IID: []*dcom.IID{IDispatchIID}, + ClientCOMVersion: ComVersion, + } + ac := &dcom.ActivationContextInfoData{} + loc := &dcom.LocationInfoData{} + scm := &dcom.SCMRequestInfoData{ + RemoteRequest: &dcom.CustomRemoteRequestSCMInfo{ + RequestedProtocolSequences: []uint16{7}, + }, + } + + ap := &dcom.ActivationProperties{ + DestinationContext: 2, + Properties: []dcom.ActivationProperty{inst, ac, loc, scm}, + } + + apin, err := ap.ActivationPropertiesIn() + if err != nil { + return err + } + + act, err := iremotescmactivator.NewRemoteSCMActivatorClient(ctx, m.client.Dce()) + if err != nil { + return err + } + + cr, err := act.RemoteCreateInstance(ctx, &iremotescmactivator.RemoteCreateInstanceRequest{ + ORPCThis: &dcom.ORPCThis{ + Version: ComVersion, + Flags: 1, + CID: &RandCid, + }, + ActPropertiesIn: apin, + }) + if err != nil { + return err + } + log.Info().Msg("RemoteCreateInstance succeeded") + + apout := new(dcom.ActivationProperties) + if err = apout.Parse(cr.ActPropertiesOut); err != nil { + return err + } + si := apout.SCMReplyInfoData() + pi := apout.PropertiesOutInfo() + + if si == nil { + return fmt.Errorf("remote create instance response: SCMReplyInfoData is nil") + } + + if pi == nil { + return fmt.Errorf("remote create instance response: PropertiesOutInfo is nil") + } + + oIPID := pi.InterfaceData[0].IPID() + opts = append(opts, si.RemoteReply.OXIDBindings.EndpointsByProtocol("ncacn_ip_tcp")...) // TODO + + err = c.Reconnect(ctx, opts...) + if err != nil { + return err + } + log.Info().Msg("created new DCERPC dialer") + + m.dispatchClient, err = idispatch.NewDispatchClient(ctx, c.Dce(), dcom.WithIPID(oIPID)) + if err != nil { + return err + } + log.Info().Msg("created IDispatch client") + + return +} diff --git a/pkg/goexec/dcom/util.go b/pkg/goexec/dcom/util.go new file mode 100644 index 0000000..5ed8a60 --- /dev/null +++ b/pkg/goexec/dcom/util.go @@ -0,0 +1,85 @@ +package dcomexec + +import ( + "context" + "fmt" + "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/oiweiwei/go-msrpc/msrpc/dcom" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/oaut" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/oaut/idispatch/v0" + "strings" +) + +func callComMethod(ctx context.Context, dc idispatch.DispatchClient, method string, args ...*oaut.Variant) (ir *idispatch.InvokeResponse, err error) { + + parts := strings.Split(method, ".") + + var id *dcom.IPID + var gr *idispatch.GetIDsOfNamesResponse + + for i, obj := range parts { + + var opts []dcerpc.CallOption + + if id != nil { + opts = append(opts, dcom.WithIPID(id)) + } + + gr, err = dc.GetIDsOfNames(ctx, &idispatch.GetIDsOfNamesRequest{ + This: ORPCThis, + IID: &dcom.IID{}, + LocaleID: LcEnglishUs, + + Names: []string{obj + "\x00"}, + }, opts...) + + if err != nil { + return nil, fmt.Errorf("get dispatch ID of name %q: %w", obj, err) + } + + if len(gr.DispatchID) < 1 { + return nil, fmt.Errorf("dispatch ID of name %q not found", obj) + } + + irq := &idispatch.InvokeRequest{ + This: ORPCThis, + IID: &dcom.IID{}, + LocaleID: LcEnglishUs, + + DispatchIDMember: gr.DispatchID[0], + } + + if i >= len(parts)-1 { + irq.Flags = 1 + irq.DispatchParams = &oaut.DispatchParams{ArgsCount: uint32(len(args)), Args: args} + return dc.Invoke(ctx, irq, opts...) + } + irq.Flags = 2 + + ir, err = dc.Invoke(ctx, irq, opts...) + if err != nil { + return nil, fmt.Errorf("get properties of object %q: %w", obj, err) + } + + di, ok := ir.VarResult.VarUnion.GetValue().(*oaut.Dispatch) + if !ok { + return nil, fmt.Errorf("invalid dispatch object for %q", obj) + } + id = di.InterfacePointer().GetStandardObjectReference().Std.IPID + } + return +} + +func stringToVariant(s string) *oaut.Variant { + return &oaut.Variant{ + Size: 5, + VT: 8, + VarUnion: &oaut.Variant_VarUnion{ + Value: &oaut.Variant_VarUnion_BSTR{ + BSTR: &oaut.String{ + Data: s, + }, + }, + }, + } +} diff --git a/pkg/goexec/exec.go b/pkg/goexec/exec.go new file mode 100644 index 0000000..cb44f19 --- /dev/null +++ b/pkg/goexec/exec.go @@ -0,0 +1,14 @@ +package goexec + +import "context" + +type ExecutionProvider interface { + Execute(ctx context.Context, in *ExecutionInput) (err error) +} + +type Executor struct{} + +type CleanExecutionProvider interface { + ExecutionProvider + CleanProvider +} diff --git a/pkg/goexec/io.go b/pkg/goexec/io.go new file mode 100644 index 0000000..846a1fd --- /dev/null +++ b/pkg/goexec/io.go @@ -0,0 +1,87 @@ +package goexec + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" +) + +type OutputProvider interface { + GetOutput(ctx context.Context) (out io.ReadCloser, err error) +} + +type ExecutionIO struct { + Cleaner + + Input *ExecutionInput + Output *ExecutionOutput +} + +type ExecutionOutput struct { + NoDelete bool + RemotePath string + Provider OutputProvider +} + +type ExecutionInput struct { + FilePath string + Executable string + ExecutablePath string + Arguments string + CommandLine string +} + +func (execIO *ExecutionIO) GetOutput(ctx context.Context) (out io.ReadCloser, err error) { + + if execIO.Output.Provider != nil { + return execIO.Output.Provider.GetOutput(ctx) + } + return nil, errors.New("output provider not set") +} + +func (execIO *ExecutionIO) CommandLine() string { + return execIO.Input.Command() +} + +func (execIO *ExecutionIO) String() (cmd string) { + + cmd = execIO.Input.Command() + + if execIO.Output.Provider != nil && execIO.Output.RemotePath != "" { + return fmt.Sprintf(`C:\Windows\System32\cmd.exe /C %s > %s`, cmd, execIO.Output.RemotePath) + } + return +} + +func (i *ExecutionInput) Command() string { + + if i.CommandLine == "" { + + if i.ExecutablePath != "" { + i.CommandLine = i.ExecutablePath + + } else if i.Executable != "" { + i.CommandLine = i.Executable + } + + if i.Arguments != "" { + i.CommandLine += " " + i.Arguments + } + } + return i.CommandLine +} + +func (i *ExecutionInput) String() string { + return i.Command() +} + +func (i *ExecutionInput) UploadReader(_ context.Context) (reader io.Reader, err error) { + + if i.FilePath != "" { + return os.OpenFile(i.FilePath, os.O_RDONLY, 0) + } + return bytes.NewBufferString(i.Command()), nil +} diff --git a/pkg/goexec/method.go b/pkg/goexec/method.go new file mode 100644 index 0000000..7ea0ddc --- /dev/null +++ b/pkg/goexec/method.go @@ -0,0 +1,72 @@ +package goexec + +import ( + "context" + "fmt" + "github.com/rs/zerolog" +) + +type Method interface{} + +type RemoteMethod interface { + Connect(ctx context.Context) error + Init(ctx context.Context) error +} + +type RemoteExecuteMethod interface { + RemoteMethod + Execute(ctx context.Context, io *ExecutionIO) error +} + +type RemoteCleanMethod interface { + RemoteMethod + Clean(ctx context.Context) error +} + +type RemoteExecuteCleanMethod interface { + RemoteExecuteMethod + Clean(ctx context.Context) error +} + +type RemoteExecuteCleanMethodWithOutput interface { + RemoteExecuteCleanMethod + OutputProvider +} + +func ExecuteMethod(ctx context.Context, module RemoteExecuteMethod, execIO *ExecutionIO) (err error) { + + log := zerolog.Ctx(ctx) + + if err = module.Connect(ctx); err != nil { + log.Error().Err(err).Msg("Connection failed") + return fmt.Errorf("connect: %w", err) + } + + if err = module.Init(ctx); err != nil { + log.Error().Err(err).Msg("Module initialization failed") + return fmt.Errorf("init module: %w", err) + } + + if err = module.Execute(ctx, execIO); err != nil { + log.Error().Err(err).Msg("Execution failed") + return fmt.Errorf("execute: %w", err) + } + return +} + +func ExecuteCleanMethod(ctx context.Context, module RemoteExecuteCleanMethod, execIO *ExecutionIO) (err error) { + + log := zerolog.Ctx(ctx) + + defer func() { + if err = module.Clean(ctx); err != nil { + log.Error().Err(err).Msg("Module cleanup failed") + } + }() + + return ExecuteMethod(ctx, module, execIO) +} + +func ExecuteCleanMethodWithOutput(ctx context.Context, module RemoteExecuteCleanMethodWithOutput, execIO *ExecutionIO) (err error) { + return ExecuteCleanMethod(ctx, module, execIO) +} diff --git a/pkg/goexec/module.go b/pkg/goexec/module.go new file mode 100644 index 0000000..6544b78 --- /dev/null +++ b/pkg/goexec/module.go @@ -0,0 +1,25 @@ +package goexec + +import "context" + +type Module interface { + Init(ctx context.Context) error +} + +type ExecutionModule interface { + Module + ExecutionProvider +} + +type CleanExecutionModule interface { + Module + ExecutionProvider + CleanProvider +} + +type CleanExecutionOutputModule interface { + Module + ExecutionProvider + CleanProvider + OutputProvider +} diff --git a/pkg/goexec/proxy.go b/pkg/goexec/proxy.go new file mode 100644 index 0000000..8f20758 --- /dev/null +++ b/pkg/goexec/proxy.go @@ -0,0 +1,33 @@ +package goexec + +import ( + "fmt" + "golang.org/x/net/proxy" + "net" + "net/url" +) + +// Dialer outlines a basic implementation for establishing network connections +type Dialer interface { + + // Dial establishes a network connection (net.Conn) using the provided parameters + Dial(network string, address string) (connection net.Conn, err error) +} + +// ParseProxyURI parses the provided proxy URI spec to a Dialer +func ParseProxyURI(uri string) (dialer Dialer, err error) { + + // Parse proxy spec as URL + u, err := url.Parse(uri) + if err != nil { + return nil, fmt.Errorf("parse proxy URI: %w", err) + } + + // Create dialer from URL + dialer, err = proxy.FromURL(u, nil) + if err != nil { + return nil, fmt.Errorf("init proxy: %w", err) + } + + return +} diff --git a/pkg/goexec/scmr/change.go b/pkg/goexec/scmr/change.go new file mode 100644 index 0000000..c8321f7 --- /dev/null +++ b/pkg/goexec/scmr/change.go @@ -0,0 +1,144 @@ +package scmrexec + +import ( + "context" + "fmt" + "github.com/FalconOpsLLC/goexec/internal/windows" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/oiweiwei/go-msrpc/msrpc/scmr/svcctl/v2" + "github.com/rs/zerolog" +) + +const ( + MethodChange = "Change" +) + +type ScmrChange struct { + Scmr + goexec.Cleaner + goexec.Executor + + NoStart bool + ServiceName string +} + +func (m *ScmrChange) Execute(ctx context.Context, in *goexec.ExecutionInput) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodChange). + Str("service", m.ServiceName). + Logger() + + svc := &service{name: m.ServiceName} + + openResponse, err := m.ctl.OpenServiceW(ctx, &svcctl.OpenServiceWRequest{ + ServiceManager: m.scm, + ServiceName: svc.name, + DesiredAccess: ServiceAllAccess, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to open service handle") + return fmt.Errorf("open service request: %w", err) + } + if openResponse.Return != 0 { + log.Error().Err(err).Msg("Failed to open service handle") + return fmt.Errorf("create service: %w", err) + } + + svc.handle = openResponse.Service + log.Info().Msg("Opened service handle") + + defer m.AddCleaner(func(ctxInner context.Context) error { + + r, errInner := m.ctl.CloseService(ctxInner, &svcctl.CloseServiceRequest{ + ServiceObject: svc.handle, + }) + if errInner != nil { + return fmt.Errorf("close service: %w", errInner) + } + if r.Return != 0 { + return fmt.Errorf("close service returned non-zero exit code: %02x", r.Return) + } + log.Info().Msg("Closed service handle") + + return nil + }) + + // Note original service configuration + queryResponse, err := m.ctl.QueryServiceConfigW(ctx, &svcctl.QueryServiceConfigWRequest{ + Service: svc.handle, + BufferLength: 8 * 1024, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to fetch service configuration") + return fmt.Errorf("get service config: %w", err) + } + if queryResponse.Return != 0 { + log.Error().Err(err).Msg("Failed to query service configuration") + return fmt.Errorf("query service config: %w", err) + } + + log.Info().Str("binaryPath", queryResponse.ServiceConfig.BinaryPathName).Msg("Fetched original service configuration") + svc.originalConfig = queryResponse.ServiceConfig + + stopResponse, err := m.ctl.ControlService(ctx, &svcctl.ControlServiceRequest{ + Service: svc.handle, + Control: windows.SERVICE_CONTROL_STOP, + }) + + if err != nil { + if stopResponse == nil || stopResponse.Return != windows.ERROR_SERVICE_NOT_ACTIVE { + + log.Error().Err(err).Msg("Failed to stop existing service") + return fmt.Errorf("stop service: %w", err) + } + + log.Debug().Msg("Service is not running") + + // TODO: restore state + /* + defer m.AddCleaner(func(ctxInner context.Context) error { + // ... + return nil + }) + */ + + } else { + log.Info().Msg("Stopped existing service") + } + + changeResponse, err := m.ctl.ChangeServiceConfigW(ctx, &svcctl.ChangeServiceConfigWRequest{ + Service: svc.handle, + BinaryPathName: in.String(), + DisplayName: svc.originalConfig.DisplayName, + ServiceType: svc.originalConfig.ServiceType, + StartType: windows.SERVICE_DEMAND_START, + ErrorControl: svc.originalConfig.ErrorControl, + LoadOrderGroup: svc.originalConfig.LoadOrderGroup, + ServiceStartName: svc.originalConfig.ServiceStartName, + TagID: svc.originalConfig.TagID, + //Dependencies: []byte(svc.originalConfig.Dependencies), // TODO + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to request service configuration change") + return fmt.Errorf("change service config request: %w", err) + } + if changeResponse.Return != 0 { + log.Error().Err(err).Msg("Failed to change service configuration") + return fmt.Errorf("change service config: %w", err) + } + + if !m.NoStart { + + err = m.startService(ctx, svc) + if err != nil { + log.Error().Err(err).Msg("Failed to start service") + } + } + + return +} diff --git a/pkg/goexec/scmr/create.go b/pkg/goexec/scmr/create.go new file mode 100644 index 0000000..6de50f8 --- /dev/null +++ b/pkg/goexec/scmr/create.go @@ -0,0 +1,125 @@ +package scmrexec + +import ( + "context" + "fmt" + "github.com/FalconOpsLLC/goexec/internal/util" + "github.com/FalconOpsLLC/goexec/internal/windows" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/oiweiwei/go-msrpc/msrpc/scmr/svcctl/v2" + "github.com/rs/zerolog" +) + +const ( + MethodCreate = "Create" +) + +type ScmrCreate struct { + Scmr + goexec.Cleaner + goexec.Executor + + NoDelete bool + NoStart bool + ServiceName string + DisplayName string +} + +func (m *ScmrCreate) ensure() { + if m.ServiceName == "" { + m.ServiceName = util.RandomString() + } + if m.DisplayName == "" { + m.DisplayName = m.ServiceName + } +} + +func (m *ScmrCreate) Execute(ctx context.Context, in *goexec.ExecutionInput) (err error) { + m.ensure() + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodCreate). + Str("service", m.ServiceName). + Logger() + + svc := &service{name: m.ServiceName} + + resp, err := m.ctl.CreateServiceW(ctx, &svcctl.CreateServiceWRequest{ + ServiceManager: m.scm, + ServiceName: m.ServiceName, + DisplayName: m.DisplayName, + BinaryPathName: in.String(), + ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, + StartType: windows.SERVICE_DEMAND_START, + DesiredAccess: ServiceAllAccess, // TODO: Replace + }) + + if err != nil { + log.Error().Err(err).Msg("Create service request failed") + return fmt.Errorf("create service request: %w", err) + } + + if resp.Return != 0 { + log.Error().Err(err).Msg("Failed to create service") + return fmt.Errorf("create service returned non-zero exit code: %02x", resp.Return) + } + + if !m.NoDelete { + m.AddCleaner(func(ctxInner context.Context) error { + + r, errInner := m.ctl.DeleteService(ctxInner, &svcctl.DeleteServiceRequest{ + Service: svc.handle, + }) + if errInner != nil { + return fmt.Errorf("delete service: %w", errInner) + } + if r.Return != 0 { + return fmt.Errorf("delete service returned non-zero exit code: %02x", r.Return) + } + log.Info().Msg("Deleted service") + + return nil + }) + } + + m.AddCleaner(func(ctxInner context.Context) error { + + r, errInner := m.ctl.CloseService(ctxInner, &svcctl.CloseServiceRequest{ + ServiceObject: svc.handle, + }) + if errInner != nil { + return fmt.Errorf("close service: %w", errInner) + } + if r.Return != 0 { + return fmt.Errorf("close service returned non-zero exit code: %02x", r.Return) + } + log.Info().Msg("Closed service handle") + + return nil + }) + + log.Info().Msg("Created service") + svc.handle = resp.Service + + if !m.NoStart { + + err = m.startService(ctx, svc) + if err != nil { + log.Error().Err(err).Msg("Failed to start service") + } + } + if svc.handle == nil { + + if err = m.Reconnect(ctx); err != nil { + return err + } + + svc, err = m.openService(ctx, svc.name) + if err != nil { + return fmt.Errorf("reopen service: %w", err) + } + } + + return +} diff --git a/pkg/goexec/scmr/delete.go b/pkg/goexec/scmr/delete.go new file mode 100644 index 0000000..d327a61 --- /dev/null +++ b/pkg/goexec/scmr/delete.go @@ -0,0 +1,46 @@ +package scmrexec + +import ( + "context" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/oiweiwei/go-msrpc/msrpc/scmr/svcctl/v2" + "github.com/rs/zerolog" +) + +const ( + MethodDelete = "Delete" +) + +type ScmrDelete struct { + Scmr + goexec.Cleaner + + ServiceName string +} + +func (m *ScmrDelete) Clean(ctx context.Context) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodDelete). + Str("service", m.ServiceName). + Logger() + + svc, err := m.openService(ctx, m.ServiceName) + if err != nil { + return err + } + deleteResponse, err := m.ctl.DeleteService(ctx, &svcctl.DeleteServiceRequest{ + Service: svc.handle, + }) + if err != nil { + return fmt.Errorf("delete service: %w", err) + } + if deleteResponse.Return != 0 { + return fmt.Errorf("delete service returned non-zero exit code: %02x", deleteResponse.Return) + } + + log.Info().Msg("Deleted service") + return +} diff --git a/pkg/goexec/scmr/module.go b/pkg/goexec/scmr/module.go new file mode 100644 index 0000000..efa3d50 --- /dev/null +++ b/pkg/goexec/scmr/module.go @@ -0,0 +1,131 @@ +package scmrexec + +import ( + "context" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/internal/util" + "github.com/FalconOpsLLC/goexec/internal/windows" + "github.com/FalconOpsLLC/goexec/pkg/goexec/dce" + "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/oiweiwei/go-msrpc/midl/uuid" + "github.com/oiweiwei/go-msrpc/msrpc/scmr/svcctl/v2" + "github.com/rs/zerolog" +) + +const ( + ModuleName = "SCMR" + + DefaultEndpoint = "ncacn_np:[svcctl]" + ScmrUuid = "367ABB81-9844-35F1-AD32-98F038001003" +) + +type Scmr struct { + client *dce.Client + ctl svcctl.SvcctlClient + scm *svcctl.Handle + + hostname string +} + +func (m *Scmr) Init(ctx context.Context, c *dce.Client) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName).Logger() + + m.client = c + + if m.client.Dce() == nil { + return errors.New("DCE connection not initialized") + } + + m.hostname, err = c.Target.Hostname(ctx) + if err != nil { + log.Debug().Err(err).Msg("Failed to determine target hostname") + } + if m.hostname == "" { + m.hostname = util.RandomHostname() + } + + m.ctl, err = svcctl.NewSvcctlClient(ctx, m.client.Dce(), dcerpc.WithObjectUUID(uuid.MustParse(ScmrUuid))) + if err != nil { + log.Error().Err(err).Msg("Failed to initialize SVCCTL client") + return fmt.Errorf("create SVCCTL client: %w", err) + } + log.Info().Msg("Created SVCCTL client") + + resp, err := m.ctl.OpenSCMW(ctx, &svcctl.OpenSCMWRequest{ + MachineName: m.hostname, + DatabaseName: "ServicesActive\x00", + DesiredAccess: ServiceAllAccess, // TODO: Replace + }) + if err != nil { + log.Debug().Err(err).Msg("Failed to open SCM handle") + return fmt.Errorf("open SCM handle: %w", err) + } + log.Info().Msg("Opened SCM handle") + + m.scm = resp.SCM + + return +} + +func (m *Scmr) Reconnect(ctx context.Context) (err error) { + + if err = m.client.Reconnect(ctx); err != nil { + return fmt.Errorf("reconnect: %w", err) + } + if err = m.Init(ctx, m.client); err != nil { + return fmt.Errorf("reconnect SCMR: %w", err) + } + return +} + +// openService will a handle to the desired service +func (m *Scmr) openService(ctx context.Context, name string) (svc *service, err error) { + + log := zerolog.Ctx(ctx) + + resp, err := m.ctl.OpenServiceW(ctx, &svcctl.OpenServiceWRequest{ + ServiceManager: m.scm, + ServiceName: name, + DesiredAccess: ServiceAllAccess, // TODO: dynamic + }) + if err != nil { + log.Error().Err(err).Msg("Failed to open service handle") + return nil, fmt.Errorf("open service: %w", err) + } + + log.Info().Msg("Opened service handle") + + svc = new(service) + svc.name = name + svc.handle = resp.Service + + return +} + +func (m *Scmr) startService(ctx context.Context, svc *service) error { + + log := zerolog.Ctx(ctx) + + sr, err := m.ctl.StartServiceW(ctx, &svcctl.StartServiceWRequest{Service: svc.handle}) + + if err != nil { + + if errors.Is(err, context.DeadlineExceeded) { // Check if execution timed out (execute "cmd.exe /c notepad" for test case) + log.Warn().Msg("Service execution deadline exceeded") + svc.handle = nil + return nil + + } else if sr.Return == windows.ERROR_SERVICE_REQUEST_TIMEOUT { + log.Info().Msg("Received request timeout. Execution was likely successful") + return nil + } + + log.Error().Err(err).Msg("Failed to start service") + return fmt.Errorf("start service: %w", err) + } + log.Info().Msg("Service started successfully") + return nil +} diff --git a/pkg/goexec/scmr/scmr.go b/pkg/goexec/scmr/scmr.go new file mode 100644 index 0000000..3ee8f9c --- /dev/null +++ b/pkg/goexec/scmr/scmr.go @@ -0,0 +1,19 @@ +package scmrexec + +import ( + "github.com/FalconOpsLLC/goexec/internal/windows" + "github.com/oiweiwei/go-msrpc/msrpc/scmr/svcctl/v2" +) + +const ( + ServiceDeleteAccess uint32 = windows.SERVICE_DELETE + ServiceModifyAccess uint32 = windows.SERVICE_QUERY_CONFIG | windows.SERVICE_CHANGE_CONFIG | windows.SERVICE_STOP | windows.SERVICE_START | windows.SERVICE_DELETE + ServiceCreateAccess uint32 = windows.SC_MANAGER_CREATE_SERVICE | windows.SERVICE_START | windows.SERVICE_STOP | windows.SERVICE_DELETE + ServiceAllAccess uint32 = ServiceCreateAccess | ServiceModifyAccess +) + +type service struct { + name string + handle *svcctl.Handle + originalConfig *svcctl.QueryServiceConfigW +} diff --git a/pkg/goexec/smb/client.go b/pkg/goexec/smb/client.go new file mode 100644 index 0000000..d9e8772 --- /dev/null +++ b/pkg/goexec/smb/client.go @@ -0,0 +1,111 @@ +package smb + +import ( + "context" + "errors" + "fmt" + "github.com/oiweiwei/go-smb2.fork" + "github.com/rs/zerolog" + "net" +) + +type Client struct { + ClientOptions + + conn net.Conn + sess *smb2.Session + mount *smb2.Share +} + +func (c *Client) Session() (sess *smb2.Session) { + return c.sess +} + +func (c *Client) String() string { + return ClientName +} + +func (c *Client) Logger(ctx context.Context) zerolog.Logger { + return zerolog.Ctx(ctx).With().Str("client", c.String()).Logger() +} + +func (c *Client) Mount(_ context.Context, share string) (err error) { + + if c.sess == nil { + return errors.New("SMB session not initialized") + } + + c.mount, err = c.sess.Mount(share) + return +} + +func (c *Client) Connect(ctx context.Context) (err error) { + + log := c.Logger(ctx) + { + if c.netDialer == nil { + panic(fmt.Errorf("TCP dialer not initialized")) + } + if c.dialer == nil { + panic(fmt.Errorf("%s dialer not initialized", c.String())) + } + } + + // Establish TCP connection + c.conn, err = c.netDialer.Dial("tcp", net.JoinHostPort(c.Host, fmt.Sprintf("%d", c.Port))) + + if err != nil { + return err + } + + log = log.With().Str("address", c.conn.RemoteAddr().String()).Logger() + log.Debug().Msgf("Connected to %s server", c.String()) + + // Open SMB session + c.sess, err = c.dialer.DialContext(ctx, c.conn) + + if err != nil { + log.Error().Err(err).Msgf("Failed to open %s session", c.String()) + return fmt.Errorf("dial %s: %w", c.String(), err) + } + + log.Debug().Msgf("Opened %s session", c.String()) + + return +} + +func (c *Client) Close(ctx context.Context) (err error) { + + log := c.Logger(ctx) + + // Close TCP connection + if c.conn != nil { + defer func() { + if err = c.conn.Close(); err != nil { + log.Error().Err(err).Msgf("Failed to close %s connection", c.String()) + } + log.Debug().Msgf("Closed %s connection", c.String()) + }() + } + + // Close SMB session + if c.sess != nil { + defer func() { + if err = c.sess.Logoff(); err != nil { + log.Error().Err(err).Msgf("Failed to discard %s session", c.String()) + } + log.Debug().Msgf("Discarded %s session", c.String()) + }() + } + + // Unmount SMB share + if c.mount != nil { + defer func() { + if err = c.mount.Umount(); err != nil { + log.Error().Err(err).Msg("Failed to unmount share") + } + log.Debug().Msg("Unmounted file share") + }() + } + return +} diff --git a/pkg/goexec/smb/default.go b/pkg/goexec/smb/default.go new file mode 100644 index 0000000..94f50de --- /dev/null +++ b/pkg/goexec/smb/default.go @@ -0,0 +1,10 @@ +package smb + +import "github.com/oiweiwei/go-msrpc/smb2" + +const ( + ClientName = "SMB" + + DefaultPort = 445 + DefaultDialect = smb2.SMB311 +) diff --git a/pkg/goexec/smb/options.go b/pkg/goexec/smb/options.go new file mode 100644 index 0000000..0c2ffb6 --- /dev/null +++ b/pkg/goexec/smb/options.go @@ -0,0 +1,98 @@ +package smb + +import ( + "context" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/RedTeamPentesting/adauth/smbauth" + msrpcSMB2 "github.com/oiweiwei/go-msrpc/smb2" + "github.com/oiweiwei/go-smb2.fork" + "net" +) + +var supportedDialects = map[msrpcSMB2.Dialect]msrpcSMB2.Dialect{ + 2_0_2: msrpcSMB2.SMB202, + 2_1_0: msrpcSMB2.SMB210, + 3_0_0: msrpcSMB2.SMB300, + 3_0_2: msrpcSMB2.SMB302, + 3_1_1: msrpcSMB2.SMB311, + + 0x202: msrpcSMB2.SMB202, + 0x210: msrpcSMB2.SMB210, + 0x300: msrpcSMB2.SMB300, + 0x302: msrpcSMB2.SMB302, + 0x311: msrpcSMB2.SMB311, +} + +// ClientOptions holds configuration settings for an SMB client +type ClientOptions struct { + goexec.ClientOptions + goexec.AuthOptions + + // NoSign disables packet signing + NoSign bool `json:"no_sign" yaml:"no_sign"` + + // NoSeal disables packet encryption + NoSeal bool `json:"no_seal" yaml:"no_seal"` + + // Dialect sets the SMB dialect to be passed to smb2.WithDialect() + Dialect msrpcSMB2.Dialect `json:"dialect" yaml:"dialect"` + + netDialer goexec.Dialer + dialer *smb2.Dialer +} + +func (c *Client) Parse(ctx context.Context) (err error) { + + var do []msrpcSMB2.DialerOption + + if c.Port == 0 { + c.Port = DefaultPort + } + if c.Dialect == 0 { + c.Dialect = DefaultDialect + } + + // Validate SMB dialect/version + if d, ok := supportedDialects[c.Dialect]; ok { + do = append(do, msrpcSMB2.WithDialect(d)) + + } else { + return errors.New("unsupported SMB version") + } + + if c.Proxy == "" { + c.netDialer = &net.Dialer{} // FUTURE: additional dial c + + } else { + // Parse proxy URL + c.netDialer, err = goexec.ParseProxyURI(c.Proxy) + if err != nil { + return err + } + } + + if !c.NoSeal { + // Enable encryption + do = append(do, msrpcSMB2.WithSeal()) + } + if !c.NoSign { + // Enable signing + do = append(do, msrpcSMB2.WithSign()) + } + + // Validate authentication parameters + c.dialer, err = smbauth.Dialer(ctx, c.Credential, c.Target, + &smbauth.Options{ + SMBOptions: do, + }) + + if err != nil { + return fmt.Errorf("set %s auth: %w", ClientName, err) + } + + c.Host = c.Target.AddressWithoutPort() + + return nil +} diff --git a/pkg/goexec/smb/output.go b/pkg/goexec/smb/output.go new file mode 100644 index 0000000..35c40c8 --- /dev/null +++ b/pkg/goexec/smb/output.go @@ -0,0 +1,63 @@ +package smb + +import ( + "context" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "io" + "os" + "regexp" + "time" +) + +var ( + DefaultOutputPollInterval = 1 * time.Second + DefaultOutputPollTimeout = 60 * time.Second + pathPrefix = regexp.MustCompile(`^([a-zA-Z]:)?\\*`) +) + +type OutputFileFetcher struct { + goexec.Cleaner + + Client *Client + Share string + File string + PollInterval time.Duration + PollTimeout time.Duration + + relativePath string +} + +func (o *OutputFileFetcher) GetOutput(ctx context.Context) (reader io.ReadCloser, err error) { + + if o.PollInterval == 0 { + o.PollInterval = DefaultOutputPollInterval + } + if o.PollTimeout == 0 { + o.PollTimeout = DefaultOutputPollTimeout + } + + o.relativePath = pathPrefix.ReplaceAllString(o.File, "") + + err = o.Client.Connect(ctx) + if err != nil { + return + } + + err = o.Client.Mount(ctx, o.Share) + if err != nil { + return + } + + stopAt := time.Now().Add(o.PollTimeout) + + for { + if time.Now().After(stopAt) { + return + } + if reader, err = o.Client.mount.OpenFile(o.relativePath, os.O_RDONLY, 0); err == nil { + return + } + time.Sleep(o.PollInterval) + } + return +} diff --git a/pkg/goexec/tsch/create.go b/pkg/goexec/tsch/create.go new file mode 100644 index 0000000..c0f24d1 --- /dev/null +++ b/pkg/goexec/tsch/create.go @@ -0,0 +1,113 @@ +package tschexec + +import ( + "context" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/FalconOpsLLC/goexec/pkg/goexec/smb" + "github.com/rs/zerolog" + "time" +) + +const ( + MethodCreate = "Create" +) + +type TschCreate struct { + Tsch + goexec.Executor + goexec.Cleaner + smb.OutputFileFetcher + + IO goexec.ExecutionIO + + NoDelete bool + CallDelete bool + StartDelay time.Duration + StopDelay time.Duration + DeleteDelay time.Duration + TimeOffset time.Duration + // FEATURE: more opts +} + +func (m *TschCreate) Execute(ctx context.Context, execIO *goexec.ExecutionIO) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodCreate). + Str("task", m.TaskName). + Logger() + + startTime := time.Now().UTC().Add(m.StartDelay) + stopTime := startTime.Add(m.StopDelay) + + trigger := taskTimeTrigger{ + StartBoundary: startTime.Format(TaskXmlDurationFormat), + Enabled: true, + } + + var deleteAfter string + + if !m.NoDelete && !m.CallDelete { + + if m.StopDelay == 0 { + m.StopDelay = time.Second // value is required, 1 second by default + } + trigger.EndBoundary = stopTime.Format(TaskXmlDurationFormat) + deleteAfter = xmlDuration(m.DeleteDelay) + } + + path, err := m.registerTask(ctx, + ®isterOptions{ + AllowStartOnDemand: true, + AllowHardTerminate: true, + Hidden: !m.NotHidden, + triggers: taskTriggers{ + TimeTriggers: []taskTimeTrigger{trigger}, + }, + DeleteAfter: deleteAfter, + }, + execIO, + ) + if err != nil { + return err + } + + if !m.NoDelete { + if m.CallDelete { + + m.AddCleaner(func(ctxInner context.Context) error { + + log.Info().Msg("Waiting for task to start...") + + select { + case <-ctxInner.Done(): + log.Warn().Msg("Task deletion cancelled") + + case <-time.After(m.StartDelay + (5 * time.Second)): // 5 second buffer + /* + for { + stat, err := m.tsch.GetLastRunInfo(ctx, &itaskschedulerservice.GetLastRunInfoRequest{ + Path: path, + }) + if err != nil { + log.Warn().Err(err).Msg("Failed to get last run info. Assuming task was executed") + + } else if stat.LastRuntime.AsTime().IsZero() { + log.Warn().Msg("Task was not yet executed. Waiting 5 additional seconds") + + time.Sleep(5 * time.Second) + continue + } + break + } + */ + } + return m.deleteTask(ctxInner, path) + }) + + } else { + log.Info().Time("when", stopTime).Msg("Task is scheduled to delete") + } + } + return +} diff --git a/pkg/goexec/tsch/demand.go b/pkg/goexec/tsch/demand.go new file mode 100644 index 0000000..cd2ccd5 --- /dev/null +++ b/pkg/goexec/tsch/demand.go @@ -0,0 +1,91 @@ +package tschexec + +import ( + "context" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/oiweiwei/go-msrpc/msrpc/tsch/itaskschedulerservice/v1" + "github.com/rs/zerolog" + "io" +) + +const ( + MethodDemand = "Demand" +) + +type TschDemand struct { + Tsch + goexec.Executor + goexec.Cleaner + + IO goexec.ExecutionIO + + NoDelete bool + NoStart bool + SessionId uint32 +} + +func (m *TschDemand) Execute(ctx context.Context, in *goexec.ExecutionIO) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodDemand). + Str("task", m.TaskName). + Logger() + + path, err := m.registerTask(ctx, + ®isterOptions{ + AllowStartOnDemand: true, + AllowHardTerminate: true, + Hidden: !m.NotHidden, + triggers: taskTriggers{}, + }, + in, + ) + if err != nil { + return err + } + + log.Info().Msg("Task registered") + + if !m.NoDelete { + m.AddCleaner(func(ctxInner context.Context) error { + return m.deleteTask(ctxInner, path) + }) + } + + if !m.NoStart { + + var flags uint32 + if m.SessionId != 0 { + flags |= 4 + } + + runResponse, err := m.tsch.Run(ctx, &itaskschedulerservice.RunRequest{ + Path: path, + Flags: flags, + SessionID: m.SessionId, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to run task") + return fmt.Errorf("run task: %w", err) + } + if ret := uint32(runResponse.Return); ret != 0 { + log.Error().Str("code", fmt.Sprintf("0x%08x", ret)).Msg("Task returned non-zero exit code") + return fmt.Errorf("task returned non-zero exit code: 0x%08x", ret) + } + + log.Info().Msg("Task started successfully") + } + return +} + +func (m *TschDemand) GetOutput(ctx context.Context) (reader io.ReadCloser, err error) { + + if m.IO.Output != nil { + return m.IO.GetOutput(ctx) + } + return nil, errors.New("no available output provider") +} diff --git a/pkg/goexec/tsch/module.go b/pkg/goexec/tsch/module.go new file mode 100644 index 0000000..13d7b24 --- /dev/null +++ b/pkg/goexec/tsch/module.go @@ -0,0 +1,173 @@ +package tschexec + +import ( + "context" + "encoding/xml" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/FalconOpsLLC/goexec/pkg/goexec/dce" + "github.com/oiweiwei/go-msrpc/msrpc/tsch/itaskschedulerservice/v1" + "github.com/rs/zerolog" + "strings" +) + +const ( + ModuleName = "TSCH" +) + +type Tsch struct { + goexec.Cleaner + + Client *dce.Client + tsch itaskschedulerservice.TaskSchedulerServiceClient + + TaskName string + TaskPath string + UserSid string + NotHidden bool +} + +type registerOptions struct { + AllowStartOnDemand bool + AllowHardTerminate bool + StartWhenAvailable bool + Hidden bool + DeleteAfter string + + triggers taskTriggers +} + +func (m *Tsch) Connect(ctx context.Context) (err error) { + + if err = m.Client.Connect(ctx); err == nil { + m.AddCleaner(m.Client.Close) + } + return +} + +func (m *Tsch) Init(ctx context.Context) (err error) { + + if m.Client.Dce() == nil { + return errors.New("DCE connection not initialized") + } + + // Create ITaskSchedulerService Client + m.tsch, err = itaskschedulerservice.NewTaskSchedulerServiceClient(ctx, m.Client.Dce()) + return +} + +func (m *Tsch) taskPath() string { + if m.TaskPath == "" { + m.TaskPath = `\` + m.TaskName + } + return m.TaskPath +} + +func (m *Tsch) registerTask(ctx context.Context, opts *registerOptions, in *goexec.ExecutionIO) (path string, err error) { + + log := zerolog.Ctx(ctx).With(). + Str("task", m.TaskName). + Logger() + + ctx = log.WithContext(ctx) + + principalId := "1" + + settings := taskSettings{ + MultipleInstancesPolicy: "IgnoreNew", + IdleSettings: taskIdleSettings{ + StopOnIdleEnd: true, + RestartOnIdle: false, + }, + Enabled: true, + Priority: 7, // a pretty standard value for scheduled tasks + + AllowHardTerminate: opts.AllowHardTerminate, + AllowStartOnDemand: opts.AllowStartOnDemand, + Hidden: opts.Hidden, + StartWhenAvailable: opts.StartWhenAvailable, + DeleteExpiredTaskAfter: opts.DeleteAfter, + } + + principals := taskPrincipals{ + Principals: []taskPrincipal{ + { + ID: principalId, // TODO: dynamic + UserID: m.UserSid, + RunLevel: "HighestAvailable", + }, + }} + + e := taskActionExec{} + + if ea := strings.SplitN(in.String(), " ", 2); len(ea) == 1 { + e.Command = ea[0] + } else { + e.Command = ea[0] + e.Arguments = ea[1] + } + + actions := taskActions{ + Context: principalId, + Exec: []taskActionExec{e}, + } + + def := task{ + TaskVersion: TaskXmlVersion, + TaskNamespace: TaskXmlNamespace, + Triggers: opts.triggers, + Actions: actions, + Principals: principals, + Settings: settings, + } + + // Generate task XML content. See https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/0d6383e4-de92-43e7-b0bb-a60cfa36379f + + doc, err := xml.Marshal(def) + + if err != nil { + log.Error().Err(err).Msg("failed to marshal task XML") + return "", fmt.Errorf("marshal task: %w", err) + } + + taskXml := TaskXmlHeader + string(doc) + + log.Debug().Str("content", taskXml).Msg("Generated task XML") + + registerResponse, err := m.tsch.RegisterTask(ctx, &itaskschedulerservice.RegisterTaskRequest{ + Path: m.taskPath(), + XML: taskXml, + Flags: 0, // FEATURE: dynamic + SDDL: "", + LogonType: 0, // FEATURE: dynamic + CredsCount: 0, + Creds: nil, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to register task") + return "", fmt.Errorf("register task: %w", err) + } + + return registerResponse.ActualPath, nil +} + +func (m *Tsch) deleteTask(ctx context.Context, taskPath string) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("path", taskPath).Logger() + + _, err = m.tsch.Delete(ctx, &itaskschedulerservice.DeleteRequest{ + Path: taskPath, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to delete task") + return fmt.Errorf("delete task: %w", err) + } + + log.Info().Msg("Task deleted") + + return +} diff --git a/pkg/goexec/tsch/tsch.go b/pkg/goexec/tsch/tsch.go new file mode 100644 index 0000000..e51433d --- /dev/null +++ b/pkg/goexec/tsch/tsch.go @@ -0,0 +1,166 @@ +package tschexec + +import ( + "encoding/xml" + "fmt" + "regexp" + "time" +) + +const ( + TaskXmlHeader = `<?xml version="1.0" encoding="UTF-16"?>` + TaskXmlNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task" + TaskXmlVersion = "1.2" + TaskXmlDurationFormat = "2006-01-02T15:04:05.9999999Z" +) + +var ( + TaskPathRegex = regexp.MustCompile(`^\\[^ :/\\][^:/]*$`) + TaskNameRegex = regexp.MustCompile(`^[^ :/\\][^:/\\]*$`) +) + +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/0d6383e4-de92-43e7-b0bb-a60cfa36379f + +type taskTriggers struct { + XMLName xml.Name `xml:"Triggers"` + TimeTriggers []taskTimeTrigger `xml:"TimeTrigger,omitempty"` +} + +type taskTimeTrigger struct { + XMLName xml.Name `xml:"TimeTrigger"` + StartBoundary string `xml:"StartBoundary,omitempty"` // Derived from time.Time + EndBoundary string `xml:"EndBoundary,omitempty"` // Derived from time.Time; must be > StartBoundary + Enabled bool `xml:"Enabled"` +} + +type taskIdleSettings struct { + XMLName xml.Name `xml:"IdleSettings"` + StopOnIdleEnd bool `xml:"StopOnIdleEnd"` + RestartOnIdle bool `xml:"RestartOnIdle"` +} + +type taskSettings struct { + XMLName xml.Name `xml:"Settings"` + Enabled bool `xml:"Enabled"` + Hidden bool `xml:"Hidden"` + DisallowStartIfOnBatteries bool `xml:"DisallowStartIfOnBatteries"` + StopIfGoingOnBatteries bool `xml:"StopIfGoingOnBatteries"` + AllowHardTerminate bool `xml:"AllowHardTerminate"` + RunOnlyIfNetworkAvailable bool `xml:"RunOnlyIfNetworkAvailable"` + AllowStartOnDemand bool `xml:"AllowStartOnDemand"` + WakeToRun bool `xml:"WakeToRun"` + RunOnlyIfIdle bool `xml:"RunOnlyIfIdle"` + StartWhenAvailable bool `xml:"StartWhenAvailable"` + Priority int `xml:"Priority,omitempty"` // 1 to 10 inclusive + MultipleInstancesPolicy string `xml:"MultipleInstancesPolicy,omitempty"` + ExecutionTimeLimit string `xml:"ExecutionTimeLimit,omitempty"` + DeleteExpiredTaskAfter string `xml:"DeleteExpiredTaskAfter,omitempty"` // Derived from time.Duration + IdleSettings taskIdleSettings `xml:"IdleSettings,omitempty"` +} + +type taskActionExec struct { + XMLName xml.Name `xml:"Exec"` + Command string `xml:"Command"` + Arguments string `xml:"Arguments,omitempty"` +} + +type taskActions struct { + XMLName xml.Name `xml:"Actions"` + Context string `xml:"Context,attr"` + Exec []taskActionExec `xml:"Exec,omitempty"` +} + +type taskPrincipals struct { + XMLName xml.Name `xml:"Principals"` + Principals []taskPrincipal `xml:"Principal,omitempty"` +} + +type taskPrincipal struct { + XMLName xml.Name `xml:"Principal"` + ID string `xml:"id,attr"` + UserID string `xml:"UserId"` + RunLevel string `xml:"RunLevel"` +} + +type task struct { + XMLName xml.Name `xml:"Task"` + TaskVersion string `xml:"version,attr"` + TaskNamespace string `xml:"xmlns,attr"` + Triggers taskTriggers `xml:"Triggers"` + Actions taskActions `xml:"Actions"` + Principals taskPrincipals `xml:"Principals"` + Settings taskSettings `xml:"Settings"` +} + +// newSettings just creates a taskSettings instance with the necessary values + a few dynamic ones +func newSettings(terminate, onDemand, startWhenAvailable bool) *taskSettings { + return &taskSettings{ + MultipleInstancesPolicy: "IgnoreNew", + AllowHardTerminate: terminate, + IdleSettings: taskIdleSettings{ + StopOnIdleEnd: true, + RestartOnIdle: false, + }, + AllowStartOnDemand: onDemand, + Enabled: true, + Hidden: true, + Priority: 7, // a pretty standard value for scheduled tasks + StartWhenAvailable: startWhenAvailable, + } +} + +// newTask creates a task with any static values filled +func newTask(se *taskSettings, pr []taskPrincipal, tr taskTriggers, cmd, args string) *task { + if se == nil { + se = newSettings(true, true, false) + } + if pr == nil || len(pr) == 0 { + pr = []taskPrincipal{ + { + ID: "1", + UserID: "S-1-5-18", + RunLevel: "HighestAvailable", + }, + } + } + return &task{ + TaskVersion: "1.2", + TaskNamespace: "http://schemas.microsoft.com/windows/2004/02/mit/task", + Triggers: tr, + Principals: taskPrincipals{Principals: pr}, + Settings: *se, + Actions: taskActions{ + Context: pr[0].ID, + Exec: []taskActionExec{ + { + Command: cmd, + Arguments: args, + }, + }, + }, + } +} + +// xmlDuration is a *very* simple implementation of xs:duration - only accepts +seconds +func xmlDuration(dur time.Duration) string { + if s := int(dur.Seconds()); s >= 0 { + return fmt.Sprintf(`PT%dS`, s) + } + return `PT0S` +} + +// ValidateTaskName will validate the provided task name according to https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/fa8809c8-4f0f-4c6d-994a-6c10308757c1 +func ValidateTaskName(taskName string) error { + if !TaskNameRegex.MatchString(taskName) { + return fmt.Errorf("invalid task name: %s", taskName) + } + return nil +} + +// ValidateTaskPath will validate the provided task path according to https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/fa8809c8-4f0f-4c6d-994a-6c10308757c1 +func ValidateTaskPath(taskPath string) error { + if !TaskPathRegex.MatchString(taskPath) { + return fmt.Errorf("invalid task path: %s", taskPath) + } + return nil +} diff --git a/pkg/goexec/wmi/call.go b/pkg/goexec/wmi/call.go new file mode 100644 index 0000000..e4386b5 --- /dev/null +++ b/pkg/goexec/wmi/call.go @@ -0,0 +1,31 @@ +package wmiexec + +import ( + "context" + "encoding/json" + "github.com/rs/zerolog" +) + +const ( + MethodCall = "Call" +) + +type WmiCall struct { + Wmi + + Class string + Method string + Args map[string]any +} + +func (m *WmiCall) Call(ctx context.Context) (out []byte, err error) { + var outMap map[string]any + + if outMap, err = m.query(ctx, m.Class, m.Method, m.Args); err != nil { + return + } + zerolog.Ctx(ctx).Info().Msg("Call succeeded") + + out, err = json.Marshal(outMap) + return +} diff --git a/pkg/goexec/wmi/module.go b/pkg/goexec/wmi/module.go new file mode 100644 index 0000000..20d687a --- /dev/null +++ b/pkg/goexec/wmi/module.go @@ -0,0 +1,140 @@ +package wmiexec + +import ( + "context" + "errors" + "fmt" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/FalconOpsLLC/goexec/pkg/goexec/dce" + "github.com/oiweiwei/go-msrpc/dcerpc" + "github.com/oiweiwei/go-msrpc/msrpc/dcom" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/iactivation/v0" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/wmi" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/wmi/iwbemlevel1login/v0" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/wmi/iwbemservices/v0" + "github.com/oiweiwei/go-msrpc/msrpc/dcom/wmio/query" + "github.com/rs/zerolog" +) + +const ( + ModuleName = "WMI" + DefaultEndpoint = "ncacn_ip_tcp:[135]" +) + +type Wmi struct { + goexec.Cleaner + Client *dce.Client + + Resource string + + servicesClient iwbemservices.ServicesClient +} + +func (m *Wmi) Connect(ctx context.Context) (err error) { + + if err = m.Client.Connect(ctx); err == nil { + m.AddCleaner(m.Client.Close) + } + return +} + +func (m *Wmi) Init(ctx context.Context) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName).Logger() + + if m.Client.Dce() == nil { + return errors.New("DCE connection not initialized") + } + + actClient, err := iactivation.NewActivationClient(ctx, m.Client.Dce()) + if err != nil { + log.Error().Err(err).Msg("Failed to initialize IActivation client") + return fmt.Errorf("create IActivation client: %w", err) + } + + actResponse, err := actClient.RemoteActivation(ctx, &iactivation.RemoteActivationRequest{ + ORPCThis: ORPCThis, + ClassID: wmi.Level1LoginClassID.GUID(), + IIDs: []*dcom.IID{iwbemlevel1login.Level1LoginIID}, + RequestedProtocolSequences: []uint16{ProtocolSequenceRPC}, // FEATURE: Named pipe support? + }) + if err != nil { + log.Error().Err(err).Msg("Failed to activate remote object") + return fmt.Errorf("request remote activation: %w", err) + } + if actResponse.HResult != 0 { + return fmt.Errorf("remote activation failed with code %d", actResponse.HResult) + } + + log.Info().Msg("Remote activation succeeded") + + var newOpts []dcerpc.Option + + for _, bind := range actResponse.OXIDBindings.GetStringBindings() { + stringBinding, err := dcerpc.ParseStringBinding("ncacn_ip_tcp:" + bind.NetworkAddr) // TODO: try bind.String() + + if err != nil { + log.Error().Err(err).Msg("Failed to parse string binding") + continue + } + stringBinding.NetworkAddress = m.Client.Target.AddressWithoutPort() + newOpts = append(newOpts, dcerpc.WithEndpoint(stringBinding.String())) + } + + if err = m.Client.Reconnect(ctx, newOpts...); err != nil { + log.Error().Err(err).Msg("Failed to connect to remote instance") + return fmt.Errorf("connect remote instance: %w", err) + } + + log.Info().Msg("Connected to remote instance") + + ipid := actResponse.InterfaceData[0].GetStandardObjectReference().Std.IPID + loginClient, err := iwbemlevel1login.NewLevel1LoginClient(ctx, m.Client.Dce(), dcom.WithIPID(ipid)) + + if err != nil { + log.Error().Err(err).Msg("Failed to create IWbemLevel1Login client") + return fmt.Errorf("create IWbemLevel1Login client: %w", err) + } + + login, err := loginClient.NTLMLogin(ctx, &iwbemlevel1login.NTLMLoginRequest{ + This: ORPCThis, + NetworkResource: m.Resource, + }) + + log.Info().Msg("Completed NTLMLogin operation") + + if err != nil { + log.Error().Err(err).Msg("Failed to login on remote instance") + return fmt.Errorf("login: IWbemLevel1Login::NTLMLogin: %w", err) + } + + ipid = login.Namespace.InterfacePointer().IPID() + m.servicesClient, err = iwbemservices.NewServicesClient(ctx, m.Client.Dce(), dcom.WithIPID(ipid)) + + if err != nil { + log.Error().Err(err).Msg("Failed to create services client") + return fmt.Errorf("create IWbemServices client: %w", err) + } + + log.Info().Msg("Initialized services client") + + return +} + +func (m *Wmi) query(ctx context.Context, class, method string, values map[string]any) (outValues map[string]any, err error) { + outValues = make(map[string]any) + + if m.servicesClient == nil { + return nil, errors.New("module has not been initialized") + } + if out, err := query.NewBuilder(ctx, m.servicesClient, ComVersion). + Spawn(class). // The class to instantiate (i.e. Win32_Process) + Method(method). // The method to call (i.e. Create) + Values(values). // The values to pass to method + Exec(). + Object(); err == nil { + return out.Values(), err + } + return nil, fmt.Errorf("spawn WMI query: %w", err) +} diff --git a/pkg/goexec/wmi/proc.go b/pkg/goexec/wmi/proc.go new file mode 100644 index 0000000..25e515c --- /dev/null +++ b/pkg/goexec/wmi/proc.go @@ -0,0 +1,64 @@ +package wmiexec + +import ( + "context" + "errors" + "github.com/FalconOpsLLC/goexec/pkg/goexec" + "github.com/rs/zerolog" + "io" +) + +const ( + MethodProc = "Proc" +) + +type WmiProc struct { + Wmi + IO goexec.ExecutionIO + WorkingDirectory string +} + +func (m *WmiProc) Execute(ctx context.Context, execIO *goexec.ExecutionIO) (err error) { + + log := zerolog.Ctx(ctx).With(). + Str("module", ModuleName). + Str("method", MethodProc). + Logger() + ctx = log.WithContext(ctx) + + if execIO == nil { + return errors.New("execution IO is nil") + } + + out, err := m.query(ctx, + "Win32_Process", + "Create", + + map[string]any{ + "CommandLine": execIO.String(), + "WorkingDir": m.WorkingDirectory, + }, + ) + if err != nil { + return + } + + if pid := out["ProcessId"].(uint32); pid != 0 { + log = log.With().Uint32("pid", pid).Logger() + } + log.Info().Err(err).Msg("Process created") + + if ret := out["ReturnValue"].(uint32); ret != 0 { + log.Error().Err(err).Uint32("return", ret).Msg("Process returned non-zero exit code") + } + return +} + +func (m *WmiProc) GetOutput(ctx context.Context) (reader io.ReadCloser, err error) { + + if m.IO.Output != nil { + + return m.IO.GetOutput(ctx) + } + return nil, errors.New("no available output provider") +} diff --git a/pkg/goexec/wmi/wmi.go b/pkg/goexec/wmi/wmi.go new file mode 100644 index 0000000..ad7d453 --- /dev/null +++ b/pkg/goexec/wmi/wmi.go @@ -0,0 +1,16 @@ +package wmiexec + +import "github.com/oiweiwei/go-msrpc/msrpc/dcom" + +const ( + ProtocolSequenceRPC uint16 = 7 + ProtocolSequenceNP uint16 = 15 +) + +var ( + ComVersion = &dcom.COMVersion{ + MajorVersion: 5, + MinorVersion: 7, + } + ORPCThis = &dcom.ORPCThis{Version: ComVersion} +) |