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:
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.
What happens
Two CAN frames addressed to an
ISOTPSoftSocketcan make Scapy's timeout scheduler executereceive callbacks without ever returning to its wait operation. The scheduler loop continued for
20 seconds on commit
1f870205baae8baf1718bc20700d0d9ffc0e4324withconf.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.
ISOTPSoftSocketimplements that receivestate machine over a CAN socket at
scapy/contrib/isotp/isotp_soft_socket.py:489-522.The implementation runs a shared background
TimeoutSchedulerfor receive polling, transmit work,and ISO-TP recovery timeouts. Applications use the socket with
sniff(opened_socket=...),recv,or
sr1when a native kernel ISO-TP socket is unavailable or unsuitable.How the code is reached
An operator imports
scapy.contrib.isotpand opens anISOTPSoftSocketwith normal transmit andreceive CAN identifiers. A malicious ECU or any participant able to send to that receive identifier
first sends:
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 anintermediate frame, and then stops. Both frames fit the CAN wire format.
The proof uses
listen_only=Trueso it does not emit a Flow Control response, but the receive andtimeout 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.
TimeoutScheduleris shared by the software ISO-TP sockets in the process, so onepoisoned 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.
This representative output was observed on the vulnerable revision:
The second run changes only the frame-type byte from Consecutive Frame
0x21to a valid one-byteSingle Frame
0x01. State zero is idle; state three isISOTP_WAIT_DATA. Callback counts vary bymachine, but the vulnerable count continued rising by tens of thousands after the queue was empty.
Scapy
2.7.1rc1.post100, Python3.12.13, defaultconf,conf.debug_dissector = 0.Where it goes wrong
The First Frame sets
ISOTP_WAIT_DATAand schedules recovery atscapy/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_cfatscapy/contrib/isotp/isotp_soft_socket.py:1029-1046cancels that timer before it checks the frame length:
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 appendingdata, changing
rx_state, or replacing the cancelled timeout.can_recvatscapy/contrib/isotp/isotp_soft_socket.py:672-685maps
ISOTP_WAIT_DATAtopoll_time = 0.0and schedules itself again. The scheduler's loop atscapy/contrib/isotp/isotp_soft_socket.py:403-426keeps 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:Validate the Consecutive Frame's length while the recovery timeout is still active:
Accepted frames still cancel and replace the timeout as before. Rejected frames leave the existing
timeout responsible for returning
ISOTP_WAIT_DATAto idle, so the scheduler reaches its normalwait 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
ISOTPSoftSockettests also passed, and the packet proof completed after about 10.14seconds 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
1f870205baae8baf1718bc20700d0d9ffc0e4324, version2.7.1rc1.post100CVSS: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.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.