Repository navigation
Expand file tree
/
Copy pathNetworkCapture.cs
More file actions
460 lines (417 loc) · 19.5 KB
/
Copy pathNetworkCapture.cs
File metadata and controls
460 lines (417 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using ETDucky.NetPath.Models;
using Microsoft.Diagnostics.Tracing;
using Microsoft.Diagnostics.Tracing.Parsers;
using Microsoft.Diagnostics.Tracing.Parsers.Kernel;
using Microsoft.Diagnostics.Tracing.Session;
namespace ETDucky.NetPath.Services;
/// <summary>
/// Drives two ETW sessions:
///
/// Kernel session — Kernel-Network + WFP (Windows Filtering Platform)
/// User-mode session — DNS-Client, Schannel, CAPI2, WinHTTP, WinINet,
/// AAD (WAM), WebAuthN, Kerberos, NTLM, LDAP-Client
///
/// Each provider has its own event ID space. We subscribe via the
/// generic Dynamic parser (TraceEvent's runtime manifest resolver),
/// which handles event decoding for any provider that publishes a
/// manifest — every Microsoft inbox provider does. Per-event we read
/// fields by name, map into the typed model record, and push onto the
/// CaptureSession queue.
///
/// Per-process filter: every event handler checks
/// _tracker.IsTracked(pid) first. The tracker also keeps following
/// child processes so a parent like Outlook → embedded WebView2 chain
/// is captured end-to-end.
///
/// Why two sessions: Windows requires kernel ETW providers to live on
/// the kernel logger session (or a private kernel session), while
/// user-mode manifest providers go on a regular session. Mixing them
/// fails at EnableProvider time.
/// </summary>
public sealed class NetworkCapture : IDisposable
{
private readonly ProcessTracker _tracker;
private readonly CaptureSession _session;
private TraceEventSession? _kernelSession;
private TraceEventSession? _userSession;
private readonly string _kernelSessionName;
private readonly string _userSessionName;
// ── Well-known provider GUIDs ─────────────────────────────────────
private static readonly Guid DnsClient = new("1C95126E-7EEA-49A9-A3FE-A378B03DDB4D");
private static readonly Guid Schannel = new("1F678132-5938-4686-9FDC-C8FF68F15C85");
private static readonly Guid Capi2 = new("5BBCA4A8-B209-48DC-A8C7-B23D3E5216FB");
private static readonly Guid WinHttp = new("7D44233D-3055-4B9C-BA64-0D47CA40A232");
private static readonly Guid WinINet = new("43D1A9CB-593D-4EDE-B893-B0E18AF08676");
private static readonly Guid Aad = new("4DE9BC9C-B27A-43C9-8994-0915F1A5E24F");
private static readonly Guid WebAuthN = new("866FB6F0-9B70-4FFC-9DCD-D1E7DD5CCB47");
private static readonly Guid Kerberos = new("0CCE9228-69AE-11D9-BED3-505054503030");
private static readonly Guid Ntlm = new("AC43300D-5FCC-4800-8E99-1BD3F85F0320");
private static readonly Guid LdapClient = new("099614A5-5DD7-4788-8BC9-E29F43DB28FC");
private static readonly Guid Wfp = new("C22D1B14-C242-49DE-9F17-1D76B8B9C458");
// Pending TCP connects — keyed by (pid, dst-addr:port) so we can
// pair SYN-out with SYN-ACK / RST. Concurrent because the kernel
// parser callbacks fire on multiple threads.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, PendingTcp> _pendingTcp = new();
public NetworkCapture(ProcessTracker tracker, CaptureSession session)
{
_tracker = tracker;
_session = session;
// Unique session names so multiple instances can coexist (and
// so a stuck session from a previous crash doesn't block us
// forever — `logman query -ets` will list any orphans).
var suffix = Guid.NewGuid().ToString("N").Substring(0, 14);
_kernelSessionName = "ETDuckyNetPath_k_" + suffix;
_userSessionName = "ETDuckyNetPath_u_" + suffix;
}
/// <summary>
/// Starts both ETW sessions and blocks the calling thread inside
/// the user-mode session's Process() loop. Call from a background
/// Task. Stop() triggers both sessions to terminate, which lets
/// Process() return.
/// </summary>
public void Start()
{
StartKernelSession();
StartUserSession();
// Kernel session events must be pumped on their own thread —
// each TraceEventSource.Process() call blocks until the session
// is stopped, so they can't share a thread.
var kernelThread = new Thread(() => _kernelSession!.Source.Process())
{
IsBackground = true,
Name = "NetPath-KernelETW"
};
kernelThread.Start();
// Block the calling thread on the user-mode session.
_userSession!.Source.Process();
}
public void Stop()
{
try { _userSession?.Stop(); } catch { }
try { _kernelSession?.Stop(); } catch { }
_session.Stop();
}
public void Dispose()
{
Stop();
_userSession?.Dispose(); _userSession = null;
_kernelSession?.Dispose(); _kernelSession = null;
}
// ── Kernel session: TCP + Process + WFP ─────────────────────────────
private void StartKernelSession()
{
_kernelSession = new TraceEventSession(_kernelSessionName)
{
BufferSizeMB = 256
};
_kernelSession.EnableKernelProvider(
KernelTraceEventParser.Keywords.NetworkTCPIP |
KernelTraceEventParser.Keywords.Process);
// WFP is a kernel-mode provider — must be enabled on the kernel session.
// EnableProvider (non-kernel overload) works for manifest providers that
// happen to run in kernel context; WFP is one of them.
try { _kernelSession.EnableProvider(Wfp, TraceEventLevel.Verbose, 0xFFFFFFFFFFFFFFFF); }
catch { /* not available on all SKUs */ }
var kernel = _kernelSession.Source.Kernel;
// Process tracking
kernel.ProcessStart += d =>
_tracker.OnProcessStart(d.ProcessID, d.ParentID, d.ImageFileName);
kernel.ProcessStop += d => _tracker.OnProcessExit(d.ProcessID);
// TCP — pair connect-out with connect-success / disconnect
kernel.TcpIpConnect += d =>
{
if (!_tracker.IsTracked(d.ProcessID)) return;
var key = $"{d.ProcessID}|{d.daddr}:{d.dport}";
_pendingTcp[key] = new PendingTcp(
d.ProcessID, d.daddr.ToString(), d.dport, d.TimeStampRelativeMSec);
};
kernel.TcpIpAccept += d => {/* server-side, not interesting here */};
kernel.TcpIpConnectIPV6 += d =>
{
if (!_tracker.IsTracked(d.ProcessID)) return;
var key = $"{d.ProcessID}|[{d.daddr}]:{d.dport}";
_pendingTcp[key] = new PendingTcp(
d.ProcessID, "[" + d.daddr + "]", d.dport, d.TimeStampRelativeMSec);
};
// Successful TCP completion fires as TcpIpSend immediately after
// SYN-ACK — we use the first send for the keyed connection as
// "connect complete". Disconnect / failure fires as TcpIpDisconnect.
kernel.TcpIpDisconnect += d =>
{
if (!_tracker.IsTracked(d.ProcessID)) return;
CompleteTcp(d.ProcessID, d.daddr.ToString(), d.dport,
d.TimeStampRelativeMSec, "Disconnected");
};
kernel.TcpIpDisconnectIPV6 += d =>
{
if (!_tracker.IsTracked(d.ProcessID)) return;
CompleteTcp(d.ProcessID, "[" + d.daddr + "]", d.dport,
d.TimeStampRelativeMSec, "Disconnected");
};
kernel.TcpIpFail += d =>
{
// TcpIpFail is a global failure event; we don't get the PID
// reliably but we record what we can.
_session.Record(new TcpEvent(
DateTime.UtcNow, 0, "<unknown>", 0,
"Failed", 0, 0));
};
}
private void CompleteTcp(int pid, string addr, int port,
double endMsec, string outcomeFallback)
{
var key = $"{pid}|{addr}:{port}";
if (_pendingTcp.TryRemove(key, out var pending))
{
var durUs = (long)((endMsec - pending.StartMsec) * 1000.0);
if (durUs < 0) durUs = 0;
_session.Record(new TcpEvent(
DateTime.UtcNow, pid, addr, port,
"Connected", durUs, 0));
}
else
{
_session.Record(new TcpEvent(
DateTime.UtcNow, pid, addr, port,
outcomeFallback, 0, 0));
}
// WFP drop events arrive via the Dynamic parser on the kernel session source.
_kernelSession.Source.Dynamic.All += ev =>
{
try { if (ev.ProviderGuid == Wfp) DispatchWfp(ev); }
catch { }
};
}
// ── User-mode session: every other provider ──────────────────────
private void StartUserSession()
{
_userSession = new TraceEventSession(_userSessionName)
{
BufferSizeMB = 128
};
// EnableProvider takes a GUID + level + keyword mask. Verbose
// (5) is fine for every provider here — the volume is tiny
// compared to kernel ETW, and we filter to tracked PIDs anyway.
var verbose = (TraceEventLevel)5;
const ulong AllKeywords = 0xFFFFFFFFFFFFFFFFUL;
EnableSafely(DnsClient, verbose, AllKeywords);
EnableSafely(Schannel, verbose, AllKeywords);
EnableSafely(Capi2, verbose, AllKeywords);
EnableSafely(WinHttp, verbose, AllKeywords);
EnableSafely(WinINet, verbose, AllKeywords);
EnableSafely(Aad, verbose, AllKeywords);
EnableSafely(WebAuthN, verbose, AllKeywords);
EnableSafely(Kerberos, verbose, AllKeywords);
EnableSafely(Ntlm, verbose, AllKeywords);
EnableSafely(LdapClient, verbose, AllKeywords);
// WFP is a kernel-mode provider — enabled on the kernel session, not here.
// Dynamic parser: handles any provider with a published manifest
// and gives us a generic TraceEvent we can read fields off via
// PayloadByName / PayloadString. This is how we avoid having to
// generate strongly-typed parsers for every provider above.
var dyn = _userSession.Source.Dynamic;
dyn.All += OnDynamicEvent;
}
private void EnableSafely(Guid providerGuid, TraceEventLevel level, ulong keywords)
{
try
{
_userSession!.EnableProvider(providerGuid, level, keywords);
}
catch
{
// Some providers aren't installed on every Windows SKU
// (WebAuthN is Win10 1903+, AAD presence depends on the
// tenant-join state, etc.). Swallow rather than crash;
// missing providers just mean no events from that source.
}
}
// ── Dispatch ────────────────────────────────────────────────────────
private void OnDynamicEvent(TraceEvent ev)
{
// Filter by tracked PID first — cheapest possible check.
var pid = ev.ProcessID;
if (!_tracker.IsTracked(pid)) return;
// Route by ProviderGuid → typed record. We don't have full
// event-ID-by-name decoding for every provider here; for v1
// we extract the most commonly useful payload fields and
// synthesize approximate records. Each branch is intentionally
// conservative — when we can't extract a field, we substitute
// an empty string rather than failing the whole event.
try
{
var g = ev.ProviderGuid;
if (g == DnsClient) DispatchDns(ev);
else if (g == Schannel) DispatchTls(ev);
else if (g == Capi2) DispatchCert(ev);
else if (g == WinHttp) DispatchHttp(ev, EventSource.WinHttp);
else if (g == WinINet) DispatchHttp(ev, EventSource.WinINet);
else if (g == Aad) DispatchAad(ev);
else if (g == Kerberos) DispatchAuth(ev, EventSource.Kerberos);
else if (g == Ntlm) DispatchAuth(ev, EventSource.Ntlm);
else if (g == LdapClient) DispatchAuth(ev, EventSource.LdapClient);
else if (g == Wfp) DispatchWfp(ev);
}
catch
{
// Field-extraction can throw on unexpected event shapes
// (unknown event IDs, manifest mismatches). Swallow to
// keep the capture loop healthy; missed events are vastly
// preferable to a stopped capture.
}
}
// ── Provider-specific extractors ──────────────────────────────────
// These are intentionally lenient — the goal is "best-effort fields
// for the common event IDs," not "complete coverage of every event
// in every provider's manifest." Where we can't read a field we
// substitute defaults so the event still lands in the queue and
// can be examined.
private void DispatchDns(TraceEvent ev)
{
// Event 3006: DNS_QUERY_COMPLETED is the most useful one.
// Fields: QueryName, QueryType, QueryStatus, QueryResults.
var name = SafeStr(ev, "QueryName");
var qtype = SafeInt(ev, "QueryType");
var status = SafeInt(ev, "QueryStatus");
var results = SafeStr(ev, "QueryResults");
if (string.IsNullOrEmpty(name)) return;
_session.Record(new DnsEvent(
DateTime.UtcNow, ev.ProcessID, name, qtype, status,
string.IsNullOrEmpty(results) ? Array.Empty<string>()
: results.Split(';', StringSplitOptions.RemoveEmptyEntries),
0));
}
private void DispatchTls(TraceEvent ev)
{
// Schannel handshake events vary by Windows build. Common
// identifiers: handshake start/end, alert sent/received.
// We use the event name + payload for a best-effort label.
var server = SafeStr(ev, "TargetName");
var proto = SafeStr(ev, "ProtocolVersion");
var cipher = SafeStr(ev, "CipherSuite");
var alert = SafeStr(ev, "AlertDescription");
var outcome = string.IsNullOrEmpty(alert) ? "Completed" : alert;
if (string.IsNullOrEmpty(server) && string.IsNullOrEmpty(alert)) return;
_session.Record(new TlsEvent(
DateTime.UtcNow, ev.ProcessID, server, proto, cipher, outcome,
string.IsNullOrEmpty(alert) ? null : alert));
}
private void DispatchCert(TraceEvent ev)
{
// CAPI2 chain validation events: usually "CertGetCertificateChain"
// with the chain status in the payload.
var subject = SafeStr(ev, "SubjectName");
var issuer = SafeStr(ev, "IssuerName");
var status = SafeStr(ev, "ChainElementStatus");
var verdict = string.IsNullOrEmpty(status) ? "Trusted" : status;
if (string.IsNullOrEmpty(subject)) return;
_session.Record(new CertValidationEvent(
DateTime.UtcNow, ev.ProcessID, subject, issuer, verdict,
verdict == "Trusted" ? null : subject));
}
private void DispatchHttp(TraceEvent ev, EventSource stack)
{
// WinHTTP / WinINet expose request/response events with URL,
// method, status, and assorted headers in the payload. We
// capture only diagnostic-relevant headers to keep payloads
// small and avoid leaking auth tokens or cookies.
var url = SafeStr(ev, "URL");
if (string.IsNullOrEmpty(url)) url = SafeStr(ev, "Url");
if (string.IsNullOrEmpty(url)) return;
var method = SafeStr(ev, "Verb");
var status = SafeInt(ev, "Status");
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var h in new[] { "x-ms-diagnostics", "www-authenticate",
"x-ms-request-id", "retry-after" })
{
var v = SafeStr(ev, h);
if (!string.IsNullOrEmpty(v)) headers[h] = v;
}
_session.Record(new HttpEvent(
DateTime.UtcNow, ev.ProcessID, stack,
string.IsNullOrEmpty(method) ? "?" : method, url, status,
status > 0 ? "Completed" : "Aborted", headers));
}
private void DispatchAad(TraceEvent ev)
{
// Microsoft-Windows-AAD: WAM token broker. Fields include
// ClientId, Resource, ErrorCode (AADSTS), ErrorDescription.
var client = SafeStr(ev, "ClientId");
var resource = SafeStr(ev, "Resource");
var err = SafeInt(ev, "ErrorCode");
var desc = SafeStr(ev, "ErrorDescription");
if (string.IsNullOrEmpty(client) && err == 0 && string.IsNullOrEmpty(desc))
return;
_session.Record(new AadEvent(
DateTime.UtcNow, ev.ProcessID, client, resource, err,
string.IsNullOrEmpty(desc) ? null : desc));
}
private void DispatchAuth(TraceEvent ev, EventSource protocol)
{
// Kerberos/NTLM/LDAP: result code is the most useful field.
// Names vary across providers — try several common ones.
var target = SafeStr(ev, "TargetName");
if (string.IsNullOrEmpty(target)) target = SafeStr(ev, "ServerName");
var resultStr = SafeStr(ev, "ResultCode");
if (string.IsNullOrEmpty(resultStr))
resultStr = SafeInt(ev, "Status").ToString();
var desc = SafeStr(ev, "Reason");
if (string.IsNullOrEmpty(target) && resultStr == "0") return;
_session.Record(new AuthEvent(
DateTime.UtcNow, ev.ProcessID, protocol, target, resultStr,
string.IsNullOrEmpty(desc) ? null : desc));
}
private void DispatchWfp(TraceEvent ev)
{
// WFP — surface drops only. Layer / filter info varies; we
// best-effort extract what's commonly named.
var op = SafeStr(ev, "Operation");
if (!string.IsNullOrEmpty(op) && !op.Contains("Drop", StringComparison.OrdinalIgnoreCase))
return;
var raddr = SafeStr(ev, "RemoteAddress");
var rport = SafeInt(ev, "RemotePort");
var proto = SafeStr(ev, "Protocol");
var filter = SafeStr(ev, "FilterName");
if (string.IsNullOrEmpty(filter)) filter = SafeStr(ev, "FilterId");
var layer = SafeStr(ev, "LayerName");
if (string.IsNullOrEmpty(raddr) && string.IsNullOrEmpty(filter)) return;
_session.Record(new FirewallEvent(
DateTime.UtcNow, ev.ProcessID, raddr, rport, proto,
string.IsNullOrEmpty(filter) ? "<unknown>" : filter,
string.IsNullOrEmpty(layer) ? null : layer));
}
// ── Payload accessors with null-safety ───────────────────────────
private static string SafeStr(TraceEvent ev, string field)
{
try
{
var v = ev.PayloadByName(field);
return v?.ToString() ?? string.Empty;
}
catch { return string.Empty; }
}
private static int SafeInt(TraceEvent ev, string field)
{
try
{
var v = ev.PayloadByName(field);
return v switch
{
int i => i,
long l => (int)l,
uint u => unchecked((int)u),
short s => s,
ushort us => us,
byte b => b,
_ => int.TryParse(v?.ToString(), out var p) ? p : 0
};
}
catch { return 0; }
}
private sealed record PendingTcp(int Pid, string Addr, int Port, double StartMsec);
}