Skip to main content

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