aboutsummaryrefslogtreecommitdiff
path: root/pkg/exec/tsch
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/exec/tsch')
-rw-r--r--pkg/exec/tsch/exec.go278
-rw-r--r--pkg/exec/tsch/module.go56
-rw-r--r--pkg/exec/tsch/tsch.go102
3 files changed, 436 insertions, 0 deletions
diff --git a/pkg/exec/tsch/exec.go b/pkg/exec/tsch/exec.go
new file mode 100644
index 0000000..030a016
--- /dev/null
+++ b/pkg/exec/tsch/exec.go
@@ -0,0 +1,278 @@
+package tschexec
+
+import (
+ "context"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "github.com/FalconOpsLLC/goexec/internal/util"
+ dce "github.com/FalconOpsLLC/goexec/pkg/client/dcerpc"
+ "github.com/FalconOpsLLC/goexec/pkg/exec"
+ "github.com/bryanmcnulty/adauth"
+ "github.com/oiweiwei/go-msrpc/dcerpc"
+ "github.com/oiweiwei/go-msrpc/msrpc/tsch/itaskschedulerservice/v1"
+ "github.com/rs/zerolog"
+ "regexp"
+ "time"
+)
+
+const (
+ TaskXMLDurationFormat = "2006-01-02T15:04:05.9999999Z"
+ TaskXMLHeader = `<?xml version="1.0" encoding="UTF-16"?>`
+)
+
+var (
+ TaskPathRegex = regexp.MustCompile(`^\\[^ :/\\][^:/]*$`) // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/fa8809c8-4f0f-4c6d-994a-6c10308757c1
+ TaskNameRegex = regexp.MustCompile(`^[^ :/\\][^:/\\]*$`)
+)
+
+// *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`
+}
+
+// Connect to the target & initialize DCE & TSCH clients
+func (mod *Module) Connect(ctx context.Context, creds *adauth.Credential, target *adauth.Target) (err error) {
+ if mod.dce == nil {
+ mod.dce = dce.NewDCEClient(ctx, false, &dce.SmbConfig{})
+ if err = mod.dce.Connect(ctx, creds, target); err != nil {
+ return fmt.Errorf("DCE connect: %w", err)
+ } else if mod.tsch, err = itaskschedulerservice.NewTaskSchedulerServiceClient(ctx, mod.dce.DCE(), dcerpc.WithSecurityLevel(dcerpc.AuthLevelPktPrivacy)); err != nil {
+ return fmt.Errorf("init MS-TSCH client: %w", err)
+ }
+ mod.log.Info().Msg("DCE connection successful")
+ }
+ return
+}
+
+func (mod *Module) Cleanup(ctx context.Context, creds *adauth.Credential, target *adauth.Target, ccfg *exec.CleanupConfig) (err error) {
+ mod.log = zerolog.Ctx(ctx).With().
+ Str("module", "tsch").
+ Str("method", ccfg.CleanupMethod).Logger()
+ mod.creds = creds
+ mod.target = target
+
+ if ccfg.CleanupMethod == MethodDelete {
+ if cfg, ok := ccfg.CleanupMethodConfig.(MethodDeleteConfig); !ok {
+ return errors.New("invalid configuration")
+ } else {
+ if err = mod.Connect(ctx, creds, target); err != nil {
+ return fmt.Errorf("connect: %w", err)
+ } else if _, err = mod.tsch.Delete(ctx, &itaskschedulerservice.DeleteRequest{
+ Path: cfg.TaskPath,
+ Flags: 0,
+ }); err != nil {
+ mod.log.Error().Err(err).Str("task", cfg.TaskPath).Msg("Failed to delete task")
+ return fmt.Errorf("delete task: %w", err)
+ } else {
+ mod.log.Info().Str("task", cfg.TaskPath).Msg("Task deleted successfully")
+ }
+ }
+ } else {
+ return fmt.Errorf("method not implemented: %s", ccfg.CleanupMethod)
+ }
+ return
+}
+
+func (mod *Module) Exec(ctx context.Context, creds *adauth.Credential, target *adauth.Target, ecfg *exec.ExecutionConfig) (err error) {
+
+ mod.log = zerolog.Ctx(ctx).With().
+ Str("module", "tsch").
+ Str("method", ecfg.ExecutionMethod).Logger()
+ mod.creds = creds
+ mod.target = target
+
+ if ecfg.ExecutionMethod == MethodRegister {
+ if cfg, ok := ecfg.ExecutionMethodConfig.(MethodRegisterConfig); !ok {
+ return errors.New("invalid configuration")
+
+ } else {
+ startTime := time.Now().UTC().Add(cfg.StartDelay)
+ task := &task{
+ TaskVersion: "1.2", // static
+ TaskNamespace: "http://schemas.microsoft.com/windows/2004/02/mit/task", // static
+ TimeTriggers: []taskTimeTrigger{
+ {
+ StartBoundary: startTime.Format(TaskXMLDurationFormat),
+ Enabled: true,
+ },
+ },
+ Principals: defaultPrincipals,
+ Settings: defaultSettings,
+ Actions: actions{Context: defaultPrincipals.Principals[0].ID, Exec: []actionExec{{Command: ecfg.ExecutableName, Arguments: ecfg.ExecutableArgs}}},
+ }
+ if !cfg.NoDelete && !cfg.CallDelete {
+ if cfg.StopDelay == 0 {
+ // EndBoundary cannot be >= StartBoundary
+ cfg.StopDelay = 1 * time.Second
+ }
+ stopTime := startTime.Add(cfg.StopDelay)
+
+ mod.log.Info().Time("when", stopTime).Msg("Task is scheduled to delete")
+ task.Settings.DeleteExpiredTaskAfter = xmlDuration(cfg.DeleteDelay)
+ task.TimeTriggers[0].EndBoundary = stopTime.Format(TaskXMLDurationFormat)
+ }
+
+ if doc, err := xml.Marshal(task); err != nil {
+ return fmt.Errorf("marshal task XML: %w", err)
+
+ } else {
+ mod.log.Debug().Str("task", string(doc)).Msg("Task XML generated")
+ docStr := TaskXMLHeader + string(doc)
+
+ taskPath := cfg.TaskPath
+ taskName := cfg.TaskName
+
+ if taskName == "" {
+ taskName = util.RandomString()
+ }
+ if taskPath == "" {
+ taskPath = `\` + taskName
+ }
+
+ if err = mod.Connect(ctx, creds, target); err != nil {
+ return fmt.Errorf("connect: %w", err)
+ }
+ defer func() {
+ if err = mod.dce.Close(ctx); err != nil {
+ mod.log.Warn().Err(err).Msg("Failed to dispose dce client")
+ } else {
+ mod.log.Debug().Msg("Disposed DCE client")
+ }
+ }()
+ var response *itaskschedulerservice.RegisterTaskResponse
+ if response, err = mod.tsch.RegisterTask(ctx, &itaskschedulerservice.RegisterTaskRequest{
+ Path: taskPath,
+ XML: docStr,
+ Flags: 0, // TODO
+ LogonType: 0, // TASK_LOGON_NONE
+ CredsCount: 0,
+ Creds: nil,
+ }); err != nil {
+ return err
+
+ } else {
+ mod.log.Info().Str("path", response.ActualPath).Msg("Task registered successfully")
+
+ if !cfg.NoDelete && cfg.CallDelete {
+ defer func() {
+ if err = mod.Cleanup(ctx, creds, target, &exec.CleanupConfig{
+ CleanupMethod: MethodDelete,
+ CleanupMethodConfig: MethodDeleteConfig{TaskPath: taskPath},
+ }); err != nil {
+ mod.log.Error().Err(err).Msg("Failed to delete task")
+ }
+ }()
+ mod.log.Info().Dur("ms", cfg.StartDelay).Msg("Waiting for task to run")
+ select {
+ case <-ctx.Done():
+ mod.log.Warn().Msg("Cancelling execution")
+ return err
+ case <-time.After(cfg.StartDelay + (time.Second * 2)): // + two seconds
+ // TODO: check if task is running yet; delete if the wait period is over
+ break
+ }
+ return err
+ }
+ }
+ }
+ }
+ } else if ecfg.ExecutionMethod == MethodDemand {
+ if cfg, ok := ecfg.ExecutionMethodConfig.(MethodDemandConfig); !ok {
+ return errors.New("invalid configuration")
+
+ } else {
+ taskPath := cfg.TaskPath
+ taskName := cfg.TaskName
+
+ if taskName == "" {
+ mod.log.Debug().Msg("Task name not defined. Using random string")
+ taskName = util.RandomString()
+ }
+ if taskPath == "" {
+ taskPath = `\` + taskName
+ }
+ if !TaskNameRegex.MatchString(taskName) {
+ return fmt.Errorf("invalid task name: %s", taskName)
+ }
+ if !TaskPathRegex.MatchString(taskPath) {
+ return fmt.Errorf("invalid task path: %s", taskPath)
+ }
+
+ mod.log.Debug().Msg("Using demand method")
+ settings := defaultSettings
+ settings.AllowStartOnDemand = true
+ task := &task{
+ TaskVersion: "1.2", // static
+ TaskNamespace: "http://schemas.microsoft.com/windows/2004/02/mit/task", // static
+ Principals: defaultPrincipals,
+ Settings: defaultSettings,
+ Actions: actions{
+ Context: defaultPrincipals.Principals[0].ID,
+ Exec: []actionExec{
+ {
+ Command: ecfg.ExecutableName,
+ Arguments: ecfg.ExecutableArgs,
+ },
+ },
+ },
+ }
+ if doc, err := xml.Marshal(task); err != nil {
+ return fmt.Errorf("marshal task: %w", err)
+ } else {
+ docStr := TaskXMLHeader + string(doc)
+
+ if err = mod.Connect(ctx, creds, target); err != nil {
+ return fmt.Errorf("connect: %w", err)
+ }
+ defer func() {
+ if err = mod.dce.Close(ctx); err != nil {
+ mod.log.Warn().Err(err).Msg("Failed to dispose dce client")
+ } else {
+ mod.log.Debug().Msg("Disposed DCE client")
+ }
+ }()
+
+ var response *itaskschedulerservice.RegisterTaskResponse
+ if response, err = mod.tsch.RegisterTask(ctx, &itaskschedulerservice.RegisterTaskRequest{
+ Path: taskPath,
+ XML: docStr,
+ Flags: 0, // TODO
+ LogonType: 0, // TASK_LOGON_NONE
+ CredsCount: 0,
+ Creds: nil,
+ }); err != nil {
+ return fmt.Errorf("register task: %w", err)
+
+ } else {
+ mod.log.Info().Str("task", response.ActualPath).Msg("Task registered successfully")
+ if !cfg.NoDelete {
+ defer func() {
+ if err = mod.Cleanup(ctx, creds, target, &exec.CleanupConfig{
+ CleanupMethod: MethodDelete,
+ CleanupMethodConfig: MethodDeleteConfig{TaskPath: taskPath},
+ }); err != nil {
+ mod.log.Error().Err(err).Msg("Failed to delete task")
+ }
+ }()
+ }
+ if _, err = mod.tsch.Run(ctx, &itaskschedulerservice.RunRequest{
+ Path: response.ActualPath,
+ Flags: 0, // Maybe we want to use these?
+ }); err != nil {
+ return err
+ } else {
+ mod.log.Info().Str("task", response.ActualPath).Msg("Started task")
+ }
+ }
+ }
+ }
+ } else {
+ return fmt.Errorf("method not implemented: %s", ecfg.ExecutionMethod)
+ }
+
+ return nil
+}
diff --git a/pkg/exec/tsch/module.go b/pkg/exec/tsch/module.go
new file mode 100644
index 0000000..3112902
--- /dev/null
+++ b/pkg/exec/tsch/module.go
@@ -0,0 +1,56 @@
+package tschexec
+
+import (
+ "context"
+ "github.com/FalconOpsLLC/goexec/pkg/client/dcerpc"
+ "github.com/bryanmcnulty/adauth"
+ "github.com/oiweiwei/go-msrpc/msrpc/tsch/itaskschedulerservice/v1"
+ "github.com/rs/zerolog"
+ "time"
+)
+
+type Step struct {
+ Name string // Name of the step
+ Status string // Status indicates whether the task succeeded, failed, etc.
+ Call func(context.Context, *Module, ...any) (interface{}, error) // Call will invoke the procedure
+ Match func(context.Context, *Module, ...any) (bool, error) // Match will make an assertion to determine whether the step was successful
+}
+
+type Module struct {
+ creds *adauth.Credential
+ target *adauth.Target
+
+ log zerolog.Logger
+ dce *dcerpc.DCEClient
+ tsch itaskschedulerservice.TaskSchedulerServiceClient
+}
+
+type MethodRegisterConfig struct {
+ NoDelete bool
+ CallDelete bool
+ TaskName string
+ TaskPath string
+ StartDelay time.Duration
+ StopDelay time.Duration
+ DeleteDelay time.Duration
+}
+
+type MethodDemandConfig struct {
+ NoDelete bool
+ CallDelete bool
+ TaskName string
+ TaskPath string
+ StopDelay time.Duration
+ DeleteDelay time.Duration
+}
+
+type MethodDeleteConfig struct {
+ TaskPath string
+}
+
+const (
+ MethodRegister string = "register"
+ MethodDemand string = "demand"
+ MethodDelete string = "delete"
+ MethodChange string = "update"
+)
diff --git a/pkg/exec/tsch/tsch.go b/pkg/exec/tsch/tsch.go
new file mode 100644
index 0000000..bc3ed0b
--- /dev/null
+++ b/pkg/exec/tsch/tsch.go
@@ -0,0 +1,102 @@
+package tschexec
+
+import (
+ "encoding/xml"
+)
+
+// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsch/0d6383e4-de92-43e7-b0bb-a60cfa36379f
+
+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 idleSettings struct {
+ StopOnIdleEnd bool `xml:"StopOnIdleEnd"`
+ RestartOnIdle bool `xml:"RestartOnIdle"`
+}
+
+type settings 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 idleSettings `xml:"IdleSettings,omitempty"`
+}
+
+type actionExec struct {
+ XMLName xml.Name `xml:"Exec"`
+ Command string `xml:"Command"`
+ Arguments string `xml:"Arguments"`
+}
+
+type actions struct {
+ XMLName xml.Name `xml:"Actions"`
+ Context string `xml:"Context,attr"`
+ Exec []actionExec `xml:"Exec,omitempty"`
+}
+
+type principals struct {
+ XMLName xml.Name `xml:"Principals"`
+ Principals []principal `xml:"Principal"`
+}
+
+type principal 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"`
+ TimeTriggers []taskTimeTrigger `xml:"Triggers>TimeTrigger,omitempty"`
+ Actions actions `xml:"Actions"`
+ Principals principals `xml:"Principals"`
+ Settings settings `xml:"Settings"`
+}
+
+var (
+ defaultSettings = settings{
+ MultipleInstancesPolicy: "IgnoreNew",
+ DisallowStartIfOnBatteries: false,
+ StopIfGoingOnBatteries: false,
+ AllowHardTerminate: true,
+ RunOnlyIfNetworkAvailable: false,
+ IdleSettings: idleSettings{
+ StopOnIdleEnd: true,
+ RestartOnIdle: false,
+ },
+ AllowStartOnDemand: true,
+ Enabled: true,
+ Hidden: true,
+ RunOnlyIfIdle: false,
+ WakeToRun: false,
+ Priority: 7, // 7 is a pretty standard value for scheduled tasks
+ StartWhenAvailable: true,
+ }
+ defaultPrincipals = principals{
+ Principals: []principal{
+ {
+ ID: "SYSTEM",
+ UserID: "S-1-5-18",
+ RunLevel: "HighestAvailable",
+ },
+ },
+ }
+)