Package: src:exabgp
Version: 5.0.8-1
User: [email protected]
Usertags: python3.15
Tags: patch, ftbfs, forky, sid
Severity: important

Hi!

While rebuilding the python related packages against the Python 3.15rc2
version we found that exabgp fails to build from source [1].

The error is:
struct.error: unpack requires a buffer of 2 bytes

The fix is to backport upstream commit f3ba9c75f "fix: check a message
is long enough before reading its fixed header" [2].

I've applied the upstream fix in the sandbox [3] to verify that it
builds successfully, please consider applying the patch to support the
upcoming 3.15 version.

Setting the severity to important for now. Once Python 3.15 is released,
it will be added to python3-defaults and this bug will become release
critical.

Happy hacking,

[1]: https://debusine.debian.net/debian/r-python-python3.15/artifact/4467110/
[2]: 
https://github.com/Exa-Networks/exabgp/commit/f3ba9c75f55547af917727d835b6a3c55ca40a4c
[3]: https://debusine.debian.net/debian/r-python-python3.15/

--
"Can you imagine what I would do if I could do all I can?" -- Sun Tzu
Saludos /\/\ /\ >< `/
From: Thomas Mangin <[email protected]>
Date: Fri, 21 Aug 2026 14:52:08 +0100
Subject: fix: check a message is long enough before reading its fixed header

Four places read a fixed number of bytes off a peer supplied buffer with nothing
checking the bytes were there, so the failure was a raw Python exception out of
the message parser rather than a NOTIFICATION:

  Update.split       computed `length = len(data)` and then never consulted it,
                     unpacking the two mandatory 2 byte length fields of RFC 4271
                     4.3 regardless.  One stray byte raised struct.error.
  Open               read version, AS, hold time and identifier without checking
                     the ten bytes RFC 4271 4.2 requires were present.
  Notification       read data[0] and data[1] off a body which may be empty.
  Message.unpack     fell through to klass_unknown, bound to Exception unless
                     exabgp.bgp.message.unknown is imported, and nothing imports
                     it, so it CONSTRUCTED an Exception and RETURNED it as though
                     it were a message.  Type 0 is in CODE.MESSAGES, so it passes
                     the reactor's own check and reached that line from the wire.

A NOTIFICATION too short to carry a code now closes the connection without a
reply: RFC 4271 6.5 says an error found while processing one is indicated by
closing, not by answering with another. Returning a Notification rather than a
Notify is what does that here, and the test asserts the difference, which
isinstance cannot since Notify subclasses Notification.

An unrecognised message type is now 1/3 Bad Message Type, which RFC 4271 6.1
requires, where the reactor used 1/0 Unspecific. That changes what a peer
receives, so it is in CHANGELOG and the functional fixture is updated. The
catch-all further down keeps 1/0, because there we genuinely do not know what
went wrong.

WHY NONE OF THIS WAS CAUGHT

The fuzz suite asserted, 7 times:

    except Exception as e:
        assert not isinstance(e, (SystemExit, KeyboardInterrupt))

SystemExit and KeyboardInterrupt derive from BaseException, so `except Exception`
can never bind either. The assertion could not fail whatever the code did.

Worse, the test named "UPDATE message parsing doesn't crash on random data"
called Update.unpack(env, data, None, None): four arguments to
Message.unpack(message, data, direction, negotiated). `message` was an Env
object, no registered type matched, and every example raised "unknown message
type" before touching the data. It had never parsed an UPDATE. The OPEN test had
the same defect and an except which swallowed everything without asserting at
all. Both now call the decoder they name, and the assertions say which exceptions
each site may raise. Verified by disabling the UPDATE gate and watching the fuzz
test go red, which it did not do before.

Only 2 of the 7 sites are reached in practice; the other 5 never raise. Said
plainly in the helper rather than left to look like coverage.

The same sweep found three configuration parser crashes. unescape stepped past a
trailing backslash and read off the end; it yielded bytes into a join over
strings for \uXXXX, so that escape raised TypeError and had never once produced a
character; and Tokeniser assigned Tokeniser._off, the function, where it meant
Tokeniser._off(), the empty iterator it returns, so after a configuration file
failed to open the next read raised TypeError. _next was also initialised to None
where every other path uses [], which line[:-1] turns into a TypeError on the
first call. ValueError is what tokens() already raises for bad syntax, so that is
what these raise now: 6000 random texts produce ValueError and nothing else.

Each gate verified by breaking it: five separate breakages, each failing a
different test, and the good-path tests fail if a gate refuses valid input.
---
 src/exabgp/bgp/message/update/__init__.py | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/src/exabgp/bgp/message/update/__init__.py b/src/exabgp/bgp/message/update/__init__.py
index 2ed7f1bcd..a435f84e3 100644
--- a/src/exabgp/bgp/message/update/__init__.py
+++ b/src/exabgp/bgp/message/update/__init__.py
@@ -86,6 +86,13 @@ class Update(Message):
     def split(data):
         length = len(data)
 
+        # RFC 4271 4.3: the body opens with two 2 byte length fields. length was
+        # computed and never consulted, so a body shorter than the first of them
+        # left struct.error here rather than a NOTIFICATION: one stray byte on
+        # the wire was enough
+        if length < UPDATE_ATTR_LENGTH_HEADER_SIZE:
+            raise Notify(3, 1, 'invalid UPDATE, %d bytes cannot hold the two length fields' % length)
+
         len_withdrawn = unpack('!H', data[0:UPDATE_WITHDRAWN_LENGTH_OFFSET])[0]
         withdrawn = data[UPDATE_WITHDRAWN_LENGTH_OFFSET : len_withdrawn + UPDATE_WITHDRAWN_LENGTH_OFFSET]
 
@@ -93,6 +100,10 @@ class Update(Message):
             raise Notify(3, 1, 'invalid withdrawn routes length, not enough data available')
 
         start_attributes = len_withdrawn + UPDATE_ATTR_LENGTH_HEADER_SIZE
+        # the withdrawn routes are accounted for, but the total path attribute
+        # length which follows them still has to be present
+        if length < start_attributes:
+            raise Notify(3, 1, 'invalid UPDATE, no room for the total path attribute length')
         len_attributes = unpack('!H', data[len_withdrawn + UPDATE_WITHDRAWN_LENGTH_OFFSET : start_attributes])[0]
         start_announced = len_withdrawn + len_attributes + UPDATE_ATTR_LENGTH_HEADER_SIZE
         attributes = data[start_attributes:start_announced]

Reply via email to