From 5129724a594493329b8f72c1068a89df7c5e8fee Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Mon, 23 Dec 2024 16:00:46 +0100 Subject: [PATCH] fix(callstack): Fix private allocation size Attackers exploit the memory of the benign module (dll) to inject their own shellcode. When the memory of the DLL is tampered, the backing memory pages release the shared attribute and become private pages. If the callstack contains such memory regions, it is a strong indicator of module stomping. To accomplish the detection of stomped modules, we use the `QueryWorkingSet` API to examine the pages starting from the stack return address. --- pkg/filter/filter_test.go | 96 ++++++++++++++++++++++++++++++++++++- pkg/kevent/callstack.go | 44 +++++++++++++++-- pkg/sys/mem.go | 72 ++++++++++++++++++++++++++++ pkg/sys/syscall.go | 3 ++ pkg/sys/zsyscall_windows.go | 9 ++++ pkg/util/va/region.go | 15 ++++++ pkg/util/va/region_test.go | 13 +++++ 7 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 pkg/sys/mem.go diff --git a/pkg/filter/filter_test.go b/pkg/filter/filter_test.go index d20dde011..9179143e2 100644 --- a/pkg/filter/filter_test.go +++ b/pkg/filter/filter_test.go @@ -29,8 +29,10 @@ import ( "github.com/rabbitstack/fibratus/pkg/pe" "github.com/rabbitstack/fibratus/pkg/ps" pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" + "github.com/rabbitstack/fibratus/pkg/sys" "github.com/rabbitstack/fibratus/pkg/util/signature" "github.com/rabbitstack/fibratus/pkg/util/va" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" @@ -39,6 +41,7 @@ import ( "path/filepath" "testing" "time" + "unsafe" ) var cfg = &config.Config{ @@ -351,7 +354,7 @@ func TestThreadFilter(t *testing.T) { {`thread.callstack.modules in ('C:\\WINDOWS\\System32\\KERNELBASE.dll', 'C:\\Program Files\\JetBrains\\GoLand 2021.2.3\\jbr\\bin\\java.dll')`, true}, {`thread.callstack.symbols imatches ('KERNELBASE.dll!CreateProcess*', 'Java_java_lang_ProcessImpl_create')`, true}, {`thread.callstack.protections in ('RWX')`, true}, - {`thread.callstack.allocation_sizes > 500`, true}, + {`thread.callstack.allocation_sizes > 0`, false}, {`length(thread.callstack.callsite_leading_assembly) > 0`, true}, {`thread.callstack.callsite_trailing_assembly matches ('*mov r10, rcx mov eax, 0x* syscall*')`, true}, {`thread.callstack.is_unbacked`, true}, @@ -366,7 +369,7 @@ func TestThreadFilter(t *testing.T) { {`thread.callstack[0].is_unbacked = true`, true}, {`thread.callstack[2].is_unbacked = false`, true}, {`thread.callstack[kernelbase.dll].symbol = 'CreateProcessW'`, true}, - {`thread.callstack[1].allocation_size >= 400`, true}, + {`thread.callstack[1].allocation_size = 0`, true}, {`thread.callstack[1].protection = 'RWX'`, true}, {`thread.callstack[1].callsite_trailing_assembly matches ('*mov r10, rcx mov eax, 0x* syscall*')`, true}, } @@ -382,6 +385,77 @@ func TestThreadFilter(t *testing.T) { t.Errorf("%d. %q thread filter mismatch: exp=%t got=%t", i, tt.filter, tt.matches, matches) } } + + // spawn a new process + var si windows.StartupInfo + si.Flags = windows.STARTF_USESHOWWINDOW + var pi windows.ProcessInformation + + argv := windows.StringToUTF16Ptr(filepath.Join(os.Getenv("windir"), "regedit.exe")) + + err = windows.CreateProcess( + nil, + argv, + nil, + nil, + true, + 0, + nil, + nil, + &si, + &pi) + require.NoError(t, err) + + for { + if sys.IsProcessRunning(pi.Process) { + break + } + time.Sleep(time.Millisecond * 100) + log.Infof("%d pid not yet ready", pi.ProcessId) + } + defer windows.TerminateProcess(pi.Process, 0) + + kevt.PID = pi.ProcessId + + // try until a valid address is returned + // or fail if max attempts are exhausted + j := 50 + ntdll := getNtdllAddress(pi.ProcessId) + for ntdll == 0 && j > 0 { + ntdll = getNtdllAddress(pi.ProcessId) + time.Sleep(time.Millisecond * 250) + j-- + } + + // overwrite ntdll address with dummy bytes + // to reproduce module stomping technique + var protect uint32 + require.NoError(t, windows.VirtualProtectEx(pi.Process, ntdll, uintptr(len(insns)), windows.PAGE_EXECUTE_READWRITE, &protect)) + + var n uintptr + require.NoError(t, windows.WriteProcessMemory(pi.Process, ntdll, &insns[0], uintptr(len(insns)), &n)) + + kevt.Callstack.PushFrame(kevent.Frame{Addr: va.Address(ntdll), Offset: 0, Symbol: "?", Module: "C:\\Windows\\System32\\ntdll.dll"}) + + var tests1 = []struct { + filter string + matches bool + }{ + + {`thread.callstack.allocation_sizes > 0`, true}, + } + + for i, tt := range tests1 { + f := New(tt.filter, cfg) + err := f.Compile() + if err != nil { + t.Fatal(err) + } + matches := f.Run(kevt) + if matches != tt.matches { + t.Errorf("%d. %q thread filter mismatch: exp=%t got=%t", i, tt.filter, tt.matches, matches) + } + } } func TestFileFilter(t *testing.T) { @@ -1208,3 +1282,21 @@ func BenchmarkFilterRun(b *testing.B) { f.Run(kevt) } } + +func getNtdllAddress(pid uint32) uintptr { + var moduleHandles [1024]windows.Handle + var cbNeeded uint32 + proc, err := windows.OpenProcess(windows.PROCESS_ALL_ACCESS, false, pid) + if err != nil { + return 0 + } + if err := windows.EnumProcessModules(proc, &moduleHandles[0], 1024, &cbNeeded); err != nil { + return 0 + } + moduleHandle := moduleHandles[1] + var moduleInfo windows.ModuleInfo + if err := windows.GetModuleInformation(proc, moduleHandle, &moduleInfo, uint32(unsafe.Sizeof(moduleInfo))); err != nil { + return 0 + } + return moduleInfo.BaseOfDll +} diff --git a/pkg/kevent/callstack.go b/pkg/kevent/callstack.go index a66218294..026f43ad5 100644 --- a/pkg/kevent/callstack.go +++ b/pkg/kevent/callstack.go @@ -27,6 +27,7 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/arch/x86/x86asm" "golang.org/x/sys/windows" + "os" "path/filepath" "strconv" "strings" @@ -45,6 +46,11 @@ var callstackFlushes = expvar.NewInt("callstack.flushes") // unbacked represents the identifier for unbacked regions in stack frames const unbacked = "unbacked" +var pageSize = uint64(os.Getpagesize()) + +// buildNumber stores the Windows OS build number +var _, _, buildNumber = windows.RtlGetNtVersionNumbers() + // Frame describes a single stack frame. type Frame struct { Addr va.Address // return address @@ -57,18 +63,43 @@ type Frame struct { // from unbacked memory section func (f Frame) IsUnbacked() bool { return f.Module == unbacked } -// AllocationSize calculates the region size -// to which the frame return address pertains if -// the memory pages within the region are private. +// AllocationSize calculates the private region size +// to which the frame return address pertains if the +// memory pages within the region are private and +// non-shareable pages. func (f *Frame) AllocationSize(proc windows.Handle) uint64 { if f.Addr.InSystemRange() { return 0 } + r := va.VirtualQuery(proc, f.Addr.Uint64()) - if r == nil || r.Type != va.MemPrivate { + if r == nil || (r.State != windows.MEM_COMMIT || r.Protect == windows.PAGE_NOACCESS || r.Type != va.MemImage) { return 0 } - return r.Size + + var size uint64 + + // traverse all pages in the region + for n := uint64(0); n < r.Size; n += pageSize { + addr := f.Addr.Inc(n) + ws := va.QueryWorkingSet(proc, addr.Uint64()) + if ws == nil || !ws.Valid() { + continue + } + + // use SharedOriginal after RS3/1709 + if buildNumber >= 16299 { + if !ws.SharedOriginal() { + size += pageSize + } + } else { + if !ws.Shared() { + size += pageSize + } + } + } + + return size } // Protection resolves the memory protection @@ -93,6 +124,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string { if f.Addr.InSystemRange() { return "" } + size := uint(512) base := f.Addr.Uintptr() if pre { @@ -102,6 +134,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string { if len(b) == 0 || va.Zeroed(b) { return "" } + var asm strings.Builder for i := 0; i < len(b); { ins, err := x86asm.Decode(b[i:], 64) @@ -112,6 +145,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string { asm.WriteRune(' ') i += ins.Len } + return asm.String() } diff --git a/pkg/sys/mem.go b/pkg/sys/mem.go new file mode 100644 index 000000000..bb6598310 --- /dev/null +++ b/pkg/sys/mem.go @@ -0,0 +1,72 @@ +/* + * Copyright 2021-2022 by Nedim Sabic Sabic + * 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 sys + +// MemoryWorkingSetExInformation describes the attributes of the memory region. +type MemoryWorkingSetExInformation struct { + VirtualAddress uintptr + VirtualAttributes MemoryWorkingSetExBlock +} + +type MemoryWorkingSetExBlock uintptr + +// Valid if this bit is 1, the subsequent members are valid. Otherwise, they should be ignored. +func (b MemoryWorkingSetExBlock) Valid() bool { + return b&1 != 0 +} + +// ShareCount specifies the number of processes that share this page. The maximum value of this member is 7. +func (b MemoryWorkingSetExBlock) ShareCount() uintptr { + return (uintptr(b) >> 1) & ((1 << 3) - 1) +} + +// Win32Protection specifies the memory protection attributes of the page. +func (b MemoryWorkingSetExBlock) Win32Protection() uintptr { + return (uintptr(b) >> 4) & ((1 << 11) - 1) +} + +// Shared evaluates to true if the page can be shared or false otherwise. +func (b MemoryWorkingSetExBlock) Shared() bool { + return b&(1<<15) != 0 +} + +// Node represents the NUMA node. The maximum value of this member is 63. +func (b MemoryWorkingSetExBlock) Node() uintptr { + return (uintptr(b) >> 16) & ((1 << 6) - 1) +} + +// Locked returns true if the virtual page is locked in physical memory. +func (b MemoryWorkingSetExBlock) Locked() bool { + return b&(1<<15) != 0 +} + +// LargePage returns true if the page is a large page. +func (b MemoryWorkingSetExBlock) LargePage() bool { + return b&(1<<16) != 0 +} + +// SharedOriginal evaluates to true if the page can be shared or false otherwise. +func (b MemoryWorkingSetExBlock) SharedOriginal() bool { + return b&(1<<30) != 0 +} + +// Bad indicates the page has been reported as bad. +func (b MemoryWorkingSetExBlock) Bad() bool { + return b&(1<<31) != 0 +} diff --git a/pkg/sys/syscall.go b/pkg/sys/syscall.go index 9e185b935..aeac71b66 100644 --- a/pkg/sys/syscall.go +++ b/pkg/sys/syscall.go @@ -82,3 +82,6 @@ package sys //sys ShellNotifyIcon(msg NotifyIconMessage, data *NotifyIconData) (err error) [failretval==0] = shell32.Shell_NotifyIconW //sys SHGetStockIconInfo(id int32, flags uint32, icon *ShStockIcon) (err error) [failretval!=0] = shell32.SHGetStockIconInfo //sys FreeConsole() = kernel32.FreeConsole + +// Memory functions +//sys QueryWorkingSet(handle windows.Handle, ws *MemoryWorkingSetExInformation, size uint32) (err error) = psapi.QueryWorkingSetEx diff --git a/pkg/sys/zsyscall_windows.go b/pkg/sys/zsyscall_windows.go index c22f380c4..ca8c39bd3 100644 --- a/pkg/sys/zsyscall_windows.go +++ b/pkg/sys/zsyscall_windows.go @@ -73,6 +73,7 @@ var ( procEnumDeviceDrivers = modpsapi.NewProc("EnumDeviceDrivers") procGetDeviceDriverFileNameW = modpsapi.NewProc("GetDeviceDriverFileNameW") procGetMappedFileNameW = modpsapi.NewProc("GetMappedFileNameW") + procQueryWorkingSetEx = modpsapi.NewProc("QueryWorkingSetEx") procSHGetStockIconInfo = modshell32.NewProc("SHGetStockIconInfo") procShell_NotifyIconW = modshell32.NewProc("Shell_NotifyIconW") procPathIsDirectoryW = modshlwapi.NewProc("PathIsDirectoryW") @@ -265,6 +266,14 @@ func GetMappedFileName(handle windows.Handle, addr uintptr, filename *uint16, si return } +func QueryWorkingSet(handle windows.Handle, ws *MemoryWorkingSetExInformation, size uint32) (err error) { + r1, _, e1 := syscall.Syscall(procQueryWorkingSetEx.Addr(), 3, uintptr(handle), uintptr(unsafe.Pointer(ws)), uintptr(size)) + if r1 == 0 { + err = errnoErr(e1) + } + return +} + func SHGetStockIconInfo(id int32, flags uint32, icon *ShStockIcon) (err error) { r1, _, e1 := syscall.Syscall(procSHGetStockIconInfo.Addr(), 3, uintptr(id), uintptr(flags), uintptr(unsafe.Pointer(icon))) if r1 != 0 { diff --git a/pkg/util/va/region.go b/pkg/util/va/region.go index 594f55304..e6616aa01 100644 --- a/pkg/util/va/region.go +++ b/pkg/util/va/region.go @@ -60,6 +60,7 @@ type RegionInfo struct { BaseAddr uint64 Size uint64 proc windows.Handle + State uint32 } // IsMapped determines if the region is backed by the section object. @@ -178,9 +179,23 @@ func VirtualQuery(process windows.Handle, addr uint64) *RegionInfo { BaseAddr: addr, Size: uint64(mem.RegionSize), proc: process, + State: mem.State, } } +// QueryWorkingSet retrieves extended information about +// the pages at specific virtual addresses in the address +// space of the specified process. +func QueryWorkingSet(process windows.Handle, addr uint64) *sys.MemoryWorkingSetExBlock { + var ws sys.MemoryWorkingSetExInformation + ws.VirtualAddress = uintptr(addr) + err := sys.QueryWorkingSet(process, &ws, uint32(unsafe.Sizeof(sys.MemoryWorkingSetExInformation{}))) + if err != nil { + return nil + } + return &ws.VirtualAttributes +} + // Remove removes the process handle from cache and closes it. // It returns true if the handle was closed successfully. func (p *RegionProber) Remove(pid uint32) bool { diff --git a/pkg/util/va/region_test.go b/pkg/util/va/region_test.go index c6d4e3b65..74be473b2 100644 --- a/pkg/util/va/region_test.go +++ b/pkg/util/va/region_test.go @@ -125,6 +125,19 @@ func TestReadArea(t *testing.T) { require.True(t, Zeroed(zeroArea)) } +func TestQueryWorkingSet(t *testing.T) { + addr, err := getModuleBaseAddress(uint32(os.Getpid())) + require.NoError(t, err) + + b := QueryWorkingSet(windows.CurrentProcess(), uint64(addr)) + require.NotNil(t, b) + + require.True(t, b.Valid()) + require.False(t, b.Bad()) + require.True(t, b.SharedOriginal()) + require.True(t, (b.Win32Protection()&windows.PAGE_READONLY) != 0) +} + func getModuleBaseAddress(pid uint32) (uintptr, error) { var moduleHandles [1024]windows.Handle var cbNeeded uint32