UDM - Firewall Ubiquity UDM - Geräte sperren Client-Quarantäne auf dem UniFi Gateway Die Client-Quarantäne dient dazu, einen verdächtigen oder kompromittierten Netzwerk-Client im Ernstfall innerhalb weniger Sekunden über SSH zu sperren. Der Client wird anhand seiner MAC-Adresse identifiziert, sodass ein Wechsel der IPv4-Adresse die Sperre nicht umgeht. Der Hauptgrund für diese Lösung ist, dass unsere Client-Netzwerke teilweise Zugriff auf viele andere Netzwerkzonen besitzen. Im Sicherheitsvorfall wäre es zu langsam und fehleranfällig, für jede einzelne Zone manuell eine Firewallregel anzulegen. Stattdessen wird die Quarantäne direkt vor den regulären UniFi-Firewallregeln eingehängt. Dabei werden IPv4, IPv6 und über das Gateway laufender Netzwerkverkehr des betroffenen Clients blockiert. Bestehende Verbindungen werden zusätzlich aus Conntrack entfernt. Wichtig: Verkehr zwischen zwei Geräten innerhalb desselben VLANs kann unter Umständen direkt über einen Switch oder Access Point laufen und das Gateway gar nicht erreichen. Bei einem besonders kritischen Sicherheitsvorfall sollte daher zusätzlich der Switch-Port deaktiviert oder der WLAN-Client isoliert werden. Hinweis: Die hier verwendeten Regeln werden direkt per SSH verwaltet und liegen außerhalb der normalen UniFi-Konfiguration. Nach Firmware-Updates, Neustarts oder einer Neu-Provisionierung sollte der Status der Quarantäne geprüft und gegebenenfalls mit quarantine-client apply erneut angewendet werden. Installation Installation Die Installation erfolgt als root direkt per SSH auf dem UniFi Gateway. Das Skript und seine Konfiguration werden unter /persistent/client-quarantine gespeichert. Der folgende Block installiert das komplette Quarantäne-Werkzeug: install -d -m 700 -o root -g root /persistent/client-quarantine cat > /persistent/client-quarantine/quarantine-client <<'QUARANTINE_SCRIPT' #!/bin/bash set -Eeuo pipefail PATH='/usr/sbin:/usr/bin:/sbin:/bin' umask 077 readonly BASE_DIR='/persistent/client-quarantine' readonly STATE_FILE="${BASE_DIR}/blocked-macs" readonly LOCK_FILE="${BASE_DIR}/lock" readonly IPT4_CHAIN='QRTN_MAC_V4' readonly IPT6_CHAIN='QRTN_MAC_V6' readonly EBT_BR_CHAIN='QRTN_MAC_BR' readonly EBT_IN_CHAIN='QRTN_MAC_IN' readonly EBT_FWD_CHAIN='QRTN_MAC_FWD' readonly EBT_OUT_CHAIN='QRTN_MAC_OUT' die() { printf 'Fehler: %s\n' "$*" >&2 exit 1 } log_message() { logger -t client-quarantine -- "$*" 2>/dev/null || true printf '%s\n' "$*" } require_root() { [ "${EUID}" -eq 0 ] || die 'Dieses Werkzeug muss als root ausgeführt werden.' } require_commands() { local command_name for command_name in \ awk sort mktemp install ip \ iptables ip6tables ebtables \ conntrack logger flock do command -v "${command_name}" >/dev/null 2>&1 || die "Benötigter Befehl fehlt: ${command_name}" done } prepare_storage() { install -d -m 700 -o root -g root "${BASE_DIR}" touch "${STATE_FILE}" "${LOCK_FILE}" chown root:root "${STATE_FILE}" "${LOCK_FILE}" chmod 600 "${STATE_FILE}" "${LOCK_FILE}" } normalize_mac() { local mac="${1,,}" [[ "${mac}" =~ ^([0-9a-f]{2}:){5}[0-9a-f]{2}$ ]] || die 'Ungültige MAC-Adresse. Erwartet wird aa:bb:cc:dd:ee:ff.' [ "${mac}" != '00:00:00:00:00:00' ] || die 'Die Null-MAC ist nicht zulässig.' [ "${mac}" != 'ff:ff:ff:ff:ff:ff' ] || die 'Die Broadcast-MAC ist nicht zulässig.' local first_octet="${mac%%:*}" (( (16#${first_octet} & 1) == 0 )) || die 'Multicast-MAC-Adressen sind nicht zulässig.' printf '%s\n' "${mac}" } reject_gateway_mac() { local target="$1" local address_file local local_mac for address_file in /sys/class/net/*/address; do [ -r "${address_file}" ] || continue IFS= read -r local_mac < "${address_file}" || true local_mac="${local_mac,,}" [ "${target}" != "${local_mac}" ] || die "Abbruch: ${target} gehört zu einer Gateway-Schnittstelle." done } get_ssh_peer_mac() { [ -n "${SSH_CONNECTION:-}" ] || return 0 local peer_ip="${SSH_CONNECTION%% *}" ip neigh show to "${peer_ip}" 2>/dev/null | awk ' { for (i = 1; i <= NF; i++) { if ($i == "lladdr") { print tolower($(i + 1)) exit } } } ' } reject_current_ssh_peer() { local target="$1" local peer_mac peer_mac="$(get_ssh_peer_mac || true)" if [ -n "${peer_mac}" ] && [ "${target}" = "${peer_mac}" ]; then die 'Diese MAC gehört zum aktuellen SSH-Gegenüber. Verwende add --force nur bei bewusstem Verbindungsabbruch.' fi } read_macs() { awk ' /^[[:space:]]*$/ { next } { print tolower($1) } ' "${STATE_FILE}" | sort -u } add_to_state() { local mac="$1" local temporary_file temporary_file="$(mktemp "${BASE_DIR}/blocked-macs.XXXXXX")" { read_macs printf '%s\n' "${mac}" } | sort -u > "${temporary_file}" chown root:root "${temporary_file}" chmod 600 "${temporary_file}" mv -f "${temporary_file}" "${STATE_FILE}" } remove_from_state() { local mac="$1" local temporary_file temporary_file="$(mktemp "${BASE_DIR}/blocked-macs.XXXXXX")" awk -v remove_mac="${mac}" ' NF && tolower($1) != remove_mac { print tolower($1) } ' "${STATE_FILE}" | sort -u > "${temporary_file}" chown root:root "${temporary_file}" chmod 600 "${temporary_file}" mv -f "${temporary_file}" "${STATE_FILE}" } prepare_iptables_chain() { local binary="$1" local custom_chain="$2" "${binary}" -w 5 -t mangle -N "${custom_chain}" 2>/dev/null || true "${binary}" -w 5 -t mangle -F "${custom_chain}" while "${binary}" \ -w 5 \ -t mangle \ -C PREROUTING \ -j "${custom_chain}" \ 2>/dev/null do "${binary}" \ -w 5 \ -t mangle \ -D PREROUTING \ -j "${custom_chain}" done "${binary}" \ -w 5 \ -t mangle \ -I PREROUTING 1 \ -j "${custom_chain}" } prepare_ebtables_chain() { local table="$1" local builtin_chain="$2" local custom_chain="$3" ebtables \ -t "${table}" \ -N "${custom_chain}" \ 2>/dev/null || true ebtables \ -t "${table}" \ -F "${custom_chain}" while ebtables \ -t "${table}" \ -D "${builtin_chain}" \ -j "${custom_chain}" \ 2>/dev/null do : done ebtables \ -t "${table}" \ -I "${builtin_chain}" 1 \ -j "${custom_chain}" } apply_rules() { local mac prepare_iptables_chain iptables "${IPT4_CHAIN}" prepare_iptables_chain ip6tables "${IPT6_CHAIN}" prepare_ebtables_chain \ broute \ BROUTING \ "${EBT_BR_CHAIN}" prepare_ebtables_chain \ filter \ INPUT \ "${EBT_IN_CHAIN}" prepare_ebtables_chain \ filter \ FORWARD \ "${EBT_FWD_CHAIN}" prepare_ebtables_chain \ filter \ OUTPUT \ "${EBT_OUT_CHAIN}" while IFS= read -r mac; do [ -n "${mac}" ] || continue iptables \ -w 5 \ -t mangle \ -A "${IPT4_CHAIN}" \ -m mac \ --mac-source "${mac}" \ -j DROP ip6tables \ -w 5 \ -t mangle \ -A "${IPT6_CHAIN}" \ -m mac \ --mac-source "${mac}" \ -j DROP ebtables \ -t broute \ -A "${EBT_BR_CHAIN}" \ -s "${mac}" \ -j RETURN ebtables \ -t filter \ -A "${EBT_IN_CHAIN}" \ -s "${mac}" \ -j DROP ebtables \ -t filter \ -A "${EBT_FWD_CHAIN}" \ -s "${mac}" \ -j DROP ebtables \ -t filter \ -A "${EBT_FWD_CHAIN}" \ -d "${mac}" \ -j DROP ebtables \ -t filter \ -A "${EBT_OUT_CHAIN}" \ -d "${mac}" \ -j DROP done < <(read_macs) } known_addresses_for_mac() { local family="$1" local mac="$2" ip "-${family}" -o neigh show 2>/dev/null | awk -v mac="${mac}" ' { for (i = 1; i <= NF; i++) { if ($i == "lladdr" && tolower($(i + 1)) == mac) { print $1 break } } } ' | sort -u } clear_conntrack_for_mac() { local mac="$1" local address while IFS= read -r address; do [ -n "${address}" ] || continue conntrack \ -D \ -f ipv4 \ -s "${address}" \ >/dev/null 2>&1 || true conntrack \ -D \ -f ipv4 \ -d "${address}" \ >/dev/null 2>&1 || true done < <(known_addresses_for_mac 4 "${mac}") while IFS= read -r address; do [ -n "${address}" ] || continue conntrack \ -D \ -f ipv6 \ -s "${address}" \ >/dev/null 2>&1 || true conntrack \ -D \ -f ipv6 \ -d "${address}" \ >/dev/null 2>&1 || true done < <(known_addresses_for_mac 6 "${mac}") } command_add() { local force=0 local mac if [ "${1:-}" = '--force' ]; then force=1 shift fi [ "$#" -eq 1 ] || die 'Verwendung: quarantine-client add [--force] MAC' mac="$(normalize_mac "$1")" reject_gateway_mac "${mac}" if [ "${force}" -eq 0 ]; then reject_current_ssh_peer "${mac}" fi add_to_state "${mac}" apply_rules clear_conntrack_for_mac "${mac}" log_message "Client ${mac} wurde quarantänisiert." } command_remove() { local mac [ "$#" -eq 1 ] || die 'Verwendung: quarantine-client remove MAC' mac="$(normalize_mac "$1")" remove_from_state "${mac}" apply_rules log_message "Quarantäne für ${mac} wurde aufgehoben." } command_apply() { [ "$#" -eq 0 ] || die 'Verwendung: quarantine-client apply' apply_rules log_message 'Gespeicherte Quarantäne-Regeln wurden angewendet.' } command_list() { [ "$#" -eq 0 ] || die 'Verwendung: quarantine-client list' if [ ! -s "${STATE_FILE}" ]; then printf 'Keine Clients in Quarantäne.\n' return fi read_macs } command_status() { [ "$#" -eq 0 ] || die 'Verwendung: quarantine-client status' printf 'Gespeicherte MAC-Adressen:\n' command_list printf '\nIPv4-Hook:\n' iptables -w 5 -t mangle -S PREROUTING | awk -v chain="${IPT4_CHAIN}" '$0 ~ chain' printf '\nIPv6-Hook:\n' ip6tables -w 5 -t mangle -S PREROUTING | awk -v chain="${IPT6_CHAIN}" '$0 ~ chain' printf '\nBridge-Hooks:\n' ebtables -t broute -L BROUTING 2>/dev/null | awk -v chain="${EBT_BR_CHAIN}" '$0 ~ chain' ebtables -t filter -L INPUT 2>/dev/null | awk -v chain="${EBT_IN_CHAIN}" '$0 ~ chain' ebtables -t filter -L FORWARD 2>/dev/null | awk -v chain="${EBT_FWD_CHAIN}" '$0 ~ chain' ebtables -t filter -L OUTPUT 2>/dev/null | awk -v chain="${EBT_OUT_CHAIN}" '$0 ~ chain' } command_self_test() { [ "$#" -eq 0 ] || die 'Verwendung: quarantine-client self-test' normalize_mac '02:00:00:00:00:01' >/dev/null printf 'Selbsttest erfolgreich. Es wurde kein Client gesperrt.\n' } show_usage() { cat <<'USAGE' Verwendung: quarantine-client add [--force] MAC quarantine-client remove MAC quarantine-client list quarantine-client apply quarantine-client status quarantine-client self-test USAGE } main() { require_root require_commands prepare_storage exec 9>"${LOCK_FILE}" flock -x 9 local action="${1:-}" if [ "$#" -gt 0 ]; then shift fi case "${action}" in add) command_add "$@" ;; remove) command_remove "$@" ;; apply) command_apply "$@" ;; list) command_list "$@" ;; status) command_status "$@" ;; self-test) command_self_test "$@" ;; help|-h|--help|'') show_usage ;; *) die "Unbekannte Aktion: ${action}" ;; esac } main "$@" QUARANTINE_SCRIPT chown root:root /persistent/client-quarantine/quarantine-client chmod 700 /persistent/client-quarantine/quarantine-client ln -sfn \ /persistent/client-quarantine/quarantine-client \ /usr/local/sbin/quarantine-client Syntax prüfen bash -n /persistent/client-quarantine/quarantine-client && echo "Syntax OK" Erwartete Ausgabe: Syntax OK Selbsttest durchführen quarantine-client self-test quarantine-client list Erwartete Ausgabe: Selbsttest erfolgreich. Es wurde kein Client gesperrt. Keine Clients in Quarantäne. Damit ist die Installation abgeschlossen. Der Selbsttest selbst legt keine Firewall-Sperre für einen Client an. Nutzung Nutzung MAC-Adresse anhand einer IP-Adresse ermitteln Wenn die aktuelle IP-Adresse des verdächtigen Clients bekannt ist, kann die dazugehörige MAC-Adresse über die Neighbor-Tabelle ermittelt werden: ip neigh show to 10.15.37.80 Beispielausgabe: 10.15.37.80 dev br40 lladdr bc:24:11:34:bb:e1 REACHABLE In diesem Beispiel lautet die MAC-Adresse des Clients: bc:24:11:34:bb:e1 Client sofort quarantänisieren quarantine-client add bc:24:11:34:bb:e1 Erwartete Ausgabe: Client bc:24:11:34:bb:e1 wurde quarantänisiert. Die MAC-Adresse wird dabei dauerhaft in der Quarantäne-Liste gespeichert. Die Firewallregeln werden unmittelbar neu aufgebaut und bekannte bestehende IPv4- und IPv6-Verbindungen des Clients werden aus Conntrack gelöscht. Schutz vor versehentlichem Aussperren Das Skript versucht zu erkennen, ob die angegebene MAC-Adresse zum Rechner gehört, über den die aktuelle SSH-Verbindung aufgebaut wurde. In diesem Fall wird die Sperre standardmäßig abgebrochen. Beispiel: Fehler: Diese MAC gehört zum aktuellen SSH-Gegenüber. Verwende add --force nur bei bewusstem Verbindungsabbruch. Soll der aktuell verwendete SSH-Client tatsächlich absichtlich gesperrt werden, kann die Schutzfunktion mit --force übergangen werden: quarantine-client add --force bc:24:11:34:bb:e1 Achtung: Ist dies tatsächlich der aktuelle Administrationsrechner, kann dadurch die SSH-Verbindung sofort abbrechen. Quarantänisierte Clients anzeigen quarantine-client list Beispielausgabe: bc:24:11:34:bb:e1 Wenn kein Client quarantänisiert ist: Keine Clients in Quarantäne. Status der Firewall-Hooks anzeigen quarantine-client status Der Befehl zeigt die gespeicherten MAC-Adressen sowie die aktuell installierten IPv4-, IPv6- und Bridge-Hooks an. Gespeicherte Quarantäne erneut anwenden Wenn UniFi die Firewall neu aufgebaut hat oder das Gateway neu gestartet wurde, können alle gespeicherten Quarantäne-Einträge erneut in die laufende Firewall übernommen werden: quarantine-client apply Erwartete Ausgabe: Gespeicherte Quarantäne-Regeln wurden angewendet. Quarantäne eines Clients aufheben quarantine-client remove bc:24:11:34:bb:e1 Erwartete Ausgabe: Quarantäne für bc:24:11:34:bb:e1 wurde aufgehoben. Danach kontrollieren quarantine-client list Wenn die MAC-Adresse nicht mehr in der Ausgabe vorhanden ist, wurde die Quarantäne aufgehoben. Kurzreferenz für den Ernstfall MAC zu einer bekannten IP ermitteln: ip neigh show to IP-ADRESSE Client sperren: quarantine-client add MAC-ADRESSE Gesperrte Clients anzeigen: quarantine-client list Client wieder freigeben: quarantine-client remove MAC-ADRESSE Quarantäne-System vollständig entfernen Vollständige Deinstallation Bei der Deinstallation sollten zuerst alle gespeicherten Clients aus der Quarantäne entfernt werden. Aktuelle Quarantäne-Liste anzeigen quarantine-client list Jede dort aufgeführte MAC-Adresse mit folgendem Befehl entfernen: quarantine-client remove bc:24:11:34:bb:e1 Danach sollte die Liste leer sein: Keine Clients in Quarantäne. Firewall-Hooks und Quarantäne-Ketten entfernen Mit folgendem Block werden die von diesem Werkzeug angelegten IPv4-, IPv6- und Bridge-Firewallketten vollständig aus der laufenden Firewall entfernt: for spec in \ "iptables QRTN_MAC_V4" \ "ip6tables QRTN_MAC_V6" do set -- $spec binary="$1" chain="$2" while "$binary" \ -w 5 \ -t mangle \ -C PREROUTING \ -j "$chain" \ 2>/dev/null do "$binary" \ -w 5 \ -t mangle \ -D PREROUTING \ -j "$chain" done "$binary" \ -w 5 \ -t mangle \ -F "$chain" \ 2>/dev/null || true "$binary" \ -w 5 \ -t mangle \ -X "$chain" \ 2>/dev/null || true done for spec in \ "broute BROUTING QRTN_MAC_BR" \ "filter INPUT QRTN_MAC_IN" \ "filter FORWARD QRTN_MAC_FWD" \ "filter OUTPUT QRTN_MAC_OUT" do set -- $spec table="$1" builtin_chain="$2" custom_chain="$3" while ebtables \ -t "$table" \ -D "$builtin_chain" \ -j "$custom_chain" \ 2>/dev/null do : done ebtables \ -t "$table" \ -F "$custom_chain" \ 2>/dev/null || true ebtables \ -t "$table" \ -X "$custom_chain" \ 2>/dev/null || true done Programmdateien entfernen rm -f /usr/local/sbin/quarantine-client rm -rf /persistent/client-quarantine Kontrolle Der folgende Befehl sollte anschließend keine Quarantäne-Ketten mehr anzeigen: { iptables -t mangle -S 2>/dev/null ip6tables -t mangle -S 2>/dev/null ebtables -t broute -L 2>/dev/null ebtables -t filter -L 2>/dev/null } | awk '/QRTN_MAC_/ { print }' Wenn keine Ausgabe erscheint, sind die Quarantäne-Firewallregeln vollständig entfernt. Zusätzlich sollte der Befehl nicht mehr vorhanden sein: command -v quarantine-client || echo "quarantine-client wurde entfernt." Erwartete Ausgabe: quarantine-client wurde entfernt. Wichtige Grenzen der Quarantäne Die Quarantäne greift auf dem UniFi Gateway und kann damit insbesondere Verkehr zur Firewall selbst, zwischen gerouteten Netzwerken und zum Internet unterbinden. Befinden sich zwei Geräte jedoch im selben VLAN und wird deren Verkehr direkt über einen externen Switch oder Access Point vermittelt, muss dieser Datenverkehr das Gateway möglicherweise überhaupt nicht passieren. Eine Gateway-Firewall kann solchen Verkehr folglich nicht zuverlässig stoppen. Bei einem bestätigten Angriff oder einer akuten Kompromittierung sollte deshalb zusätzlich eine physische beziehungsweise Layer-2-Isolation erfolgen, beispielsweise durch das Deaktivieren des betreffenden Switch-Ports oder das Trennen des WLAN-Clients. Notfall-Zusammenfassung 1. MAC-Adresse feststellen: ip neigh show to IP-ADRESSE 2. Client sofort sperren: quarantine-client add MAC-ADRESSE 3. Sperre kontrollieren: quarantine-client list quarantine-client status 4. Client wieder freigeben: quarantine-client remove MAC-ADRESSE