Browse Source

Add ARP spoofing protection for LinuxBridge agent

This patch adds ARP spoofing protection for the Linux Bridge
agent based on ebtables. This code was written to be minimally
invasive with the intent of back-porting to Kilo.

The protection is enabled and disabled with the same
'prevent_arp_spoofing' agent config flag added for the OVS agent
in I7c079b779245a0af6bc793564fa8a560e4226afe.

The protection works by setting up an ebtables chain for each port
and jumping all ARP traffic to that chain. The port-specific chains
have a default DROP policy and then have allow rules installed that
only allow ARP traffic with a source CIDR that matches one of the
port's fixed IPs or an allowed address pair.

Since this is a back-port to Kilo, it is disabled by default just
like the protection added for OVS.

This patch additionally pulls back the required ebtables filter and
the functional test helpers to support the tests.

Conflicts:
	neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py
	neutron/plugins/linuxbridge/common/config.py
	neutron/tests/common/machine_fixtures.py

Closes-Bug: #1274034
Change-Id: I0b0e3b1272472385dff060897ecbd25e93fd78e7
(cherry picked from commit 04197bc4bb)
tags/2015.1.2
Kevin Benton 4 years ago
parent
commit
bf28c724dd
8 changed files with 412 additions and 2 deletions
  1. +11
    -0
      etc/neutron/rootwrap.d/ebtables.filters
  2. +128
    -0
      neutron/plugins/linuxbridge/agent/arp_protect.py
  3. +13
    -0
      neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py
  4. +16
    -0
      neutron/plugins/linuxbridge/common/config.py
  5. +3
    -2
      neutron/plugins/openvswitch/common/config.py
  6. +96
    -0
      neutron/tests/common/machine_fixtures.py
  7. +45
    -0
      neutron/tests/common/net_helpers.py
  8. +100
    -0
      neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py

+ 11
- 0
etc/neutron/rootwrap.d/ebtables.filters View File

@@ -0,0 +1,11 @@
# neutron-rootwrap command filters for nodes on which neutron is
# expected to control network
#
# This file should be owned by (and only-writeable by) the root user

# format seems to be
# cmd-name: filter-name, raw-command, user, args

[Filters]

ebtables: CommandFilter, ebtables, root

+ 128
- 0
neutron/plugins/linuxbridge/agent/arp_protect.py View File

@@ -0,0 +1,128 @@
# Copyright (c) 2015 Mirantis, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import netaddr
from oslo_concurrency import lockutils
from oslo_log import log as logging

from neutron.agent.linux import ip_lib
from neutron.i18n import _LI

LOG = logging.getLogger(__name__)
SPOOF_CHAIN_PREFIX = 'neutronARP-'


def setup_arp_spoofing_protection(vif, port_details):
current_rules = ebtables(['-L']).splitlines()
if not port_details.get('port_security_enabled', True):
# clear any previous entries related to this port
delete_arp_spoofing_protection([vif], current_rules)
LOG.info(_LI("Skipping ARP spoofing rules for port '%s' because "
"it has port security disabled"), vif)
return
# collect all of the addresses and cidrs that belong to the port
addresses = {f['ip_address'] for f in port_details['fixed_ips']}
if port_details.get('allowed_address_pairs'):
addresses |= {p['ip_address']
for p in port_details['allowed_address_pairs']}

addresses = {ip for ip in addresses
if netaddr.IPNetwork(ip).version == 4}
if any(netaddr.IPNetwork(ip).prefixlen == 0 for ip in addresses):
# don't try to install protection because a /0 prefix allows any
# address anyway and the ARP_SPA can only match on /1 or more.
return

install_arp_spoofing_protection(vif, addresses, current_rules)


def chain_name(vif):
# start each chain with a common identifer for cleanup to find
return '%s%s' % (SPOOF_CHAIN_PREFIX, vif)


@lockutils.synchronized('ebtables')
def delete_arp_spoofing_protection(vifs, current_rules=None):
if not current_rules:
current_rules = ebtables(['-L']).splitlines()
# delete the jump rule and then delete the whole chain
jumps = [vif for vif in vifs if vif_jump_present(vif, current_rules)]
for vif in jumps:
ebtables(['-D', 'FORWARD', '-i', vif, '-j',
chain_name(vif), '-p', 'ARP'])
for vif in vifs:
if chain_exists(chain_name(vif), current_rules):
ebtables(['-X', chain_name(vif)])


def delete_unreferenced_arp_protection(current_vifs):
# deletes all jump rules and chains that aren't in current_vifs but match
# the spoof prefix
output = ebtables(['-L']).splitlines()
to_delete = []
for line in output:
# we're looking to find and turn the following:
# Bridge chain: SPOOF_CHAIN_PREFIXtap199, entries: 0, policy: DROP
# into 'tap199'
if line.startswith('Bridge chain: %s' % SPOOF_CHAIN_PREFIX):
devname = line.split(SPOOF_CHAIN_PREFIX, 1)[1].split(',')[0]
if devname not in current_vifs:
to_delete.append(devname)
LOG.info(_LI("Clearing orphaned ARP spoofing entries for devices %s"),
to_delete)
delete_arp_spoofing_protection(to_delete, output)


@lockutils.synchronized('ebtables')
def install_arp_spoofing_protection(vif, addresses, current_rules):
# make a VIF-specific ARP chain so we don't conflict with other rules
vif_chain = chain_name(vif)
if not chain_exists(vif_chain, current_rules):
ebtables(['-N', vif_chain, '-P', 'DROP'])
# flush the chain to clear previous accepts. this will cause dropped ARP
# packets until the allows are installed, but that's better than leaked
# spoofed packets and ARP can handle losses.
ebtables(['-F', vif_chain])
for addr in addresses:
ebtables(['-A', vif_chain, '-p', 'ARP', '--arp-ip-src', addr,
'-j', 'ACCEPT'])
# check if jump rule already exists, if not, install it
if not vif_jump_present(vif, current_rules):
ebtables(['-A', 'FORWARD', '-i', vif, '-j',
vif_chain, '-p', 'ARP'])


def chain_exists(chain, current_rules):
for rule in current_rules:
if rule.startswith('Bridge chain: %s' % chain):
return True
return False


def vif_jump_present(vif, current_rules):
searches = (('-i %s' % vif), ('-j %s' % chain_name(vif)), ('-p ARP'))
for line in current_rules:
if all(s in line for s in searches):
return True
return False


# Used to scope ebtables commands in testing
NAMESPACE = None


def ebtables(comm):
execute = ip_lib.IPWrapper(NAMESPACE).netns.execute
return execute(['ebtables'] + comm, run_as_root=True)

+ 13
- 0
neutron/plugins/linuxbridge/agent/linuxbridge_neutron_agent.py View File

@@ -45,6 +45,7 @@ from neutron import context
from neutron.i18n import _LE, _LI, _LW
from neutron.openstack.common import loopingcall
from neutron.plugins.common import constants as p_const
from neutron.plugins.linuxbridge.agent import arp_protect
from neutron.plugins.linuxbridge.common import config # noqa
from neutron.plugins.linuxbridge.common import constants as lconst

@@ -753,6 +754,7 @@ class LinuxBridgeNeutronAgentRPC(object):

def __init__(self, interface_mappings, polling_interval):
self.polling_interval = polling_interval
self.prevent_arp_spoofing = cfg.CONF.AGENT.prevent_arp_spoofing
self.setup_linux_bridge(interface_mappings)
configurations = {'interface_mappings': interface_mappings}
if self.br_mgr.vxlan_mode != lconst.VXLAN_NONE:
@@ -870,6 +872,11 @@ class LinuxBridgeNeutronAgentRPC(object):
if 'port_id' in device_details:
LOG.info(_LI("Port %(device)s updated. Details: %(details)s"),
{'device': device, 'details': device_details})
if self.prevent_arp_spoofing:
port = self.br_mgr.get_tap_device_name(
device_details['port_id'])
arp_protect.setup_arp_spoofing_protection(port,
device_details)
if device_details['admin_state_up']:
# create the networking for the port
network_type = device_details.get('network_type')
@@ -923,6 +930,8 @@ class LinuxBridgeNeutronAgentRPC(object):
LOG.info(_LI("Port %s updated."), device)
else:
LOG.debug("Device %s not defined on plugin", device)
if self.prevent_arp_spoofing:
arp_protect.delete_arp_spoofing_protection(devices)
return resync

def scan_devices(self, previous, sync):
@@ -943,6 +952,10 @@ class LinuxBridgeNeutronAgentRPC(object):
'current': set(),
'updated': set(),
'removed': set()}
# clear any orphaned ARP spoofing rules (e.g. interface was
# manually deleted)
if self.prevent_arp_spoofing:
arp_protect.delete_unreferenced_arp_protection(current_devices)

if sync:
# This is the first iteration, or the previous one had a problem.

+ 16
- 0
neutron/plugins/linuxbridge/common/config.py View File

@@ -62,6 +62,22 @@ agent_opts = [
"polling for local device changes.")),
cfg.BoolOpt('rpc_support_old_agents', default=False,
help=_("Enable server RPC compatibility with old agents")),
# TODO(kevinbenton): The following opt is duplicated between the OVS agent
# and the Linuxbridge agent to make it easy to back-port. These shared opts
# should be moved into a common agent config options location as part of
# the deduplication work.
cfg.BoolOpt('prevent_arp_spoofing', default=False,
help=_("Enable suppression of ARP responses that don't match "
"an IP address that belongs to the port from which "
"they originate. Note: This prevents the VMs attached "
"to this agent from spoofing, it doesn't protect them "
"from other devices which have the capability to spoof "
"(e.g. bare metal or VMs attached to agents without "
"this flag set to True). Spoofing rules will not be "
"added to any ports that have port security disabled. "
"For LinuxBridge, this requires ebtables. For OVS, it "
"requires a version that supports matching ARP "
"headers."))
]



+ 3
- 2
neutron/plugins/openvswitch/common/config.py View File

@@ -83,8 +83,9 @@ agent_opts = [
"(e.g. bare metal or VMs attached to agents without "
"this flag set to True). Spoofing rules will not be "
"added to any ports that have port security disabled. "
"This requires a version of OVS that supports matching "
"ARP headers.")),
"For LinuxBridge, this requires ebtables. For OVS, it "
"requires a version that supports matching ARP "
"headers.")),
cfg.BoolOpt('dont_fragment', default=True,
help=_("Set or un-set the don't fragment (DF) bit on "
"outgoing IP packet carrying GRE/VXLAN tunnel.")),

+ 96
- 0
neutron/tests/common/machine_fixtures.py View File

@@ -0,0 +1,96 @@
# Copyright (c) 2015 Thales Services SAS
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#

import fixtures

from neutron.agent.linux import ip_lib
from neutron.tests.common import net_helpers


class FakeMachine(fixtures.Fixture):
"""Create a fake machine.

:ivar bridge: bridge on which the fake machine is bound
:ivar ip_cidr: fake machine ip_cidr
:type ip_cidr: str
:ivar ip: fake machine ip
:type ip: str
:ivar gateway_ip: fake machine gateway ip
:type gateway_ip: str

:ivar namespace: namespace emulating the machine
:type namespace: str
:ivar port: port binding the namespace to the bridge
:type port: IPDevice
"""

def __init__(self, bridge, ip_cidr, gateway_ip=None):
super(FakeMachine, self).__init__()
self.bridge = bridge
self.ip_cidr = ip_cidr
self.ip = self.ip_cidr.partition('/')[0]
self.gateway_ip = gateway_ip

def setUp(self):
super(FakeMachine, self).setUp()
ns_fixture = self.useFixture(
net_helpers.NamespaceFixture())
self.namespace = ns_fixture.name

self.port = self.useFixture(
net_helpers.PortFixture.get(self.bridge, self.namespace)).port
self.port.addr.add(self.ip_cidr)

if self.gateway_ip:
net_helpers.set_namespace_gateway(self.port, self.gateway_ip)

def execute(self, *args, **kwargs):
ns_ip_wrapper = ip_lib.IPWrapper(self.namespace)
return ns_ip_wrapper.netns.execute(*args, **kwargs)

def assert_ping(self, dst_ip):
net_helpers.assert_ping(self.namespace, dst_ip)

def assert_no_ping(self, dst_ip):
net_helpers.assert_no_ping(self.namespace, dst_ip)


class PeerMachines(fixtures.Fixture):
"""Create 'amount' peered machines on an ip_cidr.

:ivar bridge: bridge on which peer machines are bound
:ivar ip_cidr: ip_cidr on which peer machines have ips
:type ip_cidr: str
:ivar machines: fake machines
:type machines: FakeMachine list
"""

CIDR = '192.168.0.1/24'

def __init__(self, bridge, ip_cidr=None, gateway_ip=None, amount=2):
super(PeerMachines, self).__init__()
self.bridge = bridge
self.ip_cidr = ip_cidr or self.CIDR
self.gateway_ip = gateway_ip
self.amount = amount

def setUp(self):
super(PeerMachines, self).setUp()
self.machines = []
for index in range(self.amount):
ip_cidr = net_helpers.increment_ip_cidr(self.ip_cidr, index)
self.machines.append(
self.useFixture(
FakeMachine(self.bridge, ip_cidr, self.gateway_ip)))

+ 45
- 0
neutron/tests/common/net_helpers.py View File

@@ -62,6 +62,31 @@ def set_namespace_gateway(port_dev, gateway_ip):
port_dev.route.add_gateway(gateway_ip)


def assert_arping(src_namespace, dst_ip, source=None, timeout=1, count=1):
"""Send arp request using arping executable.

NOTE: ARP protocol is used in IPv4 only. IPv6 uses Neighbour Discovery
Protocol instead.
"""
ns_ip_wrapper = ip_lib.IPWrapper(src_namespace)
arping_cmd = ['arping', '-c', count, '-w', timeout]
if source:
arping_cmd.extend(['-s', source])
arping_cmd.append(dst_ip)
ns_ip_wrapper.netns.execute(arping_cmd)


def assert_no_arping(src_namespace, dst_ip, source=None, timeout=1, count=1):
try:
assert_arping(src_namespace, dst_ip, source, timeout, count)
except RuntimeError:
pass
else:
tools.fail("destination ip %(destination)s is replying to arp from "
"namespace %(ns)s, but it shouldn't" %
{'ns': src_namespace, 'destination': dst_ip})


class NamespaceFixture(fixtures.Fixture):
"""Create a namespace.

@@ -116,6 +141,15 @@ class VethFixture(fixtures.Fixture):
# when a namespace owning a veth endpoint is deleted.
pass

@staticmethod
def get_peer_name(name):
if name.startswith(VETH0_PREFIX):
return name.replace(VETH0_PREFIX, VETH1_PREFIX)
elif name.startswith(VETH1_PREFIX):
return name.replace(VETH1_PREFIX, VETH0_PREFIX)
else:
tools.fail('%s is not a valid VethFixture veth endpoint' % name)


@six.add_metaclass(abc.ABCMeta)
class PortFixture(fixtures.Fixture):
@@ -140,6 +174,17 @@ class PortFixture(fixtures.Fixture):
if not self.bridge:
self.bridge = self.useFixture(self._create_bridge_fixture()).bridge

@classmethod
def get(cls, bridge, namespace=None):
"""Deduce PortFixture class from bridge type and instantiate it."""
if isinstance(bridge, ovs_lib.OVSBridge):
return OVSPortFixture(bridge, namespace)
if isinstance(bridge, bridge_lib.BridgeDevice):
return LinuxBridgePortFixture(bridge, namespace)
if isinstance(bridge, VethBridge):
return VethPortFixture(bridge, namespace)
tools.fail('Unexpected bridge type: %s' % type(bridge))


class OVSBridgeFixture(fixtures.Fixture):
"""Create an OVS bridge.

+ 100
- 0
neutron/tests/functional/agent/linux/test_linuxbridge_arp_protect.py View File

@@ -0,0 +1,100 @@
# Copyright (c) 2015 Mirantis, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from oslo_config import cfg

from neutron.plugins.linuxbridge.agent import arp_protect
from neutron.tests.common import machine_fixtures
from neutron.tests.common import net_helpers
from neutron.tests.functional import base as functional_base

no_arping = net_helpers.assert_no_arping
arping = net_helpers.assert_arping


class LinuxBridgeARPSpoofTestCase(functional_base.BaseSudoTestCase):

def setUp(self):
super(LinuxBridgeARPSpoofTestCase, self).setUp()
cfg.CONF.set_override('prevent_arp_spoofing', True, 'AGENT')
lbfixture = self.useFixture(net_helpers.LinuxBridgeFixture())
self.addCleanup(setattr, arp_protect, 'NAMESPACE', None)
arp_protect.NAMESPACE = lbfixture.namespace
bridge = lbfixture.bridge
self.source, self.destination, self.observer = self.useFixture(
machine_fixtures.PeerMachines(bridge, amount=3)).machines

def _add_arp_protection(self, machine, addresses, extra_port_dict=None):
port_dict = {'fixed_ips': [{'ip_address': a} for a in addresses]}
if extra_port_dict:
port_dict.update(extra_port_dict)
name = net_helpers.VethFixture.get_peer_name(machine.port.name)
arp_protect.setup_arp_spoofing_protection(name, port_dict)
self.addCleanup(arp_protect.delete_arp_spoofing_protection,
[name])

def test_arp_no_protection(self):
arping(self.source.namespace, self.destination.ip)
arping(self.destination.namespace, self.source.ip)

def test_arp_correct_protection(self):
self._add_arp_protection(self.source, [self.source.ip])
self._add_arp_protection(self.destination, [self.destination.ip])
arping(self.source.namespace, self.destination.ip)
arping(self.destination.namespace, self.source.ip)

def test_arp_fails_incorrect_protection(self):
self._add_arp_protection(self.source, ['1.1.1.1'])
self._add_arp_protection(self.destination, ['2.2.2.2'])
no_arping(self.source.namespace, self.destination.ip)
no_arping(self.destination.namespace, self.source.ip)

def test_arp_protection_removal(self):
self._add_arp_protection(self.source, ['1.1.1.1'])
self._add_arp_protection(self.destination, ['2.2.2.2'])
no_arping(self.observer.namespace, self.destination.ip)
no_arping(self.observer.namespace, self.source.ip)
name = net_helpers.VethFixture.get_peer_name(self.source.port.name)
arp_protect.delete_arp_spoofing_protection([name])
# spoofing should have been removed from source, but not dest
arping(self.observer.namespace, self.source.ip)
no_arping(self.observer.namespace, self.destination.ip)

def test_arp_protection_update(self):
self._add_arp_protection(self.source, ['1.1.1.1'])
self._add_arp_protection(self.destination, ['2.2.2.2'])
no_arping(self.observer.namespace, self.destination.ip)
no_arping(self.observer.namespace, self.source.ip)
self._add_arp_protection(self.source, ['192.0.0.0/1'])
# spoofing should have been updated on source, but not dest
arping(self.observer.namespace, self.source.ip)
no_arping(self.observer.namespace, self.destination.ip)

def test_arp_protection_port_security_disabled(self):
self._add_arp_protection(self.source, ['1.1.1.1'])
no_arping(self.observer.namespace, self.source.ip)
self._add_arp_protection(self.source, ['1.1.1.1'],
{'port_security_enabled': False})
arping(self.observer.namespace, self.source.ip)

def test_arp_protection_dead_reference_removal(self):
self._add_arp_protection(self.source, ['1.1.1.1'])
self._add_arp_protection(self.destination, ['2.2.2.2'])
no_arping(self.observer.namespace, self.destination.ip)
no_arping(self.observer.namespace, self.source.ip)
name = net_helpers.VethFixture.get_peer_name(self.source.port.name)
# This should remove all arp protect rules that aren't source port
arp_protect.delete_unreferenced_arp_protection([name])
no_arping(self.observer.namespace, self.source.ip)
arping(self.observer.namespace, self.destination.ip)

Loading…
Cancel
Save