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.
| Package | Affected versions | Patched versions |
|---|---|---|
neutronPyPI | < 16.4.1 | 16.4.1 |
neutronPyPI | >= 17.0.0, < 17.1.3 | 17.1.3 |
Affected products
2- OpenStack/Neutrondescription
Patches
2cc0d28a3e2ccMerge "Make ARP protection commands compatible with "ebtables-nft""
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])
0a931391d899Make ARP protection commands compatible with "ebtables-nft"
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- github.com/advisories/GHSA-hvm4-mc7m-22w4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-38598ghsaADVISORY
- github.com/openstack/neutron/commit/0a931391d8990f3e654b4bfda24ae4119c609bbfghsaWEB
- github.com/openstack/neutron/commit/cc0d28a3e2ccfad6fc2ff24d78f009cbe3992575ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/neutron/PYSEC-2021-360.yamlghsaWEB
- launchpad.net/bugs/1938670ghsax_refsource_MISCWEB
- opendev.org/openstack/neutron/commit/fafa5dacd5057120562184a734e7345e7c0e9639ghsaWEB
News mentions
0No linked articles in our index yet.