Skip to content

Commit

Permalink
fix(callstack): Fix private allocation size
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rabbitstack committed Dec 28, 2024
1 parent 8e81077 commit 97e5764
Show file tree
Hide file tree
Showing 7 changed files with 245 additions and 7 deletions.
96 changes: 94 additions & 2 deletions pkg/filter/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -39,6 +41,7 @@ import (
"path/filepath"
"testing"
"time"
"unsafe"
)

var cfg = &config.Config{
Expand Down Expand Up @@ -357,7 +360,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},
Expand All @@ -372,7 +375,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},
}
Expand All @@ -388,6 +391,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) {
Expand Down Expand Up @@ -1214,3 +1288,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
}
44 changes: 39 additions & 5 deletions pkg/kevent/callstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -112,6 +145,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string {
asm.WriteRune(' ')
i += ins.Len
}

return asm.String()
}

Expand Down
72 changes: 72 additions & 0 deletions pkg/sys/mem.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions pkg/sys/syscall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions pkg/sys/zsyscall_windows.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions pkg/util/va/region.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions pkg/util/va/region_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 97e5764

Please sign in to comment.