Skip to content

Commit 33cb8f0

Browse files
committed
Add TLS pcap decryption via GoGoRoboCap with SSLproxy MITM support
Adds per-analysis TLS interception and PCAP decryption pipeline: SSLproxy auxiliary module (modules/auxiliary/SSLProxy.py): - Per-analysis SSLproxy instance using autossl mode (detects TLS ClientHello on any port + STARTTLS upgrades, passthrough for non-TLS) - NAT REDIRECT via rooter for VM traffic interception - Cgroup-based VPN routing for SSLproxy upstream connections - Single PCAP output (-X) with master key logging (-M) - Activated per-task via sslproxy=1 option GoGoRoboCap decryption processor (modules/processing/decryptpcap.py): - Auto-detects best decryption method: - SSLproxy synthetic PCAPs: strips prepended TLS ClientHello via --sslproxy-clean so Suricata can do protocol identification, then merges cleaned output with original PCAP via mergecap - TLS keylog: collects keys from tlsdump, sslkeylogfile, and sslproxy master_keys.log for GoGoRoboCap decryption - Configurable via pcapsrc option (auto/pcap_with_keylog/sslproxy_synth_pcap) - Produces dump_decrypted.pcap and dump_mixed.pcap Network and Suricata integration: - Both modules auto-use dump_mixed.pcap when available - Suricata HTTP/2 header parsing (pseudo-headers, control frame filtering) - HTTP/2 passlist filtering via :authority header UI changes: - pcapzip download bundles all available PCAPs (original, decrypted, mixed, sslproxy raw, sslproxy clean) - Download buttons for decrypted and mixed PCAPs on network page - Fix _suricata_http.html method field name Java package (jar.py): - Prefer javaw.exe, fall back to java.exe - JAVA_TOOL_OPTIONS trust store override when sslproxy active (-Djavax.net.ssl.trustStoreType=Windows-ROOT) sslkeylogfile.py: - Document that Schannel KeyLogging registry key must be set in VM snapshot (requires reboot, cannot be enabled at runtime)
1 parent 817e778 commit 33cb8f0

14 files changed

Lines changed: 724 additions & 27 deletions

File tree

analyzer/windows/modules/auxiliary/sslkeylogfile.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111

1212

1313
class SslKeyLogFile(Auxiliary):
14-
"""Collect SSLKEYLOGFILE logs from guests."""
14+
"""Collect SSLKEYLOGFILE logs from guests.
15+
16+
For Schannel (Windows native TLS) key capture, the registry key
17+
HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecurityProviders\\SCHANNEL\\KeyLogging
18+
must have Enable=1 (REG_DWORD). This requires a reboot to take effect, so
19+
it should be baked into the VM snapshot — not set at runtime.
20+
This module handles setting the SSLKEYLOGFILE path at analysis start.
21+
"""
1522

1623
def __init__(self, options, config):
1724
Auxiliary.__init__(self, options, config)

analyzer/windows/modules/packages/jar.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,28 @@
22
# This file is part of Cuckoo Sandbox - http://www.cuckoosandbox.org
33
# See the file 'docs/LICENSE' for copying permission.
44

5+
import logging
6+
import os
7+
58
from lib.common.abstracts import Package
69
from lib.common.constants import OPT_CLASS
710

11+
log = logging.getLogger(__name__)
12+
813

914
class Jar(Package):
1015
"""Java analysis package."""
1116

1217
PATHS = [
18+
# javaw.exe preferred (no console window)
19+
("ProgramFiles", "Java", "jre*", "bin", "javaw.exe"),
20+
("ProgramFiles", "Java", "jdk*", "bin", "javaw.exe"),
21+
("ProgramFiles", "Java", "jdk-*", "bin", "javaw.exe"),
22+
("ProgramFiles", "Microsoft", "jdk-*", "bin", "javaw.exe"),
23+
("ProgramFiles", "Eclipse Adoptium", "jdk-*", "bin", "javaw.exe"),
24+
("ProgramFiles", "Eclipse Adoptium", "jre-*", "bin", "javaw.exe"),
25+
("ProgramFiles", "OpenJDK", "jdk-*", "bin", "javaw.exe"),
26+
# java.exe fallback
1327
("ProgramFiles", "Java", "jre*", "bin", "java.exe"),
1428
("ProgramFiles", "Java", "jdk*", "bin", "java.exe"),
1529
("ProgramFiles", "Java", "jdk-*", "bin", "java.exe"),
@@ -18,15 +32,31 @@ class Jar(Package):
1832
("ProgramFiles", "Eclipse Adoptium", "jre-*", "bin", "java.exe"),
1933
("ProgramFiles", "OpenJDK", "jdk-*", "bin", "java.exe"),
2034
]
21-
summary = "Executes a java class using java.exe."
22-
description = f"""Uses 'java.exe -jar [path]' to run the given sample.
23-
However, if the '{OPT_CLASS}' option is specified, use
24-
'java.exe -cp [path] [class]' to run the named java class."""
35+
summary = "Executes a .jar file using javaw.exe (or java.exe)."
36+
description = f"""Uses 'javaw.exe -jar [path]' to run the given sample.
37+
Falls back to java.exe if javaw.exe is not available.
38+
If the '{OPT_CLASS}' option is specified, uses '-cp [path] [class]'
39+
to run the named java class instead."""
2540
option_names = (OPT_CLASS,)
2641

2742
def start(self, path):
2843
java = self.get_path_glob("Java")
2944
class_path = self.options.get("class")
3045

46+
java_opts = []
47+
# When SSLproxy MITM is active, tell Java to use the Windows
48+
# certificate store so it trusts the MITM CA without needing
49+
# to import it into Java's cacerts keystore.
50+
if self.options.get("sslproxy"):
51+
java_opts.extend([
52+
"-Djavax.net.ssl.trustStoreType=Windows-ROOT",
53+
"-Djavax.net.ssl.trustStore=NUL",
54+
])
55+
56+
if java_opts:
57+
os.environ["JAVA_TOOL_OPTIONS"] = " ".join(java_opts)
58+
log.info("Set JAVA_TOOL_OPTIONS=%s", os.environ["JAVA_TOOL_OPTIONS"])
59+
3160
args = f'-cp "{path}" {class_path}' if class_path else f'-jar "{path}"'
61+
log.info("Executing: %s %s", java, args)
3262
return self.execute(java, args, path)

conf/default/processing.conf.default

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ country_lookup = no
122122
# For ipinfo use: Free IP to Country + IP to ASN
123123
maxmind_database = data/GeoLite2-Country.mmdb
124124

125+
[decryptpcap]
126+
enabled = yes
127+
# Path to GoGoRoboCap binary (relative to CUCKOO_ROOT or absolute)
128+
gogorobocap = data/gogorobocap/gogorobocap-linux-amd64
129+
# Decryption source: auto (default), pcap_with_keylog, or sslproxy_synth_pcap
130+
# auto: uses sslproxy synthetic pcap when available, falls back to keylog decryption
131+
pcapsrc = auto
132+
125133
[pcapng]
126134
enabled = no
127135

conf/default/sslproxy.conf.default

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[cfg]
2+
# Path to sslproxy binary
3+
bin = /usr/local/bin/sslproxy
4+
5+
# CA certificate and key for MITM signing.
6+
# The CA must be trusted by guest VMs (imported into the Windows certificate store).
7+
# Generate with:
8+
# openssl req -new -x509 -days 3650 -keyout ca.key -out ca.crt -nodes \
9+
# -subj "/CN=My MITM CA" -addext "basicConstraints=critical,CA:TRUE"
10+
ca_cert = data/sslproxy/ca.crt
11+
ca_key = data/sslproxy/ca.key
12+
13+
# Interface where VMs connect (must match your hypervisor network interface)
14+
interface = virbr0
8.67 MB
Binary file not shown.

lib/cuckoo/core/startup.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,20 @@ def init_rooter():
494494
except Exception as e:
495495
log.debug("An unexpected error occurred while checking UFW status: %s", e)
496496

497+
# SSLproxy shared NFQUEUE infrastructure (DIVERT chain + TPROXY routing)
498+
try:
499+
sslproxy_conf = Config("sslproxy")
500+
log.info("SSLproxy config loaded: %s", bool(sslproxy_conf))
501+
if sslproxy_conf:
502+
nfqueue_mark = str(sslproxy_conf.cfg.get("nfqueue_mark", 100))
503+
log.info("SSLproxy calling sslproxy_nfqueue_enable with mark=%s", nfqueue_mark)
504+
rooter("sslproxy_nfqueue_enable", nfqueue_mark)
505+
log.info("SSLproxy NFQUEUE shared infrastructure enabled (mark=%s)", nfqueue_mark)
506+
else:
507+
log.info("SSLproxy config not found, skipping NFQUEUE init")
508+
except Exception as e:
509+
log.warning("SSLproxy NFQUEUE init failed: %s", e, exc_info=True)
510+
497511

498512
def init_routing():
499513
"""Initialize and check whether the routing information is correct."""

modules/auxiliary/SSLProxy.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import logging
2+
import os
3+
import shlex
4+
import signal
5+
import socket
6+
import subprocess
7+
8+
from contextlib import closing
9+
from threading import Thread
10+
11+
from lib.cuckoo.common.abstracts import Auxiliary
12+
from lib.cuckoo.common.config import Config
13+
from lib.cuckoo.common.constants import CUCKOO_ROOT
14+
from lib.cuckoo.core.rooter import rooter
15+
16+
log = logging.getLogger(__name__)
17+
18+
sslproxy_cfg = Config("sslproxy")
19+
20+
21+
class SSLProxy(Auxiliary):
22+
"""Per-analysis SSLproxy TLS interception with STARTTLS support.
23+
24+
Uses a single SSLproxy autossl listener per analysis. All VM TCP traffic is
25+
NAT REDIRECT'd to it. SSLproxy detects TLS ClientHello on any port and
26+
STARTTLS upgrades, intercepts them with MITM, and passes non-TLS through.
27+
28+
Enabled per-task via the ``sslproxy=1`` task option.
29+
"""
30+
31+
def __init__(self):
32+
Auxiliary.__init__(self)
33+
Thread.__init__(self)
34+
self.sslproxy_thread = None
35+
36+
def start(self):
37+
self.sslproxy_thread = SSLProxyThread(self.task, self.machine)
38+
self.sslproxy_thread.start()
39+
return True
40+
41+
def stop(self):
42+
if self.sslproxy_thread:
43+
self.sslproxy_thread.stop()
44+
45+
46+
class SSLProxyThread(Thread):
47+
"""Thread controlling per-analysis SSLproxy instance."""
48+
49+
def __init__(self, task, machine):
50+
Thread.__init__(self)
51+
self.task = task
52+
self.machine = machine
53+
self.storage_dir = os.path.join(CUCKOO_ROOT, "storage", "analyses",
54+
str(self.task.id), "sslproxy")
55+
self.proc = None
56+
self.log_file = None
57+
self.do_run = True
58+
self._rooter_enabled = False
59+
60+
# Config
61+
self.sslproxy_bin = sslproxy_cfg.cfg.get("bin")
62+
self.ca_cert = sslproxy_cfg.cfg.get("ca_cert")
63+
self.ca_key = sslproxy_cfg.cfg.get("ca_key")
64+
self.interface = sslproxy_cfg.cfg.get("interface")
65+
66+
# Single autossl port handles everything
67+
self.proxy_port = self._get_unused_port()
68+
self.resultserver_port = str(getattr(self.machine, 'resultserver_port', 2042))
69+
70+
# Determine routing table for upstream VPN routing
71+
routing_conf = Config("routing")
72+
self.route = self.task.route or routing_conf.routing.route
73+
self.rt_table = ""
74+
if self.route and self.route not in ("none", "None", "drop", "false", "inetsim", "tor"):
75+
if hasattr(routing_conf, self.route):
76+
entry = routing_conf.get(self.route)
77+
self.rt_table = str(getattr(entry, 'rt_table', ''))
78+
elif self.route.startswith("tun"):
79+
self.rt_table = self.route
80+
elif self.route == "internet":
81+
self.rt_table = str(routing_conf.routing.rt_table) if routing_conf.routing.rt_table else ""
82+
83+
def _get_unused_port(self):
84+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
85+
s.bind(("", 0))
86+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
87+
return str(s.getsockname()[1])
88+
89+
def _is_sslproxy_requested(self):
90+
"""Check if sslproxy=1 is set in task options."""
91+
for opt in (self.task.options or "").split(","):
92+
opt = opt.strip()
93+
if "=" in opt:
94+
key, val = opt.split("=", 1)
95+
if key.strip() == "sslproxy":
96+
return val.strip() not in ("0", "no", "false", "")
97+
return False
98+
99+
def run(self):
100+
log.info("SSLProxy thread running for task %s", self.task.id)
101+
if not self._is_sslproxy_requested():
102+
log.info("SSLProxy not requested for task %s, skipping", self.task.id)
103+
return
104+
105+
if not self.do_run:
106+
return
107+
108+
if not self.proxy_port:
109+
log.error("SSLProxy failed to allocate port")
110+
return
111+
112+
# Set up NAT REDIRECT: all VM TCP (except ResultServer) → SSLproxy autossl listener
113+
try:
114+
rooter("sslproxy_enable", self.interface, self.machine.ip,
115+
self.proxy_port, self.resultserver_port, self.rt_table)
116+
self._rooter_enabled = True
117+
except Exception as e:
118+
log.exception("Failed to enable SSLproxy iptables rules: %s", e)
119+
return
120+
121+
try:
122+
self._start_sslproxy()
123+
except Exception as e:
124+
log.error("Failed to start SSLproxy for task %s: %s", self.task.id, e)
125+
self._disable_rooter()
126+
127+
def _start_sslproxy(self):
128+
"""Build command and launch SSLproxy process."""
129+
os.makedirs(self.storage_dir, exist_ok=True)
130+
131+
conn_log = os.path.join(self.storage_dir, "connections.log")
132+
master_keys = os.path.join(self.storage_dir, "master_keys.log")
133+
pcap_file = os.path.join(self.storage_dir, "sslproxy.pcap")
134+
135+
# Build command as a list to avoid shell injection
136+
sslproxy_cmd = [
137+
self.sslproxy_bin, "-D",
138+
"-k", self.ca_key, "-c", self.ca_cert,
139+
"-l", conn_log, "-X", pcap_file, "-M", master_keys,
140+
"-u", "root", "-o", "VerifyPeer=no", "-P",
141+
"autossl", "0.0.0.0", self.proxy_port, "up:80",
142+
]
143+
144+
# Launch in per-VM cgroup so iptables cgroup match can route upstream through VPN.
145+
# We need bash to write $$ to cgroup.procs before exec'ing sslproxy.
146+
cgroup_procs = f"/sys/fs/cgroup/sslproxy/{self.machine.ip}/cgroup.procs"
147+
shell_cmd = "echo $$ > {} 2>/dev/null; exec {}".format(
148+
shlex.quote(cgroup_procs),
149+
" ".join(shlex.quote(arg) for arg in sslproxy_cmd),
150+
)
151+
popen_args = ["sudo", "bash", "-c", shell_cmd]
152+
153+
self.log_file = open(os.path.join(self.storage_dir, "sslproxy.log"), "w")
154+
self.log_file.write(" ".join(sslproxy_cmd) + "\n")
155+
self.log_file.flush()
156+
157+
try:
158+
self.proc = subprocess.Popen(popen_args, stdout=self.log_file,
159+
stderr=self.log_file, shell=False,
160+
start_new_session=True)
161+
except (OSError, subprocess.SubprocessError):
162+
self.log_file.close()
163+
self.log_file = None
164+
raise
165+
166+
log.info("Started SSLproxy PID %d for task %s (autossl port=%s, VM=%s)",
167+
self.proc.pid, self.task.id, self.proxy_port, self.machine.ip)
168+
169+
def _disable_rooter(self):
170+
"""Remove per-VM iptables rules."""
171+
if not self._rooter_enabled:
172+
return
173+
try:
174+
rooter("sslproxy_disable", self.interface, self.machine.ip,
175+
self.proxy_port, self.resultserver_port, self.rt_table)
176+
self._rooter_enabled = False
177+
except Exception as e:
178+
log.error("Failed to disable SSLproxy iptables rules: %s", e)
179+
180+
def stop(self):
181+
self.do_run = False
182+
183+
try:
184+
if self.proc and self.proc.poll() is None:
185+
log.info("Stopping SSLproxy for task %s", self.task.id)
186+
try:
187+
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
188+
self.proc.wait(timeout=10)
189+
except subprocess.TimeoutExpired:
190+
log.warning("SSLproxy did not exit gracefully, killing")
191+
try:
192+
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
193+
self.proc.wait(timeout=5)
194+
except OSError:
195+
pass
196+
except OSError:
197+
pass # Process already exited
198+
except Exception as e:
199+
log.error("Failed to stop SSLproxy: %s", e)
200+
finally:
201+
self.proc = None
202+
if self.log_file:
203+
self.log_file.close()
204+
self.log_file = None
205+
self._disable_rooter()

0 commit comments

Comments
 (0)