Skip to content

Commit ffebaa4

Browse files
committed
Process-to-network attribution for sandbox analyses
Tie every network artifact CAPE captures (suricata alerts/tls/http/files, network.tcp/udp/dns/hosts) back to the originating Windows process so analysts and downstream signatures can answer "which process did this?" without manual correlation work. Sources, in confidence order: 1. Sysmon Event ID 3 (NetworkConnect) from evtx.zip — full image path, destination hostname, src/dst 5-tuple 2. Microsoft-Windows-Kernel-Network ETW captured live by a new analyzer auxiliary, periodically uploaded so attribution survives crashes 3. Sigma EID 3 matched events — tertiary catch for late-fire flows 4. DNS-Client ETW (originating-process DNS) cross-referenced with suricata.dns/network.dns/network.hosts/sigma EID 22 QueryResults to attribute IPs we never saw a direct connect for. Avoids the "everything routes to svchost (dnscache)" failure mode. 5. Sysmon EID 22 (DnsQuery) — covers queries that fired before the DNS-ETW auxiliary subscribed (early CDN resolutions, etc.) 6. Sysmon EID 1 (ProcessCreate) — names processes that aren't monitored by capemon but show up in connection/DNS data A single AttributionIndex consumes all sources; per-target enrichment (suricata.alerts/tls/http/files, network.tcp/udp/dns/hosts, sigma detections) goes through one of four query methods. 5-tuple matching with src_port disambiguates multi-process flows to the same destination. New analyzer aux: analyzer/windows/modules/auxiliary/network_etw.py Captures TCP/UDP connect events from Microsoft-Windows-Kernel-Network with periodic full-snapshot uploads. Off by default in auxiliary.conf.default — opt-in with [Network_ETW] enabled = yes. Stop-priority on Network_ETW + Evtx: Set stop_priority = -20 so they shut down AFTER the capemon-related auxiliaries. Late-fire C2 callbacks that fire between the analysis- stop signal and VM teardown still get captured + attributed. Result-server transport: resultserver.py refactor (allowlisted RESULT_UPLOADABLE + RESULT_DIRECTORIES) tightens path-traversal protection and adds an is_replaceable_result_upload() helper so periodic re-uploads from the auxiliaries (tlsdump.log, dns_etw.json, network_etw.json, wmi_etw.json, sslkeylogfile/sslkeys.log) truncate-write instead of silently failing with EEXIST after the first upload. Decryption pipeline: decryptpcap.py wraps gogorobocap to produce dump_decrypted.pcap + dump_mixed.pcap. network.py + suricata.py honour a new pcapsrc config knob (auto/original/mixed/decrypted) so users explicitly choose which pcap variant to analyse. UI: _suricata_http.html, _suricata_files.html, _hosts.html render process attribution columns/rows, gated on the existing NETWORK_PROC_MAP setting. _hosts.html shows multi-process attribution as badges with hover tooltips that explain the attribution chain (direct connect / DNS-resolved / etc). Pre-existing "asn cell skipped when empty causes column shift" bug fixed in the same edit. Tests: tests/test_network_capture_integration.py covers AttributionIndex build + query, pcapsrc resolution, and replaceable-upload behaviour.
1 parent 31be997 commit ffebaa4

13 files changed

Lines changed: 1301 additions & 24 deletions

File tree

analyzer/windows/modules/auxiliary/evtx.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212

1313

1414
class Evtx(Thread, Auxiliary):
15+
# Stop AFTER capemon-related auxiliaries so the final EVTX snapshot
16+
# captures sysmon events from late-fire callbacks that fire between
17+
# the analysis-stopping signal and the VM teardown (e.g. C2 callbacks
18+
# the malware schedules after a delay). Without this priority bump,
19+
# those events happen after the last EVTX snapshot and never reach
20+
# the host-side processing modules.
21+
start_priority = 0
22+
stop_priority = -20
23+
1524
evtx_dump = "evtx.zip"
1625

1726
# Event log channels to collect
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import json
2+
import logging
3+
import os
4+
import shutil
5+
import socket
6+
import time
7+
from threading import Thread
8+
9+
from lib.common.results import upload_to_host
10+
from lib.common.rand import random_string
11+
from lib.core.config import Config
12+
from lib.common.etw_utils import (
13+
ETWAuxiliaryWrapper,
14+
ETWProviderWrapper,
15+
HAVE_ETW,
16+
ProviderInfo,
17+
GUID,
18+
et,
19+
encode,
20+
)
21+
22+
log = logging.getLogger(__name__)
23+
24+
__author__ = "DNS-GEE-O (@wmetcalf)"
25+
26+
KERNEL_NETWORK_GUID = "{7DD42A49-5329-4832-8DFD-43D979153A88}"
27+
28+
CONNECT_EVENT_IDS = [12, 15, 28, 31, 42, 58]
29+
30+
EVENT_NAMES = {
31+
12: "tcp_connect_v4",
32+
15: "tcp_accept_v4",
33+
28: "tcp_connect_v6",
34+
31: "tcp_accept_v6",
35+
42: "udp_send_v4",
36+
58: "udp_send_v6",
37+
}
38+
39+
# Periodic upload interval in seconds
40+
UPLOAD_INTERVAL = 15
41+
42+
43+
if HAVE_ETW:
44+
45+
class NetworkETWProvider(ETWProviderWrapper):
46+
def __init__(
47+
self,
48+
level=et.TRACE_LEVEL_INFORMATION,
49+
logfile=None,
50+
no_conout=False,
51+
any_keywords=None,
52+
all_keywords=None,
53+
filter_ips=None,
54+
filter_ports=None,
55+
):
56+
self._filter_ips = filter_ips or set()
57+
self._filter_ports = filter_ports or set()
58+
59+
providers = [
60+
ProviderInfo(
61+
"Microsoft-Windows-Kernel-Network",
62+
GUID(KERNEL_NETWORK_GUID),
63+
level,
64+
any_keywords or 0x30,
65+
all_keywords,
66+
)
67+
]
68+
super().__init__(
69+
session_name="ETW_KernelNetwork",
70+
providers=providers,
71+
event_id_filters=CONNECT_EVENT_IDS,
72+
logfile=logfile,
73+
no_conout=no_conout,
74+
)
75+
76+
def _should_filter(self, event, event_id):
77+
src_ip = str(event.get("saddr", ""))
78+
dst_ip = str(event.get("daddr", ""))
79+
src_port = event.get("sport", 0)
80+
dst_port = event.get("dport", 0)
81+
82+
# Try int conversion for port comparison
83+
try:
84+
src_port = int(src_port)
85+
except (ValueError, TypeError):
86+
pass
87+
try:
88+
dst_port = int(dst_port)
89+
except (ValueError, TypeError):
90+
pass
91+
92+
if dst_ip in self._filter_ips:
93+
return True
94+
if event_id in (15, 31) and src_ip in self._filter_ips:
95+
return True
96+
if dst_port in self._filter_ports or src_port in self._filter_ports:
97+
return True
98+
if dst_ip in ("127.0.0.1", "::1", "0.0.0.0", ""):
99+
return True
100+
return False
101+
102+
def on_event(self, event_tufo):
103+
event_id, event = event_tufo
104+
if event_id not in self.event_id_filters:
105+
return
106+
if self._should_filter(event, event_id):
107+
return
108+
if self.logfile:
109+
self.write_to_log(self.logfile, event_id, event)
110+
111+
def write_to_log(self, file_handle, event_id, event):
112+
header = event.get("EventHeader", {})
113+
pid = event.get("PID") or header.get("ProcessId", 0)
114+
proto = "TCP" if event_id in (12, 15, 28, 31) else "UDP"
115+
direction = "outbound" if event_id in (12, 28, 42, 58) else "inbound"
116+
117+
entry = {
118+
"event_type": EVENT_NAMES.get(event_id, "unknown"),
119+
"event_id": event_id,
120+
"pid": pid,
121+
"protocol": proto,
122+
"direction": direction,
123+
"src_ip": str(event.get("saddr", "")),
124+
"src_port": event.get("sport", 0),
125+
"dst_ip": str(event.get("daddr", "")),
126+
"dst_port": event.get("dport", 0),
127+
"timestamp": str(header.get("TimeStamp", "")),
128+
}
129+
connid = event.get("connid")
130+
if connid:
131+
entry["connid"] = connid
132+
133+
json.dump(entry, file_handle)
134+
file_handle.write("\n")
135+
136+
137+
class Network_ETW(ETWAuxiliaryWrapper):
138+
"""Captures TCP/UDP connection events via Microsoft-Windows-Kernel-Network ETW.
139+
140+
Provides process-to-network 5-tuple mapping.
141+
Periodically uploads captured data to ensure availability if analysis
142+
terminates unexpectedly.
143+
144+
Output: aux/network_etw.json (NDJSON)
145+
"""
146+
147+
# Stop AFTER capemon-related modules so late-firing network calls get attributed
148+
start_priority = 0
149+
stop_priority = -20
150+
151+
def __init__(self, options, config):
152+
super().__init__(options, config, "network_etw")
153+
154+
self.output_dir = os.path.join("C:\\", random_string(5, 10))
155+
try:
156+
os.mkdir(self.output_dir)
157+
except FileExistsError:
158+
pass
159+
160+
self.log_file_path = os.path.join(self.output_dir, "%s.log" % random_string(5, 10))
161+
self.log_file = None
162+
self._do_periodic = False
163+
self._periodic_thread = None
164+
165+
if HAVE_ETW and self.enabled:
166+
filter_ips = set()
167+
filter_ports = set()
168+
169+
try:
170+
analysis_cfg = Config(cfg="analysis.conf")
171+
host_ip = getattr(analysis_cfg, "ip", "")
172+
if host_ip:
173+
filter_ips.add(host_ip)
174+
rs_port = getattr(analysis_cfg, "port", 0)
175+
if rs_port:
176+
filter_ports.add(int(rs_port))
177+
except Exception as e:
178+
log.debug("Could not read analysis config for filters: %s", e)
179+
180+
filter_ports.add(8000)
181+
filter_ports.add(53)
182+
183+
log.info("NetworkETW filters: ips=%s ports=%s", filter_ips, filter_ports)
184+
185+
try:
186+
self.log_file = open(self.log_file_path, "w", encoding="utf-8")
187+
self.capture = NetworkETWProvider(
188+
logfile=self.log_file,
189+
level=255,
190+
no_conout=True,
191+
filter_ips=filter_ips,
192+
filter_ports=filter_ports,
193+
)
194+
except Exception as e:
195+
log.error("Failed to open Network ETW log file: %s", e)
196+
197+
def start(self):
198+
result = super().start()
199+
# Start periodic upload thread
200+
if self.enabled and self.log_file:
201+
self._do_periodic = True
202+
self._periodic_thread = Thread(target=self._periodic_upload, daemon=True)
203+
self._periodic_thread.start()
204+
return result
205+
206+
def _periodic_upload(self):
207+
"""Periodically flush and upload current data."""
208+
while self._do_periodic:
209+
for _ in range(UPLOAD_INTERVAL):
210+
if not self._do_periodic:
211+
break
212+
time.sleep(1)
213+
if self._do_periodic and self.log_file:
214+
try:
215+
self.log_file.flush()
216+
# Copy the file so we don't interfere with ongoing writes
217+
snap_path = self.log_file_path + ".snap"
218+
shutil.copy2(self.log_file_path, snap_path)
219+
upload_to_host(snap_path, os.path.join("aux", "network_etw.json"))
220+
log.debug("Periodic network_etw upload: %d bytes", os.path.getsize(snap_path))
221+
os.remove(snap_path)
222+
except Exception as e:
223+
log.debug("Periodic network_etw upload failed: %s", e)
224+
225+
def upload_results(self):
226+
"""Final upload on stop."""
227+
self._do_periodic = False
228+
if self._periodic_thread:
229+
self._periodic_thread.join(timeout=5)
230+
231+
if self.log_file:
232+
try:
233+
self.log_file.close()
234+
except Exception:
235+
pass
236+
self.log_file = None
237+
238+
if os.path.isfile(self.log_file_path) and os.path.getsize(self.log_file_path) > 0:
239+
try:
240+
upload_to_host(self.log_file_path, os.path.join("aux", "network_etw.json"))
241+
except Exception as e:
242+
log.error("Final network_etw upload failed: %s", e)

conf/default/auxiliary.conf.default

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ browsermonitor = no
3232
wmi_etw = no
3333
dns_etw = no
3434
amsi_etw = no
35+
network_etw = no
3536
watchdownloads = no
3637

3738
[AzSniffer]

conf/default/processing.conf.default

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ enabled = no
101101
[network]
102102
enabled = yes
103103
sort_pcap = no
104+
# Which capture to analyze when decryptpcap produced additional outputs:
105+
# auto | original | mixed | decrypted
106+
pcapsrc = auto
104107
# Enable mapping of network events to specific processes using behavioral analysis data
105108
process_map = no
106109
# Adds network connections seen in behavior but not in PCAP. Requires process_map = yes
@@ -181,6 +184,9 @@ urlscrub = (^http:\/\/serw\.clicksor\.com\/redir\.php\?url=|&InjectedParam=.+$)
181184
[suricata]
182185
enabled = no
183186
runmode = cli
187+
# Which capture to analyze when decryptpcap produced additional outputs:
188+
# auto | original | mixed | decrypted
189+
pcapsrc = auto
184190
# Outputfiles
185191
# if evelog is specified, it will be used instead of the per-protocol log files
186192
evelog = eve.json
@@ -248,6 +254,16 @@ file_cache = no
248254
# Store pefile objects for later usage? useful if you doing something in signatures/reporting
249255
pefile_store = no
250256

257+
[decryptpcap]
258+
enabled = no
259+
gogorobocap = data/gogorobocap/gogorobocap-linux-amd64
260+
# Select how decrypted captures are generated:
261+
# auto | pcap_with_keylog | sslproxy_synth_pcap
262+
pcapsrc = auto
263+
264+
[network_etw]
265+
enabled = no
266+
251267
# Deduplicate screenshots - You need to install dependency ImageHash>=4.3.1
252268
[deduplication]
253269
#
@@ -348,4 +364,3 @@ enabled = no
348364
# Enable when using the PolarProxy option during analysis. This will merge the tls.pcap containing
349365
# plain-text TLS streams into the task PCAP.
350366
enabled = no
351-

lib/cuckoo/core/resultserver.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@
8181

8282
RESULT_DIRECTORIES = RESULT_UPLOADABLE + (b"reports", b"logs")
8383

84+
REPLACEABLE_RESULT_UPLOADS = (
85+
b"tlsdump/",
86+
b"aux/dns_etw.json",
87+
b"aux/network_etw.json",
88+
b"aux/wmi_etw.json",
89+
b"aux/sslkeylogfile/sslkeys.log",
90+
)
91+
8492

8593
def netlog_sanitize_fname(path):
8694
"""Validate agent-provided path for result files"""
@@ -90,11 +98,17 @@ def netlog_sanitize_fname(path):
9098
raise CuckooOperationalError(f"Netlog client requested banned path: {path}")
9199
if any(c in BANNED_PATH_CHARS for c in name):
92100
for c in BANNED_PATH_CHARS:
93-
path.replace(bytes([c]), b"X")
101+
path = path.replace(bytes([c]), b"X")
94102

95103
return path
96104

97105

106+
def is_replaceable_result_upload(path):
107+
"""Return True for result uploads that are expected to overwrite prior
108+
content with a full snapshot rather than append a distinct artifact."""
109+
return path.startswith(REPLACEABLE_RESULT_UPLOADS)
110+
111+
98112
class Disconnect(Exception):
99113
pass
100114

@@ -254,7 +268,15 @@ def handle(self):
254268
try:
255269
if file_path.endswith("_script.log"):
256270
self.fd = open_inclusive(file_path)
257-
elif not path_exists(file_path):
271+
elif is_replaceable_result_upload(dump_path) and path_exists(file_path):
272+
# Auxiliary modules (tlsdump, network_etw, sslkeylogfile…)
273+
# upload the SAME dump_path periodically so accumulated
274+
# key / connection data survives an unexpected analysis
275+
# termination. Each upload is a full replacement of the
276+
# prior content — truncate and rewrite rather than failing
277+
# silently with EEXIST.
278+
self.fd = open(file_path, "wb")
279+
else:
258280
# open_exclusive will fail if file_path already exists
259281
self.fd = open_exclusive(file_path)
260282
except OSError as e:

0 commit comments

Comments
 (0)