sdp: bound DataElement parse recursion to prevent RecursionError DoS#914
Conversation
DataElement.from_bytes -> list_from_bytes -> (SEQUENCE/ALTERNATIVE constructor dispatches back to list_from_bytes) had no depth limit. A malicious SDP peer could send a PDU of a few kilobytes containing ~1000 nested SEQUENCE tags and exhaust the Python recursion stack, crashing the host with an unhandled RecursionError propagating out of the SDP handler. Reachable via: any remote Bluetooth device that Bumble performs SDP service discovery against (default during Classic connection setup). Same family as PR google#912 (ATT_PDU.from_bytes empty PDU IndexError) - remote unchecked-input parser crash in the Bluetooth stack. Fix: thread-local depth counter, cap nesting at 32 (well above anything a legitimate service record uses). Added two regression tests covering the deep-nesting reject path and normal 16-level-nested SEQUENCE parsing. Reproducer (4.5 KB payload, deterministic crash on 0.0.228): from bumble.sdp import DataElement inner = b"\x35\x00" for _ in range(1500): size = len(inner) if size < 65535: inner = bytes([0x36, (size >> 8) & 0xFF, size & 0xFF]) + inner DataElement.from_bytes(inner) # RecursionError before fix Signed-off-by: ibondarenko1 <ibondarenko1@users.noreply.github.com>
|
I personally don't suggest that we need much protection agaist attacks here, as Bumble is not designed to be a product providing safety, but a tooling stack for test and study, so additional protection might limit the readability and flexibility for our major purpose. Maybe need opinion from @barbibulle |
|
Reasonable point, but there's a direct precedent — the ATT_PDU IndexError fix in #912 (same DoS class, same "malformed remote peer PDU crashes parser") was merged recently. That finding was also surfaced by fuzzing and the fix shipped in production Bumble. If #912 was in-scope as "hardening against malformed peer input," #914 applies the same reasoning to SDP's nested SEQUENCE parser — a 4.5KB PDU exhausts the Python stack. The depth cap is 32 (>10× realistic SDP records), so the legitimate flexibility for testing/research is unaffected. Happy to defer to @barbibulle's read; just wanted to name the precedent. |
|
#912 is also debatable - if we decide fuzzing is not applicable to Bumble, we might revert it. In many previous PRs, we actually minimized the validation of packets or parameters, so that users can send something invalid to controllers or remote devices to test edge cases. If we are making products for innocent users like Android or BlueZ, that's definitely necessary to protect the stack, but all users of Bumble are (should be?) developers responsible for themselves. |
|
Totally fair framing on target audience — developers responsible for their own robustness, I get the philosophy. The specific angle I had in mind was narrower: CI / fuzz-rig hygiene. Bumble is used downstream in automated discovery against arbitrary remote devices (Hermes fuzzer, interop lab rigs, BLE research pipelines). One malicious peer nearby → If that narrower reading isn't compelling either, happy to close the PR — no strong feelings. Just wanted to put that use case on the table before @barbibulle weighs in. |
|
I agree with @zxzxwu 's general statement that trying to make the stack robust against attacks would distract from its main goal. And it would take a lot of effort to actually do that, since there are so many code paths that process external data. |
|
Thanks for the review @barbibulle — appreciate the framing, happy to land this strictly as a tooling-ergonomics fix. Pushed CI runs are currently queued as |
| break | ||
| inner = bytes([0x36, (size >> 8) & 0xFF, size & 0xFF]) + inner | ||
|
|
||
| import pytest |
There was a problem hiding this comment.
Done in 16d0ed5 — moved import pytest to the top of the test module and removed the duplicate inside the test.
| # prevent a malicious peer from crashing the process via a deeply nested PDU. | ||
| # 32 levels is well beyond anything a legitimate service record uses. | ||
| _MAX_DATA_ELEMENT_NESTING = 32 | ||
| _parse_state = threading.local() |
There was a problem hiding this comment.
Not prefer to use a global threading local, but we can leave it here and refactor later.
There was a problem hiding this comment.
Acknowledged — leaving the module-level threading.local() as-is per your suggestion; happy to refactor it into a context-scoped helper in a follow-up PR if/when you'd like.
| return elements | ||
| depth = getattr(_parse_state, "depth", 0) | ||
| if depth >= _MAX_DATA_ELEMENT_NESTING: | ||
| raise ValueError( |
There was a problem hiding this comment.
Done in 16d0ed5 — switched to core.InvalidPacketError for the depth-limit raise so it matches the rest of the parser's error path.
|
Thanks for the review @zxzxwu! Pushed 721e398 addressing the two nits:
Threading-local counter left as-is per your "leave it here and refactor later" comment. Local verification: black/ruff/isort clean on both files; Ready for another look once CI re-runs. |
- bumble/sdp.py: replace raise ValueError with raise InvalidPacketError in DataElement.list_from_bytes depth guard. InvalidPacketError already imported at line 34 and extends ValueError so the existing regression test continues to match. - tests/sdp_test.py: remove duplicate 'import pytest' inside test_nested_sequence_recursion_guard; pytest already imported at module top (line 23). Threading.local counter left as-is per zxzxwu's 'leave it here and refactor later' comment on the PR.
721e398 to
16d0ed5
Compare
|
@zxzxwu friendly ping — addressed all three review nits in 16d0ed5:
CI is green. Ready for another look whenever you have a moment. |
A remote peer can send an AVDTP frame shorter than the assembler expects. The current MessageAssembler.on_pdu() unconditionally accesses pdu[0], pdu[1], and (for START packets) pdu[2], so a 0-, 1-, or 2-byte frame raises IndexError. The exception propagates up through L2CAP's read loop and tears down the channel — same DoS class as #912 (empty ATT PDU) and #914 (unbounded SDP recursion). Fix: validate length before each access. Empty PDUs and packets shorter than the type-specific minimum are logged and dropped; the assembler stays alive so the L2CAP channel is not torn down. - bumble/avdtp.py: length guards in MessageAssembler.on_pdu before accessing pdu[0], pdu[1], pdu[2]. - tests/avdtp_test.py: regression test covering empty PDU, 1-byte SINGLE, 1-byte START, 2-byte START — all four would have raised IndexError pre-fix; assembler now drops without raising.
DataElement.from_bytes -> list_from_bytes -> (SEQUENCE/ALTERNATIVE constructor dispatches back to list_from_bytes) had no depth limit. A malicious SDP peer could send a PDU of a few kilobytes containing ~1000 nested SEQUENCE tags and exhaust the Python recursion stack, crashing the host with an unhandled RecursionError propagating out of the SDP handler.
Reachable via: any remote Bluetooth device that Bumble performs SDP service discovery against (default during Classic connection setup).
Same family as PR #912 (ATT_PDU.from_bytes empty PDU IndexError) - remote unchecked-input parser crash in the Bluetooth stack.
Fix: thread-local depth counter, cap nesting at 32 (well above anything a legitimate service record uses). Added two regression tests covering the deep-nesting reject path and normal 16-level-nested SEQUENCE parsing.
Reproducer (4.5 KB payload, deterministic crash on 0.0.228):