diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 12fec791..bb3415cc 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,24 @@ +Version 3.3 +----------- +* Hide logging in DNS listener and Diverter for blacklisted processes + when not in verbose mode +* Use binary location instead of current directory when getting config + files in Pyinstaller bundles + +Version 3.2 +----------- +* Use .1 for default gateway instead of .254 because this is the default Virtual + Adapter address for VMWare and VirtualBox. +* Update documentation to use new year +* Update documentation links to current working links +* Update documentation to use Mandiant instead of FireEye +* Fix the filepath of HTML report template to work in all methods of installations + including Pyinstaller bundles. + +Version 3.1 +----------- +* HTML and text NBI after-reporting courtesy of @3V3RYONE and @tinajohnson + Version 3.0 (alpha) ----------- * Migrate diverters, listeners and other components to Python 3 diff --git a/LICENSE.txt b/LICENSE.txt index 91adcb11..dc91de02 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -175,7 +175,7 @@ END OF TERMS AND CONDITIONS - Copyright (C) 2016-2024 Mandiant, Inc. + Copyright (C) 2024 Mandiant, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 5af59db3..53c3905f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ D O C U M E N T A T I O N -FakeNet-NG 3.0 (alpha) is a next generation dynamic network analysis tool for malware +FakeNet-NG 3.3 is a next generation dynamic network analysis tool for malware analysts and penetration testers. It is open source and designed for the latest versions of Windows (and Linux, for certain modes of operation). FakeNet-NG is based on the excellent Fakenet tool developed by Andrew Honig and Michael @@ -52,23 +52,20 @@ Installation on Linux requires the following dependencies: * libnetfilterqueue development files (e.g. libnetfilter-queue-dev for Ubuntu). -To install these dependencies, use the following command: +Install these dependencies using the following command: sudo apt-get install build-essential python-dev libnetfilter-queue-dev -Either install FakeNet-NG as a Python module using pip: +Install FakeNet-NG as a Python module using pip: pip install https://github.com/mandiant/flare-fakenet-ng/zipball/master -Or, by obtaining the latest source code and installing it manually: +Or by obtaining the latest source code and installing it manually: git clone https://github.com/mandiant/flare-fakenet-ng/ -Next, install Microsoft C++ Build Tools from [here](https://visualstudio.microsoft.com/visual-cpp-build-tools/). - Change directory to the downloaded flare-fakenet-ng and run: - pip install setuptools python setup.py install Execute FakeNet-NG by running 'fakenet' in any directory. @@ -80,31 +77,30 @@ Finally if you would like to avoid installing FakeNet-NG and just want to run it as-is (e.g. for development), then you would need to obtain the source code and install dependencies as follows: -1) Install 64-bit or 32-bit Python 3.12 for the 64-bit or 32-bit versions +1) Install 64-bit or 32-bit Python 3.10.11 for the 64-bit or 32-bit versions of Windows respectively. 2) Install Python dependencies: - ``` - pip install pydivert dnslib dpkt pyopenssl pyftpdlib netifaces - ``` + + pip install pydivert dnslib dpkt pyopenssl pyftpdlib netifaces jinja2 + *NOTE*: pydivert will also download and install WinDivert library and driver in the `%PYTHONHOME%\DLLs` directory. FakeNet-NG bundles those files so they are not necessary for normal use. Optionally, you can install the following module used for testing: - ``` + pip install requests - ``` 3) Download the FakeNet-NG source code: git clone https://github.com/mandiant/flare-fakenet-ng -4) Execute FakeNet-NG by running it with a Python interpreter in a privileged +Execute FakeNet-NG by running it with a Python interpreter in a privileged shell: - ``` + python -m fakenet.fakenet - ``` + Usage ===== @@ -120,10 +116,10 @@ parameter to get simple help: | | / ____ \| . \| |____| |\ | |____ | | | |\ | |__| | |_|/_/ \_\_|\_\______|_| \_|______| |_| |_| \_|\_____| - Version 3.0 (alpha) + Version 3.2 _____________________________________________________________ Developed by FLARE Team - Copyright (C) 2016-2023 Mandiant, Inc. All rights reserved. + Copyright (C) 2016-2024 Mandiant, Inc. All rights reserved. _____________________________________________________________ Usage: python -m fakenet.fakenet [options]: @@ -175,10 +171,10 @@ and an HTTP connection: | | / ____ \| . \| |____| |\ | |____ | | | |\ | |__| | |_|/_/ \_\_|\_\______|_| \_|______| |_| |_| \_|\_____| - Version 3.0 (alpha) + Version 3.2 _____________________________________________________________ Developed by FLARE Team - Copyright (C) 2016-2022 Mandiant, Inc. All rights reserved. + Copyright (C) 2016-2024 Mandiant, Inc. All rights reserved. _____________________________________________________________ 07/06/16 10:20:52 PM [ FakeNet] Loaded configuration file: configs/default.ini @@ -250,13 +246,49 @@ logs will be labeled with the name set in the configuration file: 07/06/16 10:21:03 PM [ DNS Server] Received A request for domain 'evil.com'. -To stop FakeNet-NG and close out the generated PCAP file simply press `CTRL-C`: +To stop FakeNet-NG and save the generated PCAP file and HTML report to disk simply press `CTRL-C`: 07/06/16 10:21:41 PM [ FakeNet] Stopping... 07/06/16 10:21:42 PM [ HTTPListener80] Stopping... 07/06/16 10:21:42 PM [ HTTPListener443] Stopping... 07/06/16 10:21:42 PM [ SMTPListener] Stopping... 07/06/16 10:21:43 PM [ Diverter] Stopping... + 07/06/16 10:21:43 PM [ Diverter] Generated new HTML report: report_20160607_102143.html + +User Interface +-------------- + +With each session of FakeNet-NG, an HTML report containing the Network-Based Indicators (NBIs) captured throughout the session is generated. Upon termination of FakeNet by pressing `CTRL-C`, this HTML file will be saved to the root directory of FakeNet. A user can review the NBIs by viewing this HTML file in a browser such as Chrome or Firefox. + +The HTML report serves as an interactive Graphical User Interface (GUI) that presents the NBI summary in a user-friendly manner. It includes various features to select, filter, and copy NBIs, making network analysis easier. The UI organizes all NBIs based on their process information and then further categorizes them by the application layer or transport layer protocol they use. + +#### NBI Summary Table +The information in the NBI summary table is presented in a tabular format and includes the following details: + + * Select: Clicking on the checkbox selects the corresponding NBI. You can select multiple NBIs across different or the same protocols. The entire row can also be selected by clicking anywhere within the row. Selected NBIs can be copied using the "Copy Selected NBIs" button. + + * NBI: This cell represents the actual captured NBI. It includes commands, parameters, URIs, and other significant activity generated by the client against the listener. This cell summarizes malware behavior for better understanding. + + * Additional Information: This cell provides extra information about each NBI request such as the transport layer protocol used, destination IP, port, and SSL encryption. + + * Actions: This cell allows you to perform actions on individual NBIs. Currently, only copying is supported. Clicking the copy button copies the specific NBI cell data in a markdown format suitable for creating reports. + +#### Interactive Features +The UI also includes various interactive features: + + * Checkbox Selection: Checkboxes are available before each process and protocol block. Ticking a checkbox selects all NBIs under that process or protocol. This is useful when you want to select all NBIs from a particular process or protocol. You can then use the `Copy Selected NBIs` button to copy the selected data. + + * Search Bar: The search bar lets you type keywords, and only the rows containing these keywords in the process name, NBI, or additional information will be displayed in the HTML page. You can then use the "Copy Filtered Data" button to copy the displayed data in markdown format. Clearing the search query restores the original table view. + + * Copy Buttons: + + * `Copy Selected Data`: Copies all the selected NBIs in markdown format. You can select individual NBIs or all NBIs under a process by ticking checkboxes. + * `Copy Filtered Data`: Copies the filtered NBIs' data in markdown format. If no search query is used, this button copies the entire data. + * `Copy All NBIs`: Copies all the NBIs in markdown format present in the HTML page. Even if a filter is applied, clicking this button copies all NBIs. + + * Disclaimer Button: Displays the disclaimer, which outlines important facts for the user to consider before making assumptions about the displayed NBI summary. + + * Go To Top Button: Appears when the page's content exceeds the viewable area. Clicking this button takes you to the top of the page, where you can access important buttons like `Copy Selected NBIs`,` Copy All NBIs`, `Copy Filtered NBIs`, and the search bar. Configuration ------------- @@ -702,6 +734,15 @@ plugins and extend existing functionality. For details, see Known Issues ============ +[WinError 87] The parameter is incorrect +---------------------------------------- +As of this wriring, the default buffer size in pydivert is 1500. If FakeNet-NG +encounters a packet larger than the default buffer size, you may observe this error. +A workaround is to specify the desired buffer size in self.handle.recv(bufsize=) +in fakenet/diverters/windows. +See [here](https://github.com/ffalcinelli/pydivert/issues/42#issuecomment-495036124) + + Does not work on VMWare with host-only mode enabled --------------------------------------------------- diff --git a/docs/architecture.md b/docs/architecture.md index 9b0c7a2d..3d90e725 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,11 +12,11 @@ directly (if they are not hidden behind the ProxyListener) or through the ProxyListener. This architecture is in contrast to tools like PyNetSim (can't find an authoritative hyperlink to cite this reference) that effectively integrate all services into a bus. The benefit of this additional complexity in -FakeNet-NG’s architecture is that it can incorporate Listeners based on generic +FakeNet-NG's architecture is that it can incorporate Listeners based on generic code that expects to directly bind to ports and manage its own sockets. The FakeNet-NG architecture is diagrammed subsequently. -![FakeNet-NG Architecture](https://github.com/fireeye/flare-fakenet-ng/raw/master/docs/fakenet_architecture.png "FakeNet-NG Architecture") +![FakeNet-NG Architecture](https://github.com/mandiant/flare-fakenet-ng/blob/master/docs/fakenet_architecture.png "FakeNet-NG Architecture") # Diverters diff --git a/docs/contributors.md b/docs/contributors.md index 1134fa28..09fe09a6 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -30,5 +30,14 @@ Homan developed the original concept of using a protocol "taste" callback to sample traffic and direct clients to the appropriate server ports. Matthew Haigh, Michael Bailey, and Peter Kacherginsky conceptualized the Proxy Listener and Hidden Listener mechanisms for introducing both of these content-based -protocol detection features to FakeNet-NG. Matthew Haigh then implemented -Content-Based Protocol Detection. +protocol detection features to FakeNet-NG. Matthew Haigh then [implemented +Content-Based Protocol +Detection](https://www.mandiant.com/content/fireeye-www/en_US/blog/threat-research/2017/10/fakenet-content-based-protocol-detection.html). + +## HTML- and Text-Based NBI After-Reporting + +Google Summer of Code contributor Beleswar Prasad (@3V3RYONE) worked with +mentor Tina Johnson (@tinajohnson) to add HTML- and text-based reporting of +network-based indicators (NBIs) to FakeNet-NG, requiring significant work +throughout the codebase to facilitate the necessary communication and tracking +between components. diff --git a/docs/developing.md b/docs/developing.md index 6a58db4f..be8f2c97 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -181,8 +181,9 @@ utilities (i.e. `pip`). Use an administrative command prompt where applicable for installing Python modules for all users. Pre-requisites: -* Python 3.12 x86 with `pip` -* Microsoft C++ [Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) +* Python 3.10.11 x86 with `pip` +* Visual C++ for Python development, available at: + Before installing `pyinstaller`, you may wish to take the following steps to prevent the error `ImportError: No module named PyInstaller`: @@ -198,10 +199,11 @@ Install FakeNet-NG to acquire most modules: python setup.py install ``` -Obtain PyDivert: +Obtain PyDivert 2.0.9, the only version known to work with FakeNet-NG releases +prepared with PyInstaller: ``` -pip install pydivert +pip install pydivert==2.0.9 ``` Install `pyinstaller`: @@ -243,6 +245,7 @@ fakenet1.4.3\ | +-- CustomProviderExample.py |   +-- sample_custom_response.ini | +-- sample_raw_response.txt + | +-- html_report_template.html | +-- defaultFiles\ | +-- FakeNet.gif @@ -257,7 +260,7 @@ fakenet1.4.3\ | +-- listeners\    +-- ssl_utils - +-- __init__.py + +-- __init__.pyc +-- privkey.pem +-- server.pem +-- ssl_detector.py diff --git a/docs/srs.md b/docs/srs.md index 9e9c934c..29c49447 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -33,7 +33,8 @@ Tool](https://www.mandiant.com/resources/blog/introducing-linux-support-fakenet- The next significant FakeNet-NG release was by Matthew Haigh on October 23, 2017 to introduce a proxy listener to sample, identify, and route traffic to -the most appropriate listener by implementing Content-Based Protocol Detection. +the most appropriate listener: [New FakeNet-NG Feature: Content-Based Protocol +Detection](https://www.mandiant.com/content/fireeye-www/en_US/blog/threat-research/2017/10/fakenet-content-based-protocol-detection.html). Mandiant's [flare-fakenet-ng](https://github.com/mandiant/flare-fakenet-ng) repository contains `README.md` which documents usage and configuration; and @@ -156,7 +157,7 @@ The Configuration Logic for parsing and validating the configuration file is spread throughout the Application, Diverter, and Listeners. The configuration file is a -[ConfigParser](https://docs.python.org/2/library/configparser.html)-compatible +[ConfigParser](https://docs.python.org/3/library/configparser.html)-compatible file at an operator-specified location detailing how FakeNet-NG is to behave. Proposed: it may be beneficial to better encapsulate and centralize the diff --git a/fakenet/configs/html_report_template.html b/fakenet/configs/html_report_template.html new file mode 100644 index 00000000..b7c0c012 --- /dev/null +++ b/fakenet/configs/html_report_template.html @@ -0,0 +1,701 @@ + + + + FAKENET-NG + + + + + +
+

FAKENET-NG

+

Network-Based Indicators

+
+ +
+ +
+ +
+ + + + +
+ +
+
+ +
+
+ + + + + {% for process, process_info in nbis.items() %} +
+ +
+ {% for proto, nbis_list in process_info.items() %} + {% if proto != 'process_name' %} +
+ +
+
    +
    + + + + + + + + + {% for nbi_info in nbis_list %} + + + + + + + {% endfor %} +
    SelectNBIAdditional InformationActions
    +
      + {% for key, value in nbi_info['nbi'].items() %} + {% if key == 'Data Hexdump' %} +
    • {{ key|e }} (showing first 256 bytes): + {% for line in value %} +

      {{ line|e }}

      + {% endfor %} +

    • + {% else %} +
    • {{ key|e }}: {{ value|e }}
    • + {% endif %} + {% endfor %} +
    +
    +
      +
    • Transport Layer Protocol: {{ nbi_info['transport_layer_proto']|e }}
    • +
    • Destination IP: {{ nbi_info['dst_ip']|e }}
    • +
    • Destination port: {{ nbi_info['dport']|e }}
    • +
    • SSL encrypted: {{ nbi_info['is_ssl_encrypted']|e }}
    • +
    • Network mode: {{ nbi_info['network_mode']|e }}
    • +
    +
    +
    +
+
+
+ {% endif %} + {% endfor %} +
+
+ + {% endfor %} + +
+ + + + diff --git a/fakenet/diverters/diverterbase.py b/fakenet/diverters/diverterbase.py index 59efbd1a..38149de0 100644 --- a/fakenet/diverters/diverterbase.py +++ b/fakenet/diverters/diverterbase.py @@ -6,6 +6,7 @@ import time import dpkt import signal +import jinja2 import socket import logging import threading @@ -15,6 +16,7 @@ from .debuglevels import * from collections import namedtuple from collections import OrderedDict +from pathlib import Path class DivertParms(object): @@ -90,9 +92,14 @@ def first_packet_new_session(self): Returns: True if this pair of endpoints hasn't conversed before, else False """ - return not (self.diverter.sessions.get(self.pkt.sport) == - (self.pkt.dst_ip, self.pkt.dport)) + # sessions.get returns (dst_ip, dport, pid, comm, dport0, proto) or + # None. We just want dst_ip and dport for comparison. + session = self.diverter.sessions.get(self.pkt.sport) + if session is None: + return True + return not ((session.dst_ip, session.dport) == + (self.pkt.dst_ip, self.pkt.dport)) class DiverterPerOSDelegate(object, metaclass=abc.ABCMeta): """Delegate class for OS-specific methods that FakeNet-NG implementors must @@ -534,6 +541,18 @@ def __init__(self, diverter_config, listeners_config, ip_addrs, self.logger = logging.getLogger('Diverter') self.logger.setLevel(logging_level) + # Network Based Indicators + self.nbis = {} + + # Index remote Process IDs for MultiHost operations + self.remote_pid_counter = 0 + + # Maps Proxy initiated source ports to original source ports + self.proxy_sport_to_orig_sport_map = {} + + # Maps (proxy_sport, orig_sport) to pkt SSL encryption + self.is_proxied_pkt_ssl_encrypted = {} + # Rate limiting for displaying pid/comm/proto/IP/port self.last_conn = None @@ -683,6 +702,8 @@ def start(self): def stop(self): self.logger.info('Stopping...') + self.prettyPrintNbi() + self.generate_html_report() return self.stopCallback() @abc.abstractmethod @@ -1033,6 +1054,8 @@ def parse_diverter_config(self): self.getconfigval('processblacklist').split(',')] self.logger.debug('Blacklisted processes: %s', ', '.join( [str(p) for p in self.blacklist_processes])) + if self.logger.level == logging.INFO: + self.logger.info('Hiding logs from blacklisted processes') # Only redirect whitelisted processes if self.is_configured('processwhitelist'): @@ -1181,7 +1204,18 @@ def handle_pkt(self, pkt, callbacks3, callbacks4): pc = PidCommDest(pid, comm, pkt.proto, pkt.dst_ip0, pkt.dport0) if pc.isDistinct(self.last_conn, self.ip_addrs[pkt.ipver]): self.last_conn = pc - self.logger.info('%s' % (str(pc))) + # As a user may not wish to see any logs from a blacklisted + # process, messages are logged with level DEBUG. Executing + # FakeNet in the verbose mode will print these logs + is_process_blacklisted, _, _ = self.isProcessBlackListed( + pkt.proto, + process_name=comm, + dport=pkt.dport0 + ) + if is_process_blacklisted: + self.logger.debug('%s' % (str(pc))) + else: + self.logger.info('%s' % (str(pc))) # 2: Call layer 3 (network) callbacks for cb in callbacks3: @@ -1755,7 +1789,11 @@ def addSession(self, pkt): Returns: None """ - self.sessions[pkt.sport] = (pkt.dst_ip, pkt.dport) + session = namedtuple('session', ['dst_ip', 'dport', 'pid', + 'comm', 'dport0', 'proto']) + pid, comm = self.get_pid_comm(pkt) + self.sessions[pkt.sport] = session(pkt.dst_ip, pkt.dport, pid, + comm, pkt._dport0, pkt.proto) def maybeExecuteCmd(self, pkt, pid, comm): """Execute any ExecuteCmd associated with this port/listener. @@ -1776,3 +1814,253 @@ def maybeExecuteCmd(self, pkt, pid, comm): self.logger.info('Executing command: %s' % (execCmd)) self.execute_detached(execCmd) + def mapProxySportToOrigSport(self, proto, orig_sport, proxy_sport, + is_ssl_encrypted): + """Maps Proxy initiated source ports to their original source ports. + + The Proxy listener uses this method to notify the diverter about the + proxy originated source port for the original source port. It also + notifies if the packet uses SSL encryption. + + Args: + proto: str protocol of socket created by ProxyListener + orig_sport: int source port that originated the packet + proxy_sport: int source port initiated by Proxy listener + is_ssl_encrypted: bool is the packet SSL encrypted or not + + Returns: + None + """ + self.proxy_sport_to_orig_sport_map[(proto, proxy_sport)] = orig_sport + self.is_proxied_pkt_ssl_encrypted[(proto, proxy_sport)] = is_ssl_encrypted + + def logNbi(self, sport, nbi, proto, application_layer_proto, + is_ssl_encrypted): + """Collects the NBIs from all listeners into a dictionary. + + All listeners use this method to notify the diverter about any NBI + captured within their scope. + + Args: + sport: int port bound by listener + nbi: dict NBI captured within the listener + proto: str protocol used by the listener + application_layer_proto: str Application layer protocol of the pkt + is_ssl_encrpted: str is the listener configured to use SSL or not + + Returns: + None + """ + proxied_nbi = (proto, sport) in self.proxy_sport_to_orig_sport_map + + # For proxied nbis, override the listener's is_ssl_encrypted with Proxy + # listener's is_ssl_encrypted, and update the original sport. For + # non-proxied nbis, use listener provided is_ssl_encrypted and sport. + if proxied_nbi: + orig_sport = self.proxy_sport_to_orig_sport_map[(proto, sport)] + is_ssl_encrypted = self.is_proxied_pkt_ssl_encrypted.get((proto, sport)) + else: + orig_sport = sport + + if self.sessions.get(orig_sport) is None: + return + + dst_ip, _, pid, comm, orig_dport, transport_layer_proto = self.sessions.get(orig_sport) + + if application_layer_proto == '': + application_layer_proto = transport_layer_proto + + # Normalize pid and comm for MultiHost mode + if pid is None and comm is None and self.network_mode.lower() == 'multihost': + self.remote_pid_counter += 1 + pid = self.remote_pid_counter + comm = 'Remote Process' + + # Craft the dictionary + nbi_entry = { + 'transport_layer_proto': transport_layer_proto, + 'sport': orig_sport, + 'dst_ip': dst_ip, + 'dport': orig_dport, + 'is_ssl_encrypted': is_ssl_encrypted, + 'network_mode': self.network_mode.lower(), + 'nbi': nbi + } + application_layer_proto = application_layer_proto.lower() + + # If it's a new NBI from an exisitng process or existing protocol, + # append the nbi, else create new key + self.nbis.setdefault((pid, comm), {}).setdefault(application_layer_proto, + []).append(nbi_entry) + + def prettyPrintNbi(self): + """Convenience method to print all NBIs in appropriate format upon + fakenet session termination. Called by stop() method of diverter. + """ + banner = r""" + + + NNNNNNNN NNNNNNNNBBBBBBBBBBBBBBBBB IIIIIIIIII + N:::::::N N::::::NB::::::::::::::::B I::::::::I + N::::::::N N::::::NB::::::BBBBBB:::::B I::::::::I + N:::::::::N N::::::NBB:::::B B:::::BII::::::II + N::::::::::N N::::::N B::::B B:::::B I::::I ssssssssss + N:::::::::::N N::::::N B::::B B:::::B I::::I ss::::::::::s + N:::::::N::::N N::::::N B::::BBBBBB:::::B I::::I ss:::::::::::::s + N::::::N N::::N N::::::N B:::::::::::::BB I::::I s::::::ssss:::::s + N::::::N N::::N:::::::N B::::BBBBBB:::::B I::::I s:::::s ssssss + N::::::N N:::::::::::N B::::B B:::::B I::::I s::::::s + N::::::N N::::::::::N B::::B B:::::B I::::I s::::::s + N::::::N N:::::::::N B::::B B:::::B I::::I ssssss s:::::s + N::::::N N::::::::NBB:::::BBBBBB::::::BII::::::IIs:::::ssss::::::s + N::::::N N:::::::NB:::::::::::::::::B I::::::::Is::::::::::::::s + N::::::N N::::::NB::::::::::::::::B I::::::::I s:::::::::::ss + NNNNNNNN NNNNNNNBBBBBBBBBBBBBBBBB IIIIIIIIII sssssssssss + + + ======================================================================== + Network-Based Indicators Summary + ======================================================================== + """ + indent = " " + self.logger.info(banner) + process_counter = 0 + for process_info, values in self.nbis.items(): + process_counter += 1 + self.logger.info(f"[{process_counter}] Process ID: " + f"{process_info[0]}, Process Name: {process_info[1]}") + + for application_layer_proto, nbi_entry in values.items(): + self.logger.info(f"{indent*2} Protocol: " + f"{application_layer_proto}") + nbi_counter = 0 + + for attributes in nbi_entry: + nbi_counter += 1 + self.logger.info(f"{indent*3}{nbi_counter}.Transport Layer " + f"Protocol: {attributes['transport_layer_proto']}") + self.logger.info(f"{indent*4}Source port: {attributes['sport']}") + self.logger.info(f"{indent*4}Destination IP: {attributes['dst_ip']}") + self.logger.info(f"{indent*4}Destination port: {attributes['dport']}") + self.logger.info(f"{indent*4}SSL encrypted: " + f"{attributes['is_ssl_encrypted']}") + self.logger.info(f"{indent*4}Network mode: " + f"{attributes['network_mode']}") + + for key, v in attributes['nbi'].items(): + if v is not None: + # Let's convert the NBI value to str if it's not already + if isinstance(v, bytes): + v = v.decode('utf-8') + + # Let's print maximum 40 characters for NBI values + v = (v[:40]+"...") if len(v)>40 else v + self.logger.info(f"{indent*6}-{key}: {v}") + + self.logger.info("\r") + + self.logger.info("\r") + + def generate_html_report(self): + """Generates an interactive HTML report containing NBI summary saved + to the main working directory of flare-fakenet-ng. Called by stop() method + of diverter. + """ + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # Inside a Pyinstaller bundle + fakenet_dir_path = os.path.dirname(sys.executable) + else: + fakenet_dir_path = os.fspath(Path(__file__).parents[1]) + + template_file = os.path.join(fakenet_dir_path, "configs", "html_report_template.html") + template_loader = jinja2.FileSystemLoader(searchpath=os.path.dirname(template_file)) + template_env = jinja2.Environment(loader=template_loader) + template = template_env.get_template(os.path.basename(template_file)) + + timestamp = time.strftime('%Y%m%d_%H%M%S') + output_filename = f"report_{timestamp}.html" + + with open(output_filename, "w") as output_file: + output_file.write(template.render(nbis=self.nbis)) + + self.logger.info(f"Generated new HTML report: {output_filename}") + + def isProcessBlackListed(self, proto, sport=None, process_name=None, dport=None): + """Checks if a process is blacklisted. + Expected arguments are either: + - process_name and dport, or + - sport + """ + pid = None + + if self.single_host_mode and proto is not None: + if process_name is None or dport is None: + if sport is None: + return False, process_name, pid + + orig_sport = self.proxy_sport_to_orig_sport_map.get((proto, sport), sport) + session = self.sessions.get(orig_sport) + if session: + pid = session.pid + process_name = session.comm + dport = session.dport0 + else: + return False, process_name, pid + + # Check process blacklist + if process_name in self.blacklist_processes: + self.pdebug(DIGN, ('Ignoring %s packet from process %s ' + + 'in the process blacklist.') % (proto, + process_name)) + return True, process_name, pid + + # Check per-listener blacklisted process list + if self.listener_ports.isProcessBlackListHit( + proto, dport, process_name): + self.pdebug(DIGN, ('Ignoring %s request packet from ' + + 'process %s in the listener process ' + + 'blacklist.') % (proto, process_name)) + return True, process_name, pid + return False, process_name, pid + + +class DiverterListenerCallbacks(): + """A wrapper class for the diverter that provides controlled access to + specific methods required by listeners for reporting NBIs. This prevents + exposing the entire diverter to the listeners. + """ + def __init__(self, diverter): + """Initialize the DiverterWrapper. + + Args: + diverter: The Diverter object + """ + self.__diverter = diverter + + def logNbi(self, sport, nbi, proto, application_layer_proto, + is_ssl_encrypted): + """Delegate the logging of NBIs to the diverter. + + This method forwards the provided NBI information to the logNbi() method + in the underlying diverter object. Called by all listeners to log NBIs. + """ + self.__diverter.logNbi(sport, nbi, proto, application_layer_proto, + is_ssl_encrypted) + + def mapProxySportToOrigSport(self, proto, orig_sport, proxy_sport, + is_ssl_encrypted): + """Delegate the mapping of proxy sport to original sport to the + diverter. + + This method forwards the provided parameters to the + mapProxySportToOrigSport() method in the underlying diverter object. + Called by ProxyListener to report the mapping between proxy initiated + source port and original source port. + """ + self.__diverter.mapProxySportToOrigSport(proto, orig_sport, proxy_sport, + is_ssl_encrypted) + + def isProcessBlackListed(self, proto, sport): + """Check if the process is blacklisted. + """ + return self.__diverter.isProcessBlackListed(proto, sport=sport) diff --git a/fakenet/diverters/winutil.py b/fakenet/diverters/winutil.py index ab83fe50..6b613f56 100644 --- a/fakenet/diverters/winutil.py +++ b/fakenet/diverters/winutil.py @@ -361,9 +361,10 @@ def fix_gateway(self): # (Host-Only) if self.check_ipaddresses_interface(adapter) and adapter.DhcpEnabled: - (ip_address, netmask) = next( - self.get_ipaddresses_netmask(adapter)) - gw_address = ip_address[:ip_address.rfind('.')] + '.254' + (ip_address, netmask) = next(self.get_ipaddresses_netmask(adapter)) + # set the gateway ip address to be that of the virtual network adapter + # https://docs.vmware.com/en/VMware-Workstation-Pro/17/com.vmware.ws.using.doc/GUID-9831F49E-1A83-4881-BB8A-D4573F2C6D91.html + gw_address = ip_address[:ip_address.rfind('.')] + '.1' interface_name = self.get_adapter_friendlyname(adapter.Index) diff --git a/fakenet/fakenet.py b/fakenet/fakenet.py index 9dfcf58e..73c01fc4 100644 --- a/fakenet/fakenet.py +++ b/fakenet/fakenet.py @@ -64,7 +64,7 @@ def __init__(self, logging_level = logging.INFO): def parse_config(self, config_filename): # Handling Pyinstaller bundle scenario: https://pyinstaller.org/en/stable/runtime-information.html if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - dir_path = os.getcwd() + dir_path = os.path.dirname(sys.executable) else: dir_path = os.path.dirname(__file__) @@ -206,6 +206,10 @@ def start(self): (platform_name)) sys.exit(1) + # Import DiverterListenerCallbacks + from fakenet.diverters.diverterbase import DiverterListenerCallbacks + self.diverterListenerCallbacks = DiverterListenerCallbacks(self.diverter) + # Start all of the listeners for listener_name in self.listeners_config: @@ -265,6 +269,13 @@ def start(self): except AttributeError: self.logger.debug("acceptDiverter() not implemented by Listener %s" % listener.name) + # Only listeners that implement acceptDiverterListenerCallbacks(diverterListenerCallbacks) + # interface receive diverterListenerCallbacks + try: + listener.acceptDiverterListenerCallbacks(self.diverterListenerCallbacks) + except AttributeError: + self.logger.debug("acceptDiverterListenerCallbacks() not implemented by Listener %s" % listener.name) + def stop(self): self.logger.info("Stopping...") @@ -338,7 +349,7 @@ def main(): | | / ____ \| . \| |____| |\ | |____ | | | |\ | |__| | |_|/_/ \_\_|\_\______|_| \_|______| |_| |_| \_|\_____| - Version 3.0 (alpha) + Version 3.3 _____________________________________________________________ Developed by FLARE Team Copyright (C) 2016-2024 Mandiant, Inc. All rights reserved. diff --git a/fakenet/listeners/DNSListener.py b/fakenet/listeners/DNSListener.py index 2acbe160..0bf0afb1 100644 --- a/fakenet/listeners/DNSListener.py +++ b/fakenet/listeners/DNSListener.py @@ -49,6 +49,8 @@ def __init__( self.logger.debug('Initialized with config:') for key, value in config.items(): self.logger.debug(' %10s: %s', key, value) + if self.logger.level == logging.INFO: + self.logger.info('Hiding logs from blacklisted processes') def start(self): @@ -74,22 +76,45 @@ def stop(self): # Stop listener if self.server: self.server.shutdown() - self.server.server_close() + self.server.server_close() + + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks class DNSHandler(): + def log_message(self, log_level, is_process_blacklisted, message, *args): + """The primary objective of this method is to control the log messages + generated for requests from blacklisted processes. + + In a case where the DNS server is same as the local machine, the DNS + requests from a blacklisted process will reach the DNS listener (which + listens on port 53 locally) nevertheless. As a user may not wish to see + logs from a blacklisted process, messages are logged with level DEBUG. + Executing FakeNet in the verbose mode will print these logs. + """ + if is_process_blacklisted: + self.server.logger.log(logging.DEBUG, message, *args) + else: + self.server.logger.log(log_level, message, *args) - def parse(self,data): + def parse(self, data): response = "" - + proto = 'TCP' if self.server.socket_type == socket.SOCK_STREAM else 'UDP' + is_process_blacklisted, process_name, pid = self.server \ + .diverterListenerCallbacks \ + .isProcessBlackListed( + proto, + sport=self.client_address[1]) + try: # Parse data as DNS d = DNSRecord.parse(data) except Exception as e: - self.server.logger.error('Error: Invalid DNS Request') + self.log_message(logging.ERROR, is_process_blacklisted, 'Error: Invalid DNS Request') for line in hexdump_table(data): - self.server.logger.warning(INDENT + line) + self.log_message(logging.WARNING, is_process_blacklisted, INDENT + line) else: # Only Process DNS Queries @@ -104,8 +129,17 @@ def parse(self,data): if qname[-1] == '.': qname = qname[:-1] qtype = QTYPE[d.q.qtype] - - self.server.logger.info('Received %s request for domain \'%s\'.', qtype, qname) + self.qname = qname + self.qtype = qtype + + if process_name is None or pid is None: + self.log_message(logging.INFO, is_process_blacklisted, + 'Received %s request for domain \'%s\'.', + qtype, qname) + else: + self.log_message(logging.INFO, is_process_blacklisted, + 'Received %s request for domain \'%s\' from %s (%s)', + qtype, qname, process_name, pid) # Create a custom response to the query response = DNSRecord(DNSHeader(id=d.header.id, bitmap=d.header.bitmap, qr=1, aa=1, ra=1), q=d.q) @@ -160,11 +194,11 @@ def parse(self,data): fake_record = socket.gethostbyname(socket.gethostname()) if self.server.nxdomains > 0: - self.server.logger.info('Ignoring query. NXDomains: %d', + self.log_message(logging.INFO, is_process_blacklisted, 'Ignoring query. NXDomains: %d', self.server.nxdomains) self.server.nxdomains -= 1 else: - self.server.logger.debug('Responding with \'%s\'', + self.log_message(logging.DEBUG, is_process_blacklisted, 'Responding with \'%s\'', fake_record) response.add_answer(RR(qname, getattr(QTYPE,qtype), rdata=RDMAP[qtype](fake_record))) @@ -175,7 +209,7 @@ def parse(self,data): # dnslib doesn't like trailing dots if fake_record[-1] == ".": fake_record = fake_record[:-1] - self.server.logger.debug('Responding with \'%s\'', + self.log_message(logging.DEBUG, is_process_blacklisted, 'Responding with \'%s\'', fake_record) response.add_answer(RR(qname, getattr(QTYPE,qtype), rdata=RDMAP[qtype](fake_record))) @@ -184,7 +218,7 @@ def parse(self,data): fake_record = self.server.config.get('responsetxt', 'FAKENET') - self.server.logger.debug('Responding with \'%s\'', + self.log_message(logging.DEBUG, is_process_blacklisted, 'Responding with \'%s\'', fake_record) response.add_answer(RR(qname, getattr(QTYPE,qtype), rdata=RDMAP[qtype](fake_record))) @@ -200,6 +234,14 @@ def handle(self): (data,sk) = self.request response = self.parse(data) + # Collect NBI + nbi = { + 'Query Type': self.qtype, + 'Domain': self.qname + } + collect_nbi(self.client_address[1], nbi, 'UDP', 'No', + self.server.diverterListenerCallbacks) + if response: sk.sendto(response, self.client_address) @@ -223,6 +265,15 @@ def handle(self): # TCP DNS protocol data = data[2:] response = self.parse(data) + + # Collect NBI + nbi = { + 'Query Type': self.qtype, + 'Domain': self.qname + } + collect_nbi(self.client_address[1], nbi, 'TCP', + self.server.config.get('usessl'), + self.server.diverterListenerCallbacks) if response: # Calculate and add the additional "length" parameter @@ -268,6 +319,11 @@ def hexdump_table(data, length=16): hexdump_lines.append("%04X: %-*s %s" % (i, length*3, hex_line, ascii_line )) return hexdump_lines +def collect_nbi(sport, nbi, proto, is_ssl_encrypted, diverterListenerCallbacks): + # Report diverter everytime we capture an NBI + diverterListenerCallbacks.logNbi(sport, nbi, proto, 'DNS', is_ssl_encrypted) + + ############################################################################### # Testing code def test(config): diff --git a/fakenet/listeners/FTPListener.py b/fakenet/listeners/FTPListener.py index 71f08f9a..5b7c82a2 100644 --- a/fakenet/listeners/FTPListener.py +++ b/fakenet/listeners/FTPListener.py @@ -158,6 +158,11 @@ def ftp_PASS(self, line): if not self.authorizer.has_user(self.username): self.authorizer.add_user(self.username, line, self.ftproot_path, 'elradfmwM') + # Collect NBIs + nbi = {"Command": "PASS", "Username": self.username, "Password": line} + collect_nbi(self.remote_port, nbi, self.server.config.get('usessl'), + self.server.diverterListenerCallbacks) + return super(FakeFTPHandler, self).ftp_PASS(line) class TLS_FakeFTPHandler(TLS_FTPHandler, object): @@ -168,12 +173,23 @@ def ftp_PASS(self, line): if not self.authorizer.has_user(self.username): self.authorizer.add_user(self.username, line, self.ftproot_path, 'elradfmwM') + # Collect NBIs + nbi = {"Command": "PASS", "Username": self.username, "Password": line} + collect_nbi(self.remote_port, nbi, self.server.config.get('usessl'), + self.server.diverterListenerCallbacks) + return super(TLS_FakeFTPHandler, self).ftp_PASS(line) class FakeFS(AbstractedFS): def open(self, filename, mode): + # Collect NBIs + nbi = {"Command": "RETR", "Filename": filename, "Mode": mode} + collect_nbi(self.cmd_channel.remote_port, nbi, + self.cmd_channel.server.config.get('usessl'), + self.cmd_channel.server.diverterListenerCallbacks) + # If virtual filename does not exist return a default file based on extention if not self.lexists(filename): @@ -186,6 +202,12 @@ def open(self, filename, mode): def chdir(self, path): + # Collect NBIs + nbi = {"Command": "CWD", "Path": path} + collect_nbi(self.cmd_channel.remote_port, nbi, + self.cmd_channel.server.config.get('usessl'), + self.cmd_channel.server.diverterListenerCallbacks) + # If virtual directory does not exist change to the current directory if not self.lexists(path): path = '.' @@ -194,11 +216,29 @@ def chdir(self, path): def remove(self, path): + # Collect NBIs + actual_ftp_path = self.fs2ftp(path) + if actual_ftp_path.startswith('/'): + actual_ftp_path = actual_ftp_path[1:] # remove leading '/' + nbi = {"Command": "DELETE", "Filename": actual_ftp_path} + collect_nbi(self.cmd_channel.remote_port, nbi, + self.cmd_channel.server.config.get('usessl'), + self.cmd_channel.server.diverterListenerCallbacks) + # Don't remove anything pass def rmdir(self, path): + # Collect NBIs + actual_ftp_path = self.fs2ftp(path) + if actual_ftp_path.startswith('/'): + actual_ftp_path = actual_ftp_path[1:] # remove leading '/' + nbi = {"Command": "RMD", "Directory": actual_ftp_path} + collect_nbi(self.cmd_channel.remote_port, nbi, + self.cmd_channel.server.config.get('usessl'), + self.cmd_channel.server.diverterListenerCallbacks) + # Don't remove anything pass @@ -299,6 +339,7 @@ def start(self): self.server = ThreadedFTPServer((self.local_ip, int(self.config['port'])), self.handler) + self.server.config = self.config # Override pyftpdlib logger name logging.getLogger('pyftpdlib').name = self.name @@ -317,6 +358,14 @@ def genBanner(self): bannerfactory = BannerFactory.BannerFactory() return bannerfactory.genBanner(self.config, BANNERS) + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + +def collect_nbi(sport, nbi, is_ssl_encrypted, diverterCallbacks): + + # Report diverter everytime we capture an NBI + diverterCallbacks.logNbi(sport, nbi, 'TCP', 'FTP', is_ssl_encrypted) + ############################################################################### # Testing code def test(config): diff --git a/fakenet/listeners/HTTPListener.py b/fakenet/listeners/HTTPListener.py index 906b4123..8ac78405 100644 --- a/fakenet/listeners/HTTPListener.py +++ b/fakenet/listeners/HTTPListener.py @@ -264,6 +264,9 @@ def stop(self): self.server.shutdown() self.server.server_close() + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedHTTPServer(http.server.HTTPServer): @@ -302,6 +305,9 @@ def do_HEAD(self): for line in str(self.headers).split("\n"): self.server.logger.info(INDENT + line) + # collect nbi + self.collect_nbi(self.requestline, self.headers) + # Prepare response if not self.doCustomResponse('HEAD'): self.send_response(200) @@ -314,6 +320,9 @@ def do_GET(self): for line in str(self.headers).split("\n"): self.server.logger.info(INDENT + line) + # collect nbi + self.collect_nbi(self.requestline, self.headers) + # Prepare response if not self.doCustomResponse('GET'): # Get response type based on the requested path @@ -340,6 +349,9 @@ def do_POST(self): for line in post_body.split(b"\n"): self.server.logger.info(INDENT.encode('utf-8') + line) + # collect nbi + self.collect_nbi(self.requestline, self.headers, post_body) + # Store HTTP Posts if self.server.config.get('dumphttpposts') and self.server.config['dumphttpposts'].lower() == 'yes': http_filename = "%s_%s.txt" % (self.server.config.get('dumphttppostsfileprefix', 'http'), time.strftime("%Y%m%d_%H%M%S")) @@ -368,6 +380,24 @@ def do_POST(self): self.end_headers() self.wfile.write(response) + + def collect_nbi(self, requestline, headers, post_data=None): + nbi = {} + method, uri, version = requestline.split(" ") + nbi["Method"] = method + nbi["URI"] = uri + nbi["Version"] = version + + for line in str(headers).rstrip().split("\n"): + key, _, value = line.partition(":") + nbi[key] = value.lstrip() + + if post_data: + nbi["Request Body"] = post_data + + # report diverter everytime we capture an NBI + self.server.diverterListenerCallbacks.logNbi(self.client_address[1], + nbi, 'TCP', 'HTTP', self.server.config.get('usessl')) def get_response(self, path): response = "FakeNet

FakeNet

" diff --git a/fakenet/listeners/IRCListener.py b/fakenet/listeners/IRCListener.py index f2dcb98b..b8606f78 100644 --- a/fakenet/listeners/IRCListener.py +++ b/fakenet/listeners/IRCListener.py @@ -114,6 +114,9 @@ def genBanner(self): bannerfactory = BannerFactory.BannerFactory() return bannerfactory.genBanner(self.config, BANNERS) + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): def handle(self): @@ -156,8 +159,16 @@ def handle(self): self.server.logger.error('Error: %s', e) def irc_DEFAULT(self, cmd, params): - self.server.logger.info('Client issued an unknown command %s %s', cmd, params) - self.irc_send_server("421", "%s :Unknown command" % cmd) + self.server.logger.info('Client issued an unknown command %s %s', cmd, params) + self.irc_send_server("421", "%s :Unknown command" % cmd) + + # Collect NBIs + params = None if params == '' else params + nbi = { + "Command": cmd + ' (Unknown command)', + "Params": params + } + self.collect_nbi(nbi) def irc_NICK(self, cmd, params): @@ -168,6 +179,14 @@ def irc_NICK(self, cmd, params): self.irc_send_server("001", "%s :%s" % (self.nick, banner)) self.irc_send_server("376", "%s :End of /MOTD command." % self.nick) + # Collect NBIs + params = None if params == '' else params + nbi = { + "Command": cmd, + "Params": params + } + self.collect_nbi(nbi) + def irc_USER(self, cmd, params): if params.count(' ') == 3: @@ -177,9 +196,26 @@ def irc_USER(self, cmd, params): self.realname = realname self.request.sendall(b'') + # Collect NBIs + nbi = { + "Command": cmd, + "User": user, + "Mode": mode, + "Real name": realname + } + self.collect_nbi(nbi) + def irc_PING(self, cmd, params): self.request.sendall((":%s PONG :%s" % (self.server.servername, self.server.servername)).encode()) + # Collect NBIs + params = None if params == '' else params + nbi = { + "Command": cmd, + "Params": params + } + self.collect_nbi(nbi) + def irc_JOIN(self, cmd, params): @@ -208,6 +244,14 @@ def irc_JOIN(self, cmd, params): # Send a welcome message self.irc_send_client_custom('botmaster', 'botmaster', self.server.servername, "PRIVMSG %s %s" % (channel_name, "Welcome to the channel! %s" % self.nick)) + # Collect NBIs + nbi = { + "Command": cmd, + "Channel Names": channel_names, + "Channel Keys": channel_keys + } + self.collect_nbi(nbi) + def irc_PRIVMSG(self, cmd, params): @@ -224,11 +268,31 @@ def irc_PRIVMSG(self, cmd, params): else: self.irc_send_client_custom(target, target, self.server.servername, "PRIVMSG %s %s" % (self.nick, message)) + # Collect NBIs + nbi = { + "Command": cmd, + "Target": target, + "Message": message + } + self.collect_nbi(nbi) + def irc_NOTICE(self, cmd, params): + # Collect NBIs + nbi = { + "Command": cmd, + "Params": params + } + self.collect_nbi(nbi) pass def irc_PART(self, cmd, params): + # Collect NBIs + nbi = { + "Command": cmd, + "Params": params + } + self.collect_nbi(nbi) pass def irc_send_server(self, code, message): @@ -240,6 +304,13 @@ def irc_send_client(self, message): def irc_send_client_custom(self, nick, user, servername, message): self.request.sendall((":%s!%s@%s %s\r\n" % (nick, user, servername, message)).encode()) + def collect_nbi(self, nbi): + # Report diverter everytime we capture an NBI + # We are not handling SSL encrypted requests, so pass + # is_ssl_encrypted = 'No' + self.server.diverterListenerCallbacks.logNbi(self.client_address[1], + nbi, 'TCP', 'IRC', 'No') + class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): # Avoid [Errno 98] Address already in use due to TIME_WAIT status on TCP # sockets, for details see: diff --git a/fakenet/listeners/ListenerBase.py b/fakenet/listeners/ListenerBase.py index 19dd22ef..f0db49a3 100644 --- a/fakenet/listeners/ListenerBase.py +++ b/fakenet/listeners/ListenerBase.py @@ -34,7 +34,7 @@ def abs_config_path(path): return abspath if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - relpath = os.path.join(os.getcwd(), path) + relpath = os.path.join(os.path.dirname(sys.executable), path) else: # Try to locate the location relative to application path diff --git a/fakenet/listeners/POPListener.py b/fakenet/listeners/POPListener.py index fc04ca36..debe5309 100644 --- a/fakenet/listeners/POPListener.py +++ b/fakenet/listeners/POPListener.py @@ -101,6 +101,9 @@ def stop(self): self.server.shutdown() self.server.server_close() + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): def handle(self): @@ -129,8 +132,15 @@ def handle(self): else: cmd, params = line, b'' - handler = getattr(self, 'pop_%s' % (cmd.decode("utf-8").upper()), self.pop_DEFAULT) + cmd = cmd.decode("utf-8").upper() + handler = getattr(self, 'pop_%s' % (cmd), self.pop_DEFAULT) handler(cmd, params) + # Collect NBIs + nbi = { + 'Command': cmd, + 'Params': params + } + self.collect_nbi(nbi) except socket.timeout: self.server.logger.warning('Connection timeout') @@ -237,6 +247,11 @@ def pop_QUIT(self, cmd, params): self.request.sendall(b"+OK FakeNet POP3 server signing off\r\n") + def collect_nbi(self, nbi): + # Report diverter everytime we capture an NBI. + self.server.diverterListenerCallbacks.logNbi(self.client_address[1], + nbi, 'TCP', 'POP', self.server.config.get('usessl')) + class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): pass diff --git a/fakenet/listeners/ProxyListener.py b/fakenet/listeners/ProxyListener.py index 791513ab..5e709a6a 100644 --- a/fakenet/listeners/ProxyListener.py +++ b/fakenet/listeners/ProxyListener.py @@ -108,6 +108,9 @@ def acceptListeners(self, listeners): def acceptDiverter(self, diverter): self.server.diverter = diverter + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedTCPClientSocket(threading.Thread): @@ -122,10 +125,20 @@ def __init__(self, ip, port, listener_q, remote_q, config, log): self.logger = log self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + def connect(self): + try: + self.sock.connect((self.ip, self.port)) + new_sport = self.sock.getsockname()[1] + return new_sport + + except Exception as e: + self.logger.debug('Listener socket exception while attempting connection %s' % e) + + return None + def run(self): try: - self.sock.connect((self.ip, self.port)) while True: readable, writable, exceptional = select.select([self.sock], [], [], .001) @@ -191,16 +204,22 @@ def handle(self): except Exception as e: self.server.logger.warning('recv() error: %s' % e) + + # Is the pkt ssl encrypted? + # Using a str here instead of bool to match the format returned by + # configs of other listeners + is_ssl_encrypted = 'No' if data: if ssl_detector.looks_like_ssl(data): + is_ssl_encrypted = 'Yes' + self.server.logger.debug('SSL detected') ssl_remote_sock = self.server.sslwrapper.wrap_socket(remote_sock) data = ssl_remote_sock.recv(BUF_SZ) else: ssl_remote_sock = None - orig_src_ip = self.client_address[0] orig_src_port = self.client_address[1] @@ -214,6 +233,13 @@ def handle(self): listener_sock = ThreadedTCPClientSocket(self.server.local_ip, top_listener.port, listener_q, remote_q, self.server.config, self.server.logger) + + # Get proxy initiated source port and report to diverter + new_sport = listener_sock.connect() + if new_sport: + self.server.diverterListenerCallbacks.mapProxySportToOrigSport('TCP', + orig_src_port, new_sport, is_ssl_encrypted) + listener_sock.daemon = True listener_sock.start() remote_sock.setblocking(0) @@ -279,6 +305,12 @@ def handle(self): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((self.server.local_ip, 0)) + # Get proxy initiated source port and report to diverter + new_sport = sock.getsockname()[1] + if new_sport: + self.server.diverterListenerCallbacks.mapProxySportToOrigSport('UDP', + orig_src_port, new_sport, 'No') + sock.sendto(data, (self.server.local_ip, int(top_listener.port))) reply = sock.recv(BUF_SZ) self.server.logger.debug('Received %d bytes.', len(data)) diff --git a/fakenet/listeners/RawListener.py b/fakenet/listeners/RawListener.py index d49db6ce..fc7a4cb4 100644 --- a/fakenet/listeners/RawListener.py +++ b/fakenet/listeners/RawListener.py @@ -206,6 +206,9 @@ def stop(self): self.server.shutdown() self.server.server_close() + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class SocketWithHexdumpRecv(): def __init__(self, s, logger): self.s = s @@ -218,7 +221,9 @@ def __getattr__(self, item): return getattr(self.s, item) def do_hexdump(self, data): - for line in hexdump_table(data): + hexdump_lines = hexdump_table(data) + + for line in hexdump_lines: self.logger.info(INDENT + line) # Hook to ensure that all `recv` calls transparently emit a hex dump @@ -238,6 +243,7 @@ def handle(self): # as recv attribute became read-only self.request = SocketWithHexdumpRecv(self.request, self.server.logger) + # Timeout connection to prevent hanging self.request.settimeout(int(self.server.config.get('timeout', 5))) @@ -255,6 +261,13 @@ def handle(self): if not data: break + # Collect NBIs + hexdump_lines = hexdump_table(data) + collect_nbi(self.client_address[1], hexdump_lines, + self.server.config.get('protocol'), + self.server.config.get('usessl'), + self.server.diverterListenerCallbacks) + if cr and cr.static: self.request.sendall(cr.static) else: @@ -275,7 +288,14 @@ def handle(self): (data,sock) = self.request if data: - for line in hexdump_table(data): + # Collect NBIs + hexdump_lines = hexdump_table(data) + collect_nbi(self.client_address[1], hexdump_lines, + self.server.config.get('protocol'), + self.server.config.get('usessl'), + self.server.diverterListenerCallbacks) + + for line in hexdump_lines: self.server.logger.info(INDENT + line) cr = self.server.custom_response @@ -310,6 +330,18 @@ def hexdump_table(data, length=16): hexdump_lines.append("%04X: %-*s %s" % (i, length*3, hex_line, ascii_line )) return hexdump_lines +def collect_nbi(sport, hexdump_lines, proto, is_ssl_encrypted, + diverterCallbacks): + nbi = {} + # Show upto 16 lines of hex dump + nbi['Data Hexdump'] = hexdump_lines[:16] + + # Report diverter everytime we capture an NBI + # Using an empty string for application_layer_protocol in Raw Listener so + # that diverter can override the empty string with the + # transport_layer_protocol + diverterCallbacks.logNbi(sport, nbi, proto, '', is_ssl_encrypted) + ############################################################################### # Testing code def test(config): diff --git a/fakenet/listeners/SMTPListener.py b/fakenet/listeners/SMTPListener.py index c46ea0e1..9453d1b7 100644 --- a/fakenet/listeners/SMTPListener.py +++ b/fakenet/listeners/SMTPListener.py @@ -92,6 +92,9 @@ def stop(self): self.server.shutdown() self.server.server_close() + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): def handle(self): @@ -108,6 +111,7 @@ def handle(self): self.server.logger.debug(line) command = data[:4].decode("utf-8").upper() + args = data[4:].decode("utf-8").strip() if command == '': break @@ -142,9 +146,27 @@ def handle(self): self.request.sendall(b"250 OK\r\n") + # Collect NBIs + # Mail data can be long. Let's capture the first line of the + # data for NBI. + nbi = { + "Command": command, + "Mail Data": mail_data.decode('utf-8').split("\n")[0] + } + self.collect_nbi(nbi) + else: self.request.sendall(b"503 Command not supported\r\n") + # Collect NBIs + if command != 'DATA': + args = None if args == '' else args + nbi = { + "Command": command, + "Args": args + } + self.collect_nbi(nbi) + except socket.timeout: self.server.logger.warning('Connection timeout') @@ -154,6 +176,12 @@ def handle(self): except Exception as e: self.server.logger.error('Error: %s', e) + def collect_nbi(self, nbi): + # Report diverter everytime we capture an NBI + self.server.diverterListenerCallbacks.logNbi(self.client_address[1], + nbi,'TCP', 'SMTP', + self.server.config.get('usessl')) + class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): pass diff --git a/fakenet/listeners/TFTPListener.py b/fakenet/listeners/TFTPListener.py index ddc9517a..4a40f450 100644 --- a/fakenet/listeners/TFTPListener.py +++ b/fakenet/listeners/TFTPListener.py @@ -115,6 +115,9 @@ def stop(self): self.server.shutdown() self.server.server_close() + def acceptDiverterListenerCallbacks(self, diverterListenerCallbacks): + self.server.diverterListenerCallbacks = diverterListenerCallbacks + class ThreadedUDPRequestHandler(socketserver.BaseRequestHandler): def handle(self): @@ -132,6 +135,13 @@ def handle(self): self.server.logger.info('Received request to download %s', filename) self.handle_rrq(socket, filename.decode('utf-8')) + # Collect NBIs + indicator_filename = filename + if isinstance(filename, bytes): + indicator_filename = filename.decode('utf-8') + nbi = {"Command": "RRQ", "Filename": indicator_filename} + self.collect_nbi(nbi) + elif opcode == OPCODE_WRQ: filename, mode = self.parse_rrq_wrq_packet(data) @@ -139,11 +149,25 @@ def handle(self): self.handle_wrq(socket, filename) + # Collect NBIs + indicator_filename = filename + if isinstance(filename, bytes): + indicator_filename = filename.decode('utf-8') + nbi = {"Command": "WRQ", "Filename": indicator_filename} + self.collect_nbi(nbi) + elif opcode == OPCODE_ACK: block_num = struct.unpack('!H', data[2:4])[0] self.server.logger.debug('Received ACK for block %d', block_num) + # Collect NBIs + nbi = { + "Command": "ACK", + "Block Number": block_num + } + self.collect_nbi(nbi) + elif opcode == OPCODE_DATA: self.handle_data(socket, data) @@ -151,13 +175,30 @@ def handle(self): elif opcode == OPCODE_ERROR: error_num = struct.unpack('!H', data[2:4])[0] - error_msg = data[4:] + error_msg = data.decode('utf-8')[4:] self.server.logger.info('Received error message %d:%s', error_num, error_msg) + # Collect NBIs + nbi = { + "Command": "ERROR", + "Error Number": error_num, + "Error Message": error_msg + } + self.collect_nbi(nbi) + else: - self.server.logger.error('Unknown opcode: %d', struct.unpack('!H', data[:2])[0]) + unknown_opcode = struct.unpack('!H', data[:2])[0] + self.server.logger.error('Unknown opcode: %d', unknown_opcode) + + # Collect NBIs + nbi = { + "Command": "Unknown command", + "Opcode": str(unknown_opcode), + "Data": data.decode("utf-8")[4:] + } + self.collect_nbi(nbi) except Exception as e: self.server.logger.error('Error: %s', e) @@ -176,13 +217,31 @@ def handle_data(self, socket, data): f.write(data[4:]) f.close() + # Collect NBIs + indicator_data = data + indicator_filename = self.server.filename_path + if isinstance(data, bytes): + indicator_data = data.decode('utf-8') + if isinstance(self.server.filename_path, bytes): + indicator_filename = self.server.filename_path.decode('utf-8') + # Send ACK packet for the given block number ack_packet = OPCODE_ACK + data[2:4] socket.sendto(ack_packet, self.client_address) else: + # Collect NBIs + indicator_data = data + indicator_filename = None + if isinstance(data, bytes): + indicator_data = data.decode('utf-8') + self.server.logger.error('Received DATA packet but don\'t know where to store it.') + nbi = {"Command": "DATA", "Data": indicator_data[4:], "Filename": + indicator_filename} + self.collect_nbi(nbi) + def handle_rrq(self, socket, filename): filename_path = ListenerBase.safe_join(self.server.tftproot_path, @@ -233,6 +292,11 @@ def parse_rrq_wrq_packet(self, data): filename, mode, _ = data[2:].split(b"\x00", 2) return (filename, mode) + def collect_nbi(self, nbi): + # Report diverter everytime we capture an NBI + self.server.diverterListenerCallbacks.logNbi(self.client_address[1], + nbi, 'UDP', 'TFTP', 'No') + class ThreadedUDPServer(socketserver.ThreadingMixIn, socketserver.UDPServer): pass diff --git a/setup.py b/setup.py index f9394cb0..5aa000b4 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# Copyright (C) 2016-2023 Mandiant, Inc. All rights reserved. +# Copyright (C) 2016-2024 Mandiant, Inc. All rights reserved. import os import platform @@ -16,6 +16,7 @@ "pyftpdlib", "cryptography", "pyopenssl", + "jinja2", ] if platform.system() == 'Windows': @@ -25,7 +26,7 @@ setup( name='FakeNet NG', - version='3.0', + version='3.3', description="", long_description="", author="Mandiant FLARE Team with credit to Peter Kacherginsky as the original developer", @@ -36,8 +37,9 @@ ], package_dir={'fakenet': 'fakenet'}, package_data={'fakenet': ['*.pem','diverters/*.py', 'listeners/*.py', - 'listeners/ssl_utils/*.py', 'configs/*.crt', 'configs/*.key', - 'configs/*.ini', 'defaultFiles/*', 'lib/64/*', 'lib/32/*']}, + 'listeners/ssl_utils/*.py', 'listeners/ssl_utils/*.pem', 'configs/*.ini', + 'configs/html_report_template.html', 'defaultFiles/*', 'lib/64/*', 'lib/32/*', + 'configs/*.crt', 'configs/*.key']}, entry_points={ "console_scripts": [ "fakenet=fakenet.fakenet:main",