460 lines
23 KiB
C#
460 lines
23 KiB
C#
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<ProcessMonitor> _logger;
|
|
private readonly AgentSettings _agentSettings;
|
|
|
|
private readonly object _lock = new object();
|
|
private ConcurrentDictionary<int, ProcessInfo> _processesBuffer = new();
|
|
private ConcurrentDictionary<int, List<MemoryRegionInfo>> _memoryRegionsBuffer = new();
|
|
|
|
|
|
public DateTime LastModifiedTimestamp
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _modifyTimestamp;
|
|
}
|
|
}
|
|
|
|
private set
|
|
{
|
|
_modifyTimestamp = value;
|
|
}
|
|
}
|
|
|
|
public IEnumerable<ProcessInfo> Processes
|
|
{
|
|
get
|
|
{
|
|
lock (_lock)
|
|
{
|
|
return _processesBuffer.Values.ToList();
|
|
}
|
|
}
|
|
}
|
|
|
|
public ProcessMonitor(ILogger<ProcessMonitor> logger, IOptions<AppSettings> 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<int, ProcessInfo>();
|
|
var currentMemoryRegions = new Dictionary<int, List<MemoryRegionInfo>>();
|
|
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<int, string> wmiCommandLines = new Dictionary<int, string>();
|
|
Dictionary<int, string> wmiProcessNames = new Dictionary<int, string>();
|
|
|
|
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<int, ProcessInfo>(currentProcesses);
|
|
_memoryRegionsBuffer = new ConcurrentDictionary<int, List<MemoryRegionInfo>>(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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ñêàíèðóåò ðåãèîíû ïàìÿòè äëÿ êîíêðåòíîãî ïðîöåññà.
|
|
/// </summary>
|
|
private List<MemoryRegionInfo> ScanMemoryRegionsForProcess(System.Diagnostics.Process process)
|
|
{
|
|
var regions = new List<MemoryRegionInfo>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ñðàâíèâàåò äâà ñïèñêà ProcessInfo äëÿ âûÿâëåíèÿ èçìåíåíèé.
|
|
/// </summary>
|
|
private bool AreProcessInfosEqual(ICollection<ProcessInfo> oldList, ICollection<ProcessInfo> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ñðàâíèâàåò äâà ñïèñêà MemoryRegionInfo äëÿ âûÿâëåíèÿ èçìåíåíèé.
|
|
/// </summary>
|
|
private bool AreMemoryRegionsEqual(List<MemoryRegionInfo> oldList, List<MemoryRegionInfo> 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<MemoryRegionInfo>? GetBufferedMemoryRegions(int pid)
|
|
{
|
|
lock (_lock) // Ensure thread-safe access
|
|
{
|
|
if (_memoryRegionsBuffer.TryGetValue(pid, out var regions))
|
|
{
|
|
return new List<MemoryRegionInfo>(regions); // Return a copy
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
} |