diff --git a/python_scripts/mac_address_spoof_automator.py b/python_scripts/mac_address_spoof_automator.py index 66af7a6..6e6b528 100644 --- a/python_scripts/mac_address_spoof_automator.py +++ b/python_scripts/mac_address_spoof_automator.py @@ -1,68 +1,181 @@ #!/usr/bin/env python3 -""" -mac_address_spoof_automator.py +"""mac_address_spoof_automator.py + Day 12: System and Network Enumeration Description: - Automates the process of changing a network interface's MAC address on Linux systems. - It takes the interface down, rotates the MAC address, and brings it back up. + Automates changing a network interface's MAC address on Linux. The script + brings the named interface down, assigns the new address, and brings the + interface back up. Cybersecurity Principle: - MAC spoofing is used to maintain anonymity on a local network or to bypass - MAC-based filtering/access control lists (ACLs). It is a common technique - used in red-teaming to blend in with authorized devices. + MAC spoofing is used to maintain anonymity on a local network or to bypass + MAC-based filtering / access-control lists (ACLs). It is a common red-team + technique for blending in with authorized devices. + +Safety: + This is a state-changing tool. It requires root and momentarily interrupts + the named interface. Pass ``--dry-run`` to print the commands it would run + without touching the system. On any failure the interface is always brought + back up before the script exits, so a failed run never leaves the host + offline. + +Exit codes: + 0 - the MAC address was changed (or a dry run completed) + 1 - the change failed, or an argument was rejected + 2 - argument parsing failed (argparse default) """ -import subprocess -import sys +import argparse +import os import random import re +import shutil +import subprocess +import sys + +# Linux interface names are at most IFNAMSIZ-1 (15) characters and, in practice, +# consist of letters, digits and a few punctuation characters. The first +# character must be alphanumeric so a fat-fingered or hostile argument such as +# "-x" cannot be interpreted as an option by `ip`. +_INTERFACE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,14}\Z") + +# A MAC address is six colon-separated hex octets. The trailing \Z anchors the +# match so junk such as "aa:bb:cc:dd:ee:ff:zz" or "aa:bb:cc:dd:ee:ff00" is +# rejected rather than silently accepted by an unanchored re.match(). +_MAC_RE = re.compile(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}\Z") + +# Locally administered addresses set bit 1 of the first octet (0x02) and clear +# the multicast bit (0x01). A spoofed MAC should be locally administered so it +# cannot collide with a real, vendor-assigned address. +_LOCALLY_ADMINISTERED = 0x02 +_MULTICAST = 0x01 + def get_random_mac(): + """Return a random, valid, locally administered MAC address. + + The first octet has the locally-administered bit set and the multicast bit + cleared; the remaining five octets are random. + + Returns: + A lower-case MAC address such as ``"1a:2b:3c:4d:5e:6f"``. """ - Generates a random valid MAC address. - """ - mac = [0x00, 0x16, 0x3e, - random.randint(0x00, 0x7f), - random.randint(0x00, 0xff), - random.randint(0x00, 0xff)] - return ':'.join(map(lambda x: "%02x" % x, mac)) + first_octet = (random.randint(0x00, 0xFF) | _LOCALLY_ADMINISTERED) & ~_MULTICAST + octets = [first_octet] + [random.randint(0x00, 0xFF) for _ in range(5)] + return ":".join(f"{octet:02x}" for octet in octets) -def change_mac(interface, new_mac): + +def is_valid_mac(value): + """Return True when ``value`` is a syntactically valid MAC address. + + Args: + value: Candidate MAC address string. + + Returns: + True when the string is exactly six colon-separated hex octets. """ - Configures the network interface with a new MAC address. + return bool(_MAC_RE.fullmatch(value.lower())) + + +def is_valid_interface(interface): + """Return True when ``interface`` looks like a safe Linux interface name.""" + return bool(_INTERFACE_RE.fullmatch(interface)) + + +def change_mac(interface, new_mac, dry_run=False): + """Point ``interface`` at ``new_mac``, restoring it to the up state on failure. + + Args: + interface: Name of the network interface (for example ``eth0``). + new_mac: MAC address to assign. + dry_run: When True, print the commands without executing them. + + Returns: + True when the address was set, False on failure. """ + commands = [ + ["ip", "link", "set", interface, "down"], + ["ip", "link", "set", interface, "address", new_mac], + ["ip", "link", "set", interface, "up"], + ] + + if dry_run: + print(f"[*] Dry run: would switch {interface} to {new_mac}") + for command in commands: + print(f" {' '.join(command)}") + return True + print(f"[*] Switching {interface} to new MAC: {new_mac}") - + success = False try: - # 1. Bring interface down - subprocess.run(["ip", "link", "set", interface, "down"], check=True) - - # 2. Change MAC - subprocess.run(["ip", "link", "set", interface, "address", new_mac], check=True) - - # 3. Bring interface up - subprocess.run(["ip", "link", "set", interface, "up"], check=True) - + subprocess.run(commands[0], check=True) + subprocess.run(commands[1], check=True) + success = True + except subprocess.CalledProcessError as exc: + print(f"[-] Error: Failed to change MAC address. " + f"Ensure you have root privileges: {exc}") + finally: + # Never leave the interface down: whatever happened above, bring it back + # up. This runs on the success path as well. + try: + subprocess.run(commands[2], check=True) + except subprocess.CalledProcessError as exc: + print(f"[!] Warning: could not bring {interface} back up: {exc}") + + if success: print(f"[+] Successfully changed MAC on {interface}") - - except subprocess.CalledProcessError as e: - print(f"[-] Error: Failed to change MAC address. Ensure you have root privileges: {e}") + return success + + +def build_parser(): + """Build and return the command-line argument parser.""" + parser = argparse.ArgumentParser( + description="Change a network interface's MAC address on Linux.") + parser.add_argument( + "interface", + help="network interface to modify (for example eth0)") + parser.add_argument( + "new_mac", nargs="?", default=None, + help="MAC address to assign (default: a random, locally administered one)") + parser.add_argument( + "--dry-run", action="store_true", + help="print the commands that would run without changing anything") + return parser + + +def main(argv=None): + """Parse arguments, validate them and change the MAC address. + + Args: + argv: Argument list to parse (defaults to ``sys.argv[1:]``). + + Returns: + Process exit code: 0 on success, 1 on any failure. + """ + args = build_parser().parse_args(argv) + + interface = args.interface + new_mac = args.new_mac or get_random_mac() + + if not is_valid_interface(interface): + print(f"[-] Error: Invalid interface name: {interface!r}") + return 1 + + if not is_valid_mac(new_mac): + print(f"[-] Error: Invalid MAC address format: {new_mac!r}") + return 1 + + if not args.dry_run: + if os.geteuid() != 0: + print("[-] Error: Changing a MAC address requires root privileges.") + return 1 + if shutil.which("ip") is None: + print("[-] Error: the 'ip' command was not found in PATH.") + return 1 + + return 0 if change_mac(interface, new_mac, dry_run=args.dry_run) else 1 + if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python3 mac_address_spoof_automator.py [NEW_MAC]") - print("Example: python3 mac_address_spoof_automator.py eth0") - sys.exit(0) - - iface = sys.argv[1] - - # If no MAC provided, generate a random one - new_mac = sys.argv[2] if len(sys.argv) > 2 else get_random_mac() - - # Validate MAC format - if not re.match(r"[0-9a-f]{2}([:][0-9a-f]{2}){5}", new_mac.lower()): - print("[-] Error: Invalid MAC address format.") - sys.exit(1) - - change_mac(iface, new_mac) + sys.exit(main()) diff --git a/tests/test_mac_address_spoof_automator.py b/tests/test_mac_address_spoof_automator.py new file mode 100644 index 0000000..58041a3 --- /dev/null +++ b/tests/test_mac_address_spoof_automator.py @@ -0,0 +1,170 @@ +"""Tests for python_scripts/mac_address_spoof_automator.py. + +Focus areas: + * MAC generation - valid, locally administered, multicast bit clear + * input validation - MAC and interface names, including trailing junk + * state safety - the interface is always brought back up, even on failure + * the exit-code contract used by callers and CI +""" + +import pytest + +import mac_address_spoof_automator as mac + + +def _noop_run(*args, **kwargs): + """A subprocess.run stand-in that succeeds without running anything.""" + return None + + +# --- MAC generation --------------------------------------------------------- + +def test_get_random_mac_is_valid(): + assert mac.is_valid_mac(mac.get_random_mac()) + + +def test_get_random_mac_is_locally_administered_and_unicast(): + for _ in range(20): + first_octet = int(mac.get_random_mac().split(":")[0], 16) + assert first_octet & 0x02, "locally administered bit must be set" + assert not first_octet & 0x01, "multicast bit must be clear" + + +# --- Validation ------------------------------------------------------------- + +@pytest.mark.parametrize("value", [ + "aa:bb:cc:dd:ee:ff", + "AA:BB:CC:DD:EE:FF", + "00:00:00:00:00:00", +]) +def test_is_valid_mac_accepts_good_addresses(value): + assert mac.is_valid_mac(value) + + +@pytest.mark.parametrize("value", [ + "", + "aa:bb:cc:dd:ee", # too short + "aa:bb:cc:dd:ee:ff:00", # too long + "aa:bb:cc:dd:ee:ff:zz", # trailing junk after a full address + "aa:bb:cc:dd:ee:ff00", # trailing junk with no separator + "aabbccddeeff", # no separators + "gg:bb:cc:dd:ee:ff", # non-hex octet +]) +def test_is_valid_mac_rejects_bad_addresses(value): + assert not mac.is_valid_mac(value) + + +@pytest.mark.parametrize("value", ["eth0", "wlan0", "enp3s0", "br-abc123"]) +def test_is_valid_interface_accepts_common_names(value): + assert mac.is_valid_interface(value) + + +@pytest.mark.parametrize("value", [ + "", + "-x", # would be read as an option by `ip` + "eth 0", # whitespace + "a" * 16, # longer than IFNAMSIZ-1 + "eth0;rm -rf /", # shell metacharacters +]) +def test_is_valid_interface_rejects_unsafe_names(value): + assert not mac.is_valid_interface(value) + + +# --- change_mac ------------------------------------------------------------- + +def test_change_mac_dry_run_makes_no_calls(monkeypatch, capsys): + calls = [] + monkeypatch.setattr(mac.subprocess, "run", lambda *a, **k: calls.append(a)) + assert mac.change_mac("eth0", "aa:bb:cc:dd:ee:ff", dry_run=True) is True + assert calls == [] + assert "Dry run" in capsys.readouterr().out + + +def test_change_mac_runs_three_commands_on_success(monkeypatch): + seen = [] + monkeypatch.setattr(mac.subprocess, "run", lambda cmd, **k: seen.append(cmd)) + assert mac.change_mac("eth0", "aa:bb:cc:dd:ee:ff") is True + assert seen == [ + ["ip", "link", "set", "eth0", "down"], + ["ip", "link", "set", "eth0", "address", "aa:bb:cc:dd:ee:ff"], + ["ip", "link", "set", "eth0", "up"], + ] + + +def test_change_mac_restores_interface_up_after_failure(monkeypatch): + seen = [] + + def fake_run(cmd, check=True): + seen.append(cmd) + # Fail the address-assignment step, as the kernel would on a bad MAC. + if "address" in cmd: + raise mac.subprocess.CalledProcessError(1, cmd) + + monkeypatch.setattr(mac.subprocess, "run", fake_run) + assert mac.change_mac("eth0", "aa:bb:cc:dd:ee:ff") is False + # The interface must still be brought back up despite the failure. + assert ["ip", "link", "set", "eth0", "up"] == seen[-1] + + +def test_change_mac_reports_failure(monkeypatch, capsys): + def fake_run(cmd, check=True): + raise mac.subprocess.CalledProcessError(1, cmd) + + monkeypatch.setattr(mac.subprocess, "run", fake_run) + assert mac.change_mac("eth0", "aa:bb:cc:dd:ee:ff") is False + assert "Failed to change MAC" in capsys.readouterr().out + + +# --- main / CLI contract ---------------------------------------------------- + +def test_main_rejects_invalid_interface(capsys): + assert mac.main(["eth 0", "aa:bb:cc:dd:ee:ff"]) == 1 + assert "Invalid interface name" in capsys.readouterr().out + + +def test_main_rejects_invalid_mac(capsys): + assert mac.main(["eth0", "not-a-mac"]) == 1 + assert "Invalid MAC address format" in capsys.readouterr().out + + +def test_main_requires_root_for_real_run(monkeypatch, capsys): + monkeypatch.setattr(mac.os, "geteuid", lambda: 1000) + assert mac.main(["eth0", "aa:bb:cc:dd:ee:ff"]) == 1 + assert "root privileges" in capsys.readouterr().out + + +def test_main_dry_run_does_not_require_root(monkeypatch, capsys): + monkeypatch.setattr(mac.os, "geteuid", lambda: 1000) + assert mac.main(["eth0", "aa:bb:cc:dd:ee:ff", "--dry-run"]) == 0 + assert "Dry run" in capsys.readouterr().out + + +def test_main_success_path(monkeypatch): + monkeypatch.setattr(mac.os, "geteuid", lambda: 0) + monkeypatch.setattr(mac.shutil, "which", lambda name: "/sbin/ip") + monkeypatch.setattr(mac.subprocess, "run", _noop_run) + assert mac.main(["eth0", "aa:bb:cc:dd:ee:ff"]) == 0 + + +def test_main_generates_a_mac_when_omitted(monkeypatch): + monkeypatch.setattr(mac.os, "geteuid", lambda: 0) + monkeypatch.setattr(mac.shutil, "which", lambda name: "/sbin/ip") + seen = [] + monkeypatch.setattr(mac.subprocess, "run", lambda cmd, **k: seen.append(cmd)) + assert mac.main(["eth0"]) == 0 + assigned = seen[1][5] # ["ip", "link", "set", "eth0", "address", ] + assert mac.is_valid_mac(assigned) + + +def test_main_errors_when_ip_is_missing(monkeypatch, capsys): + monkeypatch.setattr(mac.os, "geteuid", lambda: 0) + monkeypatch.setattr(mac.shutil, "which", lambda name: None) + assert mac.main(["eth0", "aa:bb:cc:dd:ee:ff"]) == 1 + assert "'ip' command" in capsys.readouterr().out + + +def test_help_exits_zero(capsys): + with pytest.raises(SystemExit) as exc: + mac.main(["--help"]) + assert exc.value.code == 0 + assert "usage" in capsys.readouterr().out.lower()