Skip to content

Commit 0df5c1b

Browse files
erwan-jolyclaude
andcommitted
Phase 2: pure-C# out-of-process memory access, no DLL injection
Attaches to the target by opening a read/write handle with OpenProcess and uses ReadProcessMemory / WriteProcessMemory for all further interaction. Zero native code; nothing is loaded into the target's address space. New src/NosCore.PacketLogger/Remote/ folder: - NativeMethods.cs : OpenProcess, Read/WriteProcessMemory, CreateToolhelp32Snapshot, Module32FirstW/NextW, IsWow64Process. - RemoteProcess.cs : owns the handle, typed ReadBytes/WriteBytes helpers, ASCII string reader, IsWow64 flag. - ModuleEnumerator.cs : snapshot-based module list with base/size/path. - SignatureScanner.cs : byte-pattern matcher with `??` wildcards, scans a module's mapping in one ReadProcessMemory pull. - RemoteAttachmentService.cs : IInjectionService impl. On attach, opens the handle, enumerates modules, and emits them to the UI via StatusChanged so the user can verify the read path works. SendAsync/ForceReceiveAsync are intentionally no-ops until we have client-specific signatures. MainForm now mirrors every StatusChanged message into the packet log area (not just the status bar), so the module enumeration shows up where the user is already looking. Program.cs swaps NoopInjectionService for RemoteAttachmentService. The Noop class stays around as a reference/test double. Signed 140 MB single-exe publish still succeeds on win-x64. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3c37d27 commit 0df5c1b

8 files changed

Lines changed: 437 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,29 @@ Output: `./publish/NosCore.PacketLogger.exe`.
4141

4242
## Architecture ##
4343

44-
The app is a WinForms shell around three services:
44+
Pure C#, no native code. The app is a WinForms shell around four services:
4545

46-
- **`SettingsService`** — persists filters, last-used process name, and options to `%LOCALAPPDATA%\NosCore.PacketLogger\settings.json`.
47-
- **`ProcessService`** — enumerates local processes, surfaces the selector dialog, tracks attachment state.
48-
- **`InjectionService`** (stubbed in phase 1) — owns the hook into the NosTale client. Exposes `PacketReceived` / `PacketSent` events consumed by the log view, and a `SendAsync(string)` method for the custom sender.
46+
- **`SettingsService`** — persists filters, last-used process, options, window state to `%LOCALAPPDATA%\NosCore.PacketLogger\settings.json`.
47+
- **`ProcessService`** — enumerates local processes for the selector dialog.
48+
- **`Remote.RemoteAttachmentService`** (`IInjectionService`) — opens a read/write handle on the target process via `OpenProcess`; reads memory via `ReadProcessMemory`; writes via `WriteProcessMemory`. No DLL is injected, no code runs inside the target.
49+
- **`PacketLogService`** — in-memory ring of captured packets with blacklist/whitelist gating.
4950

50-
Parsing uses `NosCore.Packets`. Each log row renders `header + fields` as-is for speed; expansion into a typed packet happens on demand when the user clicks a row.
51+
The `Remote/` folder houses the out-of-process primitives:
52+
53+
- `NativeMethods.cs` — P/Invokes for `OpenProcess`, `Read/WriteProcessMemory`, `CreateToolhelp32Snapshot`, `Module32FirstW/NextW`, `IsWow64Process`.
54+
- `RemoteProcess.cs` — owns the process handle, `ReadBytes` / `WriteBytes` helpers.
55+
- `ModuleEnumerator.cs` — snapshot-based module list (name, base, size, path).
56+
- `SignatureScanner.cs` — byte-pattern scan inside a module (`48 8B ?? ?? ...`).
57+
- `RemoteAttachmentService.cs` — wires the rest into the UI: attach, emit module list as status, detach.
58+
59+
On attach, the service enumerates the target's loaded modules and emits them into the log as status lines — proof the handle and read path work.
60+
61+
Parsing uses `NosCore.Packets`. Each log row renders `header + fields` as-is; typed-packet expansion happens on demand when a row is clicked.
62+
63+
### What phase 2 does *not* ship
64+
65+
- **Packet polling**: no signatures for the current NosTale client build are in the repo. Drop candidates into a `signatures.json` (TODO) once we reverse the send/recv buffer addresses.
66+
- **Custom send**: `SendAsync` / `ForceReceiveAsync` are no-ops that log intent. Wiring them up requires calling the client's own send function via `CreateRemoteThread`, which needs the send-function signature first.
5167

5268
## Contributing ##
5369

‎src/NosCore.PacketLogger/Forms/MainForm.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,16 @@ private void WireEvents()
386386
_log.Cleared += (_, _) => BeginInvoke(() => _logListBox.Items.Clear());
387387

388388
_injection.PacketCaptured += (_, args) => _log.TryLog(args.Packet, _settings);
389-
_injection.StatusChanged += (_, msg) => BeginInvoke(() => _statusLabel.Text = msg);
389+
_injection.StatusChanged += (_, msg) => BeginInvoke(() =>
390+
{
391+
_statusLabel.Text = msg;
392+
_logListBox.Items.Add($"{DateTime.Now:HH:mm:ss.fff} [status] {msg}");
393+
if (_logListBox.Items.Count > 5000)
394+
{
395+
_logListBox.Items.RemoveAt(0);
396+
}
397+
_logListBox.TopIndex = Math.Max(0, _logListBox.Items.Count - 1);
398+
});
390399

391400
FormClosing += (_, _) =>
392401
{

‎src/NosCore.PacketLogger/Program.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using NosCore.PacketLogger.Forms;
2+
using NosCore.PacketLogger.Remote;
23
using NosCore.PacketLogger.Services;
34

45
namespace NosCore.PacketLogger;
@@ -11,7 +12,7 @@ private static void Main()
1112
ApplicationConfiguration.Initialize();
1213
var settingsService = new SettingsService();
1314
var processService = new ProcessService();
14-
var injection = new NoopInjectionService();
15+
using var injection = new RemoteAttachmentService();
1516
var log = new PacketLogService();
1617
using var mainForm = new MainForm(settingsService, processService, injection, log);
1718
Application.Run(mainForm);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
namespace NosCore.PacketLogger.Remote;
2+
3+
internal sealed record RemoteModule(string Name, IntPtr BaseAddress, int Size, string Path);
4+
5+
internal static class ModuleEnumerator
6+
{
7+
public static IReadOnlyList<RemoteModule> List(int processId)
8+
{
9+
var snapshot = NativeMethods.CreateToolhelp32Snapshot(NativeMethods.SnapshotFlags.Module, (uint)processId);
10+
if (snapshot == NativeMethods.InvalidHandleValue)
11+
{
12+
throw NativeMethods.LastWin32($"CreateToolhelp32Snapshot({processId})");
13+
}
14+
15+
try
16+
{
17+
var modules = new List<RemoteModule>();
18+
var entry = new NativeMethods.ModuleEntry32
19+
{
20+
dwSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf<NativeMethods.ModuleEntry32>(),
21+
};
22+
23+
if (!NativeMethods.Module32FirstW(snapshot, ref entry))
24+
{
25+
return modules;
26+
}
27+
28+
do
29+
{
30+
modules.Add(new RemoteModule(
31+
Name: entry.szModule ?? string.Empty,
32+
BaseAddress: entry.modBaseAddr,
33+
Size: (int)entry.modBaseSize,
34+
Path: entry.szExePath ?? string.Empty));
35+
entry.dwSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf<NativeMethods.ModuleEntry32>();
36+
}
37+
while (NativeMethods.Module32NextW(snapshot, ref entry));
38+
39+
return modules;
40+
}
41+
finally
42+
{
43+
NativeMethods.CloseHandle(snapshot);
44+
}
45+
}
46+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System.ComponentModel;
2+
using System.Runtime.InteropServices;
3+
using Microsoft.Win32.SafeHandles;
4+
5+
namespace NosCore.PacketLogger.Remote;
6+
7+
internal static class NativeMethods
8+
{
9+
[Flags]
10+
public enum ProcessAccess : uint
11+
{
12+
QueryLimitedInformation = 0x1000,
13+
VirtualMemoryRead = 0x0010,
14+
VirtualMemoryWrite = 0x0020,
15+
VirtualMemoryOperation = 0x0008,
16+
CreateThread = 0x0002,
17+
18+
ReadOnly = QueryLimitedInformation | VirtualMemoryRead,
19+
ReadWrite = ReadOnly | VirtualMemoryWrite | VirtualMemoryOperation,
20+
}
21+
22+
[Flags]
23+
public enum SnapshotFlags : uint
24+
{
25+
Module = 0x00000008,
26+
Module32 = 0x00000010,
27+
}
28+
29+
public const int MaxPath = 260;
30+
public const int MaxModuleName32 = 255;
31+
32+
public static readonly IntPtr InvalidHandleValue = new(-1);
33+
34+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
35+
public struct ModuleEntry32
36+
{
37+
public uint dwSize;
38+
public uint th32ModuleID;
39+
public uint th32ProcessID;
40+
public uint GlblcntUsage;
41+
public uint ProccntUsage;
42+
public IntPtr modBaseAddr;
43+
public uint modBaseSize;
44+
public IntPtr hModule;
45+
46+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = MaxModuleName32 + 1)]
47+
public string szModule;
48+
49+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = MaxPath)]
50+
public string szExePath;
51+
}
52+
53+
[DllImport("kernel32.dll", SetLastError = true)]
54+
public static extern SafeProcessHandle OpenProcess(ProcessAccess desiredAccess, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, int processId);
55+
56+
[DllImport("kernel32.dll", SetLastError = true)]
57+
[return: MarshalAs(UnmanagedType.Bool)]
58+
public static extern bool ReadProcessMemory(SafeProcessHandle process, IntPtr baseAddress, byte[] buffer, UIntPtr size, out UIntPtr bytesRead);
59+
60+
[DllImport("kernel32.dll", SetLastError = true)]
61+
[return: MarshalAs(UnmanagedType.Bool)]
62+
public static extern bool WriteProcessMemory(SafeProcessHandle process, IntPtr baseAddress, byte[] buffer, UIntPtr size, out UIntPtr bytesWritten);
63+
64+
[DllImport("kernel32.dll", SetLastError = true)]
65+
public static extern IntPtr CreateToolhelp32Snapshot(SnapshotFlags flags, uint processId);
66+
67+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
68+
[return: MarshalAs(UnmanagedType.Bool)]
69+
public static extern bool Module32FirstW(IntPtr snapshot, ref ModuleEntry32 entry);
70+
71+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
72+
[return: MarshalAs(UnmanagedType.Bool)]
73+
public static extern bool Module32NextW(IntPtr snapshot, ref ModuleEntry32 entry);
74+
75+
[DllImport("kernel32.dll", SetLastError = true)]
76+
[return: MarshalAs(UnmanagedType.Bool)]
77+
public static extern bool CloseHandle(IntPtr handle);
78+
79+
[DllImport("kernel32.dll", SetLastError = true)]
80+
[return: MarshalAs(UnmanagedType.Bool)]
81+
public static extern bool IsWow64Process(SafeProcessHandle process, [MarshalAs(UnmanagedType.Bool)] out bool isWow64);
82+
83+
public static Win32Exception LastWin32(string label) =>
84+
new(Marshal.GetLastWin32Error(), label);
85+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using NosCore.PacketLogger.Services;
2+
3+
namespace NosCore.PacketLogger.Remote;
4+
5+
/// <summary>
6+
/// Attaches to the target by opening a read/write handle on the
7+
/// target process, enumerates its modules, and emits a status line per
8+
/// module so the user can confirm the attachment actually worked. Packet
9+
/// polling (via SignatureScanner) and custom send (via CreateRemoteThread
10+
/// into the client's own send function) land on top of this in v2.1.
11+
/// </summary>
12+
public sealed class RemoteAttachmentService : IInjectionService
13+
{
14+
private RemoteProcess? _process;
15+
16+
#pragma warning disable CS0067 // wired in v2.1 when the poll loop starts dispatching packets
17+
public event EventHandler<PacketCapturedEventArgs>? PacketCaptured;
18+
#pragma warning restore CS0067
19+
20+
public event EventHandler<string>? StatusChanged;
21+
22+
public bool IsAttached => _process is not null;
23+
24+
public int? AttachedProcessId => _process?.ProcessId;
25+
26+
public Task AttachAsync(int processId, CancellationToken cancellationToken = default)
27+
{
28+
DetachInternal();
29+
30+
try
31+
{
32+
_process = RemoteProcess.Open(processId, writable: true);
33+
}
34+
catch (Exception ex)
35+
{
36+
StatusChanged?.Invoke(this, $"Attach failed: {ex.Message}");
37+
throw;
38+
}
39+
40+
RaiseStatus($"Attached to pid {processId} (x64={!_process.IsWow64}).");
41+
EmitModuleList();
42+
return Task.CompletedTask;
43+
}
44+
45+
public Task DetachAsync()
46+
{
47+
DetachInternal();
48+
RaiseStatus("Detached.");
49+
return Task.CompletedTask;
50+
}
51+
52+
public Task SendAsync(string rawPacket, CancellationToken cancellationToken = default)
53+
{
54+
if (_process is null)
55+
{
56+
throw new InvalidOperationException("Not attached.");
57+
}
58+
59+
RaiseStatus($"Custom send not wired yet (would send: {rawPacket})");
60+
return Task.CompletedTask;
61+
}
62+
63+
public Task ForceReceiveAsync(string rawPacket, CancellationToken cancellationToken = default)
64+
{
65+
if (_process is null)
66+
{
67+
throw new InvalidOperationException("Not attached.");
68+
}
69+
70+
RaiseStatus($"Force-receive not wired yet (would emit as recv: {rawPacket})");
71+
return Task.CompletedTask;
72+
}
73+
74+
public void Dispose()
75+
{
76+
DetachInternal();
77+
}
78+
79+
private void DetachInternal()
80+
{
81+
_process?.Dispose();
82+
_process = null;
83+
}
84+
85+
private void EmitModuleList()
86+
{
87+
if (_process is null)
88+
{
89+
return;
90+
}
91+
92+
try
93+
{
94+
var modules = ModuleEnumerator.List(_process.ProcessId);
95+
RaiseStatus($"{modules.Count} modules loaded.");
96+
foreach (var module in modules.Take(10))
97+
{
98+
RaiseStatus($" {module.Name} @ 0x{module.BaseAddress.ToInt64():X} ({module.Size / 1024} KiB)");
99+
}
100+
101+
if (modules.Count > 10)
102+
{
103+
RaiseStatus($" ... +{modules.Count - 10} more");
104+
}
105+
}
106+
catch (Exception ex)
107+
{
108+
RaiseStatus($"Module enumeration failed: {ex.Message}");
109+
}
110+
}
111+
112+
private void RaiseStatus(string line)
113+
{
114+
StatusChanged?.Invoke(this, line);
115+
}
116+
}

0 commit comments

Comments
 (0)