Skip to content

Commit ba97fe8

Browse files
committed
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.
1 parent 0799c5e commit ba97fe8

File tree

7 files changed

+245
-7
lines changed

7 files changed

+245
-7
lines changed

pkg/filter/filter_test.go

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import (
2929
"github.com/rabbitstack/fibratus/pkg/pe"
3030
"github.com/rabbitstack/fibratus/pkg/ps"
3131
pstypes "github.com/rabbitstack/fibratus/pkg/ps/types"
32+
"github.com/rabbitstack/fibratus/pkg/sys"
3233
"github.com/rabbitstack/fibratus/pkg/util/signature"
3334
"github.com/rabbitstack/fibratus/pkg/util/va"
35+
log "github.com/sirupsen/logrus"
3436
"github.com/stretchr/testify/assert"
3537
"github.com/stretchr/testify/require"
3638
"golang.org/x/sys/windows"
@@ -39,6 +41,7 @@ import (
3941
"path/filepath"
4042
"testing"
4143
"time"
44+
"unsafe"
4245
)
4346

4447
var cfg = &config.Config{
@@ -351,7 +354,7 @@ func TestThreadFilter(t *testing.T) {
351354
{`thread.callstack.modules in ('C:\\WINDOWS\\System32\\KERNELBASE.dll', 'C:\\Program Files\\JetBrains\\GoLand 2021.2.3\\jbr\\bin\\java.dll')`, true},
352355
{`thread.callstack.symbols imatches ('KERNELBASE.dll!CreateProcess*', 'Java_java_lang_ProcessImpl_create')`, true},
353356
{`thread.callstack.protections in ('RWX')`, true},
354-
{`thread.callstack.allocation_sizes > 500`, true},
357+
{`thread.callstack.allocation_sizes > 0`, false},
355358
{`length(thread.callstack.callsite_leading_assembly) > 0`, true},
356359
{`thread.callstack.callsite_trailing_assembly matches ('*mov r10, rcx mov eax, 0x* syscall*')`, true},
357360
{`thread.callstack.is_unbacked`, true},
@@ -366,7 +369,7 @@ func TestThreadFilter(t *testing.T) {
366369
{`thread.callstack[0].is_unbacked = true`, true},
367370
{`thread.callstack[2].is_unbacked = false`, true},
368371
{`thread.callstack[kernelbase.dll].symbol = 'CreateProcessW'`, true},
369-
{`thread.callstack[1].allocation_size >= 400`, true},
372+
{`thread.callstack[1].allocation_size = 0`, true},
370373
{`thread.callstack[1].protection = 'RWX'`, true},
371374
{`thread.callstack[1].callsite_trailing_assembly matches ('*mov r10, rcx mov eax, 0x* syscall*')`, true},
372375
}
@@ -382,6 +385,77 @@ func TestThreadFilter(t *testing.T) {
382385
t.Errorf("%d. %q thread filter mismatch: exp=%t got=%t", i, tt.filter, tt.matches, matches)
383386
}
384387
}
388+
389+
// spawn a new process
390+
var si windows.StartupInfo
391+
si.Flags = windows.STARTF_USESHOWWINDOW
392+
var pi windows.ProcessInformation
393+
394+
argv := windows.StringToUTF16Ptr(filepath.Join(os.Getenv("windir"), "regedit.exe"))
395+
396+
err = windows.CreateProcess(
397+
nil,
398+
argv,
399+
nil,
400+
nil,
401+
true,
402+
0,
403+
nil,
404+
nil,
405+
&si,
406+
&pi)
407+
require.NoError(t, err)
408+
409+
for {
410+
if sys.IsProcessRunning(pi.Process) {
411+
break
412+
}
413+
time.Sleep(time.Millisecond * 100)
414+
log.Infof("%d pid not yet ready", pi.ProcessId)
415+
}
416+
defer windows.TerminateProcess(pi.Process, 0)
417+
418+
kevt.PID = pi.ProcessId
419+
420+
// try until a valid address is returned
421+
// or fail if max attempts are exhausted
422+
j := 50
423+
ntdll, err := getNtdllAddress(pi.ProcessId)
424+
for ntdll == 0 && j > 0 {
425+
ntdll, err = getNtdllAddress(pi.ProcessId)
426+
time.Sleep(time.Millisecond * 250)
427+
j--
428+
}
429+
430+
// overwrite ntdll address with dummy bytes
431+
// to reproduce module stomping technique
432+
var protect uint32
433+
require.NoError(t, windows.VirtualProtectEx(pi.Process, ntdll, uintptr(len(insns)), windows.PAGE_EXECUTE_READWRITE, &protect))
434+
435+
var n uintptr
436+
require.NoError(t, windows.WriteProcessMemory(pi.Process, ntdll, &insns[0], uintptr(len(insns)), &n))
437+
438+
kevt.Callstack.PushFrame(kevent.Frame{Addr: va.Address(ntdll), Offset: 0, Symbol: "?", Module: "C:\\Windows\\System32\\ntdll.dll"})
439+
440+
var tests1 = []struct {
441+
filter string
442+
matches bool
443+
}{
444+
445+
{`thread.callstack.allocation_sizes > 0`, true},
446+
}
447+
448+
for i, tt := range tests1 {
449+
f := New(tt.filter, cfg)
450+
err := f.Compile()
451+
if err != nil {
452+
t.Fatal(err)
453+
}
454+
matches := f.Run(kevt)
455+
if matches != tt.matches {
456+
t.Errorf("%d. %q thread filter mismatch: exp=%t got=%t", i, tt.filter, tt.matches, matches)
457+
}
458+
}
385459
}
386460

387461
func TestFileFilter(t *testing.T) {
@@ -1208,3 +1282,21 @@ func BenchmarkFilterRun(b *testing.B) {
12081282
f.Run(kevt)
12091283
}
12101284
}
1285+
1286+
func getNtdllAddress(pid uint32) (uintptr, error) {
1287+
var moduleHandles [1024]windows.Handle
1288+
var cbNeeded uint32
1289+
proc, err := windows.OpenProcess(windows.PROCESS_ALL_ACCESS, false, pid)
1290+
if err != nil {
1291+
return 0, err
1292+
}
1293+
if err := windows.EnumProcessModules(proc, &moduleHandles[0], 1024, &cbNeeded); err != nil {
1294+
return 0, err
1295+
}
1296+
moduleHandle := moduleHandles[1]
1297+
var moduleInfo windows.ModuleInfo
1298+
if err := windows.GetModuleInformation(proc, moduleHandle, &moduleInfo, uint32(unsafe.Sizeof(moduleInfo))); err != nil {
1299+
return 0, err
1300+
}
1301+
return moduleInfo.BaseOfDll, nil
1302+
}

pkg/kevent/callstack.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
log "github.com/sirupsen/logrus"
2828
"golang.org/x/arch/x86/x86asm"
2929
"golang.org/x/sys/windows"
30+
"os"
3031
"path/filepath"
3132
"strconv"
3233
"strings"
@@ -45,6 +46,11 @@ var callstackFlushes = expvar.NewInt("callstack.flushes")
4546
// unbacked represents the identifier for unbacked regions in stack frames
4647
const unbacked = "unbacked"
4748

49+
var pageSize = uint64(os.Getpagesize())
50+
51+
// buildNumber stores the Windows OS build number
52+
var _, _, buildNumber = windows.RtlGetNtVersionNumbers()
53+
4854
// Frame describes a single stack frame.
4955
type Frame struct {
5056
Addr va.Address // return address
@@ -57,18 +63,43 @@ type Frame struct {
5763
// from unbacked memory section
5864
func (f Frame) IsUnbacked() bool { return f.Module == unbacked }
5965

60-
// AllocationSize calculates the region size
61-
// to which the frame return address pertains if
62-
// the memory pages within the region are private.
66+
// AllocationSize calculates the private region size
67+
// to which the frame return address pertains if the
68+
// memory pages within the region are private and
69+
// non-shareable pages.
6370
func (f *Frame) AllocationSize(proc windows.Handle) uint64 {
6471
if f.Addr.InSystemRange() {
6572
return 0
6673
}
74+
6775
r := va.VirtualQuery(proc, f.Addr.Uint64())
68-
if r == nil || r.Type != va.MemPrivate {
76+
if r == nil || (r.State != windows.MEM_COMMIT || r.Protect == windows.PAGE_NOACCESS || r.Type != va.MemImage) {
6977
return 0
7078
}
71-
return r.Size
79+
80+
var size uint64
81+
82+
// traverse all pages in the region
83+
for n := uint64(0); n < r.Size; n += pageSize {
84+
addr := f.Addr.Inc(n)
85+
ws := va.QueryWorkingSet(proc, addr.Uint64())
86+
if ws == nil || !ws.Valid() {
87+
continue
88+
}
89+
90+
// use SharedOriginal after RS3/1709
91+
if buildNumber >= 16299 {
92+
if !ws.SharedOriginal() {
93+
size += pageSize
94+
}
95+
} else {
96+
if !ws.Shared() {
97+
size += pageSize
98+
}
99+
}
100+
}
101+
102+
return size
72103
}
73104

74105
// Protection resolves the memory protection
@@ -93,6 +124,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string {
93124
if f.Addr.InSystemRange() {
94125
return ""
95126
}
127+
96128
size := uint(512)
97129
base := f.Addr.Uintptr()
98130
if pre {
@@ -102,6 +134,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string {
102134
if len(b) == 0 || va.Zeroed(b) {
103135
return ""
104136
}
137+
105138
var asm strings.Builder
106139
for i := 0; i < len(b); {
107140
ins, err := x86asm.Decode(b[i:], 64)
@@ -112,6 +145,7 @@ func (f *Frame) CallsiteAssembly(proc windows.Handle, pre bool) string {
112145
asm.WriteRune(' ')
113146
i += ins.Len
114147
}
148+
115149
return asm.String()
116150
}
117151

pkg/sys/mem.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2021-2022 by Nedim Sabic Sabic
3+
* https://www.fibratus.io
4+
* All Rights Reserved.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package sys
20+
21+
// MemoryWorkingSetExInformation describes the attributes of the memory region.
22+
type MemoryWorkingSetExInformation struct {
23+
VirtualAddress uintptr
24+
VirtualAttributes MemoryWorkingSetExBlock
25+
}
26+
27+
type MemoryWorkingSetExBlock uintptr
28+
29+
// Valid if this bit is 1, the subsequent members are valid. Otherwise, they should be ignored.
30+
func (b MemoryWorkingSetExBlock) Valid() bool {
31+
return b&1 != 0
32+
}
33+
34+
// ShareCount specifies the number of processes that share this page. The maximum value of this member is 7.
35+
func (b MemoryWorkingSetExBlock) ShareCount() uintptr {
36+
return (uintptr(b) >> 1) & ((1 << 3) - 1)
37+
}
38+
39+
// Win32Protection specifies the memory protection attributes of the page.
40+
func (b MemoryWorkingSetExBlock) Win32Protection() uintptr {
41+
return (uintptr(b) >> 4) & ((1 << 11) - 1)
42+
}
43+
44+
// Shared evaluates to true if the page can be shared or false otherwise.
45+
func (b MemoryWorkingSetExBlock) Shared() bool {
46+
return b&(1<<15) != 0
47+
}
48+
49+
// Node represents the NUMA node. The maximum value of this member is 63.
50+
func (b MemoryWorkingSetExBlock) Node() uintptr {
51+
return (uintptr(b) >> 16) & ((1 << 6) - 1)
52+
}
53+
54+
// Locked returns true if the virtual page is locked in physical memory.
55+
func (b MemoryWorkingSetExBlock) Locked() bool {
56+
return b&(1<<15) != 0
57+
}
58+
59+
// LargePage returns true if the page is a large page.
60+
func (b MemoryWorkingSetExBlock) LargePage() bool {
61+
return b&(1<<16) != 0
62+
}
63+
64+
// SharedOriginal evaluates to true if the page can be shared or false otherwise.
65+
func (b MemoryWorkingSetExBlock) SharedOriginal() bool {
66+
return b&(1<<30) != 0
67+
}
68+
69+
// Bad indicates the page has been reported as bad.
70+
func (b MemoryWorkingSetExBlock) Bad() bool {
71+
return b&(1<<31) != 0
72+
}

pkg/sys/syscall.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,6 @@ package sys
8282
//sys ShellNotifyIcon(msg NotifyIconMessage, data *NotifyIconData) (err error) [failretval==0] = shell32.Shell_NotifyIconW
8383
//sys SHGetStockIconInfo(id int32, flags uint32, icon *ShStockIcon) (err error) [failretval!=0] = shell32.SHGetStockIconInfo
8484
//sys FreeConsole() = kernel32.FreeConsole
85+
86+
// Memory functions
87+
//sys QueryWorkingSet(handle windows.Handle, ws *MemoryWorkingSetExInformation, size uint32) (err error) = psapi.QueryWorkingSetEx

pkg/sys/zsyscall_windows.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/util/va/region.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type RegionInfo struct {
6060
BaseAddr uint64
6161
Size uint64
6262
proc windows.Handle
63+
State uint32
6364
}
6465

6566
// IsMapped determines if the region is backed by the section object.
@@ -178,9 +179,23 @@ func VirtualQuery(process windows.Handle, addr uint64) *RegionInfo {
178179
BaseAddr: addr,
179180
Size: uint64(mem.RegionSize),
180181
proc: process,
182+
State: mem.State,
181183
}
182184
}
183185

186+
// QueryWorkingSet retrieves extended information about
187+
// the pages at specific virtual addresses in the address
188+
// space of the specified process.
189+
func QueryWorkingSet(process windows.Handle, addr uint64) *sys.MemoryWorkingSetExBlock {
190+
var ws sys.MemoryWorkingSetExInformation
191+
ws.VirtualAddress = uintptr(addr)
192+
err := sys.QueryWorkingSet(process, &ws, uint32(unsafe.Sizeof(sys.MemoryWorkingSetExInformation{})))
193+
if err != nil {
194+
return nil
195+
}
196+
return &ws.VirtualAttributes
197+
}
198+
184199
// Remove removes the process handle from cache and closes it.
185200
// It returns true if the handle was closed successfully.
186201
func (p *RegionProber) Remove(pid uint32) bool {

pkg/util/va/region_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ func TestReadArea(t *testing.T) {
125125
require.True(t, Zeroed(zeroArea))
126126
}
127127

128+
func TestQueryWorkingSet(t *testing.T) {
129+
addr, err := getModuleBaseAddress(uint32(os.Getpid()))
130+
require.NoError(t, err)
131+
132+
b := QueryWorkingSet(windows.CurrentProcess(), uint64(addr))
133+
require.NotNil(t, b)
134+
135+
require.True(t, b.Valid())
136+
require.False(t, b.Bad())
137+
require.True(t, b.SharedOriginal())
138+
require.True(t, (b.Win32Protection()&windows.PAGE_READONLY) != 0)
139+
}
140+
128141
func getModuleBaseAddress(pid uint32) (uintptr, error) {
129142
var moduleHandles [1024]windows.Handle
130143
var cbNeeded uint32

0 commit comments

Comments
 (0)