Skip to content

sdp: bound DataElement parse recursion to prevent RecursionError DoS#914

Open
ibondarenko1 wants to merge 2 commits intogoogle:mainfrom
ibondarenko1:fix/sdp-recursion-depth-limit
Open

sdp: bound DataElement parse recursion to prevent RecursionError DoS#914
ibondarenko1 wants to merge 2 commits intogoogle:mainfrom
ibondarenko1:fix/sdp-recursion-depth-limit

Conversation

@ibondarenko1
Copy link
Copy Markdown

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):

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

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>
@zxzxwu
Copy link
Copy Markdown
Collaborator

zxzxwu commented Apr 23, 2026

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

@ibondarenko1
Copy link
Copy Markdown
Author

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.

@zxzxwu
Copy link
Copy Markdown
Collaborator

zxzxwu commented Apr 23, 2026

#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.

@ibondarenko1
Copy link
Copy Markdown
Author

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 → RecursionError propagates uncatchably out of the SDP task → harness dies. The current fix just swaps it for a plain ValueError at a fixed depth, which a developer-user can catch + resume the fuzz loop. So even under the "no input validation" philosophy, it's a strict improvement for the developer-as-user experience (catchable vs. uncatchable).

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.

@barbibulle
Copy link
Copy Markdown
Collaborator

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.
But given the context here, where the change is not for robustness per se, but rather to aid with use as a tool, which is one fo the goals of the project, I think it makes sense to include this change.

@ibondarenko1
Copy link
Copy Markdown
Author

Thanks for the review @barbibulle — appreciate the framing, happy to land this strictly as a tooling-ergonomics fix.

Pushed c55eb15 to fix the black format check the 5 "Check Code" jobs were complaining about (missing blank line after import pytest inside the new regression test). Verified locally against black~=25.1, ruff==0.14.10, isort, mypy==1.12.0, pylint==3.3.1 on tests/sdp_test.py + bumble/sdp.py — all clean.

CI runs are currently queued as action_required (workflow-approval gate on external-contributor commits). When you have a moment, the button on the Actions tab should unblock them. No changes to the fix logic — recursion cap still 32, thread-local counter intact, both regression tests unchanged.

Comment thread tests/sdp_test.py
break
inner = bytes([0x36, (size >> 8) & 0xFF, size & 0xFF]) + inner

import pytest
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import at top

Comment thread bumble/sdp.py
# 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()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not prefer to use a global threading local, but we can leave it here and refactor later.

Comment thread bumble/sdp.py
return elements
depth = getattr(_parse_state, "depth", 0)
if depth >= _MAX_DATA_ELEMENT_NESTING:
raise ValueError(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.InvalidPacketError

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants