diff --git a/conf/default/web.conf.default b/conf/default/web.conf.default index 9f1ce89aec6..fb0ea52e205 100644 --- a/conf/default/web.conf.default +++ b/conf/default/web.conf.default @@ -59,6 +59,8 @@ reports_dl_allowed_to_all = yes expose_process_log = no # Show button to reprocess the task reprocess_tasks = no +# Allows you to define URL splitter, "," is default +url_splitter = , # ratelimit for anon users [ratelimit] diff --git a/docs/book/src/installation/host/routing.rst b/docs/book/src/installation/host/routing.rst index 7ce89cf55d4..165e374f68c 100644 --- a/docs/book/src/installation/host/routing.rst +++ b/docs/book/src/installation/host/routing.rst @@ -79,6 +79,8 @@ Following is the list of available routing options. +-------------------------+--------------------------------------------------+ | :ref:`routing_tor` | Routes all traffic through Tor. | +-------------------------+--------------------------------------------------+ +| :ref:`routing_tun` | Route traffic though any "tun" interface | ++-------------------------+--------------------------------------------------+ | :ref:`routing_vpn` | Routes all traffic through one of perhaps | | | multiple pre-defined VPN endpoints. | +-------------------------+--------------------------------------------------+ @@ -86,6 +88,7 @@ Following is the list of available routing options. | | multiple pre-defined VPN endpoints. | +-------------------------+--------------------------------------------------+ + Using Per-Analysis Network Routing ================================== @@ -358,6 +361,18 @@ correctly. .. _`latest stable version of Tor here`: https://www.torproject.org/docs/debian.html.en + +.. _routing_tun: + +Tun Routing +^^^^^^^^^^^ +This allows you to route via any ``tun`` interface. You can pass the tun +interface name on demand per analysis. The interface name can be ``tunX`` +or ``tun_foo``. This assumes you create the tunnel inferface outside of CAPE. + +Then you set the ``route=tun_foo`` on the ``/apiv2/tasks/create/file/`` +API call. + .. _routing_vpn: VPN Routing @@ -454,13 +469,13 @@ VPN persistence & auto-restart `source`_:: 6. Reload the daemons: # sudo systemctl daemon-reload - 1. Start the OpenVPN service: + 7. Start the OpenVPN service: # sudo systemctl start openvpn - 2. Test if it is working by checking the external IP: + 8. Test if it is working by checking the external IP: # curl ifconfig.co - 3. If curl is not installed: + 9. If curl is not installed: # sudo apt install curl .. _`source`: https://www.ivpn.net/knowledgebase/linux/linux-autostart-openvpn-in-systemd-ubuntu/ @@ -568,7 +583,7 @@ Assuming you already have any VM running, to test the internet connection using $ sudo python3 router_manager.py -r internet -e --vm-name win1 --verbose $ sudo python3 router_manager.py -r internet -d --vm-name win1 --verbose -The ``-e`` flag is used to enable a route and ``-d`` is used to disable it. You can read more about all the options the utility has by running:: +The ``-e`` flag is used to enable a route and ``-d`` is used to disable it. You can read more about all the options the utility has by running:: $ sudo python3 router_manager.py -h diff --git a/lib/cuckoo/core/analysis_manager.py b/lib/cuckoo/core/analysis_manager.py index 2a792586909..78ab8f81301 100644 --- a/lib/cuckoo/core/analysis_manager.py +++ b/lib/cuckoo/core/analysis_manager.py @@ -32,6 +32,7 @@ # os.listdir('/sys/class/net/') HAVE_NETWORKIFACES = False + try: import psutil @@ -43,6 +44,12 @@ latest_symlink_lock = threading.Lock() +def is_network_interface(intf: str): + global network_interfaces + network_interfaces = list(psutil.net_if_addrs().keys()) + return intf in network_interfaces + + class CuckooDeadMachine(Exception): """Exception thrown when a machine turns dead. @@ -536,6 +543,9 @@ def route_network(self): self.rt_table = vpns[self.route].rt_table elif self.route in self.socks5s: self.interface = "" + elif self.route[:3] == "tun" and is_network_interface(self.route): + # tunnel interface starts with "tun" and interface exists on machine + self.interface = self.route else: self.log.warning("Unknown network routing destination specified, ignoring routing for this analysis: %s", self.route) self.interface = None @@ -583,6 +593,9 @@ def route_network(self): elif self.route in ("none", "None", "drop"): self.rooter_response = rooter("drop_enable", self.machine.ip, str(self.cfg.resultserver.port)) + elif self.route[:3] == "tun" and is_network_interface(self.route): + self.log.info("Network interface {} is tunnel", self.interface) + self.rooter_response = rooter("interface_route_tun_enable", self.machine.ip, self.route, str(self.task.id)) self._rooter_response_check() @@ -714,6 +727,9 @@ def unroute_network(self): elif self.route in ("none", "None", "drop"): self.rooter_response = rooter("drop_disable", self.machine.ip, str(self.cfg.resultserver.port)) + elif self.route[:3] == "tun": + self.log.info("Disable tunnel interface {}", self.interface) + self.rooter_response = rooter("interface_route_tun_disable", self.machine.ip, self.route, str(self.task.id)) self._rooter_response_check() diff --git a/lib/cuckoo/core/scheduler.py b/lib/cuckoo/core/scheduler.py index 45d6b8dc506..5b43d5691f7 100644 --- a/lib/cuckoo/core/scheduler.py +++ b/lib/cuckoo/core/scheduler.py @@ -250,7 +250,7 @@ def is_short_on_disk_space(self): # Resolve the full base path to the analysis folder, just in # case somebody decides to make a symbolic link out of it. dir_path = os.path.join(CUCKOO_ROOT, "storage", "analyses") - need_space, space_available = free_space_monitor(dir_path, return_value=True, analysis=True) + need_space, space_available = free_space_monitor(dir_path, analysis=True) if need_space: log.error( "Not enough free disk space! (Only %d MB!). You can change limits it in cuckoo.conf -> freespace", space_available diff --git a/modules/processing/analysisinfo.py b/modules/processing/analysisinfo.py index 6b13436bfc2..4f9b07f5b55 100644 --- a/modules/processing/analysisinfo.py +++ b/modules/processing/analysisinfo.py @@ -7,19 +7,31 @@ import time from contextlib import suppress from datetime import datetime +from pathlib import Path from lib.cuckoo.common.abstracts import Processing -from lib.cuckoo.common.constants import CUCKOO_VERSION +from lib.cuckoo.common.constants import CUCKOO_ROOT, CUCKOO_VERSION from lib.cuckoo.common.exceptions import CuckooProcessingError from lib.cuckoo.common.path_utils import path_exists from lib.cuckoo.common.utils import get_options from lib.cuckoo.core.database import Database +# https://stackoverflow.com/questions/14989858/get-the-current-git-hash-in-a-python-script/68215738#68215738 + log = logging.getLogger(__name__) db = Database() +def get_running_commit() -> str: + git_folder = Path(CUCKOO_ROOT, ".git") + head_name = Path(git_folder, "HEAD").read_text().split("\n")[0].split(" ")[-1] + return Path(git_folder, head_name).read_text().replace("\n", "") + + +CAPE_CURRENT_COMMIT_HASH = get_running_commit() + + class AnalysisInfo(Processing): """General information about analysis session.""" @@ -111,4 +123,5 @@ def run(self): "source_url": source_url, "route": self.task.get("route"), "user_id": self.task.get("user_id"), + "CAPE_current_commit": CAPE_CURRENT_COMMIT_HASH, } diff --git a/poetry.lock b/poetry.lock index 7acc6a4f1d6..9a1f36c4f0b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -702,6 +702,22 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "crispy-bootstrap4" +version = "2024.10" +description = "Bootstrap4 template pack for django-crispy-forms" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "crispy-bootstrap4-2024.10.tar.gz", hash = "sha256:503e8922b0f3b5262a6fdf303a3a94eb2a07514812f1ca130b88f7c02dd25e2b"}, + {file = "crispy_bootstrap4-2024.10-py3-none-any.whl", hash = "sha256:138a97884044ae4c4799c80595b36c42066e4e933431e2e971611e251c84f96c"}, +] + +[package.dependencies] +django = ">=4.2" +django-crispy-forms = ">=2.3" + [[package]] name = "crudini" version = "0.9.5" @@ -5032,4 +5048,4 @@ maco = ["maco"] [metadata] lock-version = "2.1" python-versions = ">=3.10, <4.0" -content-hash = "e6ad75fb46d8336a5f0b2a9ca9bd0ed931c1d96119263cb34847a3528ebbae30" +content-hash = "ac21ceb76cd2d8a049442fec4a1370391c008b7044056754f44ec4b1eb0c7bb5" diff --git a/pyproject.toml b/pyproject.toml index 4d04fda75e2..3dde9b1234a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ pyzipper = "0.3.6" flare-capa = "9.0.0" Cython = "3.0.11" -# pyre2 = "0.3.6" # Dead for python3.11 Django = ">=4.2.18" SQLAlchemy = "1.4.50" SQLAlchemy-Utils = "0.41.1" @@ -53,6 +52,7 @@ bs4 = "0.0.1" pydeep2 = "0.5.1" django-recaptcha = "4.0.0" # https://pypi.org/project/django-recaptcha/ django-crispy-forms = "2.3" +crispy-bootstrap4 = "2024.10" django-settings-export = "1.2.1" django-csp = "3.8" django-extensions = "3.2.3" diff --git a/requirements.txt b/requirements.txt index 468e07e72d3..081a6a95762 100644 --- a/requirements.txt +++ b/requirements.txt @@ -250,6 +250,9 @@ colorclass==2.2.2 ; python_version >= "3.10" and python_version < "4.0" \ constantly==23.10.4 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9 \ --hash=sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd +crispy-bootstrap4==2024.10 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:138a97884044ae4c4799c80595b36c42066e4e933431e2e971611e251c84f96c \ + --hash=sha256:503e8922b0f3b5262a6fdf303a3a94eb2a07514812f1ca130b88f7c02dd25e2b crudini==0.9.5 ; python_version >= "3.10" and python_version < "4.0" \ --hash=sha256:59ae650f45af82a64afc33eb876909ee0c4888dc4e8711ef59731c1edfda5e24 \ --hash=sha256:84bc208dc7d89571bdc3c99274259d0b32d6b3a692d4255524f2eb4b64e9195c diff --git a/utils/process.py b/utils/process.py index 4e60ee8e7c6..aa2bb84b1a3 100644 --- a/utils/process.py +++ b/utils/process.py @@ -320,7 +320,7 @@ def autoprocess( # If not enough free disk space is available, then we print an # error message and wait another round (this check is ignored # when the freespace configuration variable is set to zero). - if cfg.cuckoo.freespace: + if cfg.cuckoo.freespace_processing: # Resolve the full base path to the analysis folder, just in # case somebody decides to make a symbolic link out of it. dir_path = os.path.join(CUCKOO_ROOT, "storage", "analyses") diff --git a/utils/rooter.py b/utils/rooter.py index 613a5911d97..8b5a375f726 100644 --- a/utils/rooter.py +++ b/utils/rooter.py @@ -6,6 +6,7 @@ import argparse import errno import grp +import ipaddress import json import logging.handlers import os @@ -47,6 +48,49 @@ def run(*args): return stdout, stderr +def get_tun_peer_address(interface_name): + """Gets the peer address of a tun interface. + + Args: + interface_name: The name of the tun interface (e.g., "tun0"). + + Returns: + The peer IP address as a string, or None if an error occurs. Returns None if the interface does not exist, or does not have a peer. + """ + try: + result = subprocess.run(["ip", "addr", "show", interface_name], capture_output=True, text=True, check=True) + output = result.stdout + + for line in output.splitlines(): + if "peer" in line: + parts = line.split() + if len(parts) > 1: # Check if there's a second element to avoid IndexError + peer_with_cidr = parts[1] + try: + # Handle CIDR notation using ipaddress library + peer_ip = ipaddress.ip_interface(peer_with_cidr).ip.exploded + return peer_ip + except ValueError: # Handle invalid CIDR notations + try: + peer_ip = peer_with_cidr.split("/")[0] # Try just splitting by / + return peer_ip + except IndexError: + return None # Invalid format - give up. + else: + return None # No peer address found on the line. + return None # "peer" not found in the output + + except subprocess.CalledProcessError as e: + if e.returncode == 1: # Interface not found + return None + else: + print(f"Error executing ip command: {e}") + return None + except FileNotFoundError: + print("ip command not found. Is iproute2 installed?") + return None + + def enable_ip_forwarding(sysctl="/usr/sbin/sysctl"): log.debug("Enabling IPv4 forwarding") run(sysctl, "-w" "net.ipv4.ip_forward=1") @@ -641,6 +685,50 @@ def inetsim_disable(ipaddr, inetsim_ip, dns_port, resultserver_port, ports): run_iptables("-D", "OUTPUT", "--source", ipaddr, "-j", "DROP") +def interface_route_tun_enable(ipaddr: str, out_interface: str, task_id: str): + """Enable routing and NAT via tun output_interface.""" + log.info(f"Enabling interface routing via: {out_interface} for task: {task_id}") + + # mark packets from analysis VM + run_iptables("-t", "mangle", "-I", "PREROUTING", "--source", ipaddr, "-j", "MARK", "--set-mark", task_id) + + run_iptables("-t", "nat", "-I", "POSTROUTING", "--source", ipaddr, "-o", out_interface, "-j", "MASQUERADE") + # ACCEPT forward + run_iptables("-t", "filter", "-I", "FORWARD", "--source", ipaddr, "-o", out_interface, "-j", "ACCEPT") + + # in routing table add route table task_id + run(s.ip, "rule", "add", "fwmark", task_id, "lookup", task_id) + + peer_ip = get_tun_peer_address(out_interface) + if peer_ip: + log.info(f"interface_route_enable {out_interface} has peer {peer_ip}") + run(s.ip, "route", "add", "default", "via", peer_ip, "table", task_id) + else: + log.error("interface_route_enable missing peer IP ") + + +def interface_route_tun_disable(ipaddr: str, out_interface: str, task_id: str): + """Disable routing and NAT via tun output_interface.""" + log.info(f"Disable interface routing via: {out_interface} for task: {task_id}") + + # mark packets from analysis VM + run_iptables("-t", "mangle", "-D", "PREROUTING", "--source", ipaddr, "-j", "MARK", "--set-mark", task_id) + + run_iptables("-t", "nat", "-D", "POSTROUTING", "--source", ipaddr, "-o", out_interface, "-j", "MASQUERADE") + # ACCEPT forward + run_iptables("-t", "filter", "-D", "FORWARD", "--source", ipaddr, "-o", out_interface, "-j", "ACCEPT") + + # in routing table add route table task_id + run(s.ip, "rule", "del", "fwmark", task_id, "lookup", task_id) + + peer_ip = get_tun_peer_address(out_interface) + if peer_ip: + log.info(f"interface_route_disable {out_interface} has peer {peer_ip}") + run(s.ip, "route", "del", "default", "via", peer_ip, "table", task_id) + else: + log.error("interface_route_disable missing peer IP ") + + def socks5_enable(ipaddr, resultserver_port, dns_port, proxy_port): """Enable hijacking of all traffic and send it to socks5.""" log.info("Enabling socks route.") @@ -750,6 +838,8 @@ def drop_disable(ipaddr, resultserver_port): "srcroute_disable": srcroute_disable, "inetsim_enable": inetsim_enable, "inetsim_disable": inetsim_disable, + "interface_route_tun_enable": interface_route_tun_enable, + "interface_route_tun_disable": interface_route_tun_disable, "socks5_enable": socks5_enable, "socks5_disable": socks5_disable, "drop_enable": drop_enable, diff --git a/web/submission/views.py b/web/submission/views.py index 6c6fa0e32b1..55070552757 100644 --- a/web/submission/views.py +++ b/web/submission/views.py @@ -422,8 +422,7 @@ def index(request, task_id=None, resubmit_hash=None): if task_category in ("url", "dlnexec"): if not samples: return render(request, "error.html", {"error": "You specified an invalid URL!"}) - - for url in samples.split(","): + for url in samples.split(web_conf.general.url_splitter): url = url.replace("hxxps://", "https://").replace("hxxp://", "http://").replace("[.]", ".") if task_category == "dlnexec": path, content, sha256 = process_new_dlnexec_task(url, route, options, custom) diff --git a/web/web/settings.py b/web/web/settings.py index 2859fe2f4e5..2ace269fcfd 100644 --- a/web/web/settings.py +++ b/web/web/settings.py @@ -265,6 +265,7 @@ # "allauth.socialaccount.providers.google", # "allauth.socialaccount.providers.microsoft", "crispy_forms", + "crispy_bootstrap4", "django_recaptcha", # https://pypi.org/project/django-recaptcha/ "rest_framework", "rest_framework.authtoken", @@ -340,7 +341,7 @@ ACCOUNT_EMAIL_REQUIRED = web_cfg.registration.get("email_required", False) ACCOUNT_EMAIL_SUBJECT_PREFIX = web_cfg.registration.get("email_prefix_subject", False) -ACCOUNT_RATE_LIMITS = {"login_failed": 3} +ACCOUNT_RATE_LIMITS = {"login_failed": "3/m"} LOGIN_REDIRECT_URL = "/" ACCOUNT_LOGOUT_REDIRECT_URL = "/accounts/login/" MANUAL_APPROVE = web_cfg.registration.get("manual_approve", False)