From 0aab5a66abcaa299c817300dc2e13e4677b0b65d Mon Sep 17 00:00:00 2001 From: Caleb Madrigal Date: Mon, 30 Apr 2018 01:01:34 -0500 Subject: [PATCH] Basic macOS support --- README.md | 8 +- trackerjacker/__main__.py | 20 +- trackerjacker/dot11_frame.py | 9 +- trackerjacker/dot11_tracker.py | 7 +- ...nagement.py => linux_device_management.py} | 0 trackerjacker/macos_device_management.py | 300 ++++++++++++++++++ trackerjacker/version.py | 2 +- 7 files changed, 334 insertions(+), 12 deletions(-) rename trackerjacker/{device_management.py => linux_device_management.py} (100%) create mode 100755 trackerjacker/macos_device_management.py diff --git a/README.md b/README.md index 83d3e5f..8ed4bb8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PyPI page: https://pypi.python.org/pypi/trackerjacker pip3 install trackerjacker -**Linux-only** at this time (tested on Ubuntu, Kali, and RPi). +*Supported platforms*: Linux (tested on Ubuntu, Kali, and RPi) and macOS (pre-alpha) ![visual description](https://i.imgur.com/I5NH5KM.jpg) @@ -296,7 +296,11 @@ Note that trackerjacker will automatically switch channels as necessary during n - [x] Plugin system - [x] Fox hunt mode - [x] Tracking by SSID (and not just BSSID) -- [ ] macOS (OS X) support (under active development) +- [x] Basic macOS (OS X) support (pre-alpha) +- [ ] macOS support: reverse airport binary to determine how to set true monitor mode +- [ ] macOS support: diverse interface support (not just `en0`) +- [ ] macOS support: get interface supported channels +- [ ] macOS support: get signal strength values correct - [ ] Mapping a specific SSID - [ ] Performance enhancement: not shelling out for channel switching - [ ] "Jack" mode - deauth attacks diff --git a/trackerjacker/__main__.py b/trackerjacker/__main__.py index 904aede..54ab261 100755 --- a/trackerjacker/__main__.py +++ b/trackerjacker/__main__.py @@ -8,14 +8,13 @@ import errno import pprint import logging -import inspect +import platform import traceback logging.getLogger("scapy.runtime").setLevel(logging.ERROR) import scapy.all as scapy from . import config_management -from . import device_management from . import dot11_frame from . import dot11_mapper from . import dot11_tracker @@ -23,6 +22,11 @@ from . import ieee_mac_vendor_db from .common import TJException +if platform.system() == 'Linux': + from . import linux_device_management as device_management +elif platform.system() == 'Darwin': + from . import macos_device_management as device_management + LOG_NAME_TO_LEVEL = {'DEBUG': 10, 'INFO': 20, 'WARNING': 30, 'ERROR': 40, 'CRITICAL': 50} @@ -200,14 +204,20 @@ def start(self): self.iface_manager.start() while True: try: - if 'exceptions' in inspect.signature(scapy.sniff).parameters: - scapy.sniff(iface=self.iface_manager.iface, prn=self.process_packet, store=0, exceptions=True) + # macOS + if platform.system() == 'Darwin': + self.logger.warning('trackerjacker macOS support is pre-alpha - most functionality is linux-only') + scapy.sniff(iface=self.iface_manager.iface, monitor=True, prn=self.process_packet, store=0) break + # linux else: # For versions of scapy that don't provide the exceptions kwarg scapy.sniff(iface=self.iface_manager.iface, prn=self.process_packet, store=0) break - except (IOError, OSError): + + except TJException: + raise + except (OSError, IOError): self.logger.error(traceback.format_exc()) self.logger.info('Sniffer error occurred. Restarting sniffer in 3 seconds...') time.sleep(3) diff --git a/trackerjacker/dot11_frame.py b/trackerjacker/dot11_frame.py index edd73aa..db32737 100644 --- a/trackerjacker/dot11_frame.py +++ b/trackerjacker/dot11_frame.py @@ -1,6 +1,6 @@ """Provides nice interface for Dot11 Frames""" -# pylint: disable=R0902 +# pylint: disable=R0902, C0413, W0703 import logging logging.getLogger("scapy.runtime").setLevel(logging.ERROR) @@ -51,7 +51,12 @@ def __init__(self, frame, channel=0, iface=None): if (frame.haslayer(scapy.Dot11Elt) and (frame.haslayer(scapy.Dot11Beacon) or frame.haslayer(scapy.Dot11ProbeResp))): - self.ssid = frame[scapy.Dot11Elt].info.decode().replace('\x00', '[NULL]') + try: + self.ssid = frame[scapy.Dot11Elt].info.decode().replace('\x00', '[NULL]') + except UnicodeDecodeError: + # Only seems to happen on macOS - probably some pcap decoding bug + self.ssid = None + #print('Error decoding ssid: {}'.format(frame[scapy.Dot11Elt].info)) if frame.haslayer(scapy.RadioTap): try: diff --git a/trackerjacker/dot11_tracker.py b/trackerjacker/dot11_tracker.py index 7acb3a8..4db0d84 100755 --- a/trackerjacker/dot11_tracker.py +++ b/trackerjacker/dot11_tracker.py @@ -286,8 +286,11 @@ def do_trigger_alert(self, raise TJException('Error occurred in trigger plugin: {}'.format(traceback.format_exc())) elif self.trigger_command: - # Start trigger_command in background process - fire and forget - subprocess.Popen(self.trigger_command) + try: + # Start trigger_command in background process - fire and forget + subprocess.Popen(self.trigger_command) + except Exception: + raise TJException('Error occurred in trigger command: {}'.format(traceback.format_exc())) else: if num_bytes: diff --git a/trackerjacker/device_management.py b/trackerjacker/linux_device_management.py similarity index 100% rename from trackerjacker/device_management.py rename to trackerjacker/linux_device_management.py diff --git a/trackerjacker/macos_device_management.py b/trackerjacker/macos_device_management.py new file mode 100755 index 0000000..4847bac --- /dev/null +++ b/trackerjacker/macos_device_management.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# pylint: disable=C0111, C0103, C0413, W0703, R0902, R0903, R0912, R0913, R0914, R0915 + +# NOTE: Horrible, horrible things... I'm sorry for this. I kind of hope nobody ever reads this - +# that it stay a confession that nobody ever hears. I'll make things better later. + +import os +import time +import random +import threading +import subprocess +import collections + +from .common import TJException # pylint: disable=E0401 + +AIRPORT_PATH = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' +MIN_FRAME_COUNT = 5 + + +class MonitorModeHack: + def __init__(self, iface): + self.iface = iface + self.sniff_time = 10 * 60 # 10 minutes + self.stop_event = threading.Event() + self.proc = None + self.starting_tmp_pcaps = self.find_new_pcap([]) + + def sniff_for(self, for_time=None): + self.proc = subprocess.Popen([AIRPORT_PATH, self.iface, 'sniff', '1'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + if for_time: + time.sleep(for_time) + self.proc.terminate() + + def find_new_pcap(self, previous_pcap_paths): + tmp_files = os.listdir('/tmp/') + pcap_files = [f for f in tmp_files if f.endswith('.cap')] + new_pcap = set(pcap_files) - set(previous_pcap_paths) + return list(new_pcap) + + def sniff_loop(self): + while not self.stop_event.is_set(): + self.starting_tmp_pcaps = self.find_new_pcap([]) + + self.sniff_for(self.sniff_time) + + # Delete pcap file we created + self.delete_pcaps_we_created() + + def delete_pcaps_we_created(self): + pcaps_we_created = self.find_new_pcap(self.starting_tmp_pcaps) + for pcap_filename in pcaps_we_created: + tmp_pcap = os.path.join('/tmp/', pcap_filename) + try: + os.remove(tmp_pcap) + except Exception as e: + print('Error removing pcap ({}): {}'.format(tmp_pcap, e)) + + def start(self): + t = threading.Thread(target=self.sniff_loop, args=()) + t.daemon = True + t.start() + + def stop(self): + self.stop_event.set() + if self.proc: + self.proc.terminate() + time.sleep(2) + self.delete_pcaps_we_created() + + +def check_interface_exists(iface): + return True # todo + + +def monitor_mode_on(iface): + raise TJException('Not curently supported in macOS') + + +def monitor_mode_off(iface): + raise TJException('Not curently supported in macOS') + + +def get_network_interfaces(): + # hack - TODO: fix this + return ['en0'] + + +def is_monitor_mode_device(iface_name): + # hack - TODO: make this better + return False + + +def find_monitor_interfaces(): + for iface_name in get_network_interfaces(): + try: + if is_monitor_mode_device(iface_name): + yield iface_name + except TJException: + # If there's any problem with any interface, keep looking + pass + + +def find_first_monitor_interface(): + try: + return next(find_monitor_interfaces()) + except StopIteration: + return None + + +def get_supported_channels(iface): + # Just a guess - TODO: get supported channels + return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 36, 38, 40, 42, 44, 46, 48, 52, 54, + 56, 58, 60, 62, 64, 100, 102, 104, 106] + + +def switch_to_channel(iface, channel_num): + subprocess.check_call([AIRPORT_PATH, '--channel={}'.format(channel_num)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def select_interface(iface, logger): + if iface == None: + # Hacky default + iface = 'en0' + + selected_iface = None + need_to_disable_monitor_mode_on_exit = False + + # If no device specified, see if there is a device already in monitor mode, and go with it... + if not iface: + monitor_mode_iface = find_first_monitor_interface() + if monitor_mode_iface: + selected_iface = monitor_mode_iface + logger.info('Using monitor mode interface: %s', selected_iface) + else: + raise TJException('Please specify interface with -i switch') + + # If specified interface is already in monitor mode, do nothing... just go with it + elif is_monitor_mode_device(iface): + selected_iface = iface + logger.debug('Interface %s is already in monitor mode...', iface) + + # Otherwise, try to put specified interface into monitor mode, but remember to undo that when done... + else: + try: + logger.info('Enabling monitor mode on %s', iface) + # monitor_mode_on(iface) + selected_iface = iface + need_to_disable_monitor_mode_on_exit = True + logger.debug('Enabled monitor mode on %s', iface) + except Exception: + # If we fail to find the specified (or default) interface, look to see if there is a monitor interface + logger.warning('Could not enable monitor mode on enterface: %s', iface) + mon_iface = find_first_monitor_interface() + if mon_iface: + selected_iface = mon_iface + logger.info('Going with interface: %s', selected_iface) + else: + raise TJException('Could not find a monitor interface') + + return selected_iface, need_to_disable_monitor_mode_on_exit + + +class Dot11InterfaceManager: + def __init__(self, iface, logger, channels_to_monitor, channel_switch_scheme, time_per_channel): + self.logger = logger + self.iface, self.need_to_disable_monitor_mode_on_exit = select_interface(iface, self.logger) + + self.channels_to_monitor = channels_to_monitor + self.channel_switch_scheme = channel_switch_scheme + self.time_per_channel = time_per_channel + + self.stop_event = threading.Event() + self.supported_channels = [] + self.current_channel = 1 + self.last_channel_switch_time = 0 + self.num_frames_received_this_channel = 0 + + self.channel_switch_func = self.switch_channel_round_robin # default + self.configure_channels(channels_to_monitor, channel_switch_scheme) + self.horrible_hack = None + + # Leaky bucket per channel to track how many frames were seen last time that channels was monitored + # The leaky bucket helps ensure that if at one time, someone downloads a video or something, + # that channel doesn't forever get dominance. + counter_leaky_bucket_size = 10 + self.frame_counts_per_channel = {c: collections.deque([(time.time(), MIN_FRAME_COUNT)], + maxlen=counter_leaky_bucket_size) + for c in self.channels_to_monitor} + + def configure_channels(self, channels_to_monitor, channel_switch_scheme): + # Find supported channels + self.supported_channels = get_supported_channels(self.iface) + if not self.supported_channels: + raise TJException('Interface either not found, or incompatible: {}'.format(self.iface)) + + if channels_to_monitor: + channels_to_monitor_set = set([int(c) for c in channels_to_monitor]) + if len(channels_to_monitor_set & set(self.supported_channels)) != len(channels_to_monitor_set): + raise TJException('Not all of channels to monitor are supported by {}'.format(self.iface)) + + self.channels_to_monitor = channels_to_monitor + self.current_channel = self.channels_to_monitor[0] + self.logger.info('Monitoring channels: %s', channels_to_monitor_set) + else: + self.channels_to_monitor = self.supported_channels + self.current_channel = self.supported_channels[0] + self.logger.info('Monitoring all available channels on %s: %s', self.iface, self.supported_channels) + + self.logger.debug('Channel switching scheme: %s', channel_switch_scheme) + + if channel_switch_scheme == 'traffic_based': + self.channel_switch_func = self.switch_channel_based_on_traffic + + self.switch_to_channel(self.current_channel, force=True) + + def channel_switcher_thread(self, firethread=True): # pylint: disable=R1710 + if firethread: + t = threading.Thread(target=self.channel_switcher_thread, args=(False,)) + t.daemon = True + t.start() + return t + + # Only worry about switching channels if we are monitoring 2 or more + if len(self.channels_to_monitor) > 1: + while not self.stop_event.is_set(): + time.sleep(self.time_per_channel) + self.channel_switch_func() + self.last_channel_switch_time = time.time() + + def get_next_channel_based_on_traffic(self): + count_by_channel = {c: sum([count for ts, count in frame_count_list]) + for c, frame_count_list in self.frame_counts_per_channel.items()} + total_count = sum(count_by_channel.values()) + percent_to_channel = [(count/total_count, channel) for channel, count in count_by_channel.items()] + + percent_sum = 0 + sum_to_reach = random.random() + for percent, channel in percent_to_channel: + percent_sum += percent + if percent_sum >= sum_to_reach: + return channel + + return random.sample(self.channels_to_monitor, 1)[0] + + def switch_channel_based_on_traffic(self): + next_channel = self.get_next_channel_based_on_traffic() + + # Don't ever set a channel to a 0% probability of being hit again + if self.num_frames_received_this_channel == 0: + self.num_frames_received_this_channel = MIN_FRAME_COUNT + + time_frames_entry = (time.time(), self.num_frames_received_this_channel) + self.frame_counts_per_channel[self.current_channel].append(time_frames_entry) + self.num_frames_received_this_channel = 0 + self.switch_to_channel(next_channel) + + def switch_channel_round_robin(self): + chans = self.channels_to_monitor + next_channel = chans[(chans.index(self.current_channel)+1) % len(chans)] + self.switch_to_channel(next_channel) + + def switch_to_channel(self, channel_num, force=False): + self.logger.debug('Switching to channel %s', channel_num) + if channel_num == self.current_channel and not force: + return + switch_to_channel(self.iface, channel_num) + self.current_channel = channel_num + + def add_frame(self, frame): + self.num_frames_received_this_channel += 1 + + def start(self): + self.do_horrible_monitor_mode_hack() + self.channel_switcher_thread() + # Need to switch to channel after starting the monitor_mode_hack + time.sleep(1) + self.switch_to_channel(self.current_channel, force=True) + + def stop(self): + self.stop_event.set() + + if self.need_to_disable_monitor_mode_on_exit: + self.logger.info('\nDisabling monitor mode for interface: %s', self.iface) + + # Try to wait long enough for the channel switching thread to see the event so + # the device isn't busy when we try to disable monitor mode. + time.sleep(self.time_per_channel + 1) + + #monitor_mode_off(self.iface) + if self.horrible_hack: + self.horrible_hack.stop() + self.logger.debug('Disabled monitor mode for interface: %s', self.iface) + + def do_horrible_monitor_mode_hack(self): + self.horrible_hack = MonitorModeHack(self.iface) + self.horrible_hack.start() diff --git a/trackerjacker/version.py b/trackerjacker/version.py index 3f91e1e..29654ee 100644 --- a/trackerjacker/version.py +++ b/trackerjacker/version.py @@ -1 +1 @@ -__version__ = "1.7.10" +__version__ = "1.8.0"