diff --git a/requirements.txt b/requirements.txt index cd3d848ff..fd5c9f49d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ chardet==5.2.0 pytest==7.4.0 scapy==2.5.0 argparse==1.4.0 -setuptools==68.1.0 +setuptools==68.1.2 diff --git a/wifite/attack/wep.py b/wifite/attack/wep.py index 8d532457e..1f30ccfa6 100755 --- a/wifite/attack/wep.py +++ b/wifite/attack/wep.py @@ -2,7 +2,6 @@ # -*- coding: utf-8 -*- import time - from ..config import Configuration from ..model.attack import Attack from ..model.wep_result import CrackResultWEP @@ -13,7 +12,7 @@ from ..util.color import Color -class AttackWEP(Attack): +class AttackWEP: """ Contains logic for attacking a WEP-encrypted access point. """ @@ -21,7 +20,7 @@ class AttackWEP(Attack): fakeauth_wait = 5 # TODO: Configuration? def __init__(self, target): - super(AttackWEP, self).__init__(target) + super().__init__(target) self.crack_result = None self.success = False @@ -53,11 +52,11 @@ def run(self): try: # Start Airodump process with Airodump(channel=self.target.channel, - target_bssid=self.target.bssid, - ivs_only=True, # Only capture IVs packets - skip_wps=True, # Don't check for WPS-compatibility - output_file_prefix='wep', - delete_existing_files=not keep_ivs) as airodump: + target_bssid=self.target.bssid, + ivs_only=True, # Only capture IVs packets + skip_wps=True, # Don't check for WPS-compatibility + output_file_prefix='wep', + delete_existing_files=not keep_ivs) as airodump: Color.clear_line() Color.p('\r{+} {O}waiting{W} for target to appear...') @@ -107,7 +106,7 @@ def run(self): current_ivs = airodump_target.ivs total_ivs = previous_ivs + current_ivs - status = '%d/{C}%d{W} IVs' % (total_ivs, Configuration.wep_crack_at_ivs) + status = f'{total_ivs:d}/{{C}}{Configuration.wep_crack_at_ivs:d}{{W}} IVs' if fakeauth_proc: status += ', {G}fakeauth{W}' if fakeauth_proc.status else ', {R}no-auth{W}' if aireplay.status is not None: @@ -117,26 +116,9 @@ def run(self): # Check if we cracked it. if aircrack and aircrack.is_cracked(): - (hex_key, ascii_key) = aircrack.get_key_hex_ascii() - # bssid = airodump_target.bssid - # if airodump_target.essid_known: - # essid = airodump_target.essid - # else: - # essid = None - Color.pl('\n{+} {C}%s{W} WEP attack {G}successful{W}\n' % attack_name) - if aireplay: - aireplay.stop() - if fakeauth_proc: - fakeauth_proc.stop() - - self.crack_result = CrackResultWEP(self.target.bssid, self.target.essid, hex_key, ascii_key) - self.crack_result.dump() - - Airodump.delete_airodump_temp_files('wep') - - self.success = True - return self.success - + return self._extracted_from_run_93( + aircrack, attack_name, aireplay, fakeauth_proc + ) if aircrack and aircrack.is_running(): # Aircrack is running in the background. Color.p('and {C}cracking{W}') @@ -173,10 +155,10 @@ def run(self): xor_file = Aireplay.get_xor() if not xor_file: # If .xor is not there, the process failed. - Color.pl('\n{!} {O}%s attack{R} did not generate a .xor file' % attack_name) + Color.pl(f'\n{{!}} {{O}}{attack_name} attack{{R}} did not generate a .xor file') # XXX: For debugging - Color.pl('{?} {O}Command: {R}%s{W}' % ' '.join(aireplay.cmd)) - Color.pl('{?} {O}Output:\n{R}%s{W}' % aireplay.get_output()) + Color.pl(f'{{?}} {{O}}Command: {{R}}{" ".join(aireplay.cmd)}{{W}}') + Color.pl(f'{{?}} {{O}}Output:\n{{R}}{aireplay.get_output()}{{W}}') break # If .xor exists, run packetforge-ng to create .cap @@ -199,16 +181,16 @@ def run(self): continue else: Color.pl('\n{!} {O}aireplay-ng exited unexpectedly{W}') - Color.pl('{?} {O}Command: {R}%s{W}' % ' '.join(aireplay.cmd)) - Color.pl('{?} {O}Output:\n{R}%s{W}' % aireplay.get_output()) + Color.pl(f'{{?}} {{O}}Command: {{R}}{" ".join(aireplay.cmd)}{{W}}') + Color.pl(f'{{?}} {{O}}Output:\n{{R}}{aireplay.get_output()}{{W}}') break # Continue to other attacks # Check if IVs stopped flowing (same for > N seconds) if airodump_target.ivs > last_ivs_count: time_unchanged_ivs = time.time() elif Configuration.wep_restart_stale_ivs > 0 and \ - attack_name != 'chopchop' and \ - attack_name != 'fragment': + attack_name != 'chopchop' and \ + attack_name != 'fragment': stale_seconds = time.time() - time_unchanged_ivs if stale_seconds > Configuration.wep_restart_stale_ivs: # No new IVs within threshold, restart aireplay @@ -224,31 +206,45 @@ def run(self): time.sleep(1) continue - # End of big while loop - # End of with-airodump + # End of big while loop + # End of with-airodump except KeyboardInterrupt: if fakeauth_proc: fakeauth_proc.stop() if not attacks_remaining: - if keep_ivs: - Airodump.delete_airodump_temp_files('wep') - - self.success = False - return self.success - + return self._extracted_from_run_206(keep_ivs) if self.user_wants_to_stop(attack_name, attacks_remaining, airodump_target): - if keep_ivs: - Airodump.delete_airodump_temp_files('wep') - - self.success = False - return self.success - + return self._extracted_from_run_206(keep_ivs) except Exception as e: Color.pexception(e) continue # End of big try-catch - # End of for-each-attack-type loop + return self._extracted_from_run_206(keep_ivs) + + # TODO Rename this here and in `run` + def _extracted_from_run_93(self, aircrack, attack_name, aireplay, fakeauth_proc): + (hex_key, ascii_key) = aircrack.get_key_hex_ascii() + # bssid = airodump_target.bssid + # if airodump_target.essid_known: + # essid = airodump_target.essid + # else: + # essid = None + Color.pl(f'\n{{+}} {{C}}{attack_name}{{W}} WEP attack {{G}}successful{{W}}\n') + if aireplay: + aireplay.stop() + if fakeauth_proc: + fakeauth_proc.stop() + + self.crack_result = CrackResultWEP(self.target.bssid, self.target.essid, hex_key, ascii_key) + self.crack_result.dump() + + Airodump.delete_airodump_temp_files('wep') + + self.success = True + return self.success + # TODO Rename this here and in `run` + def _extracted_from_run_206(self, keep_ivs): if keep_ivs: Airodump.delete_airodump_temp_files('wep') @@ -271,23 +267,23 @@ def user_wants_to_stop(current_attack, attacks_remaining, target): # Deauth clients & retry attack_index = 1 - Color.pl(' {G}1{W}: {O}Deauth clients{W} and {G}retry{W} {C}%s attack{W} against {G}%s{W}' % ( - current_attack, target_name)) + Color.pl( + f' {{G}}1{{W}}: {{O}}Deauth clients{{W}} and {{G}}retry{{W}} {{C}}{current_attack} attack{{W}} against {{G}}{target_name}{{W}}') # Move onto a different WEP attack for attack_name in attacks_remaining: attack_index += 1 Color.pl( - ' {G}%d{W}: Start new {C}%s attack{W} against {G}%s{W}' % (attack_index, attack_name, target_name)) + f' {{G}}{attack_index:d}{{W}}: Start new {{C}}{attack_name} attack{{W}} against {{G}}{target_name}{{W}}') # Stop attacking entirely attack_index += 1 - Color.pl(' {G}%d{W}: {R}Stop attacking, {O}Move onto next target{W}' % attack_index) + Color.pl(f' {{G}}{attack_index:d}{{W}}: {{R}}Stop attacking, {{O}}Move onto next target{{W}}') while True: - Color.p('{?} Select an option ({G}1-%d{W}): ' % attack_index) + Color.p(f'{{?}} Select an option ({{G}}1-{attack_index:d}{{W}}): ') answer = input() if not answer.isdigit() or int(answer) < 1 or int(answer) > attack_index: - Color.pl('{!} {R}Invalid input: {O}Must enter a number between {G}1-%d{W}' % attack_index) + Color.pl(f'{{!}} {{R}}Invalid input: {{O}}Must enter a number between {{G}}1-{attack_index:d}{{W}}') continue answer = int(answer) break @@ -306,20 +302,20 @@ def user_wants_to_stop(current_attack, attacks_remaining, target): continue # Don't deauth ourselves. Color.clear_entire_line() - Color.p('\r{+} {O}Deauthenticating client {C}%s{W}...' % client.station) + Color.p(f'\r{{+}} {{O}}Deauthenticating client {{C}}{client.station}{{W}}...') Aireplay.deauth(target.bssid, client_mac=client.station, essid=target.essid) deauth_count += 1 Color.clear_entire_line() - Color.pl('\r{+} Sent {C}%d {O}deauths{W}' % deauth_count) + Color.pl(f'\r{{+}} Sent {{C}}{deauth_count:d} {{O}}deauths{{W}}') # Re-insert current attack to top of list of attacks remaining attacks_remaining.insert(0, current_attack) return False # Don't stop - elif answer == attack_index: + if answer == attack_index: return True # Stop attacking - elif answer > 1: + if answer > 1: # User selected specific attack: Re-order attacks based on desired next-step attacks_remaining.insert(0, attacks_remaining.pop(answer - 2)) return False # Don't stop @@ -329,7 +325,7 @@ def fake_auth(self): Attempts to fake-authenticate with target. Returns: True if successful, False is unsuccessful. """ - Color.p('\r{+} attempting {G}fake-authentication{W} with {C}%s{W}...' % self.target.bssid) + Color.p(f'\r{{+}} attempting {{G}}fake-authentication{{W}} with {{C}}{self.target.bssid}{{W}}...') fakeauth = Aireplay.fakeauth(self.target, timeout=AttackWEP.fakeauth_wait) if fakeauth: Color.pl(' {G}success{W}') @@ -337,9 +333,9 @@ def fake_auth(self): Color.pl(' {R}failed{W}') if Configuration.require_fakeauth: # Fakeauth is required, fail - raise Exception('Fake-authenticate did not complete within %d seconds' % AttackWEP.fakeauth_wait) + raise Exception(f'Fake-authenticate did not complete within {AttackWEP.fakeauth_wait:d} seconds') # Warn that fakeauth failed - Color.pl('{!} {O} unable to fake-authenticate with target (%s){W}' % self.target.bssid) + Color.pl(f'{{!}} {{O}} unable to fake-authenticate with target ({self.target.bssid}){{W}}') Color.pl('{!} continuing attacks because {G}--require-fakeauth{W} was not set') return fakeauth diff --git a/wifite/attack/wpa.py b/wifite/attack/wpa.py index 39a4f302b..be3bf8b89 100755 --- a/wifite/attack/wpa.py +++ b/wifite/attack/wpa.py @@ -19,7 +19,7 @@ class AttackWPA(Attack): def __init__(self, target): - super(AttackWPA, self).__init__(target) + super().__init__(target) self.clients = [] self.crack_result = None self.success = False @@ -225,7 +225,7 @@ def save_handshake(handshake): os.makedirs(Configuration.wpa_handshake_dir) # Generate filesystem-safe filename from bssid, essid and date - if handshake.essid and type(handshake.essid) is str: + if handshake.essid and isinstance(type, handshake.essid) is str: essid_safe = re.sub('[^a-zA-Z0-9]', '', handshake.essid) else: essid_safe = 'UnknownEssid' diff --git a/wifite/attack/wps.py b/wifite/attack/wps.py index 228f284cc..804b5d0ca 100755 --- a/wifite/attack/wps.py +++ b/wifite/attack/wps.py @@ -15,7 +15,7 @@ def can_attack_wps(): return Reaver.exists() or Bully.exists() def __init__(self, target, pixie_dust=False, null_pin=False): - super(AttackWPS, self).__init__(target) + super().__init__(target) self.success = False self.crack_result = None self.pixie_dust = pixie_dust @@ -49,25 +49,24 @@ def run(self): if not Reaver.exists() and Bully.exists(): # Use bully if reaver isn't available return self.run_bully() - elif self.pixie_dust and not Reaver.is_pixiedust_supported() and Bully.exists(): + if self.pixie_dust and not Reaver.is_pixiedust_supported() and Bully.exists(): # Use bully if reaver can't do pixie-dust return self.run_bully() - elif Configuration.use_bully: + if Configuration.use_bully: # Use bully if asked by user return self.run_bully() - elif not Reaver.exists(): + if not Reaver.exists(): # Print error if reaver isn't found (bully not available) if self.pixie_dust: Color.pl('\r{!} {R}Skipping WPS Pixie-Dust attack: {O}reaver{R} not found.{W}') else: Color.pl('\r{!} {R}Skipping WPS PIN attack: {O}reaver{R} not found.{W}') return False - elif self.pixie_dust and not Reaver.is_pixiedust_supported(): - # Print error if reaver can't support pixie-dust (bully not available) - Color.pl('\r{!} {R}Skipping WPS attack: {O}reaver{R} does not support {O}--pixie-dust{W}') - return False - else: + if not self.pixie_dust or Reaver.is_pixiedust_supported(): return self.run_reaver() + # Print error if reaver can't support pixie-dust (bully not available) + Color.pl('\r{!} {R}Skipping WPS attack: {O}reaver{R} does not support {O}--pixie-dust{W}') + return False # TODO Rename this here and in `run` def _extracted_from_run_14(self, arg0): diff --git a/wifite/model/wpa_result.py b/wifite/model/wpa_result.py index b8eb20123..763471613 100755 --- a/wifite/model/wpa_result.py +++ b/wifite/model/wpa_result.py @@ -12,26 +12,26 @@ def __init__(self, bssid, essid, handshake_file, key): self.essid = essid self.handshake_file = handshake_file self.key = key - super(CrackResultWPA, self).__init__() + super().__init__() def dump(self): if self.essid: Color.pl(f'{{+}} {"Access Point Name".rjust(19)}: {{C}}{self.essid}{{W}}') if self.bssid: Color.pl(f'{{+}} {"Access Point BSSID".rjust(19)}: {{C}}{self.bssid}{{W}}') - Color.pl('{+} %s: {C}%s{W}' % ('Encryption'.rjust(19), self.result_type)) + Color.pl(f'{{+}} {"Encryption".rjust(19)}: {{C}}{self.result_type}{{W}}') if self.handshake_file: - Color.pl('{+} %s: {C}%s{W}' % ('Handshake File'.rjust(19), self.handshake_file)) + Color.pl(f'{{+}} {"Handshake File".rjust(19)}: {{C}}{self.handshake_file}{{W}}') if self.key: - Color.pl('{+} %s: {G}%s{W}' % ('PSK (password)'.rjust(19), self.key)) + Color.pl(f'{{+}} {"PSK (password)".rjust(19)}: {{G}}{self.key}{{W}}') else: - Color.pl('{!} %s {O}key unknown{W}' % ''.rjust(19)) + Color.pl(f'{{!}} {"".rjust(19)} {{O}}key unknown{{W}}') def print_single_line(self, longest_essid): self.print_single_line_prefix(longest_essid) - Color.p('{G}%s{W}' % 'WPA'.ljust(5)) + Color.p(f'{{G}}{"WPA".ljust(5)}{{W}}') Color.p(' ') - Color.p('Key: {G}%s{W}' % self.key) + Color.p(f'Key: {{G}}{self.key}{{W}}') Color.pl('') def to_dict(self): diff --git a/wifite/tools/aircrack.py b/wifite/tools/aircrack.py index b5cd639fd..3b6cec169 100755 --- a/wifite/tools/aircrack.py +++ b/wifite/tools/aircrack.py @@ -79,7 +79,7 @@ def __del__(self): def crack_handshake(handshake, show_command=False): from ..util.color import Color from ..util.timer import Timer - f'Tries to crack a handshake. Returns WPA key if found, otherwise None.' + 'Tries to crack a handshake. Returns WPA key if found, otherwise None.' key_file = Configuration.temp('wpakey.txt') command = [ diff --git a/wifite/tools/airmon.py b/wifite/tools/airmon.py index c1050c5d9..d9f4ddc6e 100755 --- a/wifite/tools/airmon.py +++ b/wifite/tools/airmon.py @@ -5,7 +5,6 @@ import os import re import signal - from .dependency import Dependency from .ip import Ip from .iw import Iw diff --git a/wifite/tools/airodump.py b/wifite/tools/airodump.py index 6d6cb98e6..fc8f70ed3 100755 --- a/wifite/tools/airodump.py +++ b/wifite/tools/airodump.py @@ -6,7 +6,7 @@ import time from .dependency import Dependency from .tshark import Tshark -#from .wash import Wash +from .wash import Wash from ..util.process import Process from ..config import Configuration from ..model.target import Target, WPSState diff --git a/wifite/tools/bully.py b/wifite/tools/bully.py index b1e7634c4..7dc08b839 100755 --- a/wifite/tools/bully.py +++ b/wifite/tools/bully.py @@ -1,6 +1,10 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- + +import time +import re +from threading import Thread from .dependency import Dependency from .airodump import Airodump from ..model.attack import Attack @@ -10,10 +14,6 @@ from ..util.process import Process from ..config import Configuration -import time -import re -from threading import Thread - class Bully(Attack, Dependency): dependency_required = False @@ -21,7 +21,7 @@ class Bully(Attack, Dependency): dependency_url = 'https://github.com/kimocoder/bully' def __init__(self, target, pixie_dust=True): - super(Bully, self).__init__(target) + super().__init__(target) self.target = target self.pixie_dust = pixie_dust @@ -99,7 +99,7 @@ def _run(self, airodump): try: self.target = self.wait_for_target(airodump) except Exception as e: - self.pattack('{R}Failed: {O}%s{W}' % e, newline=True) + self.pattack(f'{{R}}Failed: {{O}}{e}{{W}}', newline=True) Color.pexception(e) self.stop() break @@ -111,22 +111,19 @@ def _run(self, airodump): if self.pixie_dust: # Check if entire attack timed out. if self.running_time() > Configuration.wps_pixie_timeout: - self.pattack('{R}Failed: {O}Timeout after %d seconds{W}' % ( - Configuration.wps_pixie_timeout), newline=True) + self.pattack(f'{{R}}Failed: {{O}}Timeout after {Configuration.wps_pixie_timeout:d} seconds{{W}}', newline=True) self.stop() return # Check if timeout threshold was breached if self.total_timeouts >= Configuration.wps_timeout_threshold: - self.pattack('{R}Failed: {O}More than %d Timeouts{W}' % ( - Configuration.wps_timeout_threshold), newline=True) + self.pattack(f'{{R}}Failed: {{O}}More than {Configuration.wps_timeout_threshold:d} Timeouts{{W}}', newline=True) self.stop() return # Check if WPSFail threshold was breached if self.total_failures >= Configuration.wps_fail_threshold: - self.pattack('{R}Failed: {O}More than %d WPSFails{W}' % ( - Configuration.wps_fail_threshold), newline=True) + self.pattack(f'{{R}}Failed: {{O}}More than {Configuration.wps_fail_threshold:d} WPSFails{{W}}', newline=True) self.stop() return elif self.locked and not Configuration.wps_ignore_lock: @@ -149,18 +146,18 @@ def pattack(self, message, newline=False): attack_name = 'PIN Attack' if self.eta: - time_msg = '{D}ETA:{W}{C}%s{W}' % self.eta + time_msg = f'{{D}}ETA:{{W}}{{C}}{self.eta}{{W}}' else: - time_msg = '{C}%s{W}' % Timer.secs_to_str(time_left) + time_msg = f'{{C}}{Timer.secs_to_str(time_left)}{{W}}' if self.pins_remaining >= 0: - time_msg += ', {D}PINs Left:{W}{C}%d{W}' % self.pins_remaining + time_msg += f', {{D}}PINs Left:{{W}}{{C}}{self.pins_remaining:d}{{W}}' else: - time_msg += ', {D}PINs:{W}{C}%d{W}' % self.total_attempts + time_msg += f', {{D}}PINs:{{W}}{{C}}{self.total_attempts:d}{{W}}' Color.clear_entire_line() Color.pattack('WPS', self.target, attack_name, - '{W}[%s] %s' % (time_msg, message)) + f'{{W}}[{time_msg}] {message}') if newline: Color.pl('') @@ -173,10 +170,10 @@ def get_status(self): meta_statuses = [] if self.total_timeouts > 0: - meta_statuses.append('{O}Timeouts:%d{W}' % self.total_timeouts) + meta_statuses.append(f'{{O}}Timeouts:{self.total_timeouts:d}{{W}}') if self.total_failures > 0: - meta_statuses.append('{O}Fails:%d{W}' % self.total_failures) + meta_statuses.append(f'{{O}}Fails:{self.total_failures:d}{{W}}') if self.locked: meta_statuses.append('{R}Locked{W}') @@ -194,7 +191,7 @@ def parse_line_thread(self): line = line.replace('\r', '').replace('\n', '').strip() if Configuration.verbose > 1: - Color.pe('\n{P} [bully:stdout] %s' % line) + Color.pe(f'\n{{P}} [bully:stdout] {line}') self.state = self.parse_state(line) @@ -219,7 +216,7 @@ def parse_crack_result(self, line): if self.cracked_pin is not None: # Mention the PIN & that we're not done yet. - self.pattack('{G}Cracked PIN: {C}%s{W}' % self.cracked_pin, newline=True) + self.pattack(f'{{G}}Cracked PIN: {{C}}{self.cracked_pin}{{W}}', newline=True) self.state = '{G}Finding Key...{C}' time.sleep(2) @@ -228,7 +225,7 @@ def parse_crack_result(self, line): self.cracked_key = key_re[1] if not self.crack_result and self.cracked_pin and self.cracked_key: - self.pattack('{G}Cracked Key: {C}%s{W}' % self.cracked_key, newline=True) + self.pattack(f'{{G}}Cracked Key: {{C}}{self.cracked_key}{{W}}', newline=True) self.crack_result = CrackResultWPS( self.target.bssid, self.target.essid, @@ -269,7 +266,7 @@ def parse_state(self, line): # sourcery no-metrics if re_lockout := re.search(r".*WPS lockout reported, sleeping for (\d+) seconds", line): self.locked = True sleeping = re_lockout[1] - state = '{R}WPS Lock-out: {O}Waiting %s seconds...{W}' % sleeping + state = f'{{R}}WPS Lock-out: {{O}}Waiting {sleeping} seconds...{{W}}' if re.search(r".*\[Pixie-Dust] WPS pin not found", line): state = '{R}Failed: {O}Bully says "WPS pin not found"{W}' @@ -288,19 +285,19 @@ def _extracted_from_parse_state_20(self, mx_result_pin): if pin != self.last_pin: self._extracted_from_parse_state_12(pin) if result in ['Pin1Bad', 'Pin2Bad']: - result = '{G}%s{W}' % result + result = f'{{G}}{result}{{W}}' elif result == 'Timeout': self.total_timeouts += 1 - result = '{O}%s{W}' % result + result = f'{{O}}{result}{{W}}' elif result == 'WPSFail': self.total_failures += 1 - result = '{O}%s{W}' % result + result = f'{{O}}{result}{{W}}' elif result == 'NoAssoc': - result = '{O}%s{W}' % result + result = f'{{O}}{result}{{W}}' else: - result = '{R}%s{W}' % result + result = f'{{R}}{result}{{W}}' - result = '{P}%s{W}:%s' % (m_state.strip(), result.strip()) + result = f'{{P}}{m_state.strip()}{{W}}:{result.strip()}' result = f'Trying PIN ({result})' return result diff --git a/wifite/tools/cowpatty.py b/wifite/tools/cowpatty.py index 546e71abf..f05c95da1 100644 --- a/wifite/tools/cowpatty.py +++ b/wifite/tools/cowpatty.py @@ -1,23 +1,22 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- + +import os +import re from .dependency import Dependency from ..config import Configuration from ..util.color import Color from ..util.process import Process from ..tools.hashcat import HcxPcapngTool -import os -import re - class Cowpatty(Dependency): - ''' Wrapper for Cowpatty program. ''' + """ Wrapper for Cowpatty program. """ dependency_required = False dependency_name = 'cowpatty' dependency_url = 'https://tools.kali.org/wireless-attacks/cowpatty' - @staticmethod def crack_handshake(handshake, show_command=False): # Crack john file @@ -28,9 +27,9 @@ def crack_handshake(handshake, show_command=False): '-s', handshake.essid ] if show_command: - Color.pl('{+} {D}Running: {W}{P}%s{W}' % ' '.join(command)) + Color.pl(f'{{+}} {{D}}Running: {{W}}{{P}}{" ".join(command)}{{W}}') process = Process(command) - stdout, stderr = process.get_output() + stdout, _ = process.get_output() key = None for line in stdout.split('\n'): diff --git a/wifite/tools/dependency.py b/wifite/tools/dependency.py index e122e4889..87a75d2e1 100755 --- a/wifite/tools/dependency.py +++ b/wifite/tools/dependency.py @@ -68,10 +68,3 @@ def fails_dependency_check(cls): Color.p(f'{{!}} {{O}}Error: Required app {{R}}{cls.dependency_name}{{O}} was not found') Color.pl(f'. {{W}}install @ {{C}}{cls.dependency_url}{{W}}') return True - - else: - Color.p( - f'{{!}} {{O}}Warning: Recommended app ' - f'{{R}}{cls.dependency_name}{{O}} was not found') - Color.pl(f'. {{W}}install @ {{C}}{cls.dependency_url}{{W}}') - return False diff --git a/wifite/tools/iw.py b/wifite/tools/iw.py index cfe4ce2dd..ee31d3235 100755 --- a/wifite/tools/iw.py +++ b/wifite/tools/iw.py @@ -15,8 +15,6 @@ def mode(cls, iface, mode_name): if mode_name == "monitor": return Process.call(f'iw {iface} set monitor control') - else: - return Process.call(f'iw {iface} type {mode_name}') @classmethod def get_interfaces(cls, mode=None): diff --git a/wifite/tools/john.py b/wifite/tools/john.py index 84004979c..bbfcdef5b 100755 --- a/wifite/tools/john.py +++ b/wifite/tools/john.py @@ -1,14 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import os from .dependency import Dependency from ..config import Configuration from ..util.color import Color from ..util.process import Process from ..tools.hashcat import HcxPcapngTool -import os - class John(Dependency): """ Wrapper for John program. """ @@ -33,16 +32,16 @@ def crack_handshake(handshake, show_command=False): # Crack john file command = ['john', f'--format={john_format}', f'--wordlist={Configuration.wordlist}', john_file] if show_command: - Color.pl('{+} {D}Running: {W}{P}%s{W}' % ' '.join(command)) + Color.pl(f'{{+}} {{D}}Running: {{W}}{{P}}{" ".join(command)}{{W}}') process = Process(command) process.wait() # Run again with --show to consistently get the password command = ['john', '--show', john_file] if show_command: - Color.pl('{+} {D}Running: {W}{P}%s{W}' % ' '.join(command)) + Color.pl(f'{{+}} {{D}}Running: {{W}}{{P}}{" ".join(command)}{{W}}') process = Process(command) - stdout, stderr = process.get_output() + stdout, _ = process.get_output() # Parse password (regex doesn't work for some reason) if '0 password hashes cracked' in stdout: diff --git a/wifite/tools/macchanger.py b/wifite/tools/macchanger.py index 33b63649b..1bf3bc344 100755 --- a/wifite/tools/macchanger.py +++ b/wifite/tools/macchanger.py @@ -19,12 +19,12 @@ def down_macch_up(cls, iface, options): from ..util.process import Process Color.clear_entire_line() - Color.p('\r{+} {C}macchanger{W}: taking interface {C}%s{W} down...' % iface) + Color.p(f'\r{{+}} {{C}}macchanger{{W}}: taking interface {{C}}{iface}{{W}} down...') Ip.down(iface) Color.clear_entire_line() - Color.p('\r{+} {C}macchanger{W}: changing mac address of interface {C}%s{W}...' % iface) + Color.p(f'\r{{+}} {{C}}macchanger{{W}}: changing mac address of interface {{C}}{iface}{{W}}...') command = ['macchanger'] command.extend(options) @@ -32,12 +32,12 @@ def down_macch_up(cls, iface, options): macch = Process(command) macch.wait() if macch.poll() != 0: - Color.pl('\n{!} {R}macchanger{O}: error running {R}%s{O}' % ' '.join(command)) - Color.pl('{!} {R}output: {O}%s, %s{W}' % (macch.stdout(), macch.stderr())) + Color.pl(f'\n{{!}} {{R}}macchanger{{O}}: error running {{R}}{" ".join(command)}{{O}}') + Color.pl(f'{{!}} {{R}}output: {{O}}{macch.stdout()}, {macch.stderr()}{{W}}') return False Color.clear_entire_line() - Color.p('\r{+} {C}macchanger{W}: bringing interface {C}%s{W} up...' % iface) + Color.p(f'\r{{+}} {{C}}macchanger{{W}}: bringing interface {{C}}{iface}{{W}} up...') Ip.up(iface) @@ -52,13 +52,14 @@ def get_interface(cls): @classmethod def reset(cls): iface = cls.get_interface() - Color.pl('\r{+} {C}macchanger{W}: resetting mac address on %s...' % iface) + Color.pl(f'\r{{+}} {{C}}macchanger{{W}}: resetting mac address on {iface}...') # -p to reset to permanent MAC address if cls.down_macch_up(iface, ['-p']): new_mac = Ip.get_mac(iface) Color.clear_entire_line() - Color.pl('\r{+} {C}macchanger{W}: reset mac address back to {C}%s{W} on {C}%s{W}' % (new_mac, iface)) + Color.pl( + f'\r{{+}} {{C}}macchanger{{W}}: reset mac address back to {{C}}{new_mac}{{W}} on {{C}}{iface}{{W}}') @classmethod def random(cls): @@ -68,7 +69,7 @@ def random(cls): return iface = cls.get_interface() - Color.pl('\n{+} {C}macchanger{W}: changing mac address on {C}%s{W}' % iface) + Color.pl(f'\n{{+}} {{C}}macchanger{{W}}: changing mac address on {{C}}{iface}{{W}}') # -r to use random MAC address # -e to keep vendor bytes the same @@ -77,7 +78,7 @@ def random(cls): new_mac = Ip.get_mac(iface) Color.clear_entire_line() - Color.pl('\r{+} {C}macchanger{W}: changed mac address to {C}%s{W} on {C}%s{W}' % (new_mac, iface)) + Color.pl(f'\r{{+}} {{C}}macchanger{{W}}: changed mac address to {{C}}{new_mac}{{W}} on {{C}}{iface}{{W}}') @classmethod def reset_if_changed(cls): diff --git a/wifite/util/color.py b/wifite/util/color.py index fb564f586..6cf76e4c7 100755 --- a/wifite/util/color.py +++ b/wifite/util/color.py @@ -4,7 +4,7 @@ import sys -class Color(object): +class Color: """ Helper object for easily printing colored text to the terminal. """ # Basic console colors @@ -47,7 +47,7 @@ def p(text): @staticmethod def pl(text): """Prints text using colored format with trailing new line.""" - Color.p('%s\n' % text) + Color.p(f'{text}\n') Color.last_sameline_length = 0 @staticmethod @@ -56,7 +56,7 @@ def pe(text): Prints text using colored format with leading and trailing new line to STDERR. """ - sys.stderr.write(Color.s('%s\n' % text)) + sys.stderr.write(Color.s(f'{text}\n')) Color.last_sameline_length = 0 @staticmethod @@ -66,13 +66,13 @@ def s(text): for (key, value) in list(Color.replacements.items()): output = output.replace(key, value) for (key, value) in list(Color.colors.items()): - output = output.replace('{%s}' % key, value) + output = output.replace(f'{{{key}}}', value) return output @staticmethod def clear_line(): spaces = ' ' * Color.last_sameline_length - sys.stdout.write('\r%s\r' % spaces) + sys.stdout.write(f'\r{spaces}\r') sys.stdout.flush() Color.last_sameline_length = 0 @@ -91,14 +91,14 @@ def pattack(attack_type, target, attack_name, progress): ESSID (Pwr) Attack_Type: Progress e.g.: Router2G (23db) WEP replay attack: 102 IVs """ - essid = '{C}%s{W}' % target.essid if target.essid_known else '{O}unknown{W}' - Color.p('\r{+} {G}%s{W} ({C}%sdb{W}) {G}%s {C}%s{W}: %s ' % ( - essid, target.power, attack_type, attack_name, progress)) + essid = f'{{C}}{target.essid}{{W}}' if target.essid_known else '{O}unknown{W}' + Color.p( + f'\r{{+}} {{G}}{essid}{{W}} ({{C}}{target.power}db{{W}}) {{G}}{attack_type} {{C}}{attack_name}{{W}}: {progress} ') @staticmethod def pexception(exception): """Prints an exception. Includes stack trace if necessary.""" - Color.pl('\n{!} {R}Error: {O}%s' % str(exception)) + Color.pl(f'\n{{!}} {{R}}Error: {{O}}{str(exception)}') # Don't dump trace for the "no targets found" case. if 'No targets found' in str(exception): diff --git a/wifite/util/crack.py b/wifite/util/crack.py index 2b0d43855..1b5c2c42b 100755 --- a/wifite/util/crack.py +++ b/wifite/util/crack.py @@ -3,7 +3,6 @@ import os from json import loads - from ..config import Configuration from ..model.handshake import Handshake from ..model.pmkid_result import CrackResultPMKID @@ -46,7 +45,7 @@ def run(cls): Color.p('{W}') if not os.path.exists(Configuration.wordlist): - Color.pl('{!} {R}Wordlist {O}%s{R} not found. Exiting.' % Configuration.wordlist) + Color.pl(f'{{!}} {{R}}Wordlist {{O}}{Configuration.wordlist}{{R}} not found. Exiting.') return Color.pl('') @@ -72,19 +71,19 @@ def run(cls): Color.pl('\n{!} {O}Unavailable tools (install to enable):{W}') for tool, deps in missing_tools: dep_list = ', '.join([dep.dependency_name for dep in deps]) - Color.pl(' {R}* {R}%s {W}({O}%s{W})' % (tool, dep_list)) + Color.pl(f' {{R}}* {{R}}{tool} {{W}}({{O}}{dep_list}{{W}})') if all_pmkid: Color.pl('{!} {O}Note: PMKID hashes can only be cracked using {C}hashcat{W}') tool_name = 'hashcat' else: - Color.p('\n{+} Enter the {C}cracking tool{W} to use ({C}%s{W}): {G}' % ( - '{W}, {C}'.join(available_tools))) + Color.p( + f'\n{{+}} Enter the {{C}}cracking tool{{W}} to use ({{C}}{"{W}, {C}".join(available_tools)}{{W}}): {{G}}') tool_name = input() Color.p('{W}') if tool_name not in available_tools: - Color.pl('{!} {R}"%s"{O} tool not found, defaulting to {C}aircrack{W}' % tool_name) + Color.pl(f'{{!}} {{R}}"{tool_name}"{{O}} tool not found, defaulting to {{C}}aircrack{{W}}') tool_name = 'aircrack' try: @@ -119,10 +118,10 @@ def get_handshakes(cls): hs_dir = Configuration.wpa_handshake_dir if not os.path.exists(hs_dir) or not os.path.isdir(hs_dir): - Color.pl('\n{!} {O}directory not found: {R}%s{W}' % hs_dir) + Color.pl(f'\n{{!}} {{O}}directory not found: {{R}}{hs_dir}{{W}}') return [] - Color.pl('\n{+} Listing captured handshakes from {C}%s{W}:\n' % os.path.abspath(hs_dir)) + Color.pl(f'\n{{+}} Listing captured handshakes from {{C}}{os.path.abspath(hs_dir)}{{W}}:\n') for hs_file in os.listdir(hs_dir): if hs_file.count('_') != 3: continue @@ -143,7 +142,7 @@ def get_handshakes(cls): else: continue - name, essid, bssid, date = hs_file.split('_') + _, essid, bssid, date = hs_file.split('_') date = date.rsplit('.', 1)[0] days, hours = date.split('T') hours = hours.replace('-', ':') @@ -177,9 +176,9 @@ def get_handshakes(cls): if skipped_pmkid_files > 0: Color.pl( - '{!} {O}Skipping %d {R}*.22000{O} files because {R}hashcat{O} is missing.{W}\n' % skipped_pmkid_files) + f'{{!}} {{O}}Skipping {skipped_pmkid_files:d} {{R}}*.22000{{O}} files because {{R}}hashcat{{O}} is missing.{{W}}\n') if skipped_cracked_files > 0: - Color.pl('{!} {O}Skipping %d already cracked files.{W}\n' % skipped_cracked_files) + Color.pl(f'{{!}} {{O}}Skipping {skipped_cracked_files:d} already cracked files.{{W}}\n') # Sort by Date (Descending) return sorted(handshakes, key=lambda x: x.get('date'), reverse=True) @@ -200,11 +199,11 @@ def print_handshakes(cls, handshakes): Color.p(' ' + ('-' * 19) + '{W}\n') # Handshakes for index, handshake in enumerate(handshakes, start=1): - Color.p(' {G}%s{W}' % str(index).rjust(3)) - Color.p(' {C}%s{W}' % handshake['essid'].ljust(max_essid_len)) - Color.p(' {O}%s{W}' % handshake['bssid'].ljust(17)) - Color.p(' {C}%s{W}' % handshake['type'].ljust(5)) - Color.p(' {W}%s{W}\n' % handshake['date']) + Color.p(f' {{G}}{str(index).rjust(3)}{{W}}') + Color.p(f' {{C}}{handshake["essid"].ljust(max_essid_len)}{{W}}') + Color.p(f' {{O}}{handshake["bssid"].ljust(17)}{{W}}') + Color.p(f' {{C}}{handshake["type"].ljust(5)}{{W}}') + Color.p(f' {{W}}{handshake["date"]}{{W}}\n') @classmethod def get_user_selection(cls, handshakes): @@ -233,8 +232,7 @@ def get_user_selection(cls, handshakes): @classmethod def crack(cls, hs, tool): - Color.pl('\n{+} Cracking {G}%s {C}%s{W} ({C}%s{W})' % ( - cls.TYPES[hs['type']], hs['essid'], hs['bssid'])) + Color.pl(f'\n{{+}} Cracking {{G}}{cls.TYPES[hs["type"]]} {{C}}{hs["essid"]}{{W}} ({{C}}{hs["bssid"]}{{W}})') if hs['type'] == 'PMKID': crack_result = cls.crack_pmkid(hs, tool) @@ -245,24 +243,25 @@ def crack(cls, hs, tool): if crack_result is None: # Failed to crack - Color.pl('{!} {R}Failed to crack {O}%s{R} ({O}%s{R}): Passphrase not in dictionary' % ( - hs['essid'], hs['bssid'])) + Color.pl( + f'{{!}} {{R}}Failed to crack {{O}}{hs["essid"]}{{R}} ({{O}}{hs["bssid"]}{{R}}): Passphrase not in dictionary') else: # Cracked, replace existing entry (if any), or add to - Color.pl('{+} {G}Cracked{W} {C}%s{W} ({C}%s{W}). Key: "{G}%s{W}"' % ( - hs['essid'], hs['bssid'], crack_result.key)) + Color.pl( + f'{{+}} {{G}}Cracked{{W}} {{C}}{hs["essid"]}{{W}} ({{C}}{hs["bssid"]}{{W}}). Key: "{{G}}{crack_result.key}{{W}}"') crack_result.save() @classmethod def crack_4way(cls, hs, tool): + global key handshake = Handshake(hs['filename'], bssid=hs['bssid'], essid=hs['essid']) try: handshake.divine_bssid_and_essid() except ValueError as e: - Color.pl('{!} {R}Error: {O}%s{W}' % e) + Color.pl(f'{{!}} {{R}}Error: {{O}}{e}{{W}}') return None if tool == 'aircrack': diff --git a/wifite/util/process.py b/wifite/util/process.py index 8da97ebc7..d2c1483b5 100755 --- a/wifite/util/process.py +++ b/wifite/util/process.py @@ -1,17 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +import contextlib import time import signal import os - from subprocess import Popen, PIPE - from ..util.color import Color from ..config import Configuration -class Process(object): +class Process: """ Represents a running/ran process """ @staticmethod @@ -29,11 +28,11 @@ def call(command, cwd=None, shell=False): if type(command) is not str or ' ' in command or shell: shell = True if Configuration.verbose > 1: - Color.pe('\n {C}[?] {W} Executing (Shell): {B}%s{W}' % command) + Color.pe(f'\n {{C}}[?] {{W}} Executing (Shell): {{B}}{command}{{W}}') else: shell = False if Configuration.verbose > 1: - Color.pe('\n {C}[?]{W} Executing: {B}%s{W}' % command) + Color.pe(f'\n {{C}}[?]{{W}} Executing: {{B}}{command}{{W}}') pid = Popen(command, cwd=cwd, stdout=PIPE, stderr=PIPE, shell=shell) pid.wait() @@ -78,7 +77,7 @@ def __init__(self, command, devnull=False, stdout=PIPE, stderr=PIPE, cwd=None, b self.command = command if Configuration.verbose > 1: - Color.pe('\n {C}[?] {W} Executing: {B}%s{W}' % ' '.join(command)) + Color.pe(f'\n {{C}}[?] {{W}} Executing: {{B}}{" ".join(command)}{{W}}') self.out = None self.err = None @@ -97,11 +96,9 @@ def __del__(self): Ran when object is GC'd. If process is still running at this point, it should die. """ - try: + with contextlib.suppress(AttributeError): if self.pid and self.pid.poll() is None: self.interrupt() - except AttributeError: - pass def stdout(self): """ Waits for process to finish, returns stdout output """ @@ -160,37 +157,40 @@ def interrupt(self, wait_time=2.0): If process fails to exit within `wait_time` seconds, terminates it. """ try: - pid = self.pid.pid - cmd = self.command - if type(cmd) is list: - cmd = ' '.join(cmd) - - if Configuration.verbose > 1: - Color.pe('\n {C}[?] {W} sending interrupt to PID %d (%s)' % (pid, cmd)) - - os.kill(pid, signal.SIGINT) - - start_time = time.time() # Time since Interrupt was sent - while self.pid.poll() is None: - # Process is still running - try: - time.sleep(0.1) - if time.time() - start_time > wait_time: - # We waited too long for process to die, terminate it. - if Configuration.verbose > 1: - Color.pe('\n {C}[?] {W} Waited > %0.2f seconds for process to die, killing it' % wait_time) - os.kill(pid, signal.SIGTERM) - self.pid.terminate() - break - except KeyboardInterrupt: - # wait the cleanup - continue - + self._extracted_from_interrupt_7(wait_time) except OSError as e: if 'No such process' in e.__str__(): return raise e # process cannot be killed + # TODO Rename this here and in `interrupt` + def _extracted_from_interrupt_7(self, wait_time): + pid = self.pid.pid + cmd = self.command + if type(cmd) is list: + cmd = ' '.join(cmd) + + if Configuration.verbose > 1: + Color.pe(f'\n {{C}}[?] {{W}} sending interrupt to PID {pid:d} ({cmd})') + + os.kill(pid, signal.SIGINT) + + start_time = time.time() # Time since Interrupt was sent + while self.pid.poll() is None: + # Process is still running + try: + time.sleep(0.1) + if time.time() - start_time > wait_time: + # We waited too long for process to die, terminate it. + if Configuration.verbose > 1: + Color.pe(f'\n {{C}}[?] {{W}} Waited > {wait_time:0.2f} seconds for process to die, killing it') + os.kill(pid, signal.SIGTERM) + self.pid.terminate() + break + except KeyboardInterrupt: + # wait the cleanup + continue + if __name__ == '__main__': Configuration.initialize(False) diff --git a/wifite/util/scanner.py b/wifite/util/scanner.py index bbb75f41e..bea66de69 100755 --- a/wifite/util/scanner.py +++ b/wifite/util/scanner.py @@ -2,13 +2,12 @@ # -*- coding: utf-8 -*- from time import sleep, time - from ..config import Configuration from ..tools.airodump import Airodump from ..util.color import Color -class Scanner(object): +class Scanner: """ Scans wifi networks & provides menu for selecting targets """ # Console code for moving up one line @@ -58,8 +57,8 @@ def find_targets(self): if airodump.decloaking: outline += ' & decloaking' outline += '. Found' - outline += ' {G}%d{W} target(s),' % target_count - outline += ' {G}%d{W} client(s).' % client_count + outline += f' {{G}}{target_count:d}{{W}} target(s),' + outline += f' {{G}}{client_count:d}{{W}} client(s).' outline += ' {O}Ctrl+C{W} when ready ' Color.clear_entire_line() Color.p(outline) @@ -70,18 +69,22 @@ def find_targets(self): sleep(1) except KeyboardInterrupt: - if not Configuration.infinite_mode: - return True + return self._extracted_from_find_targets_50() - options = '({G}s{W}{D}, {W}{R}e{W})' - prompt = '{+} Do you want to {G}start attacking{W} or {R}exit{W}%s?' % options + # TODO Rename this here and in `find_targets` + def _extracted_from_find_targets_50(self): + if not Configuration.infinite_mode: + return True - self.print_targets() - Color.clear_entire_line() - Color.p(prompt) - answer = input().lower() + options = '({G}s{W}{D}, {W}{R}e{W})' + prompt = f'{{+}} Do you want to {{G}}start attacking{{W}} or {{R}}exit{{W}}{options}?' - return not answer.startswith('e') + self.print_targets() + Color.clear_entire_line() + Color.p(prompt) + answer = input().lower() + + return not answer.startswith('e') def update_targets(self): """ @@ -124,7 +127,7 @@ def found_target(self): break if self.target: - Color.pl('\n{+} {C}found target{G} %s {W}({G}%s{W})' % (self.target.bssid, self.target.essid)) + Color.pl(f'\n{{+}} {{C}}found target{{G}} {self.target.bssid} {{W}}({{G}}{self.target.essid}{{W}})') return True return False @@ -187,7 +190,7 @@ def print_targets(self): # Remaining rows: targets for idx, target in enumerate(self.targets, start=1): Color.clear_entire_line() - Color.p(' {G}%s ' % str(idx).rjust(3)) + Color.p(f' {{G}}{str(idx).rjust(3)} ') Color.pl(target.to_str( Configuration.show_bssids, Configuration.show_manufacturers @@ -226,9 +229,12 @@ def select_targets(self): # 1. Link to wireless drivers wiki, # 2. How to check if your device supports monitor mode, # 3. Provide airodump-ng command being executed. - raise Exception('No targets found.' - + ' You may need to wait longer,' - + ' or you may have issues with your wifi card') + raise Exception( + ( + ('No targets found.' + ' You may need to wait longer,') + + ' or you may have issues with your wifi card' + ) + ) # Return all targets if user specified a wait time ('pillage'). # A scan time is always set if run in infinite mode @@ -243,7 +249,7 @@ def select_targets(self): Color.pl(self.err_msg) input_str = '{+} Select target(s)' - input_str += ' ({G}1-%d{W})' % len(self.targets) + input_str += f' ({{G}}1-{len(self.targets):d}{{W}})' input_str += ' separated by commas, dashes' input_str += ' or {G}all{W}: ' @@ -263,7 +269,7 @@ def select_targets(self): elif choice.isdigit(): choice = int(choice) if choice > len(self.targets): - Color.pl(' {!} {O}Invalid target index (%d)... ignoring' % choice) + Color.pl(f' {{!}} {{O}}Invalid target index ({choice:d})... ignoring') continue chosen_targets.append(self.targets[choice - 1]) @@ -280,8 +286,8 @@ def select_targets(self): s.find_targets() targets = s.select_targets() except Exception as e: - Color.pl('\r {!} {R}Error{W}: %s' % str(e)) + Color.pl(f'\r {{!}} {{R}}Error{{W}}: {str(e)}') Configuration.exit_gracefully(0) for t in targets: - Color.pl(' {W}Selected: %s' % t) + Color.pl(f' {{W}}Selected: {t}') Configuration.exit_gracefully(0) diff --git a/wifite/util/timer.py b/wifite/util/timer.py index cf463d95b..aed804f45 100755 --- a/wifite/util/timer.py +++ b/wifite/util/timer.py @@ -4,7 +4,7 @@ import time -class Timer(object): +class Timer: def __init__(self, seconds): self.start_time = time.time() self.end_time = self.start_time + seconds @@ -26,15 +26,12 @@ def __str__(self): def secs_to_str(seconds): """Human-readable seconds. 193 -> 3m13s""" if seconds < 0: - return '-%ds' % seconds + return f'-{seconds:d}s' rem = int(seconds) hours = rem // 3600 mins = int((rem % 3600) / 60) secs = rem % 60 if hours > 0: - return '%dh%dm%ds' % (hours, mins, secs) - elif mins > 0: - return '%dm%ds' % (mins, secs) - else: - return '%ds' % secs + return f'{hours:d}h{mins:d}m{secs:d}s' + return f'{mins:d}m{secs:d}s' if mins > 0 else f'{secs:d}s'