[BUG] - BufferedFile.read() re-slices the full buffer on every call, causing quadratic cost with small chunk sizes
Are you using paramiko as a client or server?
Not sure
What feature(s) aren't working right?
SSH
What version(s) of paramiko are you using?
5.0.0
What version(s) of Python are you using?
3.12.3
What operating system and version are you using?
Ubuntu 24.04
If you're connecting as a client, which SSH server are you connecting to?
No response
If you're using paramiko as part of another tool, which tool/version?
No response
Expected/desired behavior
BufferedFile.read(size) should consume size bytes from the internal buffer in O(size) time, regardless of how many bytes remain buffered. Consuming from the front of the buffer should not copy the entire remainder.
Concretely, calling read(1) repeatedly on a buffered file of size N should take O(N) total time, not O(N²). The data returned to the caller must remain identical - only the internal buffer-management strategy should change.
Possible Fix
a fix that avoids re-slicing the full buffer (e.g., tracking an offset, or using a memoryview/collections.deque of chunks)
Actual behavior
Summary
paramiko.file.BufferedFile.read() trims its internal buffer with self._rbuffer = self._rbuffer[size:] on every call. Because slicing bytes copies data, repeatedly reading in small chunks from a large buffered file is O(n) per call and O(n²) overall. I understand from the security team that this is not considered a security vulnerability under paramiko's threat model (a remote SFTP server cannot control how a client chunks its read() calls), but they suggested filing it here as a performance issue.
Details
In paramiko/file.py, inside BufferedFile.read():
if size <= len(self._rbuffer):
result = self._rbuffer[:size]
self._rbuffer = self._rbuffer[size:] # copies the whole remaining buffer
self._pos += len(result)
return resultFor a file of size N, reading it in chunks of 1 byte causes N + (N-1) + … + 1 ≈ N²/2 bytes copied – quadratic instead of linear.
How to reproduce
PoC
import time, math
from paramiko.file import BufferedFile
class DummyFile(BufferedFile):
def __init__(self, data):
super().__init__()
self._data = data
self._pos = 0
self._realpos = 0
self._closed = False
self._flags = self.FLAG_READ
self._rbuffer = data # pre-fill buffer with entire data
self._pos = 0
def _read(self, size):
return b'' # no more data needed
sizes = [10000, 20000, 40000, 100000, 1000000, 2000000]
results = []
for N in sizes:
data = b'A' * N
f = DummyFile(data)
t0 = time.time()
for _ in range(N):
f.read(1)
elapsed = time.time() - t0
print(f"N={N:>6} | read 1 byte N times | time {elapsed:.6f}s")
results.append((N, elapsed))
xs = [math.log(n) for n, _ in results]
ys = [math.log(t) for _, t in results]
mx, my = sum(xs)/len(xs), sum(ys)/len(ys)
num = sum((x-mx)*(y-my) for x,y in zip(xs,ys))
den = sum((x-mx)**2 for x in xs)
slope = num/den
print(f"\nScaling exponent: k ~= {slope:.2f}")Expected output:
N= 10000 | time 0.001680s
N= 20000 | time 0.004020s
N= 40000 | time 0.014845s
N= 100000 | time 0.081721s
N=1000000 | time 6.727586s
N=2000000 | time 38.769956s
Scaling exponent ≈ 1.91, confirming quadratic behavior.Anything else?
Impact
CWE-407 (Algorithmic Complexity)
Credits
Evgenios Gkritsis, @eGkritsis (github) Vulnerability & Malware Researcher @ Athena Research Center & University of Piraeus
Source: paramiko/paramiko