Repository navigation
refactor(security): harden mac_address_spoof_automator input validation and failure handling #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. End-anchored with |
||
|
|
||
| # 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 | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Generator correctness fix: the locally-administered bit is set and the multicast bit cleared, and the whole address is randomised. The old fixed OUI |
||
| 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())) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
||
| 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: | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The key fix. Bringing the interface back |
||
| # 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: | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Root and |
||
| 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 <INTERFACE> [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()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", <mac>] | ||
| 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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New interface-name guard. Requiring an alphanumeric first character stops a value like
-xbeing interpreted as an option byip; the 15-character cap matchesIFNAMSIZ-1. Should we also verify the interface actually exists (e.g. against/sys/class/net) before attempting the change, or is that better left toipitself?