Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 160 additions & 47 deletions python_scripts/mac_address_spoof_automator.py
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")

Copy link
Copy Markdown
Owner Author

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 -x being interpreted as an option by ip; the 15-character cap matches IFNAMSIZ-1. Should we also verify the interface actually exists (e.g. against /sys/class/net) before attempting the change, or is that better left to ip itself?


# 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")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

End-anchored with \Z and used via fullmatch(), so trailing junk (aa:bb:cc:dd:ee:ff:zz, aa:bb:cc:dd:ee:ff00) is now rejected. Previously re.match() accepted it and passed the value straight to ip link set.


# 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 00:16:3e (Xen) made spoofed hosts impersonate a real vendor prefix. random is kept over secrets on purpose — a MAC address is broadcast on the wire, so unpredictability is not a security requirement here.

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()))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fullmatch() + \Z makes the anchoring explicit and readable; the previous inline re.match(...) call both failed to anchor and recompiled the pattern on every invocation.



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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key fix. Bringing the interface back up now happens in a finally, so a failure in the address step (bad MAC, lost privileges, driver rejection) can no longer leave the host offline. The inner try/except deliberately catches CalledProcessError only — if up itself fails we surface a warning rather than masking the original error. Is catching only CalledProcessError the right boundary here, or should a missing ip binary (FileNotFoundError) also be handled at this level?

# 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:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root and ip-on-PATH checks now run before any state change, so a non-root invocation fails cleanly instead of half-way through. --dry-run intentionally bypasses these so the commands can be inspected without privileges.

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())
170 changes: 170 additions & 0 deletions tests/test_mac_address_spoof_automator.py
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()
Loading