VYPR
High severityNVD Advisory· Published Aug 23, 2021· Updated Aug 4, 2024

CVE-2021-38598

CVE-2021-38598

Description

OpenStack Neutron before 16.4.1, 17.x before 17.1.3, and 18.0.0 allows hardware address impersonation when the linuxbridge driver with ebtables-nft is used on a Netfilter-based platform. By sending carefully crafted packets, anyone in control of a server instance connected to the virtual switch can impersonate the hardware addresses of other systems on the network, resulting in denial of service or in some cases possibly interception of traffic intended for other destinations.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

OpenStack Neutron before 16.4.1, 17.1.3, and 18.0.0 allows hardware address impersonation via crafted packets when using linuxbridge with ebtables-nft.

Vulnerability

OpenStack Neutron's linuxbridge agent uses ebtables rules to prevent ARP spoofing. On platforms using ebtables-nft (e.g., Ubuntu Focal, CentOS Stream), the ebtables -F command resets a chain's default policy from DROP to RETURN, effectively bypassing the anti-spoofing chain. This affects Neutron versions before 16.4.1, 17.x before 17.1.3, and 18.0.0 [1][4].

Exploitation

An attacker with control of a server instance connected to the virtual switch can send carefully crafted packets, such as gratuitous ARP replies, to impersonate the hardware addresses of other systems on the network. No additional authentication is required beyond access to the instance. The attacker can spoof MAC addresses to cause denial of service or potentially intercept traffic [1][4].

Impact

Successful exploitation allows hardware address impersonation, leading to denial of service (e.g., disrupting network connectivity for other instances) or, in some cases, interception of traffic intended for other destinations. The attacker can redirect traffic by spoofing ARP responses [1].

Mitigation

The vulnerability is fixed in Neutron 16.4.1, 17.1.3, and 18.0.0. The fix modifies the ebtables commands to avoid the flush that resets the chain policy [2][3]. Users should upgrade to these versions. No workaround is documented; if upgrade is not possible, consider using a different driver or platform that does not rely on ebtables-nft [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
neutronPyPI
< 16.4.116.4.1
neutronPyPI
>= 17.0.0, < 17.1.317.1.3

Affected products

2

Patches

2
cc0d28a3e2cc

Merge "Make ARP protection commands compatible with "ebtables-nft""

https://github.com/openstack/neutronZuulApr 15, 2021via ghsa
2 files changed · +36 69
  • neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py+24 30 modified
    @@ -73,12 +73,6 @@ def delete_arp_spoofing_protection(vifs):
         _delete_arp_spoofing_protection(vifs, current_rules, table='nat',
                                         chain='PREROUTING')
     
    -    # TODO(haleyb) this can go away in "R" cycle, it's here to cleanup
    -    # old chains in the filter table
    -    current_rules = ebtables(['-L'], table='filter').splitlines()
    -    _delete_arp_spoofing_protection(vifs, current_rules, table='filter',
    -                                    chain='FORWARD')
    -
     
     def _delete_arp_spoofing_protection(vifs, current_rules, table, chain):
         # delete the jump rule and then delete the whole chain
    @@ -92,10 +86,11 @@ def _delete_arp_spoofing_protection(vifs, current_rules, table, chain):
                                         chain=chain)
     
     
    -def _delete_unreferenced_arp_protection(current_vifs, table, chain):
    +@lockutils.synchronized('ebtables')
    +def delete_unreferenced_arp_protection(current_vifs):
         # deletes all jump rules and chains that aren't in current_vifs but match
         # the spoof prefix
    -    current_rules = ebtables(['-L'], table=table).splitlines()
    +    current_rules = ebtables(['-L'], table='nat').splitlines()
         to_delete = []
         for line in current_rules:
             # we're looking to find and turn the following:
    @@ -107,19 +102,8 @@ def _delete_unreferenced_arp_protection(current_vifs, table, chain):
                     to_delete.append(devname)
         LOG.info("Clearing orphaned ARP spoofing entries for devices %s",
                  to_delete)
    -    _delete_arp_spoofing_protection(to_delete, current_rules, table=table,
    -                                    chain=chain)
    -
    -
    -@lockutils.synchronized('ebtables')
    -def delete_unreferenced_arp_protection(current_vifs):
    -    _delete_unreferenced_arp_protection(current_vifs,
    -                                        table='nat', chain='PREROUTING')
    -
    -    # TODO(haleyb) this can go away in "R" cycle, it's here to cleanup
    -    # old chains in the filter table
    -    _delete_unreferenced_arp_protection(current_vifs,
    -                                        table='filter', chain='FORWARD')
    +    _delete_arp_spoofing_protection(to_delete, current_rules, table='nat',
    +                                    chain='PREROUTING')
     
     
     @lockutils.synchronized('ebtables')
    @@ -133,12 +117,17 @@ def _install_arp_spoofing_protection(vif, addresses, current_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])
    +        # Append a default DROP rule at the end of the chain. This will
    +        # avoid "ebtables-nft" error when listing the chain.
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
    +    else:
    +        # 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])
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
         for addr in sorted(addresses):
    -        ebtables(['-A', vif_chain, '-p', 'ARP', '--arp-ip-src', addr,
    +        ebtables(['-I', 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):
    @@ -178,17 +167,22 @@ def _install_mac_spoofing_protection(vif, port_details, current_rules):
         # mac filter chain for each vif which has a default deny
         if not chain_exists(vif_chain, current_rules):
             ebtables(['-N', vif_chain, '-P', 'DROP'])
    +        # Append a default DROP rule at the end of the chain. This will
    +        # avoid "ebtables-nft" error when listing the chain.
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
    +
         # check if jump rule already exists, if not, install it
         if not _mac_vif_jump_present(vif, current_rules):
    -        ebtables(['-A', 'PREROUTING', '-i', vif, '-j', vif_chain])
    +        ebtables(['-I', 'PREROUTING', '-i', vif, '-j', vif_chain])
    +
    +    _delete_vif_mac_rules(vif, current_rules)
         # we can't just feed all allowed macs at once because we can exceed
         # the maximum argument size. limit to 500 per rule.
         for chunk in (mac_addresses[i:i + 500]
                       for i in range(0, len(mac_addresses), 500)):
    -        new_rule = ['-A', vif_chain, '-i', vif,
    +        new_rule = ['-I', vif_chain, '-i', vif,
                         '--among-src', ','.join(sorted(chunk)), '-j', 'RETURN']
             ebtables(new_rule)
    -    _delete_vif_mac_rules(vif, current_rules)
     
     
     def _mac_vif_jump_present(vif, current_rules):
    @@ -227,7 +221,7 @@ def _delete_mac_spoofing_protection(vifs, current_rules, table, chain):
     
     @tenacity.retry(
         wait=tenacity.wait_exponential(multiplier=0.02),
    -    retry=tenacity.retry_if_exception(lambda e: e.returncode == 255),
    +    retry=tenacity.retry_if_exception(lambda e: e.returncode in [255, 4]),
         reraise=True
     )
     def ebtables(comm, table='nat'):
    
  • neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py+12 39 modified
    @@ -75,35 +75,40 @@ def _test_port_add_arp_spoofing(self, vif, port):
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.ANY,
                 mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                       'neutronMAC-%s' % vif, '-j', 'DROP'],
    +                      check_exit_code=True, extra_ok_codes=None,
    +                      log_fail_as_error=True, run_as_root=True,
    +                      privsep_exec=True),
    +            mock.ANY,
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                            'PREROUTING', '-i', vif, '-j', mac_chain],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +            mock.ANY,
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                            mac_chain, '-i', vif,
                            '--among-src', '%s' % ','.join(sorted(mac_addresses)),
                            '-j', 'RETURN'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
                 mock.ANY,
    -            mock.ANY,
                 mock.call(['ebtables', '-t', 'nat', '--concurrent', '-N',
                            spoof_chain, '-P', 'DROP'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-F',
    -                       spoof_chain],
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                       spoof_chain, '-j', 'DROP'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    +                      privsep_exec=True)
             ]
             for addr in sorted(ip_addresses):
                 expected.extend([
    -                mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                                spoof_chain, '-p', 'ARP',
                                '--arp-ip-src', addr, '-j', 'ACCEPT'],
                               check_exit_code=True, extra_ok_codes=None,
    @@ -167,38 +172,6 @@ def test_port_delete_arp_spoofing(self, ce, vjp):
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-L'],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.ANY,
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-D',
    -                       'FORWARD', '-i', VIF, '-j', spoof_chain,
    -                       '-p', 'ARP'],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-F',
    -                       spoof_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-X',
    -                       spoof_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.ANY,
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-F',
    -                       mac_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-X',
    -                       mac_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
             ]
     
             arp_protect.delete_arp_spoofing_protection([VIF])
    
0a931391d899

Make ARP protection commands compatible with "ebtables-nft"

https://github.com/openstack/neutronRodolfo Alonso HernandezApr 7, 2021via ghsa
2 files changed · +36 69
  • neutron/plugins/ml2/drivers/linuxbridge/agent/arp_protect.py+24 30 modified
    @@ -73,12 +73,6 @@ def delete_arp_spoofing_protection(vifs):
         _delete_arp_spoofing_protection(vifs, current_rules, table='nat',
                                         chain='PREROUTING')
     
    -    # TODO(haleyb) this can go away in "R" cycle, it's here to cleanup
    -    # old chains in the filter table
    -    current_rules = ebtables(['-L'], table='filter').splitlines()
    -    _delete_arp_spoofing_protection(vifs, current_rules, table='filter',
    -                                    chain='FORWARD')
    -
     
     def _delete_arp_spoofing_protection(vifs, current_rules, table, chain):
         # delete the jump rule and then delete the whole chain
    @@ -92,10 +86,11 @@ def _delete_arp_spoofing_protection(vifs, current_rules, table, chain):
                                         chain=chain)
     
     
    -def _delete_unreferenced_arp_protection(current_vifs, table, chain):
    +@lockutils.synchronized('ebtables')
    +def delete_unreferenced_arp_protection(current_vifs):
         # deletes all jump rules and chains that aren't in current_vifs but match
         # the spoof prefix
    -    current_rules = ebtables(['-L'], table=table).splitlines()
    +    current_rules = ebtables(['-L'], table='nat').splitlines()
         to_delete = []
         for line in current_rules:
             # we're looking to find and turn the following:
    @@ -107,19 +102,8 @@ def _delete_unreferenced_arp_protection(current_vifs, table, chain):
                     to_delete.append(devname)
         LOG.info("Clearing orphaned ARP spoofing entries for devices %s",
                  to_delete)
    -    _delete_arp_spoofing_protection(to_delete, current_rules, table=table,
    -                                    chain=chain)
    -
    -
    -@lockutils.synchronized('ebtables')
    -def delete_unreferenced_arp_protection(current_vifs):
    -    _delete_unreferenced_arp_protection(current_vifs,
    -                                        table='nat', chain='PREROUTING')
    -
    -    # TODO(haleyb) this can go away in "R" cycle, it's here to cleanup
    -    # old chains in the filter table
    -    _delete_unreferenced_arp_protection(current_vifs,
    -                                        table='filter', chain='FORWARD')
    +    _delete_arp_spoofing_protection(to_delete, current_rules, table='nat',
    +                                    chain='PREROUTING')
     
     
     @lockutils.synchronized('ebtables')
    @@ -133,12 +117,17 @@ def _install_arp_spoofing_protection(vif, addresses, current_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])
    +        # Append a default DROP rule at the end of the chain. This will
    +        # avoid "ebtables-nft" error when listing the chain.
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
    +    else:
    +        # 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])
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
         for addr in sorted(addresses):
    -        ebtables(['-A', vif_chain, '-p', 'ARP', '--arp-ip-src', addr,
    +        ebtables(['-I', 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):
    @@ -178,17 +167,22 @@ def _install_mac_spoofing_protection(vif, port_details, current_rules):
         # mac filter chain for each vif which has a default deny
         if not chain_exists(vif_chain, current_rules):
             ebtables(['-N', vif_chain, '-P', 'DROP'])
    +        # Append a default DROP rule at the end of the chain. This will
    +        # avoid "ebtables-nft" error when listing the chain.
    +        ebtables(['-A', vif_chain, '-j', 'DROP'])
    +
         # check if jump rule already exists, if not, install it
         if not _mac_vif_jump_present(vif, current_rules):
    -        ebtables(['-A', 'PREROUTING', '-i', vif, '-j', vif_chain])
    +        ebtables(['-I', 'PREROUTING', '-i', vif, '-j', vif_chain])
    +
    +    _delete_vif_mac_rules(vif, current_rules)
         # we can't just feed all allowed macs at once because we can exceed
         # the maximum argument size. limit to 500 per rule.
         for chunk in (mac_addresses[i:i + 500]
                       for i in range(0, len(mac_addresses), 500)):
    -        new_rule = ['-A', vif_chain, '-i', vif,
    +        new_rule = ['-I', vif_chain, '-i', vif,
                         '--among-src', ','.join(sorted(chunk)), '-j', 'RETURN']
             ebtables(new_rule)
    -    _delete_vif_mac_rules(vif, current_rules)
     
     
     def _mac_vif_jump_present(vif, current_rules):
    @@ -227,7 +221,7 @@ def _delete_mac_spoofing_protection(vifs, current_rules, table, chain):
     
     @tenacity.retry(
         wait=tenacity.wait_exponential(multiplier=0.02),
    -    retry=tenacity.retry_if_exception(lambda e: e.returncode == 255),
    +    retry=tenacity.retry_if_exception(lambda e: e.returncode in [255, 4]),
         reraise=True
     )
     def ebtables(comm, table='nat'):
    
  • neutron/tests/unit/plugins/ml2/drivers/linuxbridge/agent/test_arp_protect.py+12 39 modified
    @@ -75,35 +75,40 @@ def _test_port_add_arp_spoofing(self, vif, port):
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.ANY,
                 mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                       'neutronMAC-%s' % vif, '-j', 'DROP'],
    +                      check_exit_code=True, extra_ok_codes=None,
    +                      log_fail_as_error=True, run_as_root=True,
    +                      privsep_exec=True),
    +            mock.ANY,
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                            'PREROUTING', '-i', vif, '-j', mac_chain],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +            mock.ANY,
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                            mac_chain, '-i', vif,
                            '--among-src', '%s' % ','.join(sorted(mac_addresses)),
                            '-j', 'RETURN'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
                 mock.ANY,
    -            mock.ANY,
                 mock.call(['ebtables', '-t', 'nat', '--concurrent', '-N',
                            spoof_chain, '-P', 'DROP'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-F',
    -                       spoof_chain],
    +            mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                       spoof_chain, '-j', 'DROP'],
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    +                      privsep_exec=True)
             ]
             for addr in sorted(ip_addresses):
                 expected.extend([
    -                mock.call(['ebtables', '-t', 'nat', '--concurrent', '-A',
    +                mock.call(['ebtables', '-t', 'nat', '--concurrent', '-I',
                                spoof_chain, '-p', 'ARP',
                                '--arp-ip-src', addr, '-j', 'ACCEPT'],
                               check_exit_code=True, extra_ok_codes=None,
    @@ -167,38 +172,6 @@ def test_port_delete_arp_spoofing(self, ce, vjp):
                           check_exit_code=True, extra_ok_codes=None,
                           log_fail_as_error=True, run_as_root=True,
                           privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-L'],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.ANY,
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-D',
    -                       'FORWARD', '-i', VIF, '-j', spoof_chain,
    -                       '-p', 'ARP'],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-F',
    -                       spoof_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-X',
    -                       spoof_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.ANY,
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-F',
    -                       mac_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
    -            mock.call(['ebtables', '-t', 'filter', '--concurrent', '-X',
    -                       mac_chain],
    -                      check_exit_code=True, extra_ok_codes=None,
    -                      log_fail_as_error=True, run_as_root=True,
    -                      privsep_exec=True),
             ]
     
             arp_protect.delete_arp_spoofing_protection([VIF])
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.