using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Collections.Concurrent; using System.Management; using System.Runtime.InteropServices; using WebmrAPI.Configuration; using WebmrAPI.Exceptions; using WebmrAPI.Models; namespace WebmrAPI.Services { public class ProcessMonitor : IHostedService, IDisposable { private DateTime _modifyTimestamp = DateTime.UtcNow; private Timer? _timer; private readonly ILogger _logger; private readonly AgentSettings _agentSettings; private readonly object _lock = new object(); private ConcurrentDictionary _processesBuffer = new(); private ConcurrentDictionary> _memoryRegionsBuffer = new(); public DateTime LastModifiedTimestamp { get { lock (_lock) { return _modifyTimestamp; } } private set { _modifyTimestamp = value; } } public IEnumerable Processes { get { lock (_lock) { return _processesBuffer.Values.ToList(); } } } public ProcessMonitor(ILogger logger, IOptions appSettings) { _logger = logger; _agentSettings = appSettings.Value.Agent; } public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("ProcessMonitor started. Scan interval: {ScanIntervalSeconds} seconds.", _agentSettings.ScanIntervalSeconds); // Запускаем первый скан сразу, затем по таймеру _timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(_agentSettings.ScanIntervalSeconds)); return Task.CompletedTask; } private void DoWork(object? state) { _logger.LogDebug("Initiating process scan..."); try { var currentProcesses = new Dictionary(); var currentMemoryRegions = new Dictionary>(); bool changedDetected = false; // --- 1. Get all running processes and their command lines using WMI --- // WMI is more reliable for getting command line of arbitrary processes var searcher = new ManagementObjectSearcher("SELECT ProcessId, Name, CommandLine FROM Win32_Process"); Dictionary wmiCommandLines = new Dictionary(); Dictionary wmiProcessNames = new Dictionary(); foreach (ManagementObject queryObj in searcher.Get()) { int pid = Convert.ToInt32(queryObj["ProcessId"]); string? commandLine = queryObj["CommandLine"]?.ToString(); string? processName = queryObj["Name"]?.ToString(); wmiCommandLines[pid] = commandLine ?? string.Empty; wmiProcessNames[pid] = processName ?? string.Empty; } // --- 2. Iterate through System.Diagnostics.Process objects --- System.Diagnostics.Process[] allProcesses = System.Diagnostics.Process.GetProcesses(); foreach (var process in allProcesses) { // Исключаем системные процессы, которые не имеют обычных модулей/памяти if (process.Id == 0 || process.Id == 4) { _logger.LogDebug("Skipping special system process {ProcessName} (PID: {PID}).", process.ProcessName, process.Id); process.Dispose(); // Всегда освобождаем Process объект continue; } try { string processName = wmiProcessNames.GetValueOrDefault(process.Id, process.ProcessName); // Prefer WMI name if available string commandLine = wmiCommandLines.GetValueOrDefault(process.Id, string.Empty); var processInfo = new ProcessInfo { ProcessId = process.Id, ProcessName = processName, VirtualMemorySize = process.VirtualMemorySize64, CommandLine = commandLine }; // Try to get BaseAddress from MainModule. This can still throw Access Denied. try { // Some processes (e.g., system processes like Idle, System) might not have a MainModule if (process.MainModule != null) { processInfo.BaseAddress = $"0x{process.MainModule.BaseAddress.ToInt64():X}"; } else { _logger.LogDebug("Process {ProcessName} (PID: {PID}) has no MainModule.", processName, process.Id); processInfo.BaseAddress = null; // Ensure it's explicitly null if not found } } catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 5) // Access Denied { // Ошибка 5: Access Denied. Ошибка 0x80004005 (которая также может быть Win32Exception с общим кодом) if (ex.NativeErrorCode == 5) { _logger.LogWarning("Access denied to MainModule for process {ProcessName} (PID: {PID}). BaseAddress will be null. Error: {ErrorMessage}", processName, process.Id, ex.Message); } else { // Для других Win32Exception, таких как "Unable to enumerate the process modules." (0x80004005) _logger.LogWarning(ex, "Win32Exception getting MainModule for process {ProcessName} (PID: {PID}). BaseAddress will be null.", processName, process.Id); } processInfo.BaseAddress = null; } catch (InvalidOperationException ex) { _logger.LogDebug(ex, "Could not get MainModule for process {ProcessName} (PID: {PID}). Process might have exited or has no MainModule.", processName, process.Id); processInfo.BaseAddress = null; } catch (Exception ex) { _logger.LogError(ex, "Unexpected error getting MainModule for process {ProcessName} (PID: {PID}).", processName, process.Id); processInfo.BaseAddress = null; } currentProcesses.Add(process.Id, processInfo); // --- 3. Scan memory regions for the target process OR new processes --- bool isTargetProcess = _agentSettings.TargetProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase); // We scan memory regions for: // 1. The specific target process (if _agentSettings.TargetProcessName is set) // 2. ALL processes if _agentSettings.TargetProcessName is empty/null // 3. Any newly detected process that wasn't in our previous buffer (so we get its initial memory layout) if (isTargetProcess || string.IsNullOrEmpty(_agentSettings.TargetProcessName) || !_processesBuffer.ContainsKey(process.Id)) { try { var regions = ScanMemoryRegionsForProcess(process); if (regions.Any()) { currentMemoryRegions.Add(process.Id, regions); } else { _logger.LogDebug("No memory regions found or accessible for process {ProcessName} (PID: {PID}).", processName, process.Id); } } catch (ProcessAccessDeniedException ex) { // Определяем список системных процессов, которые обычно отказывают в доступе string[] commonSystemProcesses = { "System", "System Idle Process", "Registry", "smss.exe", "csrss.exe", "wininit.exe", "services.exe", "lsass.exe", "winlogon.exe", "fontdrvhost.exe", "Memory Compression", "dwm.exe", "RuntimeBroker.exe", // Common UWP/System processes "msrdc.exe", "svchost.exe", // svchost can be varied, but often restricted "MsMpEng.exe", "MpDefenderCoreService.exe" // Windows Defender related }; // Проверяем, является ли процесс одним из обычно защищенных if (commonSystemProcesses.Contains(processName, StringComparer.OrdinalIgnoreCase)) { _logger.LogDebug(ex, "Access denied to memory regions for known system process {ProcessName} (PID: {PID}). This is expected.", processName, process.Id); } else { _logger.LogWarning(ex, "Access denied to memory regions for process {ProcessName} (PID: {PID}). Skipping memory scan for it. Elevated privileges needed?", processName, process.Id); } } // Other exceptions from ScanMemoryRegionsForProcess will be caught by outer catch } } catch (InvalidOperationException ex) { // Process might have exited between GetProcesses and accessing its properties _logger.LogDebug(ex, "Process {ProcessName} (PID: {PID}) exited during scan. Skipping.", process.ProcessName, process.Id); } catch (Exception ex) { _logger.LogError(ex, "Error scanning process {ProcessName} (PID: {PID}) for basic info or WMI data.", process.ProcessName, process.Id); } finally { // Dispose the Process object to free resources associated with the handle process.Dispose(); } } // --- 4. Compare with buffered data and update if changes detected --- lock (_lock) { // Check for changes in processes (new, exited, basic info changed) if (!AreProcessInfosEqual(_processesBuffer.Values, currentProcesses.Values)) { changedDetected = true; _logger.LogInformation("Process list or basic info changed."); } // Check for changes in memory regions for monitored processes // Only compare if currentMemoryRegions contains the PID foreach (var kvp in currentMemoryRegions) { // If process was previously monitored AND its regions are different OR if it's a new process with regions if (!_memoryRegionsBuffer.ContainsKey(kvp.Key) || !AreMemoryRegionsEqual(_memoryRegionsBuffer[kvp.Key], kvp.Value)) { changedDetected = true; _logger.LogInformation("Memory regions for PID {PID} changed.", kvp.Key); break; // No need to check further, change detected } } // If a process disappeared (was in old buffer but not in new), it also counts as a change if (_processesBuffer.Count != currentProcesses.Count) { // If counts are different, and we haven't already marked as changed by content, mark as changed // This handles processes exiting. if (!changedDetected) { // Only set if not already set by content change changedDetected = true; _logger.LogInformation("Process count changed (some processes might have exited)."); } } if (changedDetected) { _processesBuffer = new ConcurrentDictionary(currentProcesses); _memoryRegionsBuffer = new ConcurrentDictionary>(currentMemoryRegions); LastModifiedTimestamp = DateTime.UtcNow; _logger.LogInformation("Buffered data updated. New LastModifiedTimestamp: {Timestamp}", LastModifiedTimestamp); } else { _logger.LogDebug("No changes detected in processes or monitored memory regions."); } } } catch (Exception ex) { _logger.LogError(ex, "Unhandled error during process monitoring cycle."); } } /// /// Сканирует регионы памяти для конкретного процесса. /// private List ScanMemoryRegionsForProcess(System.Diagnostics.Process process) { var regions = new List(); IntPtr processHandle = IntPtr.Zero; try { // Open process with necessary access rights processHandle = WinApi.OpenProcess( WinApi.PROCESS_QUERY_INFORMATION | WinApi.PROCESS_VM_OPERATION | WinApi.PROCESS_VM_READ, // We also need VM_READ for VirtualQueryEx if it needs to access pages false, // Do not inherit handle (uint)process.Id ); if (processHandle == IntPtr.Zero) { int errorCode = Marshal.GetLastWin32Error(); if (errorCode == 5) // ERROR_ACCESS_DENIED { throw new ProcessAccessDeniedException($"Access denied to process {process.ProcessName} (PID: {process.Id}). Error code: {errorCode}"); } WinApi.ThrowWinApiErrorIfAny($"OpenProcess for PID {process.Id}"); return regions; // Should not reach here if ThrowWinApiErrorIfAny throws } IntPtr currentAddress = IntPtr.Zero; long maxAddress = long.MaxValue; // Scan full 64-bit address space if (Environment.Is64BitProcess) { // On 64-bit, the user-mode address space typically ends at 0x7FFFFFFFFFFF // No, maxAddress should be dynamic if needed, but for iteration it means "until VirtualQueryEx returns 0" // Or 0x7FFFFFFFFFFF (2^47 -1, which is the user-mode address space limit on 64-bit Windows) // For now, let VirtualQueryEx drive it. } while (true) { if (currentAddress.ToInt64() >= (long)0x7FFFFFFFFFFF) { _logger.LogDebug("Reached effective end of 64-bit user-mode address space (0x{Address:X}) for PID {PID}.", currentAddress.ToInt64(), process.Id); break; } WinApi.MEMORY_BASIC_INFORMATION mbi; // dwLength is the size of the buffer we pass in int result = WinApi.VirtualQueryEx(processHandle, currentAddress, out mbi, (uint)Marshal.SizeOf(typeof(WinApi.MEMORY_BASIC_INFORMATION))); if (result == 0) // VirtualQueryEx returns 0 on failure or end of address space { int errorCode = Marshal.GetLastWin32Error(); // ERROR_NO_MORE_FILES (18) - ожидаемо в конце адресного пространства // ERROR_INVALID_PARAMETER (87) - часто возникает на высоких адресах или невалидных if (errorCode != 0 && errorCode != 18 && errorCode != 87) // Добавляем 87 как ожидаемое завершение { _logger.LogWarning("VirtualQueryEx for PID {PID} at address 0x{Address:X} failed with unexpected error code: {ErrorCode}.", process.Id, currentAddress.ToInt64(), errorCode); } else if (errorCode == 87) { _logger.LogDebug("VirtualQueryEx for PID {PID} at address 0x{Address:X} failed with ERROR_INVALID_PARAMETER (87). End of scannable address space reached.", process.Id, currentAddress.ToInt64()); } break; // End of address space or an unrecoverable error } // Only add committed or reserved regions if (mbi.State == WinApi.MEM_COMMIT || mbi.State == WinApi.MEM_RESERVE) { regions.Add(new MemoryRegionInfo { BaseAddress = $"0x{mbi.BaseAddress.ToInt64():X}", RegionSize = (long)mbi.RegionSize.ToUInt64(), State = WinApi.GetMemoryStateString(mbi.State), Protect = WinApi.GetMemoryProtectString(mbi.Protect), Type = WinApi.GetMemoryTypeString(mbi.Type) }); } // Calculate next address to query from (BaseAddress + RegionSize) // Ensure we don't overflow or loop indefinitely on bad data long nextAddressLong = mbi.BaseAddress.ToInt64() + (long)mbi.RegionSize.ToUInt64(); if (nextAddressLong <= currentAddress.ToInt64()) // Handle potential infinite loop if RegionSize is 0 or negative { _logger.LogWarning("VirtualQueryEx for PID {PID} advanced by non-positive amount. Breaking loop.", process.Id); break; } currentAddress = new IntPtr(nextAddressLong); // Stop if we hit max address or overflow (which shouldn't happen with long.MaxValue for 64-bit) if (nextAddressLong >= maxAddress && maxAddress != long.MaxValue) break; // In case we ever set a specific maxAddress } } finally { if (processHandle != IntPtr.Zero) { WinApi.CloseHandle(processHandle); } } return regions; } /// /// Сравнивает два списка ProcessInfo для выявления изменений. /// private bool AreProcessInfosEqual(ICollection oldList, ICollection newList) { if (oldList.Count != newList.Count) return false; var oldDict = oldList.ToDictionary(p => p.ProcessId); var newDict = newList.ToDictionary(p => p.ProcessId); foreach (var newProc in newDict.Values) { if (!oldDict.TryGetValue(newProc.ProcessId, out var oldProc)) return false; // New process or process ID changed // Compare relevant fields. BaseAddress and CommandLine might change if process restarts or for some apps. // We consider them part of "basic info" that signals a change. if (oldProc.ProcessName != newProc.ProcessName || oldProc.BaseAddress != newProc.BaseAddress || oldProc.VirtualMemorySize != newProc.VirtualMemorySize || oldProc.CommandLine != newProc.CommandLine) { return false; } } return true; } /// /// Сравнивает два списка MemoryRegionInfo для выявления изменений. /// private bool AreMemoryRegionsEqual(List oldList, List newList) { if (oldList.Count != newList.Count) return false; // Sort by BaseAddress to ensure consistent comparison order var oldSorted = oldList.OrderBy(r => r.BaseAddress).ToList(); var newSorted = newList.OrderBy(r => r.BaseAddress).ToList(); for (int i = 0; i < oldSorted.Count; i++) { var oldRegion = oldSorted[i]; var newRegion = newSorted[i]; if (oldRegion.BaseAddress != newRegion.BaseAddress || oldRegion.RegionSize != newRegion.RegionSize || oldRegion.State != newRegion.State || oldRegion.Protect != newRegion.Protect || oldRegion.Type != newRegion.Type) { return false; } } return true; } public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("ProcessMonitor is stopping."); _timer?.Change(Timeout.Infinite, 0); return Task.CompletedTask; } public void Dispose() { _timer?.Dispose(); } public List? GetBufferedMemoryRegions(int pid) { lock (_lock) // Ensure thread-safe access { if (_memoryRegionsBuffer.TryGetValue(pid, out var regions)) { return new List(regions); // Return a copy } return null; } } } }