libblueman: fix HCI/SDP resource leaks and an ifname over-read, add a 100%-covered C test suite - #3322
Open
geraldo-netto wants to merge 5 commits into
Open
Conversation
…ning
Scan of module/libblueman.{c,h} (manual + cppcheck) surfaced two real
resource leaks, an out-of-bounds read, and several robustness gaps:
High:
- connection_init leaked the opened HCI device on every error after
hci_open_dev (malloc failure and HCIGETCONNINFO ioctl failure): dd was
never closed and not returned. Close it on all error paths; keep it
open only when ownership transfers to the caller's conn_info_handles.
- get_rfcomm_channel freed search_list, attrid_list, and response_list
only on the search-error branch, leaking all three on every successful
lookup. Free them once on a unified cleanup path.
Medium:
- _create_bridge copied a fixed IFNAMSIZ bytes from the interface name
with memcpy, over-reading when the name is shorter. Zero the ifreq and
use a bounded strncpy (matching _destroy_bridge).
- _create_bridge ignored the SIOCDEVPRIVATE (forward-delay) ioctl
result; now checked and surfaced. errno is captured before close() on
every error return so it cannot be clobbered.
Low / robustness:
- Validate str2ba in connection_init, create_rfcomm_device, and
get_rfcomm_channel (new ERR_INVALID_ADDRESS, mapped in _blueman.pyx)
instead of acting on an indeterminate address.
- _destroy_bridge: strncpy bounded to IFNAMSIZ-1.
- find_conn: cast the callback arg through intptr_t, not long.
- const-correctness on read-only pointer parameters; align the header
parameter name with the definition.
Behavior is unchanged on the success paths; error paths now release every
resource. No ABI change beyond the added error code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a C test harness that #includes libblueman.c and redirects every libc and libbluetooth call with ld --wrap, so all success and error paths run without a Bluetooth adapter or root. Built under AddressSanitizer, which proves the leak and out-of-bounds-read fixes: mocks count socket/HCI/SDP allocations and the tests assert every resource is released. run_tests.sh builds, runs (ASan + LeakSanitizer), and reports gcov line coverage (100%, 231/231). test_libblueman_c.py runs the harness under the normal Python test runner and skips gracefully when the C toolchain, ASan, or BlueZ headers are unavailable. Covers: bridge create/destroy (all ioctl failure points), find_conn (match/no-match/ioctl/malloc), connection_init lifecycle including the device-leak error paths, rfcomm list/create/release, and get_rfcomm_channel including SDP record parsing and cleanup, plus fuzz passes over odd interface names and Bluetooth addresses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rdown - _create_bridge/_destroy_bridge: copy the interface name into a zero-padded IFNAMSIZ buffer before handing it to SIOCBRADDBR/ SIOCBRDELBR; the kernel always copies IFNAMSIZ bytes from the user pointer, so passing the raw string allows a read past a shorter allocation (same class as the ifr_name over-read fixed earlier) - _create_bridge: restore the old non-fatal handling of the forward- delay ioctl and document why: BRCTL_SET_BRIDGE_FORWARD_DELAY via SIOCDEVPRIVATE is a legacy interface, and failing hard here would leave a half-created bridge behind (NetConf tolerates EEXIST on retry, so a retry would silently skip the delay anyway) - connection_close: make it idempotent; invalidate dd after closing so a double close (or a close before a successful connection_init) can never hit a recycled file descriptor - find_conn: clamp conn_num to the 10 allocated entries as defense in depth against a broken kernel contract Tests updated accordingly; the hostile conn_num case is an ASan-backed OOB regression test and coverage stays at 100% (92 checks).
…re init conn_info.__init__ stored a char* into a temporary bytes object that is freed when __init__ returns, so init() passed a dangling pointer to connection_init (use-after-free). Store the bytes object on the instance instead and let Cython take the pointer at call time. Also initialize ci.dd to -1 so calling deinit() before a successful init() is a no-op in connection_close instead of closing fd 0.
…nted bounded copy The Linux kernel deprecated strncpy() (Documentation/process/deprecated.rst) because it does not guarantee NUL-termination when the source fills the buffer and its implicit zero-padding hides intent; after a six-year, 362-commit conversion effort the API was removed from the kernel entirely for v7.2: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=1a3746ccbb0a97bed3c06ccde6b880013b1dddc1 The kernel's replacement is strscpy()/strscpy_pad(), which has no portable userspace equivalent, so introduce copy_ifname(), a small helper with the strscpy_pad() semantics the bridge ioctls need: a copy bounded to IFNAMSIZ - 1 (always terminated) into an explicitly zeroed buffer (the kernel reads a full IFNAMSIZ bytes for SIOCBRADDBR/SIOCBRDELBR, so the padding is required). The bounded length scan uses memchr() rather than strnlen() to stay within ISO C; the suite still passes with 100% line coverage (92 checks), and the exact-length fuzz allocations exercise both the terminator-found and truncation branches under ASan.
geraldo-netto
force-pushed
the
feat/libblueman-hardening
branch
from
July 8, 2026 20:31
0f898b7 to
6ec2ab0
Compare
|
Contributor
Author
|
@cschramm @infirit I have updated this PR because kernel 7.2 recently removed strncpy, see the commit below: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
Hardens the native helper
module/libblueman.c(the C backing the_bluemanCython extension) and adds the module's first C test suite, at 100% line coverage. The C here is delicate: it allocates kernel/SDP structures, opens HCI/RFCOMM sockets, and walks BlueZ-owned linked lists, all on manual cleanup paths. Each finding below is a real resource or memory-safety bug on those paths, fixed conservatively so the success paths behave exactly as before.The PR is split into five commits, each reviewable on its own: the original fix, the test suite, and three follow-up commits from a second review pass over the branch (state-machine and lifecycle issues described under "Second review pass" below).
Why this needs care (the C delicateness)
Most of these functions follow the classic C pattern of "acquire a resource, do work, clean up on every exit". The bugs are all in the exit paths — the cases that only fire when a syscall or allocation fails, which is exactly where coverage and review usually thin out. Three properties make this code easy to get wrong:
connection_initopens an HCI device and hands its fd to the caller viaconn_info_handles.dd, to be closed later byconnection_close. But that ownership only transfers on the success path; on any earlier failure the fd belongs toconnection_initand must be closed there. The original closed it nowhere on those paths.get_rfcomm_channelfrees its three SDP lists only inside the search-error branch. The happy path returns without freeing them. Because the lists are allocated bysdp_list_append(BlueZ-owned memory), nothing else reclaims them — every successful lookup leaked._create_bridgeusedmemcpy(ifr.ifr_name, name, IFNAMSIZ), copying a fixed 16 bytes regardless of the actual string length — an out-of-bounds read for any shorter name. The sibling_destroy_bridgealready did this correctly withstrncpy; the two had simply drifted.Because these only manifest on error paths or under tooling (ASan), they are invisible in normal use and in a quick read. That is precisely why the test suite builds a harness that forces every error path and runs the whole thing under AddressSanitizer + LeakSanitizer.
Findings and fixes
High — resource leaks
connection_initleaked the HCI device on every post-open error. Afterdd = hci_open_dev(...)succeeds, both themallocfailure and theHCIGETCONNINFOioctl failure jumped to cleanup that onlyfree(cr)—ddwas neverhci_close_dev'd and was not returned, so the caller could not close it either. Fix: closeddon all error paths; null it out once ownership transfers to the caller'sconn_info_handlesso the shared cleanup skips it.get_rfcomm_channelleaked all three SDP lists on success.search_list,attrid_list, andresponse_listwere freed only on thesdp_service_search_attr_reqerror branch. Fix: a single cleanup label frees all three (and closes the session) for both success and error, matching how the per-record data is already freed inside the loop.Medium — memory safety
_create_bridgeout-of-bounds read.memcpy(ifr.ifr_name, name, IFNAMSIZ)over-reads whennameis shorter thanIFNAMSIZ. Fix: zero theifreqand use a bounded copy, matching_destroy_bridge.errnoclobbering on error returns. Several error paths ranreturn -errnoafterclose(sock), which can overwriteerrno. Fix: captureerrnointo a local before everyclose()on the error returns.Low — input validation and robustness
str2bainconnection_init,create_rfcomm_device, andget_rfcomm_channelinstead of proceeding on an indeterminate address. AddsERR_INVALID_ADDRESS(-16), mapped in_blueman.pyx's error table so the Python side raises a clear message rather thanKeyError._destroy_bridge: boundstrncpytoIFNAMSIZ - 1.find_conn: cast the callback'slong argthroughintptr_trather than directly to a pointer.Second review pass (follow-up commits)
A second review of the branch, focused on state-machine and lifecycle behavior, found and fixed four more issues:
Forward-delay failure is non-fatal again, now documented. An earlier revision surfaced a
SIOCDEVPRIVATE(forward-delay) failure as an error — but at that pointSIOCBRADDBRhas already succeeded, so failing there leaves a half-created bridge behind, andBRCTL_SET_BRIDGE_FORWARD_DELAYviaSIOCDEVPRIVATEis a legacy ioctl interface that a kernel may reject while bridge creation works. SinceNetConftoleratesEEXISTon retry, a retry would silently skip the delay anyway. The original non-fatal semantics are restored with a comment in the code explaining why.Bounded name buffers for
SIOCBRADDBR/SIOCBRDELBR. The kernel always doescopy_from_user(buf, uarg, IFNAMSIZ)for these ioctls, so passing the raw string pointer permits a read past a shorter allocation — the same class as theifr_nameover-read fixed above. Both bridge functions now copy the name into a zero-paddedchar ifname[IFNAMSIZ]local and pass that everywhere.No
strncpy. The bounded copy is a small documented helper (copy_ifname) rather thanstrncpy, which the Linux kernel deprecated (Documentation/process/deprecated.rst) because it does not guarantee NUL-termination when the source fills the buffer — and, after a six-year, 362-commit conversion effort, removed from the kernel entirely for v7.2. The helper implements thestrscpy_pad()semantics the bridge ioctls need — always-terminated copy into an explicitly zeroed IFNAMSIZ buffer — using ISO Cmemchr/memcpy, since userspace has no portablestrscpy.connection_closeis idempotent. It now no-ops whendd < 0and invalidatesddafter closing, so a double close (or a close before a successfulconnection_init) can never touch a recycled file descriptor.conn_infouse-after-free in_blueman.pyx.__init__stored achar*into a temporary bytes object that is freed when__init__returns, soinit()passed a dangling pointer toconnection_init. The bytes object is now kept alive on the instance.__init__also initializesci.dd = -1, sodeinit()before a successfulinit()is a no-op instead of closing fd 0.Plus defense in depth:
find_connclamps the ioctl-returnedconn_numto the 10 entries it allocated, so a broken kernel contract can never walk the scan loop out of bounds.ABI / compatibility
No change to existing behavior on success paths. The ABI additions are the new
ERR_INVALID_ADDRESScode (mapped in_blueman.pyx) andconnection_closetaking a non-const pointer so it can invalidate the descriptor. Error-path semantics are unchanged except that every resource is now released.Testing
test/module/test_libblueman.c#includes the translation unit (to reach thestatichelpers) and redirects every libc and libbluetooth call withld --wrap, so all success and error paths run with no Bluetooth adapter and no root. The mocks count socket / HCI / SDP allocations, and the tests assert each resource is released — this is what proves the leak and OOB fixes rather than merely asserting return codes. Built under AddressSanitizer + LeakSanitizer, so a regression in any cleanup path aborts the run.run_tests.shbuilds, runs (ASan), and reports gcov line coverage: 100% (243/243), 92 checks, 0 failures.test/module/test_libblueman_c.pyruns the harness under the normal Python test runner and skips gracefully when the C toolchain, ASan, or BlueZ headers are unavailable, so it never falsely fails minimal CI environments.find_conn(match / no-match / ioctl / malloc failure / hostileconn_num), the fullconnection_initlifecycle including the device-leak error paths and idempotent close, rfcomm list/create/release, andget_rfcomm_channelincluding SDP record parsing and cleanup — plus fuzz passes over odd interface names and Bluetooth addresses (the address/name strings that would have tripped the old over-read).conn_numcase is a true ASan-backed regression test: with the clamp removed, the scan loop reads past the allocation and ASan aborts the run.gcc -Wall -Wextrais clean; cppcheck reports nothing on the file.Deliberately left as-is (optional follow-up cleanups)
These are not bugs and are not part of this PR; noting them for reviewers since they touch the same file. They are code-quality cleanups, not performance wins — the module is syscall/IO-bound, so none of them changes measurable runtime.
__tv_to_jiffies({0, 0}). With a zerotimevalit always returns 0, so the bridge forward delay could be set with a plainargs[1] = 0and the helper dropped. It is kept here for clarity and because it is the one trivially unit-testable pure function in the file.printffrom a library helper.get_rfcomm_channelwrites to stdout from code that is linked into the Python extension — the wrong layer, and stdio is the only non-trivial cost in an otherwise cheap function. Replacing these with proper logging (or removing them) would be a real cleanup and would stop stray output reaching the app's stdout.breakafter the match would be clearer and skip the dead iterations, though SDP responses are small enough that the saving is unmeasurable.Happy to fold any of these into this PR or a follow-up if preferred.