├── tests ├── __init__.py ├── test_macvendordb.py ├── test_dot11map.py └── test_config_management.py ├── trackerjacker ├── plugins │ ├── __init__.py │ └── foxhunt.py ├── version.py ├── common.py ├── __init__.py ├── ieee_mac_vendor_db.py ├── plugin_parser.py ├── dot11_frame.py ├── linux_device_management.py ├── macos_device_management.py ├── __main__.py ├── dot11_mapper.py ├── config_management.py └── dot11_tracker.py ├── plugin_examples ├── builtin_plugins ├── plugin_example2.py ├── count_apples.py ├── plugin_example1.py ├── foxhunt_plugin_simple.py ├── plugin_template.py ├── count_manufacturers.py ├── find_nearby_strangers.py └── deauth_attack.py ├── requirements.txt ├── setup.cfg ├── MANIFEST.in ├── LICENSE ├── .gitignore ├── setup.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /trackerjacker/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /trackerjacker/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.8.7" 2 | -------------------------------------------------------------------------------- /plugin_examples/builtin_plugins: -------------------------------------------------------------------------------- 1 | ../trackerjacker/plugins/ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | scapy==2.4.0 2 | pyaml>=17.12.1 3 | ruamel.yaml==0.15.0 4 | 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [pep8] 5 | max-line-length = 120 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | include trackerjacker/oui.txt 5 | -------------------------------------------------------------------------------- /trackerjacker/common.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0111 2 | MACS_TO_IGNORE = {'ff:ff:ff:ff:ff:ff', '00:00:00:00:00:00'} 3 | 4 | 5 | class TJException(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /trackerjacker/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | trackerjacker 3 | 4 | Finds and tracks wifi devices through raw 802.11 monitoring 5 | """ 6 | 7 | __author__ = "Caleb Madrigal" 8 | __email__ = "caleb.madrigal@gmail.com" 9 | __license__ = "MIT" 10 | 11 | from .version import __version__ 12 | -------------------------------------------------------------------------------- /plugin_examples/plugin_example2.py: -------------------------------------------------------------------------------- 1 | __apiversion__ = 1 2 | 3 | 4 | def trigger(dev_id=None, num_bytes=None, power=None, **kwargs): 5 | """Note that we can specify any subset of kwargs we are interested in.""" 6 | if num_bytes: 7 | msg = 'Threshold reached for {} - {} bytes'.format(dev_id, num_bytes) 8 | else: 9 | msg = 'Saw {} at power level {}'.format(dev_id, power) 10 | 11 | print(msg) 12 | with open('plugin_output_test.txt', 'a') as f: 13 | f.write(msg + '\n') 14 | -------------------------------------------------------------------------------- /plugin_examples/count_apples.py: -------------------------------------------------------------------------------- 1 | """Count Apple devices""" 2 | __config__ = {'trigger_cooldown': 100000} # No need to call more than once for a single device 3 | 4 | 5 | class Trigger: 6 | def __init__(self): 7 | self.apples_seen = set() 8 | 9 | def __call__(self, dev_id=None, vendor=None, ssid=None, bssid=None, iface=None, power=None, **kwargs): 10 | if vendor and vendor.lower().find('apple') >= 0: 11 | self.apples_seen |= {dev_id} 12 | print('Apple devices seen (power={}): {}, new mac: {}'.format(power, len(self.apples_seen), dev_id)) 13 | 14 | -------------------------------------------------------------------------------- /plugin_examples/plugin_example1.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | __apiversion__ = 1 4 | 5 | 6 | class Trigger: 7 | def __init__(self): 8 | # dev_id -> [timestamp1, timestamp2, ...] 9 | self.seen_at_times = {} 10 | 11 | def __call__(self, dev_id=None, **kwargs): 12 | """Note that we can specify any subset of arguments we care about... in this case, just dev_id.""" 13 | if dev_id not in self.seen_at_times: 14 | self.seen_at_times[dev_id] = [time.time()] 15 | else: 16 | self.seen_at_times[dev_id].append(time.time()) 17 | 18 | print('{} seen at: {}'.format(dev_id, self.seen_at_times[dev_id])) 19 | -------------------------------------------------------------------------------- /trackerjacker/ieee_mac_vendor_db.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0103, C0111, W0703, R0903 2 | import os 3 | 4 | 5 | class MacVendorDB: 6 | """Maps from MACs to Manufacturers via the IEEE Organizationally Unique Identifier (oui) list.""" 7 | def __init__(self, oui_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'oui.txt')): 8 | self.db = {} 9 | with open(oui_file, 'r') as f: 10 | for line in f.readlines(): 11 | mac, vendor = line.split('=', maxsplit=1) 12 | self.db[mac] = vendor.strip() 13 | 14 | def lookup(self, mac): 15 | """MAC -> Manufacturer ('48:AD:08:AA:BB:CC' -> 'HUAWEI TECHNOLOGIES CO.,LTD')""" 16 | try: 17 | oui_prefix = mac.upper().replace(':', '')[0:6] 18 | if oui_prefix in self.db: 19 | return self.db[oui_prefix] 20 | except Exception: 21 | pass 22 | 23 | return '' 24 | -------------------------------------------------------------------------------- /plugin_examples/foxhunt_plugin_simple.py: -------------------------------------------------------------------------------- 1 | """Outputs the ordered list of the top 30 closest WiFi devices.""" 2 | import time 3 | import heapq 4 | 5 | __author__ = 'Caleb Madrigal' 6 | __email__ = 'caleb.madrigal@gmail.com' 7 | __version__ = '0.0.1' 8 | __apiversion__ = 1 9 | 10 | TOP_N_TO_SHOW = 20 11 | 12 | 13 | class Trigger: 14 | def __init__(self): 15 | # Maps from dev_id to last seen signal/power level 16 | self.dev_to_power = {} 17 | 18 | def __call__(self, dev_id=None, dev_type=None, power=None, **kwargs): 19 | # Only look at individual devices (device and bssids), and only look when power is present 20 | if (not power) or (not dev_id) or (dev_type == 'ssid'): 21 | return 22 | self.dev_to_power[dev_id] = power 23 | for power, dev_id in heapq.nlargest(TOP_N_TO_SHOW, [(power, mac) for mac, power in self.dev_to_power.items()]): 24 | print('{}dBm\t{}'.format(power, dev_id)) 25 | 26 | -------------------------------------------------------------------------------- /tests/test_macvendordb.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0111, C0413, C0103, E0401 2 | 3 | import os 4 | import sys 5 | import unittest 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 7 | import trackerjacker.ieee_mac_vendor_db as ieee_mac_vendor_db 8 | 9 | 10 | class MacVendorDBTest(unittest.TestCase): 11 | def setUp(self): 12 | self.mac_vendor_db = ieee_mac_vendor_db.MacVendorDB() 13 | 14 | def test_channel_parsing(self): 15 | oui_tests = { 16 | 'a4:c0:e1:7d:7e:32': 'Nintendo Co., Ltd.', 17 | 'c0:56:27:2a:4c:15': 'Belkin International Inc.', 18 | 'f4:f5:d8:b8:c8:64': 'Google, Inc.', 19 | '8c:8e:f2:4e:87:c7': 'Apple, Inc.', 20 | 'e4:11:5b:75:b4:68': 'Hewlett Packard' 21 | } 22 | 23 | for mac, vendor in oui_tests.items(): 24 | self.assertEqual(vendor, self.mac_vendor_db.lookup(mac)) 25 | 26 | 27 | if __name__ == '__main__': 28 | unittest.main() 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Caleb Madrigal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_dot11map.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0111, C0413, C0103, E0401, R0903 2 | import os 3 | import sys 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 5 | 6 | import unittest 7 | import trackerjacker.dot11_mapper as dot11_mapper 8 | 9 | 10 | class Dot11MapperTest(unittest.TestCase): 11 | def test_trim_frames_to_window(self): 12 | frames = [(1521090725, 0), (1521090726, 100), (1521090727, 200), (1521090728, 300), 13 | (1521090729, 400), (1521090730, 500), (1521090731, 600), (1521090732, 700), 14 | (1521090733, 800), (1521090734, 900), (1521090735, 1000), (1521090736, 1100), 15 | (1521090737, 1200), (1521090738, 1300), (1521090739, 1400), (1521090740, 1500)] 16 | expected_trimmed_frames = [(1521090736, 1100), (1521090737, 1200), 17 | (1521090738, 1300), (1521090739, 1400), (1521090740, 1500)] 18 | now = 1521090740.4395268 19 | window = 5 # seconds 20 | trimmed_frames = dot11_mapper.trim_frames_to_window(frames, window, now=now) 21 | self.assertEqual(expected_trimmed_frames, trimmed_frames) 22 | 23 | 24 | if __name__ == '__main__': 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # Misc 92 | .vscode 93 | wifi_map.yaml 94 | deploy_to_pypi.sh 95 | 96 | -------------------------------------------------------------------------------- /plugin_examples/plugin_template.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Caleb Madrigal' 2 | __email__ = 'caleb.madrigal@gmail.com' 3 | __version__ = '0.0.1' 4 | __apiversion__ = 1 5 | __config__ = {'power': -100, 'log_level': 'ERROR', 'trigger_cooldown': 1} 6 | 7 | 8 | class Trigger: 9 | def __init__(self): 10 | # dev_id -> [timestamp1, timestamp2, ...] 11 | self.packets_seen = 0 12 | self.unique_mac_addrs = set() 13 | 14 | def __call__(self, 15 | dev_id=None, 16 | dev_type=None, 17 | num_bytes=None, 18 | data_threshold=None, 19 | vendor=None, 20 | power=None, 21 | power_threshold=None, 22 | bssid=None, 23 | ssid=None, 24 | iface=None, 25 | channel=None, 26 | frame_type=None, 27 | frame=None, 28 | **kwargs): 29 | self.packets_seen += 1 30 | self.unique_mac_addrs |= {dev_id} 31 | print('[!] Total packets: {}, Unique devices: {}'.format(self.packets_seen, len(self.unique_mac_addrs))) 32 | print('\tdev_id = {}, dev_type = {}, num_bytes = {}, data_threshold = {}, vendor = {}, ' 33 | 'power = {}, power_threshold = {}, bssid = {}, ssid = {}, iface = {}, channel = {}' 34 | 'frame_types = {}, frame = {}' 35 | .format(dev_id, dev_type, num_bytes, data_threshold, vendor, 36 | power, power_threshold, bssid, ssid, iface, channel, 37 | frame_type, frame)) 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0103, C0111, C0326, W0122 2 | 3 | from setuptools import setup 4 | 5 | with open('requirements.txt', 'r') as f: 6 | requirements = f.read().splitlines() 7 | 8 | 9 | def get_version(): 10 | version = {} 11 | with open("trackerjacker/version.py") as fp: 12 | exec(fp.read(), version) 13 | return version['__version__'] 14 | 15 | 16 | def get_readme(): 17 | try: 18 | import pypandoc 19 | readme_data = pypandoc.convert('README.md', 'rst') 20 | except(IOError, ImportError): 21 | readme_data = open('README.md').read() 22 | return readme_data 23 | 24 | 25 | setup( 26 | name = 'trackerjacker', 27 | packages = ['trackerjacker', 'trackerjacker/plugins'], 28 | url = 'https://github.com/calebmadrigal/trackerjacker', 29 | version = get_version(), 30 | description = 'Finds and tracks wifi devices through raw 802.11 monitoring', 31 | long_description = get_readme(), 32 | author = 'Caleb Madrigal', 33 | author_email = 'caleb.madrigal@gmail.com', 34 | license = 'MIT', 35 | keywords = ['hacking', 'network', 'wireless', 'packets', 'scapy'], 36 | install_requires = requirements, 37 | tests_require = requirements, 38 | test_suite='tests', 39 | entry_points={'console_scripts': ['trackerjacker = trackerjacker.__main__:main']}, 40 | include_package_data = True, 41 | classifiers = [ 42 | 'License :: OSI Approved :: MIT License', 43 | 'Programming Language :: Python :: 3', 44 | 'Programming Language :: Python :: 3.2', 45 | 'Programming Language :: Python :: 3.3', 46 | 'Programming Language :: Python :: 3.4', 47 | 'Programming Language :: Python :: 3.5', 48 | 'Programming Language :: Python :: 3.6', 49 | 'Programming Language :: Python :: Implementation :: CPython', 50 | 'Topic :: System :: Networking', 51 | 'Topic :: System :: Networking :: Monitoring', 52 | 'Topic :: Security', 53 | 'Operating System :: POSIX :: Linux', 54 | 'Operating System :: MacOS' 55 | ], 56 | ) 57 | -------------------------------------------------------------------------------- /plugin_examples/count_manufacturers.py: -------------------------------------------------------------------------------- 1 | """Count manufacturers""" 2 | 3 | import os 4 | import pickle 5 | import collections 6 | 7 | __config__ = {'trigger_cooldown': 100000} # No need to call more than once for a single device 8 | REPORT_FILE = 'top_manufacturers.txt' 9 | SAVE_FILE = 'count_manufacturers.pkl' 10 | 11 | 12 | class Trigger: 13 | def __init__(self): 14 | self.manufacturer_to_count = collections.Counter() 15 | self.devices_seen = set() 16 | self.packets_seen = 0 17 | self.load_progress() 18 | 19 | def __call__(self, dev_id=None, vendor=None, ssid=None, bssid=None, iface=None, power=None, **kwargs): 20 | if vendor and dev_id not in self.devices_seen: 21 | self.devices_seen |= {dev_id} 22 | self.manufacturer_to_count[vendor] += 1 23 | print('Saw device (mac: {}) from vendor: {}; total from {}: {}' 24 | .format(dev_id, vendor, vendor, self.manufacturer_to_count[vendor])) 25 | 26 | self.packets_seen += 1 27 | if self.packets_seen % 100 == 0: 28 | self.output_report() 29 | 30 | def load_progress(self): 31 | if os.path.exists(SAVE_FILE): 32 | with open(SAVE_FILE, 'rb') as f: 33 | save_point = pickle.load(f) 34 | self.devices_seen = save_point['devices_seen'] 35 | self.manufacturer_to_count = save_point['manufacturer_to_count'] 36 | print('Loaded {} seen devices'.format(len(self.devices_seen))) 37 | 38 | def save_progress(self): 39 | save_point = {'devices_seen': self.devices_seen, 'manufacturer_to_count': self.manufacturer_to_count} 40 | with open(SAVE_FILE, 'wb') as f: 41 | pickle.dump(save_point, f) 42 | 43 | def output_report(self): 44 | descending_order = sorted([(count, vendor) for vendor, count in self.manufacturer_to_count.items()], reverse=True) 45 | total_device_count = 0 46 | with open(REPORT_FILE, 'w') as f: 47 | for (count, vendor) in descending_order: 48 | f.write('{0:8}: {1}\n'.format(count, vendor)) 49 | total_device_count += count 50 | 51 | f.write('\n{}\nTotal unique devices: {}\n\n'.format('='*100, total_device_count)) 52 | 53 | print('[!] Report saved to {}'.format(REPORT_FILE)) 54 | self.save_progress() 55 | 56 | -------------------------------------------------------------------------------- /plugin_examples/find_nearby_strangers.py: -------------------------------------------------------------------------------- 1 | """Finds nearby strangers. Basically a low-pass filter that alerts on infrequently seen (or unseen) nearby devices.""" 2 | import os 3 | import time 4 | import pickle 5 | import datetime 6 | 7 | __author__ = 'Caleb Madrigal' 8 | __email__ = 'caleb.madrigal@gmail.com' 9 | __version__ = '0.0.1' 10 | __apiversion__ = 1 11 | __config__ = {'power': -100, 'trigger_cooldown': 60, 'channel_switch_scheme': 'round_robin', 'time_per_channel': 0.1} 12 | 13 | DEVS_TO_IGNORE = {'ff:ff:ff:ff:ff:ff', '00:00:00:00:00:00'} 14 | POWER_THRESHOLD = -50 15 | TIME_THRESHOLD = 15 * 60 # 15 minutes 16 | SAVE_PERIOD = 30 # seconds 17 | SAVE_FILE = 'find_nearby_strangers.pkl' 18 | 19 | 20 | class Trigger: 21 | def __init__(self): 22 | self.mac_to_seen = {} 23 | self.last_save = time.time() 24 | if os.path.exists(SAVE_FILE): 25 | with open(SAVE_FILE, 'rb') as f: 26 | self.mac_to_seen = pickle.load(f) 27 | print('Loaded {} devices from disk: {}'.format(len(self.mac_to_seen), list(self.mac_to_seen.keys()))) 28 | 29 | def __call__(self, dev_id=None, dev_type=None, power=None, vendor=None, **kwargs): 30 | # Only look at individual devices (device and bssids), and only look when power is present 31 | if ((not power) or 32 | (not dev_id) or 33 | (dev_type == 'ssid') or 34 | (dev_id in DEVS_TO_IGNORE) or 35 | (power < POWER_THRESHOLD)): 36 | return 37 | 38 | seen_first_time = False 39 | 40 | if dev_id not in self.mac_to_seen: 41 | seen_first_time = True 42 | last_seen = time.time() 43 | self.mac_to_seen[dev_id] = [last_seen] 44 | else: 45 | last_seen = self.mac_to_seen[dev_id][-1] 46 | self.mac_to_seen[dev_id].append(time.time()) 47 | 48 | if time.time() - last_seen > TIME_THRESHOLD: 49 | self.alert_stranger(dev_id, vendor, last_seen, power) 50 | elif seen_first_time: 51 | self.alert_stranger(dev_id, vendor, last_seen, power, first_seen=True) 52 | 53 | if time.time() - self.last_save > SAVE_PERIOD: 54 | self.save_to_disk() 55 | 56 | def alert_stranger(self, dev_id, vendor, last_seen, power, first_seen=False): 57 | # visual indicator 58 | if first_seen: 59 | msg = '[!] ' 60 | else: 61 | msg = '[*] ' 62 | 63 | # timestamp 64 | msg += '{} - '.format(str(datetime.datetime.now().replace(microsecond=0))) 65 | 66 | # device id 67 | msg += '{} '.format(dev_id) 68 | if vendor: 69 | msg += '({}) '.format(vendor) 70 | 71 | # spotted at 72 | msg += 'spotted at {:+d} '.format(power) 73 | 74 | # last seen 75 | if first_seen: 76 | msg += '(never before seen)' 77 | else: 78 | msg += '(last seen {} seconds ago)'.format(int(time.time() - last_seen)) 79 | 80 | # actually display msg 81 | print(msg) 82 | 83 | def save_to_disk(self): 84 | with open(SAVE_FILE, 'wb') as f: 85 | pickle.dump(self.mac_to_seen, f) 86 | -------------------------------------------------------------------------------- /plugin_examples/deauth_attack.py: -------------------------------------------------------------------------------- 1 | """Looks for and deauths the specified mac_to_deauth or vendor_to_deauth using aircrack-ng. 2 | 3 | Be careful with vendor_to_deauth - deauth attack every device by that vendor nearby... theoretically. 4 | 5 | Example of how to call: 6 | trackerjacker --track --plugin plugin_examples/deauth_attack.py --plugin-config "{'vendor_to_deauth': 'Apple'}" 7 | 8 | """ 9 | import subprocess 10 | 11 | __author__ = 'Caleb Madrigal' 12 | __email__ = 'caleb.madrigal@gmail.com' 13 | __version__ = '0.0.4' 14 | __apiversion__ = 1 15 | __config__ = {'trigger_cooldown': 1} 16 | 17 | 18 | class Trigger: 19 | def __init__(self, mac_to_deauth=None, vendor_to_deauth=None, deauth_count=3): 20 | if not mac_to_deauth and not vendor_to_deauth: 21 | raise Exception('deauth_attack requires either "mac_to_deauth" or "vendor_to_deauth"') 22 | 23 | self.mac_to_deauth = mac_to_deauth 24 | self.vendor_to_deauth = vendor_to_deauth 25 | self.deauth_count = deauth_count 26 | print('deauth_mac plugin - looking for {}'.format(mac_to_deauth)) 27 | 28 | def __call__(self, dev_id=None, vendor=None, ssid=None, bssid=None, iface=None, **kwargs): 29 | if ((self.mac_to_deauth and dev_id == self.mac_to_deauth) or 30 | (self.vendor_to_deauth and self.vendor_fuzzy_match(vendor))): 31 | 32 | print('Saw MAC ({}) on bssid={}, ssid={}, iface={}, vendor={}'.format(dev_id, bssid, ssid, iface, vendor)) 33 | if iface: 34 | if bssid and bssid.lower() not in {'00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'}: 35 | print('\tDeauthing {}'.format(dev_id)) 36 | deauth_cmd = 'aireplay-ng -0 {count} -a {bssid} -c {mac} {iface}'.format(count=self.deauth_count, 37 | bssid=bssid, 38 | iface=iface, 39 | mac=dev_id) 40 | print('\tDeauth cmd: {}'.format(deauth_cmd)) 41 | subprocess.call(deauth_cmd, shell=True) 42 | return 43 | elif ssid: 44 | print('\tDeauthing {}'.format(dev_id)) 45 | deauth_cmd = 'aireplay-ng -0 {count} -e {ssid} -c {mac} {iface}'.format(count=self.deauth_count, 46 | ssid=ssid, 47 | iface=iface, 48 | mac=dev_id) 49 | print('\tDeauth cmd: {}'.format(deauth_cmd)) 50 | subprocess.call(deauth_cmd, shell=True) 51 | return 52 | 53 | print('\tNot enough data to deauth - need ssid or bssid') 54 | 55 | def vendor_fuzzy_match(self, vendor): 56 | if not vendor: 57 | return False 58 | return self.vendor_to_deauth.lower() in vendor.lower() 59 | -------------------------------------------------------------------------------- /trackerjacker/plugin_parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0111, C0103, W0703, R0902, R0903, R0912, R0913, R0914, R0915, C0413, W0122 3 | 4 | """Handles parsing trigger plugin files and running them. 5 | 6 | The plugin file must be a python file which contains either a function called 'trigger' 7 | or a class called 'Trigger'. It's also recommended to specify a '__apiversion__' (which is just an int) 8 | for backward compatibility if api changes are made in the future. 9 | 10 | If specifying a 'trigger' function, the trigger can take the args specified by default_trigger, and should 11 | always take a catch-all **kwargs for future compatibility. 12 | 13 | Likewise, if specifying a 'Trigger' class, that class must define a '__call__' method, which takes 14 | a subset of the kwargs specified by 'default_trigger', and should always contain a catch-all **kwargs 15 | for future compatibility. 16 | 17 | plugin_config is a dict of config passed to Trigger as kwargs. 18 | 19 | Note that this plugin system is not sandboxed, so if the code in trigger_path brakes something, 20 | the host program will break (unless it is explicitly handling any errors). 21 | 22 | Last, a trigger can override any config parameters with the __config__ param. 23 | """ 24 | 25 | import ast 26 | import json 27 | from .common import TJException 28 | 29 | CURRENT_TRIGGER_API_VERSION = 1 30 | 31 | 32 | def parse_trigger_plugin(trigger_path, plugin_config, parse_only=False): 33 | """Parse plugin file and return the trigger config.""" 34 | 35 | # Open and exec plugin definitions 36 | with open(trigger_path, 'r') as f: 37 | trigger_code = f.read() 38 | trigger_vars = {} 39 | exec(trigger_code, trigger_vars) 40 | 41 | # Get trigger data 42 | api_version = trigger_vars.get('__apiversion__', CURRENT_TRIGGER_API_VERSION) 43 | config = trigger_vars.get('__config__', {}) 44 | trigger = trigger_vars.get('trigger', None) 45 | trigger_class = trigger_vars.get('Trigger', None) 46 | 47 | if parse_only: 48 | trigger = None 49 | else: 50 | if trigger_class: 51 | # Pass optional plugin_config to trigger class 52 | plugin_config = parse_plugin_config(plugin_config) 53 | 54 | # Instantiate class. Note that only a trigger function or class can be defined (and class takes priority) 55 | # Assume the class is called 'Trigger' 56 | try: 57 | trigger = trigger_class(**plugin_config) 58 | except Exception as e: 59 | raise TJException('Error loading plugin ({}): {}'.format(trigger_path, e)) 60 | 61 | if not trigger: 62 | raise TJException('Plugin file must specify a "trigger" function or a "Trigger" class') 63 | 64 | return {'trigger': trigger, 'api_version': api_version, 'config': config} 65 | 66 | 67 | def parse_plugin_config(plugin_config_str): 68 | """Attempt to parse the config as ast or json.""" 69 | if not plugin_config_str: 70 | return {} 71 | 72 | try: 73 | return ast.literal_eval(plugin_config_str) 74 | except SyntaxError: 75 | pass 76 | 77 | try: 78 | return json.loads(plugin_config_str) 79 | except json.decoder.JSONDecodeError: 80 | pass 81 | 82 | return {} 83 | -------------------------------------------------------------------------------- /trackerjacker/dot11_frame.py: -------------------------------------------------------------------------------- 1 | """Provides nice interface for Dot11 Frames""" 2 | 3 | # pylint: disable=R0902, C0413, W0703 4 | 5 | import logging 6 | logging.getLogger("scapy.runtime").setLevel(logging.ERROR) 7 | import scapy.all as scapy 8 | 9 | 10 | class Dot11Frame: 11 | """Takes a scapy Dot11 frame and turns it into a format we want.""" 12 | TO_DS = 0x1 13 | FROM_DS = 0x2 14 | DOT11_FRAME_TYPE_MANAGEMENT = 0 15 | DOT11_FRAME_TYPE_CONTROL = 1 16 | DOT11_FRAME_TYPE_DATA = 2 17 | 18 | def __init__(self, frame, channel=0, iface=None): 19 | self.frame = frame 20 | self.bssid = None 21 | self.ssid = None 22 | self.signal_strength = 0 23 | self.channel = channel 24 | self.iface = iface 25 | self.frame_bytes = len(frame) 26 | 27 | # DS = Distribution System; wired infrastructure connecting multiple BSSs to form an ESS 28 | # Needed to determine the meanings of addr1-4 29 | to_ds = frame.FCfield & Dot11Frame.TO_DS != 0 30 | from_ds = frame.FCfield & Dot11Frame.FROM_DS != 0 31 | if to_ds and from_ds: 32 | self.dst = frame.addr3 33 | self.src = frame.addr4 34 | self.macs = {frame.addr1, frame.addr2, frame.addr3, frame.addr4} 35 | elif to_ds: 36 | self.src = frame.addr2 37 | self.dst = frame.addr3 38 | self.bssid = frame.addr1 39 | self.macs = {frame.addr2, frame.addr3} 40 | elif from_ds: 41 | self.src = frame.addr3 42 | self.dst = frame.addr1 43 | self.bssid = frame.addr2 44 | self.macs = {frame.addr1, frame.addr3} 45 | else: 46 | self.dst = frame.addr1 47 | self.src = frame.addr2 48 | self.bssid = frame.addr3 49 | self.macs = {frame.addr1, frame.addr2} 50 | 51 | if (frame.haslayer(scapy.Dot11Elt) and 52 | (frame.haslayer(scapy.Dot11Beacon) or frame.haslayer(scapy.Dot11ProbeResp))): 53 | 54 | try: 55 | self.ssid = frame[scapy.Dot11Elt].info.decode().replace('\x00', '[NULL]') 56 | except UnicodeDecodeError: 57 | # Only seems to happen on macOS - probably some pcap decoding bug 58 | self.ssid = None 59 | #print('Error decoding ssid: {}'.format(frame[scapy.Dot11Elt].info)) 60 | 61 | if frame.haslayer(scapy.RadioTap): 62 | # This will be uncommented once this scapy PR is merged: https://github.com/secdev/scapy/pull/1381 63 | #try: 64 | # self.signal_strength = frame[scapy.RadioTap].dBm_AntSignal 65 | #except AttributeError: 66 | # try: 67 | self.signal_strength = -(256-ord(frame.notdecoded[-4:-3])) 68 | # except Exception: 69 | # self.signal_strength = -257 70 | 71 | def frame_type(self): 72 | """Returns the 802.11 frame type.""" 73 | return self.frame.type 74 | 75 | def frame_type_name(self): 76 | """Returns the type of frame - 'management', 'control', 'data', or 'unknown'.""" 77 | if self.frame.type == self.DOT11_FRAME_TYPE_MANAGEMENT: 78 | return 'management' 79 | elif self.frame.type == self.DOT11_FRAME_TYPE_CONTROL: 80 | return 'control' 81 | elif self.frame.type == self.DOT11_FRAME_TYPE_DATA: 82 | return 'data' 83 | return 'unknown' 84 | 85 | def __str__(self): 86 | return 'Dot11 (type={}, from={}, to={}, bssid={}, ssid={}, signal_strength={})'.format( 87 | self.frame_type_name(), self.src, self.dst, self.bssid, self.ssid, self.signal_strength) 88 | 89 | def __repr__(self): 90 | return self.__str__() 91 | -------------------------------------------------------------------------------- /trackerjacker/plugins/foxhunt.py: -------------------------------------------------------------------------------- 1 | """Outputs the ordered list of the most powerful WiFi devices based on signal.""" 2 | import time 3 | import heapq 4 | import curses 5 | 6 | __author__ = 'Caleb Madrigal' 7 | __email__ = 'caleb.madrigal@gmail.com' 8 | __version__ = '0.0.6' 9 | __apiversion__ = 1 10 | __config__ = {'power': -100, 'log_level': 'ERROR', 'trigger_cooldown': 1} 11 | 12 | DEVS_TO_IGNORE = {'ff:ff:ff:ff:ff:ff', '00:00:00:00:00:00'} 13 | 14 | 15 | class Trigger: 16 | def __init__(self): 17 | self.dev_to_power = {} 18 | self.dev_to_vendor = {} 19 | self.dev_to_last_seen = {} 20 | self.frame_count = 0 21 | 22 | # Pronounce a curse 23 | self.stdscr = curses.initscr() 24 | curses.noecho() 25 | curses.cbreak() 26 | self.num_to_show = 30 # Default until we get number of lines 27 | 28 | def __call__(self, dev_id=None, dev_type=None, power=None, vendor=None, **kwargs): 29 | # Only look at individual devices (device and bssids), and only look when power is present 30 | if (not power) or (not dev_id) or (dev_type == 'ssid') or (dev_id in DEVS_TO_IGNORE): 31 | return 32 | 33 | self.frame_count += 1 34 | self.dev_to_power[dev_id] = power 35 | self.dev_to_vendor[dev_id] = vendor 36 | self.dev_to_last_seen[dev_id] = time.time() 37 | self.decay_items() 38 | self.show_top_devices() 39 | 40 | def show_top_devices(self): 41 | top_devices = [] 42 | num_lines = self.stdscr.getmaxyx()[0] 43 | num_devices_to_show = num_lines - 2 # 2 lines for header 44 | for _, dev_id in heapq.nlargest(num_devices_to_show, 45 | [(power, mac) for mac, power in self.dev_to_power.items()]): 46 | top_devices.append({'dev_id': dev_id, 47 | 'power': self.dev_to_power[dev_id], 48 | 'vendor': self.dev_to_vendor[dev_id]}) 49 | try: 50 | self.show_list(top_devices) 51 | except Exception as e: 52 | # Ignore any screen drawing exceptions 53 | with open('debug.txt', 'a') as f: 54 | f.write('Error in foxhunt: {}'.format(e)) 55 | 56 | def show_list(self, top_devices): 57 | self.stdscr.erase() 58 | header = '{:>7} {:<17} {}'.format('POWER', 'DEVICE ID', 'VENDOR') 59 | lines = '=' * 7 + ' ' * 8 + '=' * 17 + ' ' * 8 + '=' * 32 60 | self.stdscr.addstr(0, 0, header) 61 | self.stdscr.addstr(1, 0, lines) 62 | for index, device in enumerate(top_devices): 63 | msg = '{:>4}dBm {:<17} '.format(device['power'], device['dev_id']) 64 | if device['vendor']: 65 | msg += '{}'.format(device['vendor']) 66 | self.stdscr.addstr(index + 2, 0, msg) 67 | self.stdscr.refresh() 68 | 69 | def decay_items(self): 70 | # Only decay ever 100 frames (for efficiency) and don't bother removing items if we are under the limit 71 | if self.frame_count % 100 != 0 or len(self.dev_to_power) <= self.num_to_show: 72 | return # Don't bother removing items if we are under the limit 73 | 74 | num_items_to_remove = len(self.dev_to_power) - self.num_to_show 75 | for _, dev_id in heapq.nsmallest(num_items_to_remove, 76 | [(last_seen, mac) for mac, last_seen in 77 | self.dev_to_last_seen.items()]): 78 | self.dev_to_last_seen.pop(dev_id) 79 | self.dev_to_vendor.pop(dev_id) 80 | self.dev_to_power.pop(dev_id) 81 | 82 | def __del__(self): 83 | curses.echo() 84 | curses.nocbreak() 85 | curses.endwin() 86 | 87 | if __name__ == '__main__': 88 | # Smoke test 89 | t = Trigger() 90 | for i in range(100): 91 | t(dev_id=i, power=i) 92 | time.sleep(.1) 93 | -------------------------------------------------------------------------------- /tests/test_config_management.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0111, C0413, C0103, E0401 2 | import unittest 3 | import trackerjacker.config_management as cm 4 | 5 | 6 | class TestParseWatchList(unittest.TestCase): 7 | def test_list_basic(self): 8 | # Test basic MAC-only string 9 | test1 = 'aa:bb:cc:dd:ee:ff' 10 | parsed = cm.parse_command_line_watch_list(test1) 11 | self.assertEqual(parsed, {'aa:bb:cc:dd:ee:ff': {'threshold': None, 'power': None}}) 12 | 13 | def test_2_macs(self): 14 | test2 = 'aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66' 15 | parsed = cm.parse_command_line_watch_list(test2) 16 | self.assertEqual(parsed, {'aa:bb:cc:dd:ee:ff': {'threshold': None, 'power': None}, 17 | '11:22:33:44:55:66': {'threshold': None, 'power': None}}) 18 | 19 | def test_2_macs_explicit(self): 20 | """ Test 2 devices with explicitly setting threshold and power. """ 21 | test3 = 'aa:bb:cc:dd:ee:ff=1000, 11:22:33:44:55:66=-32' 22 | parsed = cm.parse_command_line_watch_list(test3) 23 | self.assertEqual(parsed, {'aa:bb:cc:dd:ee:ff': {'threshold': 1000, 'power': None}, 24 | '11:22:33:44:55:66': {'threshold': None, 'power': -32}}) 25 | 26 | 27 | class TestCommandLineBasics(unittest.TestCase): 28 | def test_default_config(self): 29 | # Just making sure I understand how parse_args works 30 | cmd_line_args = cm.get_arg_parser().parse_args([]) 31 | self.assertEqual(cmd_line_args.do_map, False) 32 | cmd_line_args = cm.get_arg_parser().parse_args(['--map']) 33 | self.assertEqual(cmd_line_args.do_map, True) 34 | 35 | # Test overriding the map_file 36 | cmd_line_args = cm.get_arg_parser().parse_args([]) 37 | config = cm.build_config(cmd_line_args) 38 | self.assertEqual(config['map_file'], 'wifi_map.yaml') 39 | 40 | def test_override_config(self): 41 | cmd_line_args = cm.get_arg_parser().parse_args(['--map-file', 'my_network.yaml']) 42 | config = cm.build_config(cmd_line_args) 43 | self.assertEqual(config['map_file'], 'my_network.yaml') 44 | 45 | def test_config_macs_to_watch_default_threshold_to_1(self): 46 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21']) 47 | config = cm.build_config(cmd_line_args) 48 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 1, 49 | 'power': None,}}) 50 | 51 | def test_config_macs_to_watch_explicit_threshold(self): 52 | """ Test setting an explicit threshold. """ 53 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21=100']) 54 | config = cm.build_config(cmd_line_args) 55 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 100, 56 | 'power': None}}) 57 | 58 | def test_config_macs_to_watch_explicit_threshold_multiple(self): 59 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21=100,aa:bb:cc:dd:ee:ff']) 60 | config = cm.build_config(cmd_line_args) 61 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 100, 62 | 'power': None}, 63 | 'aa:bb:cc:dd:ee:ff': {'threshold': 1, 64 | 'power': None}}) 65 | 66 | def test_config_macs_to_watch_power_and_threshold(self): 67 | """ Test setting power and threshold. """ 68 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21=100,aa:bb:cc:dd:ee:ff=-50']) 69 | config = cm.build_config(cmd_line_args) 70 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 100, 71 | 'power': None}, 72 | 'aa:bb:cc:dd:ee:ff': {'threshold': None, 73 | 'power': -50}}) 74 | 75 | def test_config_macs_to_watch_general_threshold(self): 76 | """ Test that general threshold is used if no explicit specified. """ 77 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21,aa:bb:cc:dd:ee:ff', 78 | '--threshold', '1337']) 79 | config = cm.build_config(cmd_line_args) 80 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 1337, 81 | 'power': None}, 82 | 'aa:bb:cc:dd:ee:ff': {'threshold': 1337, 83 | 'power': None}}) 84 | 85 | class TestCommandLineGeneralPower(unittest.TestCase): 86 | def test_config_macs_to_watch_power(self): 87 | """ Test that general threshold is used if no explicit specified. """ 88 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21,aa:bb:cc:dd:ee:ff', 89 | '--power', '-42']) 90 | config = cm.build_config(cmd_line_args) 91 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': None, 92 | 'power': -42}, 93 | 'aa:bb:cc:dd:ee:ff': {'threshold': None, 94 | 'power': -42}}) 95 | 96 | 97 | class TestCommandLinePower(unittest.TestCase): 98 | def test_config_macs_to_watch_mixed_override(self): 99 | """ Test that we can have explicitly-set threshold and still get general power. """ 100 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21=123,aa:bb:cc:dd:ee:ff', 101 | '--power', '-42']) 102 | config = cm.build_config(cmd_line_args) 103 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 123, 104 | 'power': None}, 105 | 'aa:bb:cc:dd:ee:ff': {'threshold': None, 106 | 'power': -42}}) 107 | 108 | class TestCommandLineExplicitPowerGeneralThreshold(unittest.TestCase): 109 | def test_config_macs_to_watch_mixed_override_reverse(self): 110 | """ Test that we can have explicitly-set power and still get general threshold. """ 111 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-m', '7C:70:BC:78:70:21=-22,11:bb:cc:dd:ee:ff', 112 | '--threshold', '1024']) 113 | config = cm.build_config(cmd_line_args) 114 | self.assertEqual(config['devices_to_watch'], {'7C:70:BC:78:70:21': {'threshold': None, 115 | 'power': -22}, 116 | '11:bb:cc:dd:ee:ff': {'threshold': 1024, 117 | 'power': None}}) 118 | 119 | 120 | class TestCommandLineApsToWatch(unittest.TestCase): 121 | def test_config_aps_to_watch(self): 122 | """ Test setting explicit threshold and power, and test ssid. """ 123 | cmd_line_args = cm.get_arg_parser().parse_args(['--track', '-a', '7C:70:BC:78:70:21=100,my_network']) 124 | config = cm.build_config(cmd_line_args) 125 | self.assertEqual(config['aps_to_watch'], {'7C:70:BC:78:70:21': {'threshold': 100, 126 | 'power': None}, 127 | 'my_network': {'threshold': 1, 128 | 'power': None}}) 129 | 130 | 131 | if __name__ == '__main__': 132 | unittest.main() 133 | -------------------------------------------------------------------------------- /trackerjacker/linux_device_management.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0111, C0103, C0413, W0703, R0902, R0903, R0912, R0913, R0914, R0915 3 | 4 | import os 5 | import re 6 | import time 7 | import random 8 | import threading 9 | import subprocess 10 | import collections 11 | 12 | from .common import TJException # pylint: disable=E0401 13 | 14 | ADAPTER_MODE_MANAGED = 1 # ARPHRD_ETHER 15 | ADAPTER_MONITOR_MODE = 803 # ARPHRD_IEEE80211_RADIOTAP 16 | MIN_FRAME_COUNT = 5 17 | 18 | 19 | def check_interface_exists(iface): 20 | if not os.path.exists('/sys/class/net/{}'.format(iface)): 21 | raise TJException('Interface {} not found'.format(iface)) 22 | 23 | 24 | def set_interface_mode(iface, mode): 25 | check_interface_exists(iface) 26 | subprocess.check_call('ifconfig {} down'.format(iface), shell=True) 27 | subprocess.check_call('iwconfig {} mode {}'.format(iface, mode), shell=True) 28 | subprocess.check_call('ifconfig {} up'.format(iface), shell=True) 29 | 30 | 31 | def monitor_mode_on(iface): 32 | set_interface_mode(iface, 'monitor') 33 | 34 | 35 | def monitor_mode_off(iface): 36 | set_interface_mode(iface, 'managed') 37 | 38 | 39 | def get_network_interfaces(): 40 | return os.listdir('/sys/class/net') 41 | 42 | 43 | def is_monitor_mode_device(iface_name): 44 | check_interface_exists(iface_name) 45 | with open('/sys/class/net/{}/type'.format(iface_name), 'r') as f: 46 | adapter_mode = f.read().strip() 47 | 48 | try: 49 | adapter_mode = int(adapter_mode) 50 | except ValueError: 51 | return False 52 | 53 | return adapter_mode == ADAPTER_MONITOR_MODE 54 | 55 | 56 | def find_monitor_interfaces(): 57 | for iface_name in get_network_interfaces(): 58 | try: 59 | if is_monitor_mode_device(iface_name): 60 | yield iface_name 61 | except TJException: 62 | # If there's any problem with any interface, keep looking 63 | pass 64 | 65 | 66 | def find_first_monitor_interface(): 67 | try: 68 | return next(find_monitor_interfaces()) 69 | except StopIteration: 70 | return None 71 | 72 | 73 | def get_supported_channels(iface): 74 | iwlist_output = subprocess.check_output('iwlist {} freq'.format(iface), shell=True).decode() 75 | lines = [line.strip() for line in iwlist_output.split('\n')] 76 | channel_regex = re.compile(r'Channel\W+(\d+)') 77 | channels = [] 78 | for line in lines: 79 | m = re.search(channel_regex, line) 80 | if m: 81 | c = m.groups()[0] 82 | channels.append(c) 83 | 84 | # '07' -> 7, and sort 85 | channels = list(sorted(list(set([int(chan) for chan in channels])))) 86 | return channels 87 | 88 | 89 | def switch_to_channel(iface, channel_num): 90 | subprocess.call('iw dev {} set channel {}'.format(iface, channel_num), shell=True) 91 | 92 | 93 | def select_interface(iface, logger): 94 | selected_iface = None 95 | need_to_disable_monitor_mode_on_exit = False 96 | 97 | # If no device specified, see if there is a device already in monitor mode, and go with it... 98 | if not iface: 99 | monitor_mode_iface = find_first_monitor_interface() 100 | if monitor_mode_iface: 101 | selected_iface = monitor_mode_iface 102 | logger.info('Using monitor mode interface: %s', selected_iface) 103 | else: 104 | raise TJException('Please specify interface with -i switch') 105 | 106 | # If specified interface is already in monitor mode, do nothing... just go with it 107 | elif is_monitor_mode_device(iface): 108 | selected_iface = iface 109 | logger.debug('Interface %s is already in monitor mode...', iface) 110 | 111 | # Otherwise, try to put specified interface into monitor mode, but remember to undo that when done... 112 | else: 113 | try: 114 | logger.info('Enabling monitor mode on %s', iface) 115 | monitor_mode_on(iface) 116 | selected_iface = iface 117 | need_to_disable_monitor_mode_on_exit = True 118 | logger.debug('Enabled monitor mode on %s', iface) 119 | except Exception: 120 | # If we fail to find the specified (or default) interface, look to see if there is a monitor interface 121 | logger.warning('Could not enable monitor mode on enterface: %s', iface) 122 | mon_iface = find_first_monitor_interface() 123 | if mon_iface: 124 | selected_iface = mon_iface 125 | logger.info('Going with interface: %s', selected_iface) 126 | else: 127 | raise TJException('Could not find a monitor interface') 128 | 129 | return selected_iface, need_to_disable_monitor_mode_on_exit 130 | 131 | 132 | class Dot11InterfaceManager: 133 | def __init__(self, iface, logger, channels_to_monitor, channel_switch_scheme, time_per_channel): 134 | self.logger = logger 135 | self.iface, self.need_to_disable_monitor_mode_on_exit = select_interface(iface, self.logger) 136 | 137 | self.channels_to_monitor = channels_to_monitor 138 | self.channel_switch_scheme = channel_switch_scheme 139 | self.time_per_channel = time_per_channel 140 | 141 | self.stop_event = threading.Event() 142 | self.supported_channels = [] 143 | self.current_channel = 1 144 | self.last_channel_switch_time = 0 145 | self.num_frames_received_this_channel = 0 146 | 147 | self.channel_switch_func = self.switch_channel_round_robin # default 148 | self.configure_channels(channels_to_monitor, channel_switch_scheme) 149 | 150 | # Leaky bucket per channel to track how many frames were seen last time that channels was monitored 151 | # The leaky bucket helps ensure that if at one time, someone downloads a video or something, 152 | # that channel doesn't forever get dominance. 153 | counter_leaky_bucket_size = 10 154 | self.frame_counts_per_channel = {c: collections.deque([(time.time(), MIN_FRAME_COUNT)], 155 | maxlen=counter_leaky_bucket_size) 156 | for c in self.channels_to_monitor} 157 | 158 | def configure_channels(self, channels_to_monitor, channel_switch_scheme): 159 | # Find supported channels 160 | self.supported_channels = get_supported_channels(self.iface) 161 | if not self.supported_channels: 162 | raise TJException('Interface either not found, or incompatible: {}'.format(self.iface)) 163 | 164 | if channels_to_monitor: 165 | channels_to_monitor_set = set([int(c) for c in channels_to_monitor]) 166 | if len(channels_to_monitor_set & set(self.supported_channels)) != len(channels_to_monitor_set): 167 | raise TJException('Not all of channels to monitor are supported by {}'.format(self.iface)) 168 | 169 | self.channels_to_monitor = channels_to_monitor 170 | self.current_channel = self.channels_to_monitor[0] 171 | self.logger.info('Monitoring channels: %s', channels_to_monitor_set) 172 | else: 173 | self.channels_to_monitor = self.supported_channels 174 | self.current_channel = self.supported_channels[0] 175 | self.logger.info('Monitoring all available channels on %s: %s', self.iface, self.supported_channels) 176 | 177 | self.logger.debug('Channel switching scheme: %s', channel_switch_scheme) 178 | 179 | if channel_switch_scheme == 'traffic_based': 180 | self.channel_switch_func = self.switch_channel_based_on_traffic 181 | 182 | self.switch_to_channel(self.current_channel, force=True) 183 | 184 | def channel_switcher_thread(self, firethread=True): # pylint: disable=R1710 185 | if firethread: 186 | t = threading.Thread(target=self.channel_switcher_thread, args=(False,)) 187 | t.daemon = True 188 | t.start() 189 | return t 190 | 191 | # Only worry about switching channels if we are monitoring 2 or more 192 | if len(self.channels_to_monitor) > 1: 193 | while not self.stop_event.is_set(): 194 | time.sleep(self.time_per_channel) 195 | self.channel_switch_func() 196 | self.last_channel_switch_time = time.time() 197 | 198 | def get_next_channel_based_on_traffic(self): 199 | count_by_channel = {c: sum([count for ts, count in frame_count_list]) 200 | for c, frame_count_list in self.frame_counts_per_channel.items()} 201 | total_count = sum(count_by_channel.values()) 202 | percent_to_channel = [(count/total_count, channel) for channel, count in count_by_channel.items()] 203 | 204 | percent_sum = 0 205 | sum_to_reach = random.random() 206 | for percent, channel in percent_to_channel: 207 | percent_sum += percent 208 | if percent_sum >= sum_to_reach: 209 | return channel 210 | 211 | return random.sample(self.channels_to_monitor, 1)[0] 212 | 213 | def switch_channel_based_on_traffic(self): 214 | next_channel = self.get_next_channel_based_on_traffic() 215 | 216 | # Don't ever set a channel to a 0% probability of being hit again 217 | if self.num_frames_received_this_channel == 0: 218 | self.num_frames_received_this_channel = MIN_FRAME_COUNT 219 | 220 | time_frames_entry = (time.time(), self.num_frames_received_this_channel) 221 | self.frame_counts_per_channel[self.current_channel].append(time_frames_entry) 222 | self.num_frames_received_this_channel = 0 223 | self.switch_to_channel(next_channel) 224 | 225 | def switch_channel_round_robin(self): 226 | chans = self.channels_to_monitor 227 | next_channel = chans[(chans.index(self.current_channel)+1) % len(chans)] 228 | self.switch_to_channel(next_channel) 229 | 230 | def switch_to_channel(self, channel_num, force=False): 231 | self.logger.debug('Switching to channel %s', channel_num) 232 | if channel_num == self.current_channel and not force: 233 | return 234 | switch_to_channel(self.iface, channel_num) 235 | self.current_channel = channel_num 236 | 237 | def add_frame(self, frame): 238 | self.num_frames_received_this_channel += 1 239 | 240 | def start(self): 241 | self.channel_switcher_thread() 242 | 243 | def stop(self): 244 | self.stop_event.set() 245 | 246 | if self.need_to_disable_monitor_mode_on_exit: 247 | self.logger.info('\nDisabling monitor mode for interface: %s', self.iface) 248 | 249 | # Try to wait long enough for the channel switching thread to see the event so 250 | # the device isn't busy when we try to disable monitor mode. 251 | time.sleep(self.time_per_channel + 1) 252 | 253 | monitor_mode_off(self.iface) 254 | self.logger.debug('Disabled monitor mode for interface: %s', self.iface) 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trackerjacker 2 | 3 | Like nmap for mapping wifi networks you're not connected to. Maps and tracks wifi networks and devices through raw 802.11 monitoring. 4 | 5 | PyPI page: https://pypi.python.org/pypi/trackerjacker 6 | 7 | #### Install 8 | 9 | pip3 install trackerjacker 10 | 11 | *Supported platforms*: Linux (tested on Ubuntu, Kali, and RPi) and macOS (pre-alpha) 12 | 13 | ![visual description](https://i.imgur.com/I5NH5KM.jpg) 14 | 15 | trackerjacker can help with the following: 16 | 17 | * I want to know all the nearby wifi networks **and know all the devices connected to each network.** 18 | * I want to know who's hogging all the bandwidth. 19 | * I want to run a command when this MAC address sends more than 100000 bytes in a 30 second window (maybe to determine when an IP camera is uploading a video, which is indicative that it just saw motion). 20 | * I want to deauth anyone who uses more than 100000 bytes in a 10 second window. 21 | * I want to deauth every Dropcam in the area so my Airbnb hosts don't spy on me. 22 | * I want to be alerted when any MAC address is seen at a power level greater than -40dBm that I've never seen before. 23 | * I want to see when this particular person is nearby (based on the MAC of their mobile phone) and run a command to alert me. 24 | * I want to write my own plugin to run some script to do something fun every time a new Apple device shows up nearby. 25 | 26 | ## Usage 27 | 28 | Find detailed usage like this: 29 | 30 | trackerjacker -h 31 | 32 | There are 2 major usage modes for `trackerjacker`: **map** mode and **track** mode: 33 | 34 | ### Map mode example 35 | 36 | Map command: 37 | 38 | trackerjacker -i wlan1337 --map 39 | 40 | By default, this outputs the `wifi_map.yaml` YAML file, which is a map of all the nearby WiFi networks and all of their users. Here's an example `wifi_map.yaml` file: 41 | 42 | TEST_SSID: 43 | 00:10:18:6b:7a:ea: 44 | bssid: 00:10:18:6b:7a:ea 45 | bytes: 5430 46 | channels: 47 | - 11 48 | devices: 49 | 3c:07:71:15:f1:48: 50 | bytes: 798 51 | signal: 1 52 | vendor: Sony Corporation 53 | 78:31:c1:7f:25:43: 54 | bytes: 4632 55 | signal: -52 56 | vendor: Apple, Inc. 57 | signal: -86 58 | ssid: TEST_SSID 59 | vendor: Broadcom 60 | 61 | BRANSONS_WIFI: 62 | 90:48:9a:e3:58:25: 63 | bssid: 90:48:9a:e3:58:25 64 | bytes: 5073 65 | channels: 66 | - 1 67 | devices: 68 | 01:00:5e:96:e1:89: 69 | bytes: 476 70 | signal: -62 71 | vendor: '' 72 | 30:8c:fb:66:23:91: 73 | bytes: 278 74 | signal: -46 75 | vendor: Dropcam 76 | 34:23:ba:1c:ba:e7: 77 | bytes: 548 78 | signal: 4 79 | vendor: SAMSUNG ELECTRO-MECHANICS(THAILAND) 80 | signal: -80 81 | ssid: BRANSONS_WIFI 82 | vendor: Hon Hai Precision Ind. Co.,Ltd. 83 | 84 | hacker_network: 85 | 80:2a:a8:e5:de:92: 86 | bssid: 80:2a:a8:e5:de:92 87 | bytes: 5895 88 | channels: 89 | - 11 90 | devices: 91 | 80:1f:02:e6:44:96: 92 | bytes: 960 93 | signal: -46 94 | vendor: Edimax Technology Co. Ltd. 95 | 80:2a:a8:8a:ec:c8: 96 | bytes: 472 97 | signal: 4 98 | vendor: Ubiquiti Networks Inc. 99 | 80:2a:a8:be:09:a9: 100 | bytes: 5199 101 | signal: 4 102 | vendor: Ubiquiti Networks Inc. 103 | d8:49:2f:7a:f0:8f: 104 | bytes: 548 105 | signal: 4 106 | vendor: CANON INC. 107 | signal: -46 108 | ssid: hacker 109 | vendor: Ubiquiti Networks Inc. 110 | 80:2a:a8:61:aa:2f: 111 | bssid: 80:2a:a8:61:aa:2f 112 | bytes: 5629 113 | channels: 114 | - 44 115 | - 48 116 | devices: 117 | 78:88:6d:4e:e2:c9: 118 | bytes: 948 119 | signal: -52 120 | vendor: '' 121 | e4:8b:7f:d4:cb:25: 122 | bytes: 986 123 | signal: -48 124 | vendor: Apple, Inc. 125 | signal: -48 126 | ssid: null 127 | vendor: Ubiquiti Networks Inc. 128 | 82:2a:a8:51:32:25: 129 | bssid: 82:2a:a8:51:32:25 130 | bytes: 3902 131 | channels: 132 | - 48 133 | devices: 134 | b8:e8:56:f5:a0:70: 135 | bytes: 1188 136 | signal: -34 137 | vendor: Apple, Inc. 138 | signal: -14 139 | ssid: hacker 140 | vendor: '' 141 | 82:2a:a8:fc:33:b6: 142 | bssid: 82:2a:a8:fc:33:b6 143 | bytes: 7805 144 | channels: 145 | - 10 146 | - 11 147 | - 12 148 | devices: 149 | 78:31:c1:7f:25:43: 150 | bytes: 4632 151 | signal: -52 152 | vendor: Apple, Inc. 153 | 7c:dd:90:fe:b4:87: 154 | bytes: 423223 155 | signal: 4 156 | vendor: Shenzhen Ogemray Technology Co., Ltd. 157 | 80:2a:a8:be:09:a9: 158 | bytes: 5199 159 | signal: 4 160 | vendor: Ubiquiti Networks Inc. 161 | signal: -62 162 | ssid: null 163 | vendor: '' 164 | 165 | Note that, since this is YAML, you can easily use it as an input for other scripts of your own devising. I have an example script to parse this "YAML DB" here: [parse_trackerjacker_wifi_map.py](https://gist.github.com/calebmadrigal/fdb8855a6d05c87bbb0254a1424ee582). 166 | 167 | ### Example: Track mode with trigger command 168 | 169 | Track mode allows you to specify some number of MAC addresses to watch, and if any specific devices exceeds the threshold (in bytes), specified here with the `-t 4000` (specifying an alert threshold of 4000 bytes) an alert will be triggered. 170 | 171 | trackerjacker --track -m 3c:2e:ff:31:32:59 --t 4000 --trigger-command "./alert.sh" --channels-to-monitor 10,11,12,44 172 | Using monitor mode interface: wlan1337 173 | Monitoring channels: {10, 11, 12, 44} 174 | 175 | [@] Device (3c:2e:ff:31:32:59) threshold hit: 4734 176 | 177 | [@] Device (3c:2e:ff:31:32:59) threshold hit: 7717 178 | 179 | [@] Device (3c:2e:ff:31:32:59) threshold hit: 7124 180 | 181 | [@] Device (3c:2e:ff:31:32:59) threshold hit: 8258 182 | 183 | [@] Device (3c:2e:ff:31:32:59) threshold hit: 8922 184 | 185 | In this particular example, I was watching a security camera to determine when it was uploading a video (indicating motion was detected) so that I could turn on my security system sirens (which was the original genesis of this project). 186 | 187 | ### Example: Track mode with foxhunt plugin 188 | 189 | trackerjacker -i wlan1337 --track --trigger-plugin foxhunt 190 | 191 | Displays a curses screen like this: 192 | 193 | POWER DEVICE ID VENDOR 194 | ======= ================= ================================ 195 | -82dBm 1c:1b:68:35:c6:5d ARRIS Group, Inc. 196 | -84dBm fc:3f:db:ed:e9:8e Hewlett Packard 197 | -84dBm dc:0b:34:7a:11:63 LG Electronics (Mobile Communications) 198 | -84dBm 94:62:69:af:c3:64 ARRIS Group, Inc. 199 | -84dBm 90:48:9a:34:15:65 Hon Hai Precision Ind. Co.,Ltd. 200 | -84dBm 64:00:6a:07:48:13 Dell Inc. 201 | -84dBm 00:30:44:38:76:c8 CradlePoint, Inc 202 | -86dBm 44:1c:a8:fc:c0:53 Hon Hai Precision Ind. Co.,Ltd. 203 | -86dBm 18:16:c9:c0:3b:75 Samsung Electronics Co.,Ltd 204 | -86dBm 01:80:c2:62:9e:36 205 | -86dBm 01:00:5e:11:90:47 206 | -86dBm 00:24:a1:97:68:83 ARRIS Group, Inc. 207 | -88dBm f8:2c:18:f8:f3:aa 2Wire Inc 208 | -88dBm 84:a1:d1:a6:34:08 209 | 210 | 211 | * Note that `foxhunt` is a builtin plugin, but you can define your own plugins using the same Plugin API. 212 | 213 | ### Example: Track mode with trigger plugin 214 | 215 | $ trackerjacker --track -m 3c:2e:ff:31:32:59 --threshold 10 --trigger-plugin examples/plugin_example1.py --channels-to-monitor 10,11,12,44 --trigger-cooldown 1 216 | Using monitor mode interface: wlan1337 217 | Monitoring channels: {10, 11, 12, 44} 218 | [@] Device (device 3c:2e:ff:31:32:59) threshold hit: 34 bytes 219 | 3c:2e:ff:31:32:59 seen at: [1521926768.756529] 220 | [@] Device (device 3c:2e:ff:31:32:59) threshold hit: 11880 bytes 221 | 3c:2e:ff:31:32:59 seen at: [1521926768.756529, 1521926769.758929] 222 | [@] Device (device 3c:2e:ff:31:32:59) threshold hit: 18564 bytes 223 | 3c:2e:ff:31:32:59 seen at: [1521926768.756529, 1521926769.758929, 1521926770.7622838] 224 | 225 | This runs `examples/plugin_example1.py` every time `3c:2e:ff:31:32:59` is seen sending/receiving 10 bytes or more. 226 | 227 | trackerjacker plugins are simply python files that contain either: 228 | * `Trigger` class which defines a `__call__(**kwargs)` method (example: `examples/plugin_example1.py`) 229 | * `trigger(**kwargs)` function (example: `examples/plugin_example2.py`) 230 | 231 | And optionally a `__apiversion__ = 1` line (for future backward compatibility) 232 | 233 | ### Example: Configuring with config file 234 | 235 | trackerjacker.py -c my_config.json 236 | 237 | And here's the example config file called `my_config.json`: 238 | 239 | ``` 240 | { 241 | "iface": "wlan1337", 242 | "devices_to_watch": {"5f:cb:53:1c:8a:2c": 1000, "32:44:1b:d7:a1:5b": 2000}, 243 | "aps_to_watch": {"c6:23:ef:33:cc:a2": 500}, 244 | "threshold_window": 10, 245 | "channels_to_monitor": [1, 6, 11, 52], 246 | "channel_switch_scheme": "round_robin" 247 | } 248 | ``` 249 | 250 | A few notes about this: 251 | 252 | * `threshold_bytes` is the default threshold of bytes which, if seen, a causes the alert function to be called 253 | * `threshold_window` is the time window in which the `threshold_bytes` is analyzed. 254 | * `devices_to_watch` is a list which can contain either strings (representing MACs) or dicts (which allow the specification of a `name` and `threshold`) 255 | - `name` is simply what a label you want to be printed when this device is seen. 256 | - `threshold` in the "Security camera" is how many bytes must be seen 257 | * `channels_to_monitor` - list of 802.11 wifi channels to monitor. The list of channels your wifi card supports is printed when trackerjacker starts up. By default, all supported channels are monitored. 258 | * `channel_switch_scheme` - either `default`, `round_robin`, or `traffic_based`. `traffic_based` determines the channels of most traffic, and probabilistically monitors them more. 259 | 260 | ### Example: Enable/Disable monitor mode on interface 261 | 262 | Trackerjacker comes with a few other utility functions relevant to WiFi hacking. One of these is the ability to turn on monitor mode on a specific interface. 263 | 264 | Enable monitor mode: 265 | 266 | trackerjacker --monitor-mode-on -i wlan0 267 | 268 | Disable monitor mode: 269 | 270 | trackerjacker --monitor-mode-off -i wlan0mon 271 | 272 | Note that trackerjacker will automatically enable/disable monitor mode if necessary. This functionality is just useful if you want to enable monitor mode on an interface for use with other applications (or for quicker starup of trackerjacker, if you plan to be starting/exiting to test stuff). 273 | 274 | ### Example: Set adapter channel 275 | 276 | trackerjacker --set-channel 11 -i wlan0 277 | 278 | Note that trackerjacker will automatically switch channels as necessary during normal map/track actions. This option is just useful if you want to set the channel on an interface for use with other applications. 279 | 280 | ## Recommended hardware 281 | 282 | * Panda PAU07 N600 Dual Band (nice, small, 2.4GHz and 5GHz) 283 | * Panda PAU09 N600 Dual Band (higher power, 2.4GHz and 5GHz) 284 | * Alfa AWUS052NH Dual-Band 2x 5dBi (high power, 2.4GHz and 5GHz, large, ugly) 285 | * TP-Link N150 (works well, but not dual band) 286 | 287 | ## Roadmap 288 | 289 | - [x] Hosted in PyPI 290 | - [x] Radio signal strength for APs 291 | - [x] Radio signal strength for individual macs 292 | - [x] Build map by data exchanged (exclude beacons) 293 | - [x] Packet count by AP 294 | - [x] Packet count by MAC 295 | - [x] Easier way to input per-device tracking thresholds 296 | - [x] Plugin system 297 | - [x] Fox hunt mode 298 | - [x] Tracking by SSID (and not just BSSID) 299 | - [x] Basic macOS (OS X) support (pre-alpha) 300 | - [ ] macOS support: get signal strength values correct (will be fixed in https://github.com/secdev/scapy/pull/1381 301 | - [ ] macOS support: reverse airport binary to determine how to set true monitor mode 302 | - [ ] macOS support: diverse interface support (not just `en0`) 303 | - [ ] macOS support: get interface supported channels 304 | - [ ] Mapping a specific SSID 305 | - [ ] Performance enhancement: not shelling out for channel switching 306 | - [ ] "Jack" mode - deauth attacks 307 | 308 | -------------------------------------------------------------------------------- /trackerjacker/macos_device_management.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0111, C0103, C0413, W0703, R0902, R0903, R0912, R0913, R0914, R0915 3 | 4 | # NOTE: Horrible, horrible things... I'm sorry for this. I kind of hope nobody ever reads this - 5 | # that it stay a confession that nobody ever hears. I'll make things better later. 6 | 7 | import os 8 | import time 9 | import random 10 | import threading 11 | import subprocess 12 | import collections 13 | 14 | from .common import TJException # pylint: disable=E0401 15 | 16 | AIRPORT_PATH = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' 17 | MIN_FRAME_COUNT = 5 18 | 19 | 20 | class MonitorModeHack: 21 | def __init__(self, iface): 22 | self.iface = iface 23 | self.sniff_time = 10 * 60 # 10 minutes 24 | self.stop_event = threading.Event() 25 | self.proc = None 26 | self.starting_tmp_pcaps = self.find_new_pcap([]) 27 | 28 | def sniff_for(self, for_time=None): 29 | self.proc = subprocess.Popen([AIRPORT_PATH, self.iface, 'sniff', '1'], 30 | stdout=subprocess.DEVNULL, 31 | stderr=subprocess.DEVNULL) 32 | if for_time: 33 | time.sleep(for_time) 34 | self.proc.terminate() 35 | 36 | def find_new_pcap(self, previous_pcap_paths): 37 | tmp_files = os.listdir('/tmp/') 38 | pcap_files = [f for f in tmp_files if f.endswith('.cap')] 39 | new_pcap = set(pcap_files) - set(previous_pcap_paths) 40 | return list(new_pcap) 41 | 42 | def sniff_loop(self): 43 | while not self.stop_event.is_set(): 44 | self.starting_tmp_pcaps = self.find_new_pcap([]) 45 | 46 | self.sniff_for(self.sniff_time) 47 | 48 | # Delete pcap file we created 49 | self.delete_pcaps_we_created() 50 | 51 | def delete_pcaps_we_created(self): 52 | pcaps_we_created = self.find_new_pcap(self.starting_tmp_pcaps) 53 | for pcap_filename in pcaps_we_created: 54 | tmp_pcap = os.path.join('/tmp/', pcap_filename) 55 | try: 56 | os.remove(tmp_pcap) 57 | except Exception as e: 58 | print('Error removing pcap ({}): {}'.format(tmp_pcap, e)) 59 | 60 | def start(self): 61 | t = threading.Thread(target=self.sniff_loop, args=()) 62 | t.daemon = True 63 | t.start() 64 | 65 | def stop(self): 66 | self.stop_event.set() 67 | if self.proc: 68 | self.proc.terminate() 69 | time.sleep(2) 70 | self.delete_pcaps_we_created() 71 | 72 | 73 | def check_interface_exists(iface): 74 | return True # todo 75 | 76 | 77 | def monitor_mode_on(iface): 78 | raise TJException('Not curently supported in macOS') 79 | 80 | 81 | def monitor_mode_off(iface): 82 | raise TJException('Not curently supported in macOS') 83 | 84 | 85 | def get_network_interfaces(): 86 | # hack - TODO: fix this 87 | return ['en0'] 88 | 89 | 90 | def is_monitor_mode_device(iface_name): 91 | # hack - TODO: make this better 92 | return False 93 | 94 | 95 | def find_monitor_interfaces(): 96 | for iface_name in get_network_interfaces(): 97 | try: 98 | if is_monitor_mode_device(iface_name): 99 | yield iface_name 100 | except TJException: 101 | # If there's any problem with any interface, keep looking 102 | pass 103 | 104 | 105 | def find_first_monitor_interface(): 106 | try: 107 | return next(find_monitor_interfaces()) 108 | except StopIteration: 109 | return None 110 | 111 | 112 | def get_supported_channels(iface): 113 | # TODO: query supported channels 114 | return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 115 | 112, 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165] 116 | 117 | 118 | def switch_to_channel(iface, channel_num): 119 | subprocess.check_call([AIRPORT_PATH, '--channel={}'.format(channel_num)], 120 | stdout=subprocess.DEVNULL, 121 | stderr=subprocess.DEVNULL) 122 | 123 | 124 | def select_interface(iface, logger): 125 | if iface == None: 126 | # Hacky default 127 | iface = 'en0' 128 | 129 | selected_iface = None 130 | need_to_disable_monitor_mode_on_exit = False 131 | 132 | # If no device specified, see if there is a device already in monitor mode, and go with it... 133 | if not iface: 134 | monitor_mode_iface = find_first_monitor_interface() 135 | if monitor_mode_iface: 136 | selected_iface = monitor_mode_iface 137 | logger.info('Using monitor mode interface: %s', selected_iface) 138 | else: 139 | raise TJException('Please specify interface with -i switch') 140 | 141 | # If specified interface is already in monitor mode, do nothing... just go with it 142 | elif is_monitor_mode_device(iface): 143 | selected_iface = iface 144 | logger.debug('Interface %s is already in monitor mode...', iface) 145 | 146 | # Otherwise, try to put specified interface into monitor mode, but remember to undo that when done... 147 | else: 148 | try: 149 | logger.info('Enabling monitor mode on %s', iface) 150 | # monitor_mode_on(iface) 151 | selected_iface = iface 152 | need_to_disable_monitor_mode_on_exit = True 153 | logger.debug('Enabled monitor mode on %s', iface) 154 | except Exception: 155 | # If we fail to find the specified (or default) interface, look to see if there is a monitor interface 156 | logger.warning('Could not enable monitor mode on enterface: %s', iface) 157 | mon_iface = find_first_monitor_interface() 158 | if mon_iface: 159 | selected_iface = mon_iface 160 | logger.info('Going with interface: %s', selected_iface) 161 | else: 162 | raise TJException('Could not find a monitor interface') 163 | 164 | return selected_iface, need_to_disable_monitor_mode_on_exit 165 | 166 | 167 | class Dot11InterfaceManager: 168 | def __init__(self, iface, logger, channels_to_monitor, channel_switch_scheme, time_per_channel): 169 | self.logger = logger 170 | self.iface, self.need_to_disable_monitor_mode_on_exit = select_interface(iface, self.logger) 171 | 172 | self.channels_to_monitor = channels_to_monitor 173 | self.channel_switch_scheme = channel_switch_scheme 174 | self.time_per_channel = time_per_channel 175 | 176 | self.stop_event = threading.Event() 177 | self.supported_channels = [] 178 | self.current_channel = 1 179 | self.last_channel_switch_time = 0 180 | self.num_frames_received_this_channel = 0 181 | 182 | self.channel_switch_func = self.switch_channel_round_robin # default 183 | self.configure_channels(channels_to_monitor, channel_switch_scheme) 184 | self.horrible_hack = None 185 | 186 | # Leaky bucket per channel to track how many frames were seen last time that channels was monitored 187 | # The leaky bucket helps ensure that if at one time, someone downloads a video or something, 188 | # that channel doesn't forever get dominance. 189 | counter_leaky_bucket_size = 10 190 | self.frame_counts_per_channel = {c: collections.deque([(time.time(), MIN_FRAME_COUNT)], 191 | maxlen=counter_leaky_bucket_size) 192 | for c in self.channels_to_monitor} 193 | 194 | def configure_channels(self, channels_to_monitor, channel_switch_scheme): 195 | # Find supported channels 196 | self.supported_channels = get_supported_channels(self.iface) 197 | if not self.supported_channels: 198 | raise TJException('Interface either not found, or incompatible: {}'.format(self.iface)) 199 | 200 | if channels_to_monitor: 201 | channels_to_monitor_set = set([int(c) for c in channels_to_monitor]) 202 | if len(channels_to_monitor_set & set(self.supported_channels)) != len(channels_to_monitor_set): 203 | raise TJException('Not all of channels to monitor are supported by {}'.format(self.iface)) 204 | 205 | self.channels_to_monitor = channels_to_monitor 206 | self.current_channel = self.channels_to_monitor[0] 207 | self.logger.info('Monitoring channels: %s', channels_to_monitor_set) 208 | else: 209 | self.channels_to_monitor = self.supported_channels 210 | self.current_channel = self.supported_channels[0] 211 | self.logger.info('Monitoring all available channels on %s: %s', self.iface, self.supported_channels) 212 | 213 | self.logger.debug('Channel switching scheme: %s', channel_switch_scheme) 214 | 215 | if channel_switch_scheme == 'traffic_based': 216 | self.channel_switch_func = self.switch_channel_based_on_traffic 217 | 218 | self.switch_to_channel(self.current_channel, force=True) 219 | 220 | def channel_switcher_thread(self, firethread=True): # pylint: disable=R1710 221 | if firethread: 222 | t = threading.Thread(target=self.channel_switcher_thread, args=(False,)) 223 | t.daemon = True 224 | t.start() 225 | return t 226 | 227 | # Only worry about switching channels if we are monitoring 2 or more 228 | if len(self.channels_to_monitor) > 1: 229 | while not self.stop_event.is_set(): 230 | time.sleep(self.time_per_channel) 231 | self.channel_switch_func() 232 | self.last_channel_switch_time = time.time() 233 | 234 | def get_next_channel_based_on_traffic(self): 235 | count_by_channel = {c: sum([count for ts, count in frame_count_list]) 236 | for c, frame_count_list in self.frame_counts_per_channel.items()} 237 | total_count = sum(count_by_channel.values()) 238 | percent_to_channel = [(count/total_count, channel) for channel, count in count_by_channel.items()] 239 | 240 | percent_sum = 0 241 | sum_to_reach = random.random() 242 | for percent, channel in percent_to_channel: 243 | percent_sum += percent 244 | if percent_sum >= sum_to_reach: 245 | return channel 246 | 247 | return random.sample(self.channels_to_monitor, 1)[0] 248 | 249 | def switch_channel_based_on_traffic(self): 250 | next_channel = self.get_next_channel_based_on_traffic() 251 | 252 | # Don't ever set a channel to a 0% probability of being hit again 253 | if self.num_frames_received_this_channel == 0: 254 | self.num_frames_received_this_channel = MIN_FRAME_COUNT 255 | 256 | time_frames_entry = (time.time(), self.num_frames_received_this_channel) 257 | self.frame_counts_per_channel[self.current_channel].append(time_frames_entry) 258 | self.num_frames_received_this_channel = 0 259 | self.switch_to_channel(next_channel) 260 | 261 | def switch_channel_round_robin(self): 262 | chans = self.channels_to_monitor 263 | next_channel = chans[(chans.index(self.current_channel)+1) % len(chans)] 264 | self.switch_to_channel(next_channel) 265 | 266 | def switch_to_channel(self, channel_num, force=False): 267 | self.logger.debug('Switching to channel %s', channel_num) 268 | if channel_num == self.current_channel and not force: 269 | return 270 | switch_to_channel(self.iface, channel_num) 271 | self.current_channel = channel_num 272 | 273 | def add_frame(self, frame): 274 | self.num_frames_received_this_channel += 1 275 | 276 | def start(self): 277 | self.do_horrible_monitor_mode_hack() 278 | self.channel_switcher_thread() 279 | # Need to switch to channel after starting the monitor_mode_hack 280 | time.sleep(1) 281 | self.switch_to_channel(self.current_channel, force=True) 282 | 283 | def stop(self): 284 | self.stop_event.set() 285 | 286 | if self.need_to_disable_monitor_mode_on_exit: 287 | self.logger.info('\nDisabling monitor mode for interface: %s', self.iface) 288 | 289 | # Try to wait long enough for the channel switching thread to see the event so 290 | # the device isn't busy when we try to disable monitor mode. 291 | time.sleep(self.time_per_channel + 1) 292 | 293 | #monitor_mode_off(self.iface) 294 | if self.horrible_hack: 295 | self.horrible_hack.stop() 296 | self.logger.debug('Disabled monitor mode for interface: %s', self.iface) 297 | 298 | def do_horrible_monitor_mode_hack(self): 299 | self.horrible_hack = MonitorModeHack(self.iface) 300 | self.horrible_hack.start() 301 | -------------------------------------------------------------------------------- /trackerjacker/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0111, C0103, W0703, R0902, R0903, R0912, R0913, R0914, R0915, C0413 3 | 4 | import os 5 | import sys 6 | import time 7 | import json 8 | import errno 9 | import pprint 10 | import logging 11 | import platform 12 | import traceback 13 | 14 | logging.getLogger("scapy.runtime").setLevel(logging.ERROR) 15 | import scapy.all as scapy 16 | 17 | from . import config_management 18 | from . import dot11_frame 19 | from . import dot11_mapper 20 | from . import dot11_tracker 21 | from . import plugin_parser 22 | from . import ieee_mac_vendor_db 23 | from .common import TJException 24 | 25 | if platform.system() == 'Linux': 26 | from . import linux_device_management as device_management 27 | elif platform.system() == 'Darwin': 28 | from . import macos_device_management as device_management 29 | 30 | LOG_NAME_TO_LEVEL = {'DEBUG': 10, 'INFO': 20, 'WARNING': 30, 'ERROR': 40, 'CRITICAL': 50} 31 | 32 | 33 | def make_logger(log_path=None, log_level_str='INFO'): 34 | logger = logging.getLogger('trackerjacker') 35 | formatter = logging.Formatter('%(asctime)s: (%(levelname)s): %(message)s') 36 | if log_path: 37 | log_handler = logging.FileHandler(log_path) 38 | log_handler.setFormatter(formatter) 39 | # Print errors to stderr if logging to a file 40 | stdout_handler = logging.StreamHandler(sys.stderr) 41 | stdout_handler.setLevel('ERROR') 42 | stdout_handler.setFormatter(logging.Formatter('%(message)s')) 43 | logger.addHandler(stdout_handler) 44 | else: 45 | log_handler = logging.StreamHandler(sys.stdout) 46 | log_handler.setFormatter(logging.Formatter('%(message)s')) 47 | logger.addHandler(log_handler) 48 | log_level = LOG_NAME_TO_LEVEL.get(log_level_str.upper(), 20) 49 | logger.setLevel(log_level) 50 | return logger 51 | 52 | 53 | class TrackerJacker: 54 | def __init__(self, 55 | logger=None, 56 | iface=None, 57 | channels_to_monitor=None, 58 | channel_switch_scheme='default', 59 | time_per_channel=2, 60 | display_matching_packets=False, 61 | display_all_packets=False, 62 | # map args 63 | do_map=True, 64 | map_file='wifi_map.yaml', 65 | map_save_interval=10, # seconds 66 | # track args 67 | do_track=False, 68 | threshold=None, 69 | power=None, 70 | devices_to_watch=(), 71 | aps_to_watch=(), 72 | threshold_window=10, 73 | trigger_plugin=None, 74 | plugin_config=None, 75 | trigger_command=None, 76 | trigger_cooldown=30, 77 | beep_on_trigger=False): # seconds 78 | 79 | self.iface = iface 80 | self.do_map = do_map 81 | self.do_track = do_track 82 | self.map_file = map_file 83 | self.map_save_interval = map_save_interval 84 | self.display_matching_packets = display_matching_packets 85 | self.display_all_packets = display_all_packets 86 | self.mac_vendor_db = ieee_mac_vendor_db.MacVendorDB() 87 | 88 | if logger: 89 | self.logger = logger 90 | else: 91 | self.logger = make_logger() 92 | 93 | # Even if we are not in map mode, we still need to build the map for tracking purposes 94 | self.dot11_map = None 95 | if self.do_map: 96 | self.map_last_save = time.time() 97 | 98 | # Try to load map 99 | self.logger.info('Map output file: %s', self.map_file) 100 | if os.path.exists(self.map_file): 101 | self.dot11_map = dot11_mapper.Dot11Map.load_from_file(self.map_file) 102 | if self.dot11_map: 103 | self.logger.info('Loaded %d devices and %d ssids from %s', 104 | len(self.dot11_map.devices), 105 | len(self.dot11_map.ssid_to_access_point), 106 | self.map_file) 107 | else: 108 | self.logger.warning('Specified map file not found - creating new map file.') 109 | 110 | if not self.dot11_map: 111 | self.dot11_map = dot11_mapper.Dot11Map() 112 | 113 | self.dot11_map.window = threshold_window 114 | 115 | if channel_switch_scheme == 'default': 116 | if self.do_map: 117 | channel_switch_scheme = 'round_robin' 118 | else: # track mode 119 | channel_switch_scheme = 'traffic_based' 120 | 121 | self.devices_to_watch_set = set([dev['mac'].lower() for dev in devices_to_watch if 'mac' in dev]) 122 | self.aps_to_watch_set = set([ap['bssid'].lower() for ap in aps_to_watch if 'bssid' in ap]) 123 | 124 | if self.do_track: 125 | # Build trigger hit function 126 | if trigger_plugin: 127 | trigger_plugin = config_management.get_real_plugin_path(trigger_plugin) 128 | parsed_trigger_plugin = plugin_parser.parse_trigger_plugin(trigger_plugin, plugin_config) 129 | else: 130 | parsed_trigger_plugin = None 131 | 132 | self.dot11_tracker = dot11_tracker.Dot11Tracker(self.logger, 133 | threshold, 134 | power, 135 | devices_to_watch, 136 | aps_to_watch, 137 | parsed_trigger_plugin, 138 | trigger_command, 139 | trigger_cooldown, 140 | threshold_window, 141 | beep_on_trigger, 142 | self.dot11_map) 143 | 144 | self.iface_manager = device_management.Dot11InterfaceManager(iface, 145 | self.logger, 146 | channels_to_monitor, 147 | channel_switch_scheme, 148 | time_per_channel) 149 | 150 | def process_packet(self, pkt): 151 | if pkt.haslayer(scapy.Dot11): 152 | looking_for_specifics_and_none_found = self.aps_to_watch_set or self.devices_to_watch_set 153 | 154 | try: 155 | frame = dot11_frame.Dot11Frame(pkt, 156 | int(self.iface_manager.current_channel), 157 | iface=self.iface_manager.iface) 158 | except Exception as e: 159 | # Thank you DEF CON (https://github.com/secdev/scapy/issues/1552) 160 | self.logger.warning('Error decoding Dot11Frame: %s', e) 161 | return 162 | 163 | if self.do_map: 164 | self.log_newly_found(frame) 165 | 166 | if self.display_all_packets: 167 | print('\t', pkt.summary()) 168 | 169 | # See if any APs we care about (if we're looking for specific APs) 170 | if self.aps_to_watch_set: 171 | if frame.bssid not in self.aps_to_watch_set: 172 | looking_for_specifics_and_none_found = False 173 | 174 | # See if any MACs we care about (if we're looking for specific MACs) 175 | if self.devices_to_watch_set: 176 | matched_macs = self.devices_to_watch_set & frame.macs 177 | if matched_macs: 178 | looking_for_specifics_and_none_found = False 179 | 180 | # Display matched packets (if specified) 181 | if self.display_matching_packets and not self.display_all_packets: 182 | print('\t', pkt.summary()) 183 | 184 | # If we are looking for specific APs or Devices and none are found, no further processing needed 185 | if looking_for_specifics_and_none_found: 186 | return 187 | 188 | # If map mode enabled, do it. Note that we don't exclude non-matching MACs from the mapping 189 | # (which is why this isn't under the 'if matched_matcs' block). 190 | # Note: we update the map whether do_map is true or false since it's used for tracking; just don't save map 191 | self.dot11_map.add_frame(frame) 192 | if self.do_map: 193 | if time.time() - self.map_last_save >= self.map_save_interval: 194 | self.dot11_map.save_to_file(self.map_file) 195 | self.map_last_save = time.time() 196 | 197 | if self.do_track: 198 | self.dot11_tracker.add_frame(frame, pkt) 199 | 200 | # Update device tracking (for traffic-based) 201 | self.iface_manager.add_frame(frame) 202 | 203 | def log_newly_found(self, frame): 204 | # Log newly-found things 205 | if frame.ssid and frame.bssid not in self.dot11_map.access_points.keys(): 206 | self.logger.info('SSID found: %s, BSSID: %s, Channel: %d', frame.ssid, frame.bssid, frame.channel) 207 | 208 | new_macs = [mac for mac in frame.macs 209 | if mac not in (self.dot11_map.devices.keys() | 210 | self.dot11_map.access_points.keys() | 211 | dot11_mapper.MACS_TO_IGNORE)] 212 | for mac in new_macs: 213 | if mac: # The frame can be crafted to include a null mac 214 | self.logger.info('MAC found: %s, Channel: %d', mac, frame.channel) 215 | 216 | def start(self): 217 | self.logger.debug('Starting monitoring on %s', self.iface_manager.iface) 218 | self.iface_manager.start() 219 | while True: 220 | try: 221 | # macOS 222 | if platform.system() == 'Darwin': 223 | self.logger.warning('macOS support is pre-alpha - many improvements coming soon') 224 | scapy.sniff(iface=self.iface_manager.iface, monitor=True, prn=self.process_packet, store=0) 225 | break 226 | # linux 227 | else: 228 | # For versions of scapy that don't provide the exceptions kwarg 229 | scapy.sniff(iface=self.iface_manager.iface, prn=self.process_packet, store=0) 230 | break 231 | 232 | except TJException: 233 | raise 234 | except (OSError, IOError): 235 | self.logger.error(traceback.format_exc()) 236 | self.logger.info('Sniffer error occurred. Restarting sniffer in 3 seconds...') 237 | time.sleep(3) 238 | 239 | def stop(self): 240 | self.iface_manager.stop() 241 | 242 | if self.do_map: 243 | # Flush map to disk 244 | self.dot11_map.save_to_file(self.map_file) 245 | 246 | 247 | def do_simple_tasks_if_specified(args): 248 | if args.version: 249 | from .version import __version__ 250 | print('trackerjacker {}'.format(__version__)) 251 | sys.exit(0) 252 | elif args.do_enable_monitor_mode: 253 | if not args.iface: 254 | raise TJException('You must specify the interface with the -i paramter') 255 | device_management.monitor_mode_on(args.iface) 256 | print('Enabled monitor mode on {}'.format(args.iface)) 257 | sys.exit(0) 258 | elif args.do_disable_monitor_mode: 259 | if not args.iface: 260 | raise TJException('You must specify the interface with the -i paramter') 261 | device_management.monitor_mode_off(args.iface) 262 | print('Disabled monitor mode on {}'.format(args.iface)) 263 | sys.exit(0) 264 | elif args.mac_lookup: 265 | vendor = ieee_mac_vendor_db.MacVendorDB().lookup(args.mac_lookup) 266 | if vendor: 267 | print(vendor) 268 | else: 269 | print('Vendor for {} not found'.format(args.mac_lookup), file=sys.stderr) 270 | sys.exit(0) 271 | elif args.print_default_config: 272 | print(json.dumps(config_management.DEFAULT_CONFIG, indent=4, sort_keys=True)) 273 | sys.exit(0) 274 | elif args.set_channel: 275 | if not args.iface: 276 | raise TJException('You must specify the interface with the -i paramter') 277 | channel = args.set_channel[0] 278 | device_management.switch_to_channel(args.iface, channel) 279 | print('Set channel to {} on {}'.format(channel, args.iface)) 280 | sys.exit(0) 281 | 282 | 283 | def main(): 284 | if not os.getuid() == 0: 285 | print('trackerjacker requires r00t!', file=sys.stderr) 286 | sys.exit(errno.EPERM) 287 | 288 | argparse_args = config_management.get_arg_parser().parse_args() 289 | 290 | # Some command-line args specify to just perform a simple task and then exit 291 | try: 292 | do_simple_tasks_if_specified(argparse_args) 293 | except TJException as e: 294 | print('Error: {}'.format(e), file=sys.stderr) 295 | sys.exit(1) 296 | 297 | try: 298 | config = config_management.build_config(argparse_args) 299 | except TJException as e: 300 | print('{}'.format(e)) 301 | sys.exit(1) 302 | 303 | if config['log_level'] == 'DEBUG': 304 | print('Config:') 305 | pprint.pprint(config) 306 | 307 | # Setup logger 308 | logger = make_logger(config.pop('log_path'), config.pop('log_level')) 309 | 310 | try: 311 | tj = TrackerJacker(**dict(config, **{'logger': logger})) # pylint: disable=E1123 312 | tj.start() 313 | except TJException as e: 314 | logger.critical('Error: %s', e) 315 | except KeyboardInterrupt: 316 | print('Stopping...') 317 | finally: 318 | try: 319 | tj.stop() 320 | except UnboundLocalError: 321 | # Exception was thrown in TrackerJacker initializer, so 'tj' doesn't exist 322 | pass 323 | 324 | if __name__ == '__main__': 325 | main() 326 | -------------------------------------------------------------------------------- /trackerjacker/dot11_mapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0103, C0111, W0703, C0413, R0902 3 | 4 | import time 5 | import copy 6 | import threading 7 | import collections 8 | from functools import reduce 9 | 10 | import pyaml 11 | import ruamel.yaml 12 | from . import dot11_frame # pylint: disable=E0401 13 | from . import ieee_mac_vendor_db # pylint: disable=E0401 14 | from .common import MACS_TO_IGNORE 15 | 16 | 17 | def trim_frames_to_window(frames, window, now=None): 18 | if not now: 19 | now = time.time() 20 | oldest_time_in_window = now - window 21 | oldest_in_window = -1 # Assume everything is in the window 22 | for index, frame in enumerate(frames): 23 | if frame[0] >= oldest_time_in_window: 24 | oldest_in_window = index 25 | break 26 | return frames[oldest_in_window:] 27 | 28 | 29 | class Dot11Map: 30 | """Represents the observed state of the 802.11 radio space.""" 31 | 32 | def __init__(self, map_data=None): 33 | self.lock = threading.RLock() 34 | 35 | # Used for determining when to trim frame lists 36 | self.frame_count_by_device = collections.Counter() 37 | self.trim_every_num_frames = 50 # empirically-derived 38 | self.window = 10 # seconds 39 | 40 | # Needed for efficiently determining if there is no ssid known for a given bssid 41 | self.bssids_associated_with_ssids = set() 42 | 43 | # 'linksys' -> {'90:35:ab:1c:25:19', '80:81:a6:f5:29:22'} 44 | self.ssid_to_access_point = {} 45 | 46 | # '90:35:cb:1c:25:19' -> {'bssid': '90:35:cb:1c:25:19', 47 | # (bssid) 'ssid': 'hacker', 48 | # 'vendor': 'Linksys', 49 | # 'frames': [(timestamp1, num_bytes), (timestamp2, num_bytes)], 50 | # 'signal': -75, 51 | # 'channels': {1, 11}, 52 | # 'devices': {'00:03:7f:84:f8:09', 'e8:51:8b:36:5e:bb'}} 53 | self.access_points = {} 54 | 55 | # '00:03:7f:84:f8:09' -> {'signal': -60, 56 | # (mac) 'vendor': 'Apple', 57 | # 'frames_in': [(timestamp1, num_bytes), (timestamp2, num_bytes2)], 58 | # 'frames_out': [(timestamp1, num_bytes)] } 59 | self.devices = {} 60 | 61 | # Used by load_from_file factory function 62 | if map_data: 63 | self.bssids_associated_with_ssids = map_data['bssids_associated_with_ssids'] 64 | self.ssid_to_access_point = map_data['ssid_to_access_point'] 65 | self.access_points = map_data['access_points'] 66 | self.devices = map_data['devices'] 67 | 68 | self.mac_vendor_db = ieee_mac_vendor_db.MacVendorDB() 69 | 70 | def add_frame(self, frame): 71 | with self.lock: 72 | # Update Access Point data 73 | if frame.bssid: 74 | self.update_access_point(frame.bssid, frame) 75 | 76 | # Update Device data 77 | for mac in frame.macs - {frame.bssid}: 78 | self.update_device(mac, frame) 79 | 80 | # Enrich the frame by adding the ssid if not already there and if we know it 81 | if not frame.ssid and frame.bssid in self.access_points: 82 | ssid = self.access_points[frame.bssid].get('ssid', None) 83 | if ssid: 84 | frame.ssid = ssid 85 | 86 | # TODO: Make sure beacons add 1 to frame counts (so that if looking for a threshold of 1 bytes they show up) 87 | 88 | def get_dev_node(self, mac): 89 | """Returns ap_node associated with mac in a thread-safe manner.""" 90 | device_node = None 91 | with self.lock: 92 | if mac in self.devices: 93 | device_node = copy.deepcopy(self.devices[mac]) 94 | return device_node 95 | 96 | def get_ap_by_bssid(self, bssid): 97 | """Returns ap_node associated with mac in a thread-safe manner.""" 98 | ap_node = None 99 | with self.lock: 100 | if bssid in self.access_points: 101 | ap_node = copy.deepcopy(self.access_points[bssid]) 102 | return ap_node 103 | 104 | def get_ap_nodes_by_ssid(self, ssid): 105 | ap_nodes = None 106 | with self.lock: 107 | if ssid in self.ssid_to_access_point: 108 | ap_bssid_list = self.ssid_to_access_point[ssid] 109 | ap_nodes = [self.get_ap_by_bssid(bssid) for bssid in ap_bssid_list] 110 | return ap_nodes 111 | 112 | def get_channels_by_mac(self, mac): 113 | dev_node = self.get_dev_node(mac) 114 | return dev_node.get('channels', ()) if dev_node else () 115 | 116 | def get_channels_by_bssid(self, bssid): 117 | ap_node = self.get_ap_by_bssid(bssid) 118 | return ap_node.get('channels', ()) if ap_node else () 119 | 120 | def get_channels_by_ssid(self, ssid): 121 | ap_nodes = self.get_ap_nodes_by_ssid(ssid) 122 | return reduce(lambda acc, ap_chans: acc+ap_chans, [ap.get('channels', ()) for ap in ap_nodes], []) 123 | 124 | def update_access_point(self, bssid, frame): 125 | if bssid in MACS_TO_IGNORE: 126 | return 127 | 128 | if bssid not in self.access_points: 129 | ap_node = {'bssid': bssid, 130 | 'ssid': frame.ssid, 131 | 'vendor': self.mac_vendor_db.lookup(bssid), 132 | 'channels': {frame.channel}, 133 | 'devices': set(), 134 | 'frames': []} 135 | self.access_points[bssid] = ap_node 136 | 137 | else: 138 | ap_node = self.access_points[frame.bssid] 139 | 140 | # Associate with ssid if ssid available 141 | if frame.ssid: 142 | if frame.ssid in self.ssid_to_access_point: 143 | self.ssid_to_access_point[frame.ssid] |= {bssid} 144 | else: 145 | self.ssid_to_access_point[frame.ssid] = {bssid} 146 | 147 | self.bssids_associated_with_ssids |= {bssid} 148 | 149 | # Make sure we didn't previously categorize this as an unknown_ssid 150 | missing_ssid_name = 'unknown_ssid_{}'.format(bssid) 151 | if missing_ssid_name in self.ssid_to_access_point: 152 | self.ssid_to_access_point[frame.ssid] |= self.ssid_to_access_point.pop(missing_ssid_name) 153 | elif bssid not in self.bssids_associated_with_ssids: 154 | # If no ssid is known, use the ssid name "unknown_ssid_80:21:46:af:28:66" 155 | missing_ssid_name = 'unknown_ssid_{}'.format(bssid) 156 | if missing_ssid_name in self.ssid_to_access_point: 157 | self.ssid_to_access_point[missing_ssid_name] |= {bssid} 158 | else: 159 | self.ssid_to_access_point[missing_ssid_name] = {bssid} 160 | 161 | if frame.signal_strength: 162 | ap_node['signal'] = frame.signal_strength 163 | 164 | # Only associate with channels and devices for data packets since, for example, APs 165 | # send beacons on channels that they don't actually communicate on. 166 | if frame.frame_type() == dot11_frame.Dot11Frame.DOT11_FRAME_TYPE_DATA: 167 | ap_node['devices'] |= (frame.macs - MACS_TO_IGNORE - {bssid}) 168 | ap_node['channels'] |= {frame.channel} 169 | 170 | ap_node['frames'].append((time.time(), frame.frame_bytes)) 171 | 172 | # Trim old frames (those that are older than window) 173 | self.frame_count_by_device[bssid] += 1 174 | if self.frame_count_by_device[bssid] % self.trim_every_num_frames == 0: 175 | ap_node['frames'] = trim_frames_to_window(ap_node['frames'], self.window) 176 | 177 | def update_device(self, mac, frame): 178 | if mac in MACS_TO_IGNORE: 179 | return 180 | 181 | if mac not in self.devices: 182 | dev_node = {'vendor': self.mac_vendor_db.lookup(mac), 183 | 'signal': frame.signal_strength, 184 | 'frames_in': [], 185 | 'frames_out': []} 186 | self.devices[mac] = dev_node 187 | else: 188 | dev_node = self.devices[mac] 189 | 190 | dev_node['signal'] = frame.signal_strength 191 | 192 | if mac == frame.src: 193 | dev_node['frames_out'].append((time.time(), frame.frame_bytes)) 194 | elif mac == frame.dst: 195 | dev_node['frames_in'].append((time.time(), frame.frame_bytes)) 196 | 197 | # Trim old frames (those that are older than window) 198 | self.frame_count_by_device[mac] += 1 199 | if self.frame_count_by_device[mac] % self.trim_every_num_frames == 0: 200 | dev_node['frames_out'] = trim_frames_to_window(dev_node['frames_out'], self.window) 201 | dev_node['frames_in'] = trim_frames_to_window(dev_node['frames_in'], self.window) 202 | 203 | def save_to_file(self, file_path): 204 | """Serializes to file_path in a YAML format something like this: 205 | 206 | example_ssid_name: 207 | 80:29:94:14:8a:1d: 208 | channels: 209 | - 6 210 | - 11 211 | signal: -86 212 | vendor: Google, Inc. 213 | devices: 214 | f4:f5:d8:2b:9f:f6: 215 | signal: -84 216 | vendor: Apple 217 | bytes_transfered: 200 218 | 00:25:00:ff:94:73: 219 | signal: -55 220 | vendor: Google, Inc. 221 | bytes_transfered: 138 222 | 71:29:94:14:8a:1d: ... 223 | example_ssid_2: ... 224 | 225 | Note that the bytes_in/out are lossily summarized in this process (and they are dropped upon map load, 226 | which only takes place on program start). 227 | """ 228 | 229 | with self.lock: 230 | serialized_map = {} 231 | dev_map = {mac: self._with_frames_summed(self.devices[mac]) for mac in self.devices} 232 | 233 | associated_devices = set() 234 | 235 | for ssid in self.ssid_to_access_point: 236 | serialized_map[ssid] = {} 237 | 238 | associated_devices |= set(self.ssid_to_access_point[ssid]) 239 | 240 | for bssid in self.ssid_to_access_point[ssid]: 241 | serialized_map[ssid][bssid] = copy.deepcopy(self.access_points[bssid]) 242 | serialized_map[ssid][bssid]['bytes'] = sum([num_bytes for _, num_bytes in 243 | serialized_map[ssid][bssid].pop('frames', ())]) 244 | serialized_map[ssid][bssid]['devices'] = {mac: copy.deepcopy(dev_map[mac]) 245 | for mac in self.access_points[bssid]['devices']} 246 | 247 | associated_devices |= set(serialized_map[ssid][bssid]['devices'].keys()) 248 | 249 | unassociated_devices = set(self.devices.keys()) - associated_devices 250 | serialized_map['~unassociated_devices'] = {mac: dev_map[mac] for mac in unassociated_devices} 251 | 252 | with open(file_path, 'w') as f: 253 | pyaml.dump(serialized_map, f, vspacing=[1, 0], safe=True) 254 | 255 | @staticmethod 256 | def _with_frames_summed(dev_node): 257 | """Helper function to aid in serialization.""" 258 | dev_node = copy.deepcopy(dev_node) 259 | frames_in = sum([num_bytes for _, num_bytes in dev_node.pop('frames_in', ())]) 260 | frames_out = sum([num_bytes for _, num_bytes in dev_node.pop('frames_out', ())]) 261 | dev_node['bytes'] = frames_in + frames_out 262 | return dev_node 263 | 264 | @staticmethod 265 | def load_from_file(file_path): 266 | """Factory function to load a Dot11Map from file_path provided.""" 267 | with open(file_path, 'r') as f: 268 | yaml_data = f.read() 269 | 270 | yaml = ruamel.yaml.YAML(typ='safe') 271 | map_data = yaml.load(yaml_data) 272 | 273 | # If file is empty, return empty map 274 | if not map_data: 275 | return Dot11Map() 276 | 277 | bssids_associated_with_ssids = set() 278 | ssid_to_access_point = {} 279 | access_points = {} 280 | devices = {} 281 | 282 | for ssid, ssid_entry in map_data.items(): 283 | if ssid == '~unassociated_devices': 284 | # ~unassociated_devices is not an SSID, but a special name to denote the list of devices 285 | # not associated with any network, so it needs to be processed differently. 286 | for mac, dev_node in ssid_entry.items(): 287 | dev_node.pop('bytes') 288 | dev_node['frames_out'] = [] 289 | dev_node['frames_in'] = [] 290 | devices[mac] = dev_node 291 | continue 292 | 293 | unknown_ssid = ssid.startswith('unknown_ssid_') 294 | 295 | for bssid, ap_node in ssid_entry.items(): 296 | if not unknown_ssid: 297 | bssids_associated_with_ssids |= {bssid} 298 | 299 | # Clean up access_point nodes 300 | ap_node = {k: v for k, v in ap_node.items() if k not in {'bssid', 'ssid', 'bytes'}} 301 | 302 | # We serialize by reducing the list of frames to a summation of the bytes, but loading back, 303 | # we replace that with an empty list. This means frames data is intentionally lost in serialize/load. 304 | ap_node['frames'] = [] 305 | ap_node['channels'] = set(ap_node['channels']) 306 | 307 | if ssid not in ssid_to_access_point: 308 | ssid_to_access_point[ssid] = {bssid} 309 | else: 310 | ssid_to_access_point[ssid] |= {bssid} 311 | 312 | access_points[bssid] = ap_node 313 | 314 | for mac, dev_node in ap_node['devices'].items(): 315 | dev_node.pop('bytes') 316 | dev_node['frames_out'] = [] 317 | dev_node['frames_in'] = [] 318 | devices[mac] = dev_node 319 | 320 | ap_node['devices'] = set([mac for mac in ap_node.pop('devices').keys()]) 321 | 322 | dot11_map = Dot11Map({ 323 | 'bssids_associated_with_ssids': bssids_associated_with_ssids, 324 | 'ssid_to_access_point': ssid_to_access_point, 325 | 'access_points': access_points, 326 | 'devices': devices 327 | }) 328 | return dot11_map 329 | -------------------------------------------------------------------------------- /trackerjacker/config_management.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0111, C0103, W0703, R0902, R0903, R0912, R0913, R0914, R0915, C0413 3 | 4 | import os 5 | import copy 6 | import json 7 | import argparse 8 | from .common import TJException 9 | from . import plugin_parser 10 | 11 | # Default config 12 | DEFAULT_CONFIG = {'log_path': None, 13 | 'log_level': 'INFO', 14 | 'iface': None, 15 | 'devices_to_watch': [], 16 | 'aps_to_watch': [], 17 | 'threshold': None, 18 | 'power': None, 19 | 'threshold_window': 10, 20 | 'do_map': True, 21 | 'do_track': False, 22 | 'map_file': 'wifi_map.yaml', 23 | 'map_save_interval': 10, 24 | 'trigger_plugin': None, 25 | 'plugin_config': None, 26 | 'trigger_command': None, 27 | 'trigger_cooldown': 30, 28 | 'channels_to_monitor': None, 29 | 'channel_switch_scheme': 'default', 30 | 'time_per_channel': 0.5, 31 | 'display_matching_packets': False, 32 | 'display_all_packets': False, 33 | 'beep_on_trigger': False} 34 | 35 | 36 | def get_arg_parser(): 37 | """Returns the configured argparse object.""" 38 | parser = argparse.ArgumentParser() 39 | # Modes 40 | parser.add_argument('--map', action='store_true', dest='do_map', 41 | help='Map mode - output map to wifi_map.yaml') 42 | parser.add_argument('--track', action='store_true', dest='do_track', 43 | help='Track mode') 44 | parser.add_argument('--monitor-mode-on', action='store_true', dest='do_enable_monitor_mode', 45 | help='Enables monitor mode on the specified interface and exit') 46 | parser.add_argument('--monitor-mode-off', action='store_true', dest='do_disable_monitor_mode', 47 | help='Disables monitor mode on the specified interface and exit') 48 | parser.add_argument('--set-channel', metavar='CHANNEL', dest='set_channel', nargs=1, 49 | help='Set the specified wireless interface to the specified channel and exit') 50 | parser.add_argument('--mac-lookup', type=str, dest='mac_lookup', 51 | help='Lookup the vendor of the specified MAC address and exit') 52 | parser.add_argument('--print-default-config', action='store_true', dest='print_default_config', 53 | help='Print boilerplate config file and exit') 54 | 55 | # Normal switches 56 | parser.add_argument('-v', '--version', action='store_true', dest='version', 57 | help='Display trackerjacker version') 58 | parser.add_argument('-i', '--interface', type=str, dest='iface', 59 | help='Network interface to use; if empty, try to find monitor inferface') 60 | parser.add_argument('-m', '--macs', type=str, dest='devices_to_watch', 61 | help='MAC(s) to track; comma separated for multiple') 62 | parser.add_argument('-a', '--access-points', type=str, dest='aps_to_watch', 63 | help='Access point(s) to track - specified by BSSID; comma separated for multiple') 64 | parser.add_argument('--channels-to-monitor', type=str, dest='channels_to_monitor', 65 | help='Channels to monitor; comma separated for multiple') 66 | parser.add_argument('--channel-switch-scheme', type=str, dest='channel_switch_scheme', 67 | help='Options: "round_robin" or "traffic_based"', default='default') 68 | parser.add_argument('--time-per-channel', type=float, dest='time_per_channel', 69 | help='Seconds spent on each channel before hopping') 70 | parser.add_argument('-w', '--time-window', type=int, dest='threshold_window', 71 | help='Time window (in seconds) which alert threshold is applied to') 72 | parser.add_argument('--map-save-interval', type=float, dest='map_save_interval', 73 | help='Number of seconds between saving the wifi map to disk') 74 | parser.add_argument('--threshold', type=int, dest='threshold', 75 | help='Default data threshold (unless overridden on a per-dev basis) for triggering') 76 | parser.add_argument('--power', type=int, dest='power', 77 | help='Default power threshold (unless overridden on a per-dev basis) for triggering') 78 | parser.add_argument('--plugin', type=str, dest='trigger_plugin', 79 | help='Python trigger plugin file path; for more information') 80 | parser.add_argument('--trigger-plugin', type=str, dest='trigger_plugin', 81 | help='Python trigger plugin file path; for more information') 82 | parser.add_argument('--plugin-config', type=str, dest='plugin_config', 83 | help='Config to pass to python trigger plugin. Must be a python dict or json obj.') 84 | parser.add_argument('--trigger-command', type=str, dest='trigger_command', 85 | help='Command to execute upon alert') 86 | parser.add_argument('--trigger-cooldown', type=str, dest='trigger_cooldown', 87 | help='Time in seconds between trigger executions for a particular device') 88 | parser.add_argument('--display-all-packets', action='store_true', dest='display_all_packets', 89 | help='If true, displays all packets matching filters') 90 | parser.add_argument('--beep-on-trigger', action='store_true', dest='beep_on_trigger', 91 | help='If enabled, beep each time a trigger hits (off by default)') 92 | parser.add_argument('--map-file', type=str, dest='map_file', default='wifi_map.yaml', 93 | help='File path to which to output wifi map; default: wifi_map.yaml') 94 | parser.add_argument('--log-path', type=str, dest='log_path', default=None, 95 | help='Log path; default is stdout') 96 | parser.add_argument('--log-level', type=str, dest='log_level', default='INFO', 97 | help='Log level; Options: DEBUG, INFO, WARNING, ERROR, CRITICAL') 98 | parser.add_argument('-c', '--config', type=str, dest='config', 99 | help='Path to config json file; For example config file, use --print-default-config') 100 | return parser 101 | 102 | 103 | def parse_command_line_watch_list(watch_str): 104 | """Parse string that represents devices to watch. 105 | 106 | Valid examples: 107 | * aa:bb:cc:dd:ee:ff 108 | - Threshold of 1 for the given MAC address 109 | * aa:bb:cc:dd:ee:ff,11:22:33:44:55:66 110 | - This means look for any traffic from either address 111 | * aa:bb:cc:dd:ee:ff=1337, 11:22:33:44:55:66=1000 112 | - This means look for 1337 bytes for the first address, and 1000 for the second 113 | * my_ssid, 11:22:33:44:55:66=1000 114 | - This means look for 1 byte from my_ssid or 1000 for the second 115 | * 11:22:33:44:55:66=-30 116 | - This means trigger if 11:22:33:44:55:66 is seen at a power level >= -30dBm (negative value implies power) 117 | 118 | Returns dict in this format: 119 | {'aa:bb:cc:dd:ee:ff': {'threshold': 100, 'power': None}, 120 | '11:22:33:44:55:66': {'threshold': None, 'power': -30}} 121 | """ 122 | 123 | watch_list = [i.strip() for i in watch_str.split(',')] 124 | watch_dict = {} 125 | 126 | for watch_part in watch_list: 127 | power = None 128 | threshold = None 129 | 130 | if '=' in watch_part: 131 | # dev_id is a MAC, BSSID, or SSID 132 | dev_id, val = [i.strip() for i in watch_part.split('=')] 133 | try: 134 | val = int(val) 135 | except ValueError: 136 | # Can't parse with "dev_id=threshold" formula, so assume '=' sign was part of ssid 137 | dev_id = watch_part 138 | 139 | if val > 0: 140 | threshold = val 141 | else: 142 | power = val 143 | else: 144 | dev_id = watch_part 145 | 146 | watch_dict[dev_id] = {'threshold': threshold, 'power': power} 147 | 148 | return watch_dict 149 | 150 | 151 | def determine_watch_list(to_watch_from_args, 152 | to_watch_from_config, 153 | generic_threshold, 154 | generic_power): 155 | """Builds the list of devices to watch. 156 | 157 | Coalesces the to_watch list from the command-line arguments, the config file, 158 | and the the general threshold, power, and trigger command. The main idea here is to look 159 | for the config values set on a per-device basis, and prioritize those, but if they are not there, 160 | fall back to the "generic_*" version. And if those have not been specified, fall back to defaults. 161 | 162 | Example input: 163 | to_watch_from_args = 'aa:bb:cc:dd:ee:ff=1337, 11:22:33:44:55:66=100, ff:ee:dd:cc:bb:aa', 164 | to_watch_from_config = {} 165 | generic_threshold = 1337 166 | generic_power = None 167 | 168 | Returns a dict in this format: 169 | { 170 | 'aa:bb:cc:dd:ee:ff': {'threshold': None, 'power': -40}, 171 | '11:22:33:44:55:66': {'threshold': 100, 'power': None}, 172 | 'ff:ee:dd:cc:bb:aa': {'threshold': 1337, 'power': None} 173 | } 174 | """ 175 | 176 | # Converts from cli param format like: "aa:bb:cc:dd:ee:ff=-40,11:22:33:44:55:66=100' to a map like: 177 | # {'aa:bb:cc:dd:ee:ff': {'threshold': None, 'power': -40}, 178 | # '11:22:33:44:55:66': {'threshold': 100, 'power': None} 179 | if to_watch_from_args: 180 | to_watch_from_args = parse_command_line_watch_list(to_watch_from_args) 181 | 182 | if not to_watch_from_args: 183 | to_watch_from_args = {} 184 | if not to_watch_from_config: 185 | to_watch_from_config = {} 186 | 187 | watch_config_dict = {} 188 | for dev_id in to_watch_from_args.keys() | to_watch_from_config.keys(): 189 | watch_entry = {'threshold': None, 190 | 'power': None} 191 | watch_entry['threshold'] = (to_watch_from_args.get(dev_id, {}).get('threshold', None) or 192 | to_watch_from_config.get(dev_id, {}).get('threshold', None)) 193 | watch_entry['power'] = (to_watch_from_args.get(dev_id, {}).get('power', None) or 194 | to_watch_from_config.get(dev_id, {}).get('power', None)) 195 | 196 | if not watch_entry['threshold'] and not watch_entry['power']: 197 | if generic_threshold and not generic_power: 198 | watch_entry['threshold'] = generic_threshold 199 | elif generic_power: 200 | watch_entry['power'] = generic_power 201 | else: 202 | watch_entry['threshold'] = 1 203 | 204 | watch_config_dict[dev_id] = watch_entry 205 | 206 | return watch_config_dict 207 | 208 | 209 | def build_config(args): 210 | """Builds the config from the command-line args, config file, and defaults.""" 211 | config = copy.deepcopy(DEFAULT_CONFIG) 212 | devices_from_config = {} 213 | aps_from_config = {} 214 | 215 | if args.config: 216 | try: 217 | with open(args.config, 'r') as f: 218 | config_from_file = json.loads(f.read()) 219 | 220 | # If there are any keys defined in the config file not allowed, error out 221 | invalid_keys = set(config_from_file.keys()) - set(config.keys()) 222 | if invalid_keys: 223 | raise TJException('Invalid keys found in config file: {}'.format(invalid_keys)) 224 | 225 | devices_from_config = config_from_file.pop('devices_to_watch', {}) 226 | aps_from_config = config_from_file.pop('aps_to_watch', {}) 227 | 228 | config.update(config_from_file) 229 | print('Loaded configuration from {}'.format(args.config)) 230 | 231 | except (IOError, OSError, json.decoder.JSONDecodeError) as e: 232 | raise TJException('Error loading config file ({}): {}'.format(args.config, e)) 233 | 234 | non_config_args = {'config', 'devices_to_watch', 'aps_to_watch', 'do_enable_monitor_mode', 'version', 235 | 'do_disable_monitor_mode', 'set_channel', 'print_default_config', 'mac_lookup'} 236 | 237 | config_from_args = vars(args) 238 | config_from_args = {k: v for k, v in config_from_args.items() 239 | if v is not None and k not in non_config_args} 240 | 241 | # Config from args trumps default or config file 242 | config.update(config_from_args) 243 | 244 | # Allow any plugins to override config 245 | if config['trigger_plugin']: 246 | trigger_plugin_path = get_real_plugin_path(config['trigger_plugin']) 247 | parsed_trigger_plugin = plugin_parser.parse_trigger_plugin(trigger_plugin_path, 248 | config['plugin_config'], 249 | parse_only=True) 250 | 251 | # Allow plugin to override any config parameters 252 | if 'config' in parsed_trigger_plugin: 253 | trigger_config = parsed_trigger_plugin['config'] 254 | config.update(**trigger_config) 255 | 256 | try: 257 | config['trigger_cooldown'] = float(config['trigger_cooldown']) 258 | except ValueError: 259 | raise TJException('trigger_cooldown must be a number') 260 | 261 | # If we're in track mode and no other threshold info is set, default to a 1 byte data threshold 262 | if config['do_track']: 263 | if not config['threshold'] and not config['power']: 264 | config['threshold'] = 1 265 | 266 | config['devices_to_watch'] = determine_watch_list(args.devices_to_watch, 267 | devices_from_config, 268 | config['threshold'], 269 | config['power']) 270 | 271 | config['aps_to_watch'] = determine_watch_list(args.aps_to_watch, 272 | aps_from_config, 273 | config['threshold'], 274 | config['power']) 275 | 276 | if args.channels_to_monitor: 277 | channels_to_monitor = args.channels_to_monitor.split(',') 278 | config['channels_to_monitor'] = channels_to_monitor 279 | 280 | return config 281 | 282 | 283 | def get_real_plugin_path(trigger_plugin): 284 | if not trigger_plugin.lower().endswith('.py') and '/' not in trigger_plugin: 285 | possible_builtin_path = os.path.join(os.path.dirname(__file__), 286 | 'plugins', 287 | '{}.py'.format(trigger_plugin)) 288 | if os.path.exists(possible_builtin_path): 289 | trigger_plugin = possible_builtin_path 290 | return trigger_plugin 291 | -------------------------------------------------------------------------------- /trackerjacker/dot11_tracker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # pylint: disable=C0103, W0703, C0111, R0902, R0913 3 | 4 | import re 5 | import time 6 | import traceback 7 | import threading 8 | import subprocess 9 | from functools import reduce 10 | from .common import TJException, MACS_TO_IGNORE 11 | 12 | 13 | class Dot11Tracker: 14 | """Responsible for tracking access points and devices of interest. 15 | 16 | Args: 17 | logger: A person who chops down our forest friends for a living 18 | devices_to_watch: Dict of macs to watch; In this format - 19 | {'mac1': 315, "mac2": 512} 20 | where threshold is the number of bytes which, if seen within threshold_window, will cause an alert 21 | and where power is the minumum RSSI power level which will cause an alert when seen for that mac 22 | (example usage: to cause an alert when a device is within a certain physical distance). 23 | aps_to_watch: List of access points in this format - {"ssid1": threshold1, "bssid2": threshold2} 24 | trigger_plugin: parsed trigger plugin - a dict in the form {'trigger': trigger function, 'api_version': 1} 25 | trigger_command: string representing command to run on each trigger match 26 | trigger_cooldown: seconds between calling the trigger_plugin or trigger_command for a particular device id 27 | threshold_window: Time window in which the threshold must be reached to cause an alert 28 | dot11_map: Reference to dott11_mapper.Do11Map object, where the traffic info is stored 29 | """ 30 | # pylint: disable=E1101, W0613 31 | def __init__(self, 32 | logger, 33 | threshold, 34 | power, 35 | devices_to_watch, 36 | aps_to_watch, 37 | trigger_plugin, 38 | trigger_command, 39 | trigger_cooldown, 40 | threshold_window, 41 | beep_on_trigger, 42 | dot11_map): 43 | 44 | self.stop_event = threading.Event() 45 | self.last_alerted = {} 46 | 47 | # Same as self.arg = arg for every arg (except devices_to_watch and aps_to_watch) 48 | self.__dict__.update({k: v for k, v in locals().items() if k != 'aps_to_watch'}) 49 | 50 | # If no particular things are specified to be watched, assume everything should be watched 51 | self.track_all = (not aps_to_watch and not devices_to_watch) 52 | 53 | # Creates a map like: {'my_ssid1': {'threshold': 5000, 'last_alert': timestamp}, 'bssid2': {...} } 54 | self.bssids_to_watch = {} 55 | self.ssids_to_watch = {} 56 | for ap_identifier, watch_entry in aps_to_watch.items(): 57 | # Try to determine if the ap_identifier is a bssid or ssid based on pattern, and behave accordingly 58 | # Note that this means ssids that are named like a bssid will be treated like a bssid instead of an 59 | # essid, but that's a trade off in terms of simplicity of use I'm willing to make right now. 60 | if re.match(r'^([a-fA-F0-9]{2}[:|\-]?){6}$', ap_identifier): 61 | self.bssids_to_watch[ap_identifier.lower()] = watch_entry 62 | else: 63 | self.ssids_to_watch[ap_identifier] = watch_entry 64 | 65 | def add_frame(self, frame, raw_frame): 66 | if self.track_all: 67 | self.eval_general_mac_trigger(frame.macs, frame, raw_frame) 68 | self.eval_general_bssid_trigger(frame.bssid, frame, raw_frame) 69 | self.eval_general_ssid_trigger(frame.ssid, frame, raw_frame) 70 | else: 71 | self.eval_mac_triggers(frame.macs, frame, raw_frame) 72 | self.eval_bssid_triggers(frame.bssid, frame, raw_frame) 73 | self.eval_ssid_triggers(frame.ssid, frame, raw_frame) 74 | 75 | def eval_general_mac_trigger(self, macs, frame, raw_frame): 76 | for mac in macs: 77 | dev_node = self.dot11_map.get_dev_node(mac) 78 | if not dev_node: 79 | continue 80 | 81 | if self.threshold: 82 | # Calculate bytes received in the alert_window 83 | bytes_in_window = (self.get_bytes_in_window(dev_node['frames_in']) + 84 | self.get_bytes_in_window(dev_node['frames_out'])) 85 | if bytes_in_window >= self.threshold: 86 | self.do_trigger_alert(mac, 87 | 'mac', 88 | num_bytes=bytes_in_window, 89 | data_threshold=self.threshold, 90 | vendor=dev_node['vendor'], 91 | frame=frame, 92 | raw_frame=raw_frame) 93 | 94 | if self.power and frame.signal_strength > self.power: 95 | self.do_trigger_alert(mac, 96 | 'mac', 97 | vendor=dev_node['vendor'], 98 | power_threshold=self.power, 99 | frame=frame, 100 | raw_frame=raw_frame) 101 | 102 | def eval_general_bssid_trigger(self, bssid, frame, raw_frame): 103 | bssid_node = self.dot11_map.get_ap_by_bssid(bssid) 104 | if self.threshold and bssid_node and 'frames' in bssid_node: 105 | bytes_in_window = self.get_bytes_in_window(bssid_node['frames']) 106 | if bytes_in_window >= self.threshold: 107 | vendor = None 108 | if bssid_node: 109 | vendor = bssid_node['vendor'] 110 | self.do_trigger_alert(bssid, 111 | 'bssid', 112 | num_bytes=bytes_in_window, 113 | data_threshold=self.threshold, 114 | vendor=vendor, 115 | frame=frame, 116 | raw_frame=raw_frame) 117 | 118 | if self.power and frame.signal_strength >= self.power: 119 | vendor = None 120 | if bssid_node: 121 | vendor = bssid_node['vendor'] 122 | self.do_trigger_alert(bssid, 123 | 'bssid', 124 | power_threshold=self.power, 125 | vendor=vendor, 126 | frame=frame, 127 | raw_frame=raw_frame) 128 | 129 | def eval_general_ssid_trigger(self, ssid, frame, raw_frame): 130 | bssid_nodes = self.dot11_map.get_ap_nodes_by_ssid(ssid) 131 | if bssid_nodes: 132 | if self.threshold: 133 | bytes_in_window = reduce(lambda acc, bssid_bytes: acc+bssid_bytes, 134 | [self.get_bytes_in_window(bssid_node['frames']) for bssid_node in bssid_nodes], 135 | 0) 136 | if bytes_in_window >= self.threshold: 137 | self.do_trigger_alert(ssid, 138 | 'ssid', 139 | num_bytes=bytes_in_window, 140 | data_threshold=self.threshold, 141 | frame=frame, 142 | raw_frame=raw_frame) 143 | 144 | if self.power and frame.signal_strength >= self.power: 145 | self.do_trigger_alert(ssid, 146 | 'ssid', 147 | power_threshold=self.power, 148 | frame=frame, 149 | raw_frame=raw_frame) 150 | 151 | def eval_mac_triggers(self, macs, frame, raw_frame): 152 | # Only eval macs both on the "to watch" list and in the frame 153 | devices_to_eval = macs & self.devices_to_watch.keys() 154 | for mac in devices_to_eval: 155 | if mac in MACS_TO_IGNORE: 156 | continue 157 | dev_watch_node = self.devices_to_watch[mac] 158 | dev_node = self.dot11_map.get_dev_node(mac) 159 | bytes_in_window = 0 160 | triggered = False 161 | 162 | if dev_node: 163 | if dev_watch_node['threshold']: 164 | # Calculate bytes received in the alert_window 165 | bytes_in_window = (self.get_bytes_in_window(dev_node['frames_in']) + 166 | self.get_bytes_in_window(dev_node['frames_out'])) 167 | if bytes_in_window >= dev_watch_node['threshold']: 168 | self.do_trigger_alert(mac, 169 | 'mac', 170 | num_bytes=bytes_in_window, 171 | data_threshold=dev_watch_node['threshold'], 172 | vendor=dev_node['vendor'], 173 | frame=frame, 174 | raw_frame=raw_frame) 175 | triggered = True 176 | 177 | if dev_watch_node['power'] and frame.signal_strength > dev_watch_node['power']: 178 | self.do_trigger_alert(mac, 179 | 'mac', 180 | vendor=dev_node['vendor'], 181 | power_threshold=dev_watch_node['power'], 182 | frame=frame, 183 | raw_frame=raw_frame) 184 | triggered = True 185 | 186 | if not triggered: 187 | self.logger.debug('Bytes received for {} (threshold: {}) in last {} seconds: {}' 188 | .format(mac, dev_watch_node['threshold'], self.threshold_window, bytes_in_window)) 189 | 190 | def eval_bssid_triggers(self, bssid, frame, raw_frame): 191 | if (bssid not in self.bssids_to_watch) or (bssid in MACS_TO_IGNORE): 192 | return 193 | 194 | bssid_watch_node = self.bssids_to_watch[bssid] 195 | bssid_node = self.dot11_map.get_ap_by_bssid(bssid) 196 | bytes_in_window = 0 197 | triggered = False 198 | 199 | if bssid_node: 200 | bytes_in_window = self.get_bytes_in_window(bssid_node['frames']) 201 | if bssid_watch_node['threshold'] and bytes_in_window >= bssid_watch_node['threshold']: 202 | self.do_trigger_alert(bssid, 203 | 'bssid', 204 | num_bytes=bytes_in_window, 205 | data_threshold=bssid_watch_node['threshold'], 206 | vendor=bssid_node['vendor'], 207 | frame=frame, 208 | raw_frame=raw_frame) 209 | triggered = True 210 | 211 | if bssid_watch_node['power'] and frame.signal_strength >= bssid_watch_node['power']: 212 | self.do_trigger_alert(bssid, 213 | 'bssid', 214 | vendor=bssid_node['vendor'], 215 | power_threshold=bssid_watch_node['power'], 216 | frame=frame, 217 | raw_frame=raw_frame) 218 | triggered = True 219 | 220 | if not triggered: 221 | self.logger.info('Bytes received for {} in last {} seconds: {}' 222 | .format(bssid, self.threshold_window, bytes_in_window)) 223 | 224 | def eval_ssid_triggers(self, ssid, frame, raw_frame): 225 | if ssid not in self.ssids_to_watch: 226 | return 227 | 228 | ssid_watch_node = self.ssids_to_watch[ssid] 229 | bssid_nodes = self.dot11_map.get_ap_nodes_by_ssid(ssid) 230 | bytes_in_window = 0 231 | 232 | if bssid_nodes: 233 | bytes_in_window = reduce(lambda acc, bssid_bytes: acc+bssid_bytes, 234 | [self.get_bytes_in_window(bssid_node['frames']) for bssid_node in bssid_nodes], 235 | 0) 236 | if bytes_in_window >= ssid_watch_node['threshold']: 237 | self.do_trigger_alert(ssid, 238 | 'ssid', 239 | num_bytes=bytes_in_window, 240 | data_threshold=ssid_watch_node['threshold'], 241 | frame=frame, 242 | raw_frame=raw_frame) 243 | return 244 | 245 | self.logger.info('Bytes received for {} in last {} seconds: {}' 246 | .format(ssid, self.threshold_window, bytes_in_window)) 247 | 248 | def do_trigger_alert(self, 249 | dev_id, 250 | dev_type, 251 | num_bytes=None, 252 | data_threshold=None, 253 | power_threshold=None, 254 | vendor=None, 255 | frame=None, 256 | raw_frame=None): 257 | """Do alert for triggered item. 258 | 259 | Args: 260 | alert_msg: Message to log for the alert 261 | """ 262 | if time.time() - self.last_alerted.get(dev_id, 9999999) < self.trigger_cooldown: 263 | self.logger.debug('[*] Saw {}, but still in cooldown period ({} seconds)' 264 | .format(dev_id, self.trigger_cooldown)) 265 | return 266 | 267 | if self.beep_on_trigger: 268 | print(chr(0x07)) 269 | 270 | if self.trigger_plugin: 271 | try: 272 | self.trigger_plugin['trigger'](dev_id=dev_id, 273 | dev_type=dev_type, 274 | num_bytes=num_bytes, 275 | data_threshold=data_threshold, 276 | vendor=vendor, 277 | power=frame.signal_strength, 278 | power_threshold=power_threshold, 279 | bssid=frame.bssid, 280 | ssid=frame.ssid, 281 | iface=frame.iface, 282 | channel=frame.channel, 283 | frame_type=frame.frame_type_name(), 284 | frame=raw_frame) 285 | except Exception: 286 | raise TJException('Error occurred in trigger plugin: {}'.format(traceback.format_exc())) 287 | 288 | elif self.trigger_command: 289 | try: 290 | # Start trigger_command in background process - fire and forget 291 | subprocess.Popen(self.trigger_command) 292 | except Exception: 293 | raise TJException('Error occurred in trigger command: {}'.format(traceback.format_exc())) 294 | 295 | else: 296 | if num_bytes: 297 | alert_msg = '[@] Device ({} {}) data threshold ({}) hit: {} bytes'.format(dev_type, 298 | dev_id, 299 | data_threshold, 300 | num_bytes) 301 | else: 302 | alert_msg = '[@] Device ({} {}) power threshold ({}) hit: {}dBm'.format(dev_type, 303 | dev_id, 304 | power_threshold, 305 | frame.signal_strength) 306 | self.logger.info(alert_msg) 307 | 308 | self.last_alerted[dev_id] = time.time() 309 | 310 | def get_bytes_in_window(self, frame_list): 311 | """Returns number of bytes in a frame_list. 312 | 313 | Args: 314 | frame_list: List in format - [(ts1, num_bytes1), (ts2, num_bytes2)] 315 | """ 316 | bytes_in_window = 0 317 | now = time.time() 318 | for ts, num_bytes in frame_list: 319 | if (now - ts) > self.threshold_window: 320 | break 321 | bytes_in_window += num_bytes 322 | return bytes_in_window 323 | --------------------------------------------------------------------------------