Skip to content

Commit a4f9fdf

Browse files
authored
Merge pull request kevoreilly#2964 from wmetcalf/feature/guac-auth-evtx-snapshots
Guac session auth, EVTX periodic snapshots, and web fixes
2 parents 416e11a + 8d2a302 commit a4f9fdf

18 files changed

Lines changed: 810 additions & 268 deletions

File tree

analyzer/windows/modules/auxiliary/evtx.py

Lines changed: 147 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import itertools
21
import logging
32
import os
43
import subprocess
4+
import time
55
import zipfile
66
from threading import Thread
77

@@ -88,12 +88,17 @@ class Evtx(Thread, Auxiliary):
8888
"Microsoft-Windows-DriverFrameworks-UserMode/Operational",
8989
]
9090

91+
# Interval in seconds between periodic snapshots
92+
SNAPSHOT_INTERVAL = 30
93+
9194
def __init__(self, options=None, config=None):
9295
if options is None:
9396
options = {}
9497
Thread.__init__(self)
9598
Auxiliary.__init__(self, options, config)
9699
self.enabled = config.evtx
100+
self.do_run = True
101+
self.snapshot_count = 0
97102
self.startupinfo = subprocess.STARTUPINFO()
98103
self.startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
99104

@@ -181,27 +186,56 @@ def enable_advanced_logging(self):
181186
except Exception as err:
182187
log.error("Cannot enable audit policy %s (%s) - %s", description, guid, err)
183188

184-
def collect_windows_logs(self):
185-
"""Collect selected evtx files, as specified in self.windows_logs."""
186-
logs_folder = None
189+
def export_windows_logs(self, output_zip):
190+
"""Export event logs using wevtutil epl (proper export, flushes buffers).
191+
192+
Unlike raw file copy, wevtutil epl ensures all buffered events are
193+
written and the exported file is a consistent snapshot.
194+
195+
"""
196+
export_dir = os.path.join(os.environ.get("TEMP", "C:\\Windows\\Temp"), "evtx_export")
197+
os.makedirs(export_dir, exist_ok=True)
198+
199+
exported = []
200+
for channel in self.windows_logs:
201+
safe_name = channel.replace("/", "%4") + ".evtx"
202+
export_path = os.path.join(export_dir, safe_name)
203+
try:
204+
# Remove previous export if exists
205+
if os.path.exists(export_path):
206+
os.unlink(export_path)
207+
cmd = f'wevtutil epl "{channel}" "{export_path}" /ow:true'
208+
result = subprocess.call(cmd, startupinfo=self.startupinfo,
209+
timeout=30)
210+
if result == 0 and os.path.exists(export_path):
211+
size = os.path.getsize(export_path)
212+
if size > 0:
213+
exported.append((export_path, safe_name))
214+
except subprocess.TimeoutExpired:
215+
log.debug("Timeout exporting %s", channel)
216+
except Exception as err:
217+
log.debug("Cannot export %s - %s", channel, err)
218+
219+
if not exported:
220+
return
221+
222+
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zip_obj:
223+
for export_path, safe_name in exported:
224+
try:
225+
zip_obj.write(export_path, safe_name)
226+
except Exception as err:
227+
log.debug("Cannot add %s to zip - %s", safe_name, err)
228+
229+
# Clean up exported files
230+
for export_path, _ in exported:
231+
try:
232+
os.unlink(export_path)
233+
except Exception:
234+
pass
187235
try:
188-
logs_folder = "C:/windows/Sysnative/winevt/Logs"
189-
os.listdir(logs_folder)
236+
os.rmdir(export_dir)
190237
except Exception:
191-
logs_folder = "C:/Windows/System32/winevt/Logs"
192-
193-
with zipfile.ZipFile(self.evtx_dump, "w", zipfile.ZIP_DEFLATED) as zip_obj:
194-
for evtx_file_name, selected_evtx in itertools.product(os.listdir(logs_folder), self.windows_logs):
195-
_selected_evtx = f"{selected_evtx}.evtx"
196-
_selected_evtx = _selected_evtx.replace("/", "%4")
197-
if _selected_evtx == evtx_file_name:
198-
full_path = os.path.join(logs_folder, evtx_file_name)
199-
if os.path.exists(full_path):
200-
log.debug("Adding %s to zip dump", full_path)
201-
zip_obj.write(full_path, evtx_file_name)
202-
203-
log.debug("Uploading %s to host", self.evtx_dump)
204-
upload_to_host(self.evtx_dump, f"evtx/{self.evtx_dump}")
238+
pass
205239

206240
def wipe_windows_logs(self):
207241
"""Wipe sequentially Windows logs."""
@@ -213,16 +247,100 @@ def wipe_windows_logs(self):
213247
except Exception as err:
214248
log.error("Module error - %s", err)
215249

216-
def run(self):
217-
if self.enabled:
218-
self.enable_cmdline_logging()
219-
self.configure_log_sizes()
220-
self.enable_advanced_logging()
250+
def take_snapshot(self):
251+
"""Export current logs to a local snapshot dir, then wipe.
252+
253+
Snapshots are kept locally on the guest and merged into a single
254+
evtx.zip at stop() time. This protects against malware clearing
255+
logs — each snapshot captures events since the last wipe.
256+
"""
257+
self.snapshot_count += 1
258+
snapshot_dir = os.path.join(
259+
os.environ.get("TEMP", "C:\\Windows\\Temp"),
260+
"evtx_snapshots",
261+
str(self.snapshot_count),
262+
)
263+
os.makedirs(snapshot_dir, exist_ok=True)
264+
265+
try:
266+
for channel in self.windows_logs:
267+
safe_name = channel.replace("/", "%4") + ".evtx"
268+
export_path = os.path.join(snapshot_dir, safe_name)
269+
try:
270+
cmd = f'wevtutil epl "{channel}" "{export_path}" /ow:true'
271+
subprocess.call(cmd, startupinfo=self.startupinfo, timeout=30)
272+
except Exception:
273+
pass
274+
221275
self.wipe_windows_logs()
222-
return True
223-
return False
276+
log.debug("Took evtx snapshot %d", self.snapshot_count)
277+
except Exception as err:
278+
log.error("Failed to take evtx snapshot - %s", err)
279+
280+
def run(self):
281+
if not self.enabled:
282+
return False
283+
284+
self.enable_cmdline_logging()
285+
self.configure_log_sizes()
286+
self.enable_advanced_logging()
287+
self.wipe_windows_logs()
288+
289+
# Periodic snapshot loop — captures events even if malware wipes logs
290+
while self.do_run:
291+
for _ in range(self.SNAPSHOT_INTERVAL):
292+
if not self.do_run:
293+
break
294+
time.sleep(1)
295+
if self.do_run:
296+
self.take_snapshot()
297+
298+
return True
224299

225300
def stop(self):
226-
if self.enabled:
227-
self.collect_windows_logs()
301+
self.do_run = False
302+
if not self.enabled:
303+
return True
304+
305+
# Take final snapshot of remaining events
306+
self.take_snapshot()
307+
308+
# Merge all snapshots into a single evtx.zip.
309+
# Each snapshot is incremental (logs wiped after each), so we
310+
# include ALL snapshots. Since evtx files can't be concatenated,
311+
# we add each snapshot's evtx with a unique name (channel_N.evtx).
312+
snapshot_base = os.path.join(
313+
os.environ.get("TEMP", "C:\\Windows\\Temp"),
314+
"evtx_snapshots",
315+
)
316+
317+
with zipfile.ZipFile(self.evtx_dump, "w", zipfile.ZIP_DEFLATED) as zip_obj:
318+
if os.path.isdir(snapshot_base):
319+
for snap_name in sorted(os.listdir(snapshot_base), key=lambda x: int(x) if x.isdigit() else x):
320+
snap_dir = os.path.join(snapshot_base, snap_name)
321+
if not os.path.isdir(snap_dir):
322+
continue
323+
for evtx_file in os.listdir(snap_dir):
324+
if not evtx_file.lower().endswith(".evtx"):
325+
continue
326+
full_path = os.path.join(snap_dir, evtx_file)
327+
if os.path.getsize(full_path) == 0:
328+
continue
329+
# Add with snapshot number prefix to avoid name collisions
330+
arc_name = f"{snap_name}_{evtx_file}"
331+
try:
332+
zip_obj.write(full_path, arc_name)
333+
except Exception:
334+
pass
335+
336+
if os.path.exists(self.evtx_dump):
337+
upload_to_host(self.evtx_dump, f"evtx/{self.evtx_dump}")
338+
339+
# Clean up
340+
try:
341+
import shutil
342+
shutil.rmtree(snapshot_base, ignore_errors=True)
343+
except Exception:
344+
pass
345+
228346
return True
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Guacamole session tokens tied to CAPE task lifecycle."""
2+
3+
from datetime import datetime
4+
5+
from .db_common import Base, _utcnow_naive
6+
7+
try:
8+
from sqlalchemy import DateTime, Integer, String
9+
from sqlalchemy.orm import Mapped, mapped_column
10+
except ImportError:
11+
from lib.cuckoo.common.exceptions import CuckooDependencyError
12+
raise CuckooDependencyError("Unable to import sqlalchemy")
13+
14+
15+
class GuacSession(Base):
16+
"""Ties a guacamole browser session to a CAPE task lifecycle.
17+
18+
A UUID token is generated when a user opens the guac view for a running
19+
task. The WebSocket consumer validates this token before proxying the
20+
VNC connection. The row is deleted when the task ends or the WebSocket
21+
disconnects, preventing session camping.
22+
"""
23+
24+
__tablename__ = "guac_sessions"
25+
26+
id: Mapped[int] = mapped_column(Integer(), primary_key=True)
27+
token: Mapped[str] = mapped_column(String(36), unique=True, index=True, nullable=False)
28+
task_id: Mapped[int] = mapped_column(Integer(), index=True, nullable=False)
29+
vm_label: Mapped[str] = mapped_column(String(128), nullable=False)
30+
guest_ip: Mapped[str] = mapped_column(String(128), nullable=True)
31+
created_at: Mapped[datetime] = mapped_column(DateTime(), default=_utcnow_naive, nullable=False)
32+
33+
def __repr__(self):
34+
return f"<GuacSession task={self.task_id} vm={self.vm_label}>"

lib/cuckoo/core/database.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from lib.cuckoo.common.utils import create_folder
2525

2626
from .data.db_common import Base
27+
from .data.guac_session import GuacSession # noqa: F401 - must be imported before create_all()
2728
from .data.tasking import TasksMixIn
2829
from .data.machines import MachinesMixIn
2930
from .data.samples import SamplesMixIn
@@ -220,6 +221,53 @@ def drop(self):
220221
except SQLAlchemyError as e:
221222
raise CuckooDatabaseError(f"Unable to create or connect to database: {e}")
222223

224+
225+
# ---- Guac session helpers ----
226+
227+
def create_guac_session(self, token, task_id, vm_label, guest_ip):
228+
"""Create a new guac session for a task."""
229+
session = self.session()
230+
try:
231+
guac = GuacSession(token=str(token), task_id=task_id, vm_label=vm_label, guest_ip=guest_ip)
232+
session.add(guac)
233+
session.commit()
234+
return guac
235+
except Exception:
236+
session.rollback()
237+
raise
238+
239+
def get_guac_session(self, token):
240+
"""Look up a guac session by token. Returns dict or None."""
241+
from lib.cuckoo.core.data.guac_session import GuacSession
242+
session = self.session()
243+
try:
244+
row = session.query(GuacSession).filter_by(token=str(token)).first()
245+
if row:
246+
return {"task_id": row.task_id, "vm_label": row.vm_label, "guest_ip": getattr(row, "guest_ip", None)}
247+
return None
248+
except Exception:
249+
return None
250+
251+
def delete_guac_session(self, token):
252+
"""Delete a guac session token."""
253+
from lib.cuckoo.core.data.guac_session import GuacSession
254+
session = self.session()
255+
try:
256+
session.query(GuacSession).filter_by(token=str(token)).delete()
257+
session.commit()
258+
except Exception:
259+
session.rollback()
260+
261+
def delete_guac_sessions_for_task(self, task_id):
262+
"""Delete all guac sessions for a task."""
263+
from lib.cuckoo.core.data.guac_session import GuacSession
264+
session = self.session()
265+
try:
266+
session.query(GuacSession).filter_by(task_id=task_id).delete()
267+
session.commit()
268+
except Exception:
269+
session.rollback()
270+
223271
_DATABASE: Optional[_Database] = None
224272

225273

@@ -244,3 +292,4 @@ def reset_database_FOR_TESTING_ONLY():
244292
"""Used for testing."""
245293
global _DATABASE
246294
_DATABASE = None
295+

0 commit comments

Comments
 (0)