|
| 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