diff --git a/.github/workflows/linux-port.yml b/.github/workflows/linux-port.yml index 68a0b432c..667ef3e8a 100644 --- a/.github/workflows/linux-port.yml +++ b/.github/workflows/linux-port.yml @@ -38,6 +38,12 @@ jobs: make -B ebpf git diff --exit-code -- internal/ebpf/bpf - name: Unit tests - run: go test ./internal/ebpf ./internal/bootstrap ./pkg/event ./pkg/ps ./pkg/api ./pkg/util/signals + run: make test + - name: Validate rules + run: | + make build + ./cmd/fibratus/fibratus rules validate \ + --filters.rules.from-paths="rules/linux/*.yml" \ + --filters.macros.from-paths="rules/linux/macros/*.yml" - name: Privileged process source run: sudo -E env "PATH=$PATH" go test -tags ebpf_integration -count=1 ./internal/ebpf diff --git a/Makefile b/Makefile index f2a352ba9..3d82486be 100644 --- a/Makefile +++ b/Makefile @@ -60,9 +60,21 @@ build: fmt: $(GOFMT) -e -s -l -w pkg cmd internal +TEST_PKGS := \ + ./internal/ebpf \ + ./internal/bootstrap \ + ./pkg/api \ + ./pkg/config \ + ./pkg/event \ + ./pkg/filter \ + ./pkg/ps \ + ./pkg/rules \ + ./pkg/rules/action \ + ./pkg/util/signals + .PHONY: test test: - $(GO) test ./internal/ebpf ./internal/bootstrap ./pkg/event ./pkg/ps ./pkg/api ./pkg/util/signals ./pkg/filter ./pkg/rules + $(GO) test $(TEST_PKGS) .PHONY: clean clean: diff --git a/internal/ebpf/events.go b/internal/ebpf/events.go index ec78e5add..761325283 100644 --- a/internal/ebpf/events.go +++ b/internal/ebpf/events.go @@ -164,16 +164,16 @@ func (r rawEvent) toEvent() *event.Event { evt.Params.Append(params.FD, params.Int64, int64(r.Arg3)) evt.Params.Append(params.MmapOffset, params.Uint64, r.Arg0) case event.ProcessVMRead, event.ProcessVMWrite: - evt.Params.Append(params.TargetProcessID, params.PID, uint64(uint32(r.Arg0))) + evt.Params.Append(params.TargetProcessID, params.Int64, int64(int32(r.Arg0))) evt.Params.Append(params.MemBaseAddress, params.Address, r.Arg2) evt.Params.Append(params.MemRegionSize, params.Uint64, r.Arg3) evt.Params.Append(params.MmapFlags, params.Uint64, r.Flags) case event.Kill: - evt.Params.Append(params.TargetProcessID, params.PID, uint64(uint32(r.Arg0))) + evt.Params.Append(params.TargetProcessID, params.Int64, int64(int32(r.Arg0))) evt.Params.Append(params.Signal, params.Int32, int32(r.Arg1)) case event.Ptrace: evt.Params.Append(params.PtraceRequest, params.Int64, int64(r.Arg0)) - evt.Params.Append(params.TargetProcessID, params.PID, uint64(uint32(r.Arg1))) + evt.Params.Append(params.TargetProcessID, params.Int64, int64(int32(r.Arg1))) evt.Params.Append(params.PtraceAddr, params.Address, r.Arg2) evt.Params.Append(params.PtraceData, params.Uint64, r.Arg3) case event.Prctl: diff --git a/internal/ebpf/events_test.go b/internal/ebpf/events_test.go index da221ec6c..9c7524433 100644 --- a/internal/ebpf/events_test.go +++ b/internal/ebpf/events_test.go @@ -146,3 +146,39 @@ func TestSucceededHelper(t *testing.T) { evt.Params.Append(params.Retval, params.Int64, int64(-2)) assert.False(t, succeeded(evt)) } + +// The first argument of kill is a pid_t whose sign selects the scope of the +// signal, so it must survive the conversion to an event parameter. +func TestSignalTargetKeepsPidSign(t *testing.T) { + var tests = []struct { + name string + arg0 uint64 + want int64 + }{ + {"single process", 4242, 4242}, + {"caller process group", 0, 0}, + {"every permitted process", uint64(^uint32(0)), -1}, + {"process group", uint64(uint32(0xFFFFEF6E)), -4242}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + evt := rawEvent{Type: uint32(event.Kill), Arg0: tt.arg0, Arg1: 9}.toEvent() + target, err := evt.Params.GetInt64(params.TargetProcessID) + require.NoError(t, err) + assert.Equal(t, tt.want, target) + }) + } +} + +func TestPtraceAndProcessVMTargetsAreSigned(t *testing.T) { + ptrace := rawEvent{Type: uint32(event.Ptrace), Arg0: 16, Arg1: 4242}.toEvent() + target, err := ptrace.Params.GetInt64(params.TargetProcessID) + require.NoError(t, err) + assert.Equal(t, int64(4242), target) + + vmread := rawEvent{Type: uint32(event.ProcessVMRead), Arg0: 4242}.toEvent() + target, err = vmread.Params.GetInt64(params.TargetProcessID) + require.NoError(t, err) + assert.Equal(t, int64(4242), target) +} diff --git a/make.bat b/make.bat index 452ca0cae..3dbff7a78 100644 --- a/make.bat +++ b/make.bat @@ -121,7 +121,7 @@ copy /y ".\configs\fibratus.yml" "%RELEASE_DIR%\Config\fibratus.yml" copy /y ".\pkg\outputs\eventlog\mc\fibratus.dll" "%RELEASE_DIR%\fibratus.dll" robocopy ".\filaments" "%RELEASE_DIR%\Filaments" /E /S /XF *.md /XD __pycache__ .idea -robocopy ".\rules" "%RELEASE_DIR%\Rules" /E /S /XF *.md *.png +robocopy ".\rules" "%RELEASE_DIR%\Rules" /E /S /XF *.md *.png /XD linux :: Download the embedded Python distribution echo Downloading Python %PYTHON_VER%... @@ -180,7 +180,7 @@ copy /y ".\cmd\fibratus\fibratus.ico" "%RELEASE_DIR%\Bin" copy /y ".\configs\fibratus.yml" "%RELEASE_DIR%\Config\fibratus.yml" copy /y ".\pkg\outputs\eventlog\mc\fibratus.dll" "%RELEASE_DIR%\fibratus.dll" -robocopy ".\rules" "%RELEASE_DIR%\Rules" /E /S /XF *.md *.png +robocopy ".\rules" "%RELEASE_DIR%\Rules" /E /S /XF *.md *.png /XD linux :: Copy Debug Help DLL copy %SystemRoot%\System32\dbghelp.dll "%RELEASE_DIR%\Bin" diff --git a/pkg/filter/accessor_linux.go b/pkg/filter/accessor_linux.go index 100f6e41d..3c61eb9b6 100644 --- a/pkg/filter/accessor_linux.go +++ b/pkg/filter/accessor_linux.go @@ -214,7 +214,7 @@ func (a *psAccessor) Get(f Field, e *event.Event) (params.Value, error) { } return int64(sig), nil case fields.PsTargetPID: - return e.Params.GetUint64(params.TargetProcessID) + return e.Params.GetInt64(params.TargetProcessID) case fields.PsPtraceRequest: return e.Params.GetInt64(params.PtraceRequest) case fields.PsPrctlOption: @@ -308,7 +308,7 @@ func (*memAccessor) Get(f Field, e *event.Event) (params.Value, error) { case fields.MemMmapOffset: return e.Params.GetUint64(params.MmapOffset) case fields.MemTargetPID: - return e.Params.GetUint64(params.TargetProcessID) + return e.Params.GetInt64(params.TargetProcessID) default: return nil, nil } diff --git a/pkg/filter/fields/defs_linux.go b/pkg/filter/fields/defs_linux.go index f4f569596..26e643df2 100644 --- a/pkg/filter/fields/defs_linux.go +++ b/pkg/filter/fields/defs_linux.go @@ -43,7 +43,7 @@ var fields = platformFields(map[Field]FieldInfo{ PsParentUsername: {PsParentUsername, "parent process username", platformString(), []string{"ps.parent.username = 'root'"}, nil, nil}, PsParentEnvs: {PsParentEnvs, "parent process environment variables", params.Slice, []string{"ps.parent.envs in ('PATH:/usr/bin')"}, nil, nil}, PsSignal: {PsSignal, "POSIX signal number", params.Int64, []string{"ps.signal = 9"}, nil, nil}, - PsTargetPID: {PsTargetPID, "target process identifier of kill, ptrace, or process_vm operations", params.Uint64, []string{"ps.target.pid = 4242"}, nil, nil}, + PsTargetPID: {PsTargetPID, "target process identifier of kill, ptrace, or process_vm operations. Signal targets keep the pid_t sign, where zero is the caller process group, -1 is every permitted process, and any other negative value is the process group of its absolute value", params.Int64, []string{"ps.target.pid = 4242", "ps.target.pid < 0"}, nil, nil}, PsPtraceRequest: {PsPtraceRequest, "ptrace request code", params.Int64, []string{"ps.ptrace.request = 16"}, nil, nil}, PsPrctlOption: {PsPrctlOption, "prctl option code", params.Int64, []string{"ps.prctl.option = 15"}, nil, nil}, PsCloneFlags: {PsCloneFlags, "clone flags bitmask", params.Uint64, []string{"ps.clone_flags = 0"}, nil, nil}, @@ -69,7 +69,7 @@ var fields = platformFields(map[Field]FieldInfo{ MemMmapFlags: {MemMmapFlags, "mmap flags bitmask", params.Uint64, []string{"mem.mmap.flags = 1"}, nil, nil}, MemMmapFD: {MemMmapFD, "file descriptor backing a mapping", params.Int64, []string{"mem.mmap.fd = 3"}, nil, nil}, MemMmapOffset: {MemMmapOffset, "file offset of a memory mapping", params.Uint64, []string{"mem.mmap.offset = 0"}, nil, nil}, - MemTargetPID: {MemTargetPID, "target process identifier of a process_vm operation", params.Uint64, []string{"mem.target.pid = 4242"}, nil, nil}, + MemTargetPID: {MemTargetPID, "target process identifier of a process_vm operation", params.Int64, []string{"mem.target.pid = 4242"}, nil, nil}, ThreadTID: {ThreadTID, "thread identifier", params.Uint64, []string{"thread.tid = 1024"}, nil, nil}, ThreadPID: {ThreadPID, "thread group identifier of the thread", params.Uint64, []string{"thread.pid = 1024"}, nil, nil}, }) diff --git a/pkg/filter/filter_linux_test.go b/pkg/filter/filter_linux_test.go index 77e074279..4370c5e76 100644 --- a/pkg/filter/filter_linux_test.go +++ b/pkg/filter/filter_linux_test.go @@ -204,11 +204,34 @@ func TestProcFilter(t *testing.T) { PID: 4242, PS: ps, Params: event.Params{ - params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(99)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(99)}, params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}, params.Retval: {Name: params.Retval, Type: params.Int64, Value: int64(0)}, }, } + // kill accepts a pid_t whose sign selects the scope of the signal + broadcastKill := &event.Event{ + Type: event.Kill, + Category: event.Process, + Name: "kill", + PID: 4242, + PS: ps, + Params: event.Params{ + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(-1)}, + params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}, + }, + } + groupKill := &event.Event{ + Type: event.Kill, + Category: event.Process, + Name: "kill", + PID: 4242, + PS: ps, + Params: event.Params{ + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(-4242)}, + params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}, + }, + } ptrace := &event.Event{ Type: event.Ptrace, Category: event.Process, @@ -217,7 +240,7 @@ func TestProcFilter(t *testing.T) { PS: ps, Params: event.Params{ params.PtraceRequest: {Name: params.PtraceRequest, Type: params.Int64, Value: int64(16)}, - params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(99)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(99)}, }, } prctl := &event.Event{ @@ -272,6 +295,11 @@ func TestProcFilter(t *testing.T) { {execve, `evt.arg[exe] = '/bin/bash'`, true}, {kill, `ps.signal = 9`, true}, {kill, `ps.target.pid = 99`, true}, + {kill, `ps.target.pid > 0`, true}, + {kill, `ps.target.pid < 0`, false}, + {broadcastKill, `ps.target.pid < 0`, true}, + {broadcastKill, `ps.target.pid > 0`, false}, + {groupKill, `ps.target.pid < 0`, true}, {ptrace, `ps.ptrace.request = 16`, true}, {ptrace, `ps.target.pid = 99`, true}, {prctl, `ps.prctl.option = 15`, true}, @@ -413,7 +441,7 @@ func TestMemFilter(t *testing.T) { Name: "process_vm_readv", PID: 4242, Params: event.Params{ - params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(99)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(99)}, params.MemBaseAddress: {Name: params.MemBaseAddress, Type: params.Address, Value: uint64(4096)}, params.MemRegionSize: {Name: params.MemRegionSize, Type: params.Uint64, Value: uint64(16)}, }, @@ -424,7 +452,7 @@ func TestMemFilter(t *testing.T) { Name: "process_vm_writev", PID: 4242, Params: event.Params{ - params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(77)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(77)}, params.MemBaseAddress: {Name: params.MemBaseAddress, Type: params.Address, Value: uint64(4096)}, }, } @@ -541,8 +569,8 @@ func TestEveryLinuxEventType(t *testing.T) { {&event.Event{Type: event.Connect, Category: event.Net, Name: "connect", PS: ps, Params: event.Params{params.NetDport: {Name: params.NetDport, Type: params.Port, Value: uint16(443)}}}, `net.dport = 443`}, {&event.Event{Type: event.Accept, Category: event.Net, Name: "accept", PS: ps, Params: event.Params{params.NetSport: {Name: params.NetSport, Type: params.Port, Value: uint16(80)}}}, `net.sport = 80`}, {&event.Event{Type: event.Mmap, Category: event.Mem, Name: "mmap", PS: ps, Params: event.Params{params.MemRegionSize: {Name: params.MemRegionSize, Type: params.Uint64, Value: uint64(4096)}}}, `mem.size = 4096`}, - {&event.Event{Type: event.ProcessVMRead, Category: event.Mem, Name: "process_vm_readv", PS: ps, Params: event.Params{params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(9)}}}, `mem.target.pid = 9`}, - {&event.Event{Type: event.ProcessVMWrite, Category: event.Mem, Name: "process_vm_writev", PS: ps, Params: event.Params{params.TargetProcessID: {Name: params.TargetProcessID, Type: params.PID, Value: uint64(8)}}}, `mem.target.pid = 8`}, + {&event.Event{Type: event.ProcessVMRead, Category: event.Mem, Name: "process_vm_readv", PS: ps, Params: event.Params{params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(9)}}}, `mem.target.pid = 9`}, + {&event.Event{Type: event.ProcessVMWrite, Category: event.Mem, Name: "process_vm_writev", PS: ps, Params: event.Params{params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(8)}}}, `mem.target.pid = 8`}, {&event.Event{Type: event.Kill, Category: event.Process, Name: "kill", PS: ps, Params: event.Params{params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}}}, `ps.signal = 9`}, {&event.Event{Type: event.Ptrace, Category: event.Process, Name: "ptrace", PS: ps, Params: event.Params{params.PtraceRequest: {Name: params.PtraceRequest, Type: params.Int64, Value: int64(16)}}}, `ps.ptrace.request = 16`}, {&event.Event{Type: event.Prctl, Category: event.Process, Name: "prctl", PS: ps, Params: event.Params{params.PrctlOption: {Name: params.PrctlOption, Type: params.Int64, Value: int64(15)}}}, `ps.prctl.option = 15`}, diff --git a/pkg/rules/_fixtures/kill_action_linux.yml b/pkg/rules/_fixtures/kill_action_linux.yml new file mode 100644 index 000000000..46f117847 --- /dev/null +++ b/pkg/rules/_fixtures/kill_action_linux.yml @@ -0,0 +1,7 @@ +name: Kill process executed from a temporary directory +id: 33333333-3333-4333-8333-333333333333 +version: 1.0.0 +condition: evt.name = 'execve' and ps.exe imatches '/tmp/*' +action: + - name: kill +min-engine-version: 3.0.0 diff --git a/pkg/rules/_fixtures/shared/semantic_net.yml b/pkg/rules/_fixtures/shared/semantic_net.yml new file mode 100644 index 000000000..7039155b6 --- /dev/null +++ b/pkg/rules/_fixtures/shared/semantic_net.yml @@ -0,0 +1,5 @@ +name: match destination port +id: 22222222-2222-4222-8222-222222222222 +version: 1.0.0 +condition: evt.category = 'net' and net.dport = 443 +min-engine-version: 2.0.0 diff --git a/pkg/rules/_fixtures/shared/semantic_process.yml b/pkg/rules/_fixtures/shared/semantic_process.yml new file mode 100644 index 000000000..806e994e3 --- /dev/null +++ b/pkg/rules/_fixtures/shared/semantic_process.yml @@ -0,0 +1,5 @@ +name: match process category +id: 11111111-1111-4111-8111-111111111111 +version: 1.0.0 +condition: evt.category = 'process' +min-engine-version: 2.0.0 diff --git a/pkg/rules/action/kill_linux.go b/pkg/rules/action/kill_linux.go index 1cc0bbbb0..8f993476c 100644 --- a/pkg/rules/action/kill_linux.go +++ b/pkg/rules/action/kill_linux.go @@ -7,6 +7,7 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 @@ -21,13 +22,184 @@ package action import ( + "bytes" + "errors" "fmt" + "os" + "slices" + "strconv" + "github.com/rabbitstack/fibratus/pkg/config" "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/event/params" + "github.com/rabbitstack/fibratus/pkg/util/multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" ) -// Kill terminates all processes with specified pids. -// The signal-based Linux implementation lands with the detection work. -func Kill(pids []event.PID) error { - return fmt.Errorf("kill action is not implemented on Linux yet (pids=%v)", pids) +// userHZ is the tick rate in which /proc//stat expresses the process start +// time. The kernel derives that field from task->start_boottime through +// nsec_to_clock_t, so dividing the captured nanoseconds by the same tick length +// reproduces it exactly. +const userHZ = uint64(100) + +const nsecPerTick = uint64(1_000_000_000) / userHZ + +type processInstance struct { + pid event.PID + startBootTime uint64 +} + +type killer struct { + open func(pid event.PID) (int, error) + readStartTicks func(pid event.PID) (uint64, error) + signal func(fd int, sig unix.Signal) error + closeFD func(fd int) error +} + +var defaultKiller = killer{ + open: openPidfd, + readStartTicks: readProcStartTicks, + signal: sendSignal, + closeFD: unix.Close, +} + +// Kill terminates each process referenced by the action context. +func Kill(ctx *config.ActionContext) error { + return defaultKiller.kill(ctx) +} + +func (k killer) kill(ctx *config.ActionContext) error { + if ctx == nil { + return nil + } + instances := processInstances(ctx) + log.Infof("killing pids=%v", pids(instances)) + + errs := make([]error, 0) + for _, inst := range instances { + if err := k.killInstance(inst); err != nil { + errs = append(errs, err) + } + } + return multierror.Wrap(errs...) +} + +// killInstance signals the process only if it is still the instance the rule +// matched on. The pidfd pins the process identifier for as long as it is open, +// so the identifier cannot be recycled between the start time comparison and +// the delivery of the signal. +func (k killer) killInstance(inst processInstance) error { + if inst.pid == 0 { + return fmt.Errorf("refusing to kill pid 0") + } + if inst.startBootTime == 0 { + return fmt.Errorf("refusing to kill pid %d: missing process start time", inst.pid) + } + + fd, err := k.open(inst.pid) + if err != nil { + if isGone(err) { + return nil + } + return fmt.Errorf("couldn't pin pid %d for termination: %v", inst.pid, err) + } + defer func() { + _ = k.closeFD(fd) + }() + + ticks, err := k.readStartTicks(inst.pid) + if err != nil { + if isGone(err) { + return nil + } + return fmt.Errorf("couldn't revalidate pid %d: %v", inst.pid, err) + } + if ticks != inst.startBootTime/nsecPerTick { + return fmt.Errorf("refusing to kill pid %d: process instance identity does not match captured start time", inst.pid) + } + + if err := k.signal(fd, unix.SIGKILL); err != nil { + if isGone(err) { + return nil + } + return fmt.Errorf("failed to kill pid %d: %v", inst.pid, err) + } + return nil +} + +// processInstances resolves the distinct process instances the rule matched on. +// Clone events already carry the child identity, so the event process +// identifier always designates the process the rule fired for. +func processInstances(ctx *config.ActionContext) []processInstance { + seen := make(map[event.PID]processInstance, len(ctx.Events)) + for _, e := range ctx.Events { + if e == nil { + continue + } + start := e.GetParamAsUint64(params.StartBootTime) + if start == 0 && e.PS != nil { + start = e.PS.StartBootTime + } + seen[e.PID] = processInstance{pid: e.PID, startBootTime: start} + } + instances := make([]processInstance, 0, len(seen)) + for _, inst := range seen { + instances = append(instances, inst) + } + slices.SortFunc(instances, func(a, b processInstance) int { + return int(a.pid) - int(b.pid) + }) + return instances +} + +func pids(instances []processInstance) []event.PID { + p := make([]event.PID, 0, len(instances)) + for _, inst := range instances { + p = append(p, inst.pid) + } + return p +} + +func openPidfd(pid event.PID) (int, error) { + if pid > uint64(^uint32(0)>>1) { + return 0, fmt.Errorf("pid %d is out of range", pid) + } + return unix.PidfdOpen(int(pid), 0) +} + +func sendSignal(fd int, sig unix.Signal) error { + return unix.PidfdSendSignal(fd, sig, nil, 0) +} + +func readProcStartTicks(pid event.PID) (uint64, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0, err + } + return parseProcStatStartTicks(data) +} + +// parseProcStatStartTicks extracts the starttime field from a /proc//stat +// record. The comm field is parenthesized and may itself contain spaces and +// parentheses, so the field split starts after its final delimiter. +func parseProcStatStartTicks(stat []byte) (uint64, error) { + i := bytes.LastIndexByte(stat, ')') + if i < 0 { + return 0, fmt.Errorf("malformed /proc stat: missing comm") + } + fields := bytes.Fields(stat[i+1:]) + // starttime is the 22nd field, which is index 19 once pid and comm are cut + if len(fields) < 20 { + return 0, fmt.Errorf("malformed /proc stat: too few fields") + } + ticks, err := strconv.ParseUint(string(fields[19]), 10, 64) + if err != nil { + return 0, fmt.Errorf("malformed /proc stat starttime: %w", err) + } + return ticks, nil +} + +func isGone(err error) bool { + return errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) } diff --git a/pkg/rules/action/kill_linux_test.go b/pkg/rules/action/kill_linux_test.go new file mode 100644 index 000000000..b0bcd1c5a --- /dev/null +++ b/pkg/rules/action/kill_linux_test.go @@ -0,0 +1,301 @@ +//go:build linux + +/* + * Copyright 2026 by Mostafa Moradian + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package action + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/event/params" + pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +const ( + testPID = event.PID(4242) + testStartNs = uint64(1_234_500_000_000) + testStartTicks = testStartNs / nsecPerTick + testPidfd = 7 +) + +// recorder captures what a killer attempted so tests never signal a real process. +type recorder struct { + opened []event.PID + signaled []int + signals []unix.Signal + closed []int +} + +func (r *recorder) killer(ticks func(pid event.PID) (uint64, error)) killer { + return killer{ + open: func(pid event.PID) (int, error) { + r.opened = append(r.opened, pid) + return testPidfd, nil + }, + readStartTicks: ticks, + signal: func(fd int, sig unix.Signal) error { + r.signaled = append(r.signaled, fd) + r.signals = append(r.signals, sig) + return nil + }, + closeFD: func(fd int) error { + r.closed = append(r.closed, fd) + return nil + }, + } +} + +func constTicks(ticks uint64) func(event.PID) (uint64, error) { + return func(event.PID) (uint64, error) { return ticks, nil } +} + +func killEvent(pid event.PID, startNs uint64) *event.Event { + return &event.Event{ + Type: event.Execve, + Name: "execve", + Category: event.Process, + PID: pid, + PS: &pstypes.PS{ + PID: pid, + Name: "malware", + StartBootTime: startNs, + }, + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: pid}, + params.StartBootTime: {Name: params.StartBootTime, Type: params.Uint64, Value: startNs}, + }, + } +} + +func killContext(evts ...*event.Event) *config.ActionContext { + return &config.ActionContext{Events: evts} +} + +func TestParseProcStatStartTicks(t *testing.T) { + stat := []byte("1 (systemd) S 0 1 1 0 -1 4194560 123 0 0 0 1 1 0 0 20 0 1 0 12345 123456 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n") + ticks, err := parseProcStatStartTicks(stat) + require.NoError(t, err) + assert.Equal(t, uint64(12345), ticks) + + // comm may contain spaces and parentheses + spaced := []byte("4242 (my (odd) process) S 1 4242 4242 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 99 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n") + ticks, err = parseProcStatStartTicks(spaced) + require.NoError(t, err) + assert.Equal(t, uint64(99), ticks) + + _, err = parseProcStatStartTicks([]byte("1 systemd S 0 1")) + require.Error(t, err) + _, err = parseProcStatStartTicks([]byte("1 (systemd) S 0")) + require.Error(t, err) +} + +func TestReadProcStartTicks(t *testing.T) { + ticks, err := readProcStartTicks(event.PID(os.Getpid())) + require.NoError(t, err) + assert.Greater(t, ticks, uint64(0)) + + _, err = readProcStartTicks(event.PID(1 << 30)) + require.Error(t, err) + assert.True(t, isGone(err)) +} + +func TestKillSignalsMatchingInstance(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + + require.NoError(t, k.kill(killContext(killEvent(testPID, testStartNs)))) + assert.Equal(t, []event.PID{testPID}, r.opened) + assert.Equal(t, []int{testPidfd}, r.signaled) + assert.Equal(t, []unix.Signal{unix.SIGKILL}, r.signals) + assert.Equal(t, []int{testPidfd}, r.closed) +} + +func TestKillPinsProcessBeforeRevalidating(t *testing.T) { + r := &recorder{} + var openedBeforeRead bool + k := r.killer(func(event.PID) (uint64, error) { + openedBeforeRead = len(r.opened) == 1 + return testStartTicks, nil + }) + + require.NoError(t, k.kill(killContext(killEvent(testPID, testStartNs)))) + assert.True(t, openedBeforeRead, "pidfd must be open before the start time is compared") +} + +func TestKillRefusesReusedPID(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks + 1)) + + err := k.kill(killContext(killEvent(testPID, testStartNs))) + require.Error(t, err) + assert.Contains(t, err.Error(), "process instance identity does not match") + assert.Empty(t, r.signaled) + assert.Equal(t, []int{testPidfd}, r.closed) +} + +func TestKillMissingProcessIsSuccess(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + k.open = func(event.PID) (int, error) { return 0, unix.ESRCH } + + require.NoError(t, k.kill(killContext(killEvent(testPID, testStartNs)))) + assert.Empty(t, r.signaled) +} + +func TestKillProcessExitingDuringRevalidationIsSuccess(t *testing.T) { + r := &recorder{} + k := r.killer(func(event.PID) (uint64, error) { return 0, os.ErrNotExist }) + + require.NoError(t, k.kill(killContext(killEvent(testPID, testStartNs)))) + assert.Empty(t, r.signaled) + assert.Equal(t, []int{testPidfd}, r.closed) +} + +func TestKillESRCHOnSignalIsSuccess(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + k.signal = func(int, unix.Signal) error { return unix.ESRCH } + + require.NoError(t, k.kill(killContext(killEvent(testPID, testStartNs)))) +} + +func TestKillRefusesMissingStartTime(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + + evt := killEvent(testPID, 0) + evt.PS.StartBootTime = 0 + evt.Params.Remove(params.StartBootTime) + + err := k.kill(killContext(evt)) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing process start time") + assert.Empty(t, r.opened) + assert.Empty(t, r.signaled) +} + +func TestKillUsesEventStartBootTimeWhenPSMissing(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + + evt := killEvent(testPID, testStartNs) + evt.PS = nil + require.NoError(t, k.kill(killContext(evt))) + assert.Equal(t, []event.PID{testPID}, r.opened) +} + +// Clone events carry the child identity, so the event pid designates the +// process the rule matched on without consulting the pid parameter. +func TestKillResolvesCloneChildPid(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + + clone := killEvent(testPID, testStartNs) + clone.Type = event.Clone + clone.Name = "clone" + clone.Params.Append(params.CloneFlags, params.Uint64, uint64(0)) + require.True(t, clone.IsCreateProcess()) + + require.NoError(t, k.kill(killContext(clone))) + assert.Equal(t, []event.PID{testPID}, r.opened) +} + +func TestKillDeduplicatesAndOrdersPids(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + + err := k.kill(killContext( + killEvent(90, testStartNs), + killEvent(12, testStartNs), + killEvent(90, testStartNs), + )) + require.NoError(t, err) + assert.Equal(t, []event.PID{12, 90}, r.opened) +} + +func TestKillNilContext(t *testing.T) { + require.NoError(t, Kill(nil)) +} + +// TestKillTerminatesLiveProcess exercises the real pidfd and procfs path end to +// end, including the refusal branch when the captured start time disagrees. +func TestKillTerminatesLiveProcess(t *testing.T) { + start := func(t *testing.T) (event.PID, *exec.Cmd, uint64) { + t.Helper() + cmd := exec.Command("sleep", "300") + require.NoError(t, cmd.Start()) + t.Cleanup(func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + pid := event.PID(cmd.Process.Pid) + ticks, err := readProcStartTicks(pid) + require.NoError(t, err) + return pid, cmd, ticks * nsecPerTick + } + + t.Run("matching instance is terminated", func(t *testing.T) { + pid, cmd, startNs := start(t) + + require.NoError(t, Kill(killContext(killEvent(pid, startNs)))) + + state, err := cmd.Process.Wait() + require.NoError(t, err) + assert.Equal(t, syscall.SIGKILL, state.Sys().(syscall.WaitStatus).Signal()) + }) + + t.Run("mismatched instance is spared", func(t *testing.T) { + pid, _, startNs := start(t) + + err := Kill(killContext(killEvent(pid, startNs+uint64(time.Second)))) + require.Error(t, err) + assert.Contains(t, err.Error(), "process instance identity does not match") + + ticks, err := readProcStartTicks(pid) + require.NoError(t, err) + assert.Equal(t, startNs/nsecPerTick, ticks, "process should still be alive") + }) +} + +func TestOpenPidfdRejectsOutOfRangePid(t *testing.T) { + _, err := openPidfd(event.PID(1) << 40) + require.Error(t, err) + assert.Contains(t, err.Error(), "out of range") +} + +func TestKillReportsUnexpectedOpenError(t *testing.T) { + r := &recorder{} + k := r.killer(constTicks(testStartTicks)) + k.open = func(event.PID) (int, error) { return 0, fmt.Errorf("boom") } + + err := k.kill(killContext(killEvent(testPID, testStartNs))) + require.Error(t, err) + assert.Contains(t, err.Error(), "couldn't pin pid 4242") +} diff --git a/pkg/rules/action/kill_windows.go b/pkg/rules/action/kill_windows.go index 1b2bf830a..80da07678 100644 --- a/pkg/rules/action/kill_windows.go +++ b/pkg/rules/action/kill_windows.go @@ -23,13 +23,20 @@ import ( "os" "syscall" - "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/config" "github.com/rabbitstack/fibratus/pkg/util/multierror" + log "github.com/sirupsen/logrus" "golang.org/x/sys/windows" ) -// Kill terminates all processes with specified pids. -func Kill(pids []event.PID) error { +// Kill terminates all processes referenced by the action context. +func Kill(ctx *config.ActionContext) error { + if ctx == nil { + return nil + } + pids := ctx.UniquePids() + log.Infof("killing pids=%v", pids) + errs := make([]error, 0) for _, pid := range pids { err := terminate(pid) diff --git a/pkg/rules/compiler_linux_test.go b/pkg/rules/compiler_linux_test.go index 5ab4c325b..fdb2ba7ce 100644 --- a/pkg/rules/compiler_linux_test.go +++ b/pkg/rules/compiler_linux_test.go @@ -50,3 +50,64 @@ condition: evt.name = 'execve' and ps.name = 'bash' require.NotNil(t, result) require.True(t, result.HasProcEvents) } + +func TestCompilerShippedLinuxRules(t *testing.T) { + cfg := newLinuxConfig(filepath.Join("..", "..", "rules", "linux", "*.yml")) + filters, result, err := newCompiler(ps.NewSnapshotter(), cfg).compile() + require.NoError(t, err) + require.NotEmpty(t, filters) + require.NotNil(t, result) + require.True(t, result.HasProcEvents) + require.True(t, result.HasNetworkEvents) + + // every shipped rule carries the labels the rule validator warns about + for f := range filters { + for _, label := range []string{"tactic.id", "tactic.name", "tactic.ref", "technique.id", "technique.name", "technique.ref"} { + require.True(t, f.HasLabel(label), "%s is missing the %s label", f.Name, label) + } + } +} + +func TestCompilerSharedSemanticFixtures(t *testing.T) { + cfg := newLinuxConfig("_fixtures/shared/*.yml") + filters, result, err := newCompiler(ps.NewSnapshotter(), cfg).compile() + require.NoError(t, err) + require.Len(t, filters, 2) + require.NotNil(t, result) +} + +func TestCompilerRejectsWindowsEventNames(t *testing.T) { + rule := filepath.Join(t.TempDir(), "windows.yml") + require.NoError(t, os.WriteFile(rule, []byte(`name: Windows process execution +id: 5d17dc44-cc9f-4f13-9a31-23c6529ff46e +version: 1.0.0 +min-engine-version: 3.0.0 +condition: evt.name = 'CreateProcess' +`), 0o600)) + + cfg := &config.Config{ + Filters: &config.Filters{ + Rules: config.Rules{FromPaths: []string{rule}}, + }, + } + _, _, err := newCompiler(ps.NewSnapshotter(), cfg).compile() + require.EqualError(t, err, ErrUnknownEventName("Windows process execution", "CreateProcess").Error()) +} + +func TestCompilerRejectsDeprecatedFields(t *testing.T) { + rule := filepath.Join(t.TempDir(), "kevt.yml") + require.NoError(t, os.WriteFile(rule, []byte(`name: Deprecated field +id: 6d17dc44-cc9f-4f13-9a31-23c6529ff46e +version: 1.0.0 +min-engine-version: 3.0.0 +condition: kevt.name = 'execve' +`), 0o600)) + + cfg := &config.Config{ + Filters: &config.Filters{ + Rules: config.Rules{FromPaths: []string{rule}}, + }, + } + _, _, err := newCompiler(ps.NewSnapshotter(), cfg).compile() + require.Error(t, err) +} diff --git a/pkg/rules/compiler_windows_test.go b/pkg/rules/compiler_windows_test.go index 21455bc2b..bdfc80c3f 100644 --- a/pkg/rules/compiler_windows_test.go +++ b/pkg/rules/compiler_windows_test.go @@ -85,6 +85,7 @@ func TestCompileEventCategoryFieldNames(t *testing.T) { {"_fixtures/field_values/incorrect_event_name_in_operator.yml", ErrUnknownEventName("match https connections", "CreateProc")}, {"_fixtures/field_values/correct_category_name_field.yml", nil}, {"_fixtures/field_values/incorrect_category_name_field.yml", ErrUnknownCategoryName("match https connections", "network")}, + {"_fixtures/shared/*.yml", nil}, } for _, tt := range tests { diff --git a/pkg/rules/engine.go b/pkg/rules/engine.go index cd0f4974a..c5a20afcd 100644 --- a/pkg/rules/engine.go +++ b/pkg/rules/engine.go @@ -298,8 +298,8 @@ func (e *Engine) processActions() error { for _, act := range actions { switch t := act.(type) { case config.KillAction: - log.Infof("executing kill action: pids=%v rule=%s", m.ctx.UniquePids(), f.Name) - if err := action.Kill(m.ctx.UniquePids()); err != nil { + log.Infof("executing kill action: rule=%s", f.Name) + if err := action.Kill(m.ctx); err != nil { return ErrRuleAction(f.Name, err) } case config.IsolateAction: diff --git a/pkg/rules/engine_linux_test.go b/pkg/rules/engine_linux_test.go index 5d514a30f..932d75c12 100644 --- a/pkg/rules/engine_linux_test.go +++ b/pkg/rules/engine_linux_test.go @@ -7,6 +7,7 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 @@ -21,16 +22,258 @@ package rules import ( + "net" + "path/filepath" "testing" + "time" + "github.com/rabbitstack/fibratus/pkg/alertsender" "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/event/params" "github.com/rabbitstack/fibratus/pkg/ps" + pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type mockNoneSender struct{} + +func (s *mockNoneSender) Send(alertsender.Alert) error { return nil } +func (s *mockNoneSender) Type() alertsender.Type { return alertsender.None } +func (s *mockNoneSender) Shutdown() error { return nil } +func (s *mockNoneSender) SupportsMarkdown() bool { return true } + +func makeNoneSender(alertsender.Config) (alertsender.Sender, error) { + return &mockNoneSender{}, nil +} + +func init() { + alertsender.Register(alertsender.None, makeNoneSender) +} + +func newLinuxConfig(fromFiles ...string) *config.Config { + return &config.Config{ + EventSource: config.EventSourceConfig{ + EnableFileIOEvents: true, + EnableNetEvents: true, + EnableMemEvents: true, + }, + Filters: &config.Filters{ + Rules: config.Rules{ + FromPaths: fromFiles, + }, + Macros: config.Macros{ + FromPaths: []string{filepath.Join("..", "..", "rules", "linux", "macros", "*")}, + }, + }, + } +} + +func compileRules(t *testing.T, e *Engine) *config.RulesCompileResult { + t.Helper() + rs, err := e.Compile() + require.NoError(t, err) + require.NotNil(t, rs) + return rs +} + +func processEvent(t *testing.T, e *Engine, evt *event.Event) bool { + t.Helper() + match, err := e.ProcessEvent(evt) + require.NoError(t, err) + return match +} + +func testPS(name, exe string, pid uint64, start uint64) *pstypes.PS { + return &pstypes.PS{ + PID: pid, + Ppid: 1, + Name: name, + Cmdline: exe, + Exe: exe, + StartBootTime: start, + Parent: &pstypes.PS{PID: 1, Name: "systemd", StartBootTime: 1}, + } +} + func TestNewEngineAcceptsSnapshotter(t *testing.T) { cfg := &config.Config{Filters: &config.Filters{}} e := NewEngine(ps.NewSnapshotter(), cfg) require.NotNil(t, e) require.NotNil(t, e.compiler) } + +func TestCompileIndexesLinuxTypesAndCategories(t *testing.T) { + e := NewEngine(ps.NewSnapshotter(), newLinuxConfig( + filepath.Join("..", "..", "rules", "linux", "*.yml"), + "_fixtures/shared/*.yml", + )) + rs := compileRules(t, e) + + require.True(t, rs.HasProcEvents) + require.True(t, rs.HasNetworkEvents) + assert.Contains(t, rs.UsedEvents, event.Execve) + assert.Contains(t, rs.UsedEvents, event.Ptrace) + assert.Contains(t, rs.UsedEvents, event.Kill) + assert.Contains(t, rs.UsedEvents, event.Connect) + + assert.NotEmpty(t, e.filters.types[event.Execve]) + assert.NotEmpty(t, e.filters.types[event.Ptrace]) + assert.NotEmpty(t, e.filters.types[event.Kill]) + assert.NotEmpty(t, e.filters.types[event.Connect]) + assert.NotEmpty(t, e.filters.categories[event.Process.Index()]) + assert.NotEmpty(t, e.filters.categories[event.Net.Index()]) +} + +func TestRunLinuxDetectionRules(t *testing.T) { + require.NoError(t, alertsender.LoadAll([]alertsender.Config{{Type: alertsender.None}})) + + e := NewEngine(ps.NewSnapshotter(), newLinuxConfig(filepath.Join("..", "..", "rules", "linux", "*.yml"))) + compileRules(t, e) + + tmpPS := testPS("dropper", "/tmp/dropper", 4242, 200) + execTmp := &event.Event{ + Type: event.Execve, + Name: "execve", + Category: event.Process, + PID: 4242, + Timestamp: time.Now(), + PS: tmpPS, + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.Exe: {Name: params.Exe, Type: params.Path, Value: "/tmp/dropper"}, + params.Retval: {Name: params.Retval, Type: params.Int64, Value: int64(0)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + require.True(t, processEvent(t, e, execTmp)) + + ptrace := &event.Event{ + Type: event.Ptrace, + Name: "ptrace", + Category: event.Process, + PID: 4242, + Timestamp: time.Now(), + PS: testPS("injector", "/usr/bin/injector", 4242, 200), + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.PtraceRequest: {Name: params.PtraceRequest, Type: params.Int64, Value: int64(16)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(99)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + require.True(t, processEvent(t, e, ptrace)) + + sigkill := &event.Event{ + Type: event.Kill, + Name: "kill", + Category: event.Process, + PID: 4242, + Timestamp: time.Now(), + PS: testPS("killer", "/usr/bin/killer", 4242, 200), + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(99)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + require.True(t, processEvent(t, e, sigkill)) + + selfKill := &event.Event{ + Type: event.Kill, + Name: "kill", + Category: event.Process, + PID: 4242, + Timestamp: time.Now(), + PS: testPS("killer", "/usr/bin/killer", 4242, 200), + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.Signal: {Name: params.Signal, Type: params.Int32, Value: int32(9)}, + params.TargetProcessID: {Name: params.TargetProcessID, Type: params.Int64, Value: int64(4242)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + require.False(t, processEvent(t, e, selfKill)) +} + +func TestLinuxLifecycleSequence(t *testing.T) { + require.NoError(t, alertsender.LoadAll([]alertsender.Config{{Type: alertsender.None}})) + + e := NewEngine(ps.NewSnapshotter(), newLinuxConfig(filepath.Join("..", "..", "rules", "linux", "command_and_control_interpreter_outbound_connection.yml"))) + compileRules(t, e) + + ps := testPS("bash", "/bin/bash", 4242, 200) + execve := &event.Event{ + Seq: 1, + Type: event.Execve, + Name: "execve", + Category: event.Process, + PID: 4242, + Timestamp: time.Now(), + PS: ps, + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.Exe: {Name: params.Exe, Type: params.Path, Value: "/bin/bash"}, + params.Retval: {Name: params.Retval, Type: params.Int64, Value: int64(0)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + connect := &event.Event{ + Seq: 2, + Type: event.Connect, + Name: "connect", + Category: event.Net, + PID: 4242, + Timestamp: time.Now().Add(time.Second), + PS: ps, + Params: event.Params{ + params.ProcessID: {Name: params.ProcessID, Type: params.PID, Value: uint64(4242)}, + params.SockFamily: {Name: params.SockFamily, Type: params.Uint16, Value: uint16(2)}, + params.NetDIP: {Name: params.NetDIP, Type: params.IPv4, Value: net.IPv4(1, 2, 3, 4).To4()}, + params.NetDport: {Name: params.NetDport, Type: params.Port, Value: uint16(4444)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + + require.False(t, processEvent(t, e, execve)) + require.True(t, processEvent(t, e, connect)) +} + +func TestSharedSemanticRuleFixtures(t *testing.T) { + require.NoError(t, alertsender.LoadAll([]alertsender.Config{{Type: alertsender.None}})) + + e := NewEngine(ps.NewSnapshotter(), newLinuxConfig("_fixtures/shared/*.yml")) + compileRules(t, e) + + proc := &event.Event{ + Type: event.Execve, + Name: "execve", + Category: event.Process, + PID: 7, + PS: testPS("bash", "/bin/bash", 7, 10), + Params: event.Params{}, + Metadata: make(map[event.MetadataKey]any), + } + netevt := &event.Event{ + Type: event.Connect, + Name: "connect", + Category: event.Net, + PID: 7, + PS: testPS("bash", "/bin/bash", 7, 10), + Params: event.Params{ + params.NetDport: {Name: params.NetDport, Type: params.Port, Value: uint16(443)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + + require.True(t, processEvent(t, e, proc)) + require.True(t, processEvent(t, e, netevt)) +} + +func TestCompileKillActionRule(t *testing.T) { + e := NewEngine(ps.NewSnapshotter(), newLinuxConfig("_fixtures/kill_action_linux.yml")) + compileRules(t, e) + assert.NotEmpty(t, e.filters.types[event.Execve]) +} diff --git a/pkg/rules/engine_windows_test.go b/pkg/rules/engine_windows_test.go index 4c4ee43b3..dceadc584 100644 --- a/pkg/rules/engine_windows_test.go +++ b/pkg/rules/engine_windows_test.go @@ -182,6 +182,33 @@ func TestRunSimpleRules(t *testing.T) { } } +func TestSharedSemanticRuleFixtures(t *testing.T) { + e := NewEngine(new(ps.SnapshotterMock), newConfig("_fixtures/shared/*.yml")) + compileRules(t, e) + + proc := &event.Event{ + Type: event.CreateProcess, + Name: "CreateProcess", + Category: event.Process, + PID: 859, + Params: event.Params{}, + Metadata: make(map[event.MetadataKey]any), + } + netevt := &event.Event{ + Type: event.RecvTCPv4, + Name: "Recv", + Category: event.Net, + PID: 859, + Params: event.Params{ + params.NetDport: {Name: params.NetDport, Type: params.Uint16, Value: uint16(443)}, + }, + Metadata: make(map[event.MetadataKey]any), + } + + require.True(t, wrapProcessEvent(proc, e.ProcessEvent)) + require.True(t, wrapProcessEvent(netevt, e.ProcessEvent)) +} + func TestRunSequenceRule(t *testing.T) { log.SetLevel(log.DebugLevel) diff --git a/rules/linux/command_and_control_interpreter_outbound_connection.yml b/rules/linux/command_and_control_interpreter_outbound_connection.yml new file mode 100644 index 000000000..b6868d1e3 --- /dev/null +++ b/rules/linux/command_and_control_interpreter_outbound_connection.yml @@ -0,0 +1,32 @@ +name: Interpreter established an outbound connection +id: bf1a30dc-8e54-46c7-9b9c-28c3b7169129 +version: 1.0.0 +description: | + Identifies a script interpreter that executes and then opens an outbound + IP connection. This sequence is commonly used for reverse shells and + follow-on command and control after a dropper runs under bash, python, + or a similar interpreter. The socket families are AF_INET and AF_INET6, + so local UNIX domain sockets are out of scope. +labels: + tactic.id: TA0011 + tactic.name: Command and Control + tactic.ref: https://attack.mitre.org/tactics/TA0011/ + technique.id: T1071 + technique.name: Application Layer Protocol + technique.ref: https://attack.mitre.org/techniques/T1071/ +references: + - https://attack.mitre.org/techniques/T1071/ + +condition: > + sequence + maxspan 30s + by ps.uuid + |execve and ps.name in script_interpreters| + |connect and net.family in (2, 10) and net.dport != 0| + + +output: > + %1.ps.name connected to %2.net.dip:%2.net.dport after execution + +severity: high +min-engine-version: 3.0.0 diff --git a/rules/linux/defense_evasion_process_traced_via_ptrace.yml b/rules/linux/defense_evasion_process_traced_via_ptrace.yml new file mode 100644 index 000000000..3db43d734 --- /dev/null +++ b/rules/linux/defense_evasion_process_traced_via_ptrace.yml @@ -0,0 +1,27 @@ +name: Process traced via ptrace +id: 992bc26b-b4c2-47d8-bf76-e7fb895caa1e +version: 1.0.0 +description: | + Identifies a process attaching to another process with ptrace. + PTRACE_ATTACH and PTRACE_SEIZE are used to inspect or modify another + process, including for injection and credential theft. Requests denied + by the kernel also match, since a blocked attempt carries the same intent + and often precedes a privilege escalation attempt. +labels: + tactic.id: TA0005 + tactic.name: Defense Evasion + tactic.ref: https://attack.mitre.org/tactics/TA0005/ + technique.id: T1055 + technique.name: Process Injection + technique.ref: https://attack.mitre.org/techniques/T1055/ +references: + - https://man7.org/linux/man-pages/man2/ptrace.2.html + +condition: > + ptrace_attach and ps.target.pid != 0 and ps.target.pid != evt.pid + +output: > + %ps.name attached to process %ps.target.pid via ptrace + +severity: high +min-engine-version: 3.0.0 diff --git a/rules/linux/execution_process_executed_from_temporary_directory.yml b/rules/linux/execution_process_executed_from_temporary_directory.yml new file mode 100644 index 000000000..ecd5711f3 --- /dev/null +++ b/rules/linux/execution_process_executed_from_temporary_directory.yml @@ -0,0 +1,25 @@ +name: Process executed from a temporary directory +id: 640b10f4-e9ad-487d-a04a-917595ab93c4 +version: 1.0.0 +description: | + Identifies programs executed from world-writable temporary directories. + Adversaries stage payloads in /tmp, /var/tmp, or /dev/shm to avoid writing + into more tightly controlled filesystem locations. +labels: + tactic.id: TA0002 + tactic.name: Execution + tactic.ref: https://attack.mitre.org/tactics/TA0002/ + technique.id: T1059 + technique.name: Command and Scripting Interpreter + technique.ref: https://attack.mitre.org/techniques/T1059/ +references: + - https://attack.mitre.org/techniques/T1059/ + +condition: > + execve and ps.exe matches world_writable_exec_paths + +output: > + %ps.name executed %ps.exe from a world-writable directory + +severity: high +min-engine-version: 3.0.0 diff --git a/rules/linux/impact_process_terminated_via_sigkill.yml b/rules/linux/impact_process_terminated_via_sigkill.yml new file mode 100644 index 000000000..e2d5d5256 --- /dev/null +++ b/rules/linux/impact_process_terminated_via_sigkill.yml @@ -0,0 +1,32 @@ +name: Process terminated via SIGKILL +id: cada79b8-9cfe-4ee9-8240-d3550e0859a7 +version: 1.0.0 +description: | + Identifies a process sending SIGKILL to a different process. + Adversaries use SIGKILL to stop security agents, terminate forensic + tools, or disrupt services that cannot catch the signal. Requests denied + by the kernel also match, since a blocked attempt carries the same intent. + + The target keeps the pid_t sign of the syscall argument, so a negative + target denotes a process group and -1 denotes every process the caller is + permitted to signal. Narrow this rule with ps.target.pid < 0 to look only + for those broader terminations. Signals a process sends to its own group + are out of scope. +labels: + tactic.id: TA0040 + tactic.name: Impact + tactic.ref: https://attack.mitre.org/tactics/TA0040/ + technique.id: T1489 + technique.name: Service Stop + technique.ref: https://attack.mitre.org/techniques/T1489/ +references: + - https://man7.org/linux/man-pages/man2/kill.2.html + +condition: > + sigkill and ps.target.pid != 0 and ps.target.pid != evt.pid + +output: > + %ps.name sent SIGKILL to target %ps.target.pid + +severity: medium +min-engine-version: 3.0.0 diff --git a/rules/linux/macros/macros.yml b/rules/linux/macros/macros.yml new file mode 100644 index 000000000..9b87619fc --- /dev/null +++ b/rules/linux/macros/macros.yml @@ -0,0 +1,57 @@ +- macro: execve + expr: evt.name = 'execve' and evt.retval = 0 + description: | + Matches a successful program execution. Failed execve calls are excluded + because no new program image is mapped. + +- macro: ptrace + expr: evt.name = 'ptrace' + description: | + Matches a ptrace request, whether or not the kernel permitted it. + +- macro: ptrace_attach + expr: ptrace and (ps.ptrace.request = 16 or ps.ptrace.request = 16902) + description: | + Matches a PTRACE_ATTACH (16) or PTRACE_SEIZE (16902) request. Denied + attempts are in scope because they reveal the same intent. + +- macro: send_signal + expr: evt.name = 'kill' + description: | + Matches a kill syscall, whether or not the signal was delivered. + +- macro: sigkill + expr: send_signal and ps.signal = 9 + description: | + Matches a SIGKILL request. Denied attempts are in scope because they + reveal the same intent. + +- macro: connect + expr: evt.name = 'connect' + description: | + Matches a socket connect syscall. Refused and unreachable destinations + are in scope because they still indicate outbound intent. + +- macro: world_writable_exec_paths + list: + - '/tmp/*' + - '/var/tmp/*' + - '/dev/shm/*' + description: | + Common world-writable directories used to stage and execute payloads. + +- macro: script_interpreters + list: + - bash + - sh + - dash + - zsh + - python + - python3 + - perl + - php + - ruby + - node + - lua + description: | + Script interpreter process names commonly used for staging outbound access.