Skip to content

feat: Auto-detect L2CAP function offsets via dlsym for Android#449

Open
ayaanngandhi wants to merge 2 commits intokavishdevar:mainfrom
ayaanngandhi:main
Open

feat: Auto-detect L2CAP function offsets via dlsym for Android#449
ayaanngandhi wants to merge 2 commits intokavishdevar:mainfrom
ayaanngandhi:main

Conversation

@ayaanngandhi
Copy link

@ayaanngandhi ayaanngandhi commented Feb 6, 2026

Summary

Adds automatic offset detection for L2CAP hook functions, eliminating the need for users to manually find and set offsets on most custom ROMs.

Problem

The hardcoded fallback offset 0x00a55e30 doesn't work on most custom ROMs, causing L2CAP connection failures. Users had to manually extract offsets from their libbluetooth_jni.so using tools like nm or readelf.

Solution

Added runtime symbol lookup using dlsym() to automatically find function offsets when the ROM exports them in the dynamic symbol table.

Changes

  • Added findSymbolOffset() — uses dlsym to look up symbols at runtime
  • Added findLibraryPath() — locates the Bluetooth library via /proc/self/maps
  • Updated all offset loaders with fallback chain:
    1. System property override (backward compatible)
    2. Dynamic dlsym lookup (auto-detection)
    3. Hardcoded fallback (last resort)

Symbols auto-detected

  • l2c_fcr_chk_chan_modes
  • l2cu_process_our_cfg_req
  • l2c_csm_execute
  • l2cu_send_peer_info_req

Testing

Tested on Project Elixir v4.2 (Android 14, Realme 6) with AirPods Pro, 1st Generation, where symbols are exported. Auto-detection correctly found offset 0x7f2ac0 without manual configuration.

Backward Compatibility

  • System property overrides still work (persist.librepods.hook_offset, etc.)
  • Falls back to hardcoded offset if dynamic lookup fails

Summary by CodeRabbit

Release Notes

  • Chores
    • Updated NDK version to 27.1.12297006 for improved build consistency
    • Enhanced system compatibility and stability through improved Bluetooth library detection and management

…Version

- Fixed compilation error: static declaration follows non-static declaration
- Added explicit ndkVersion = 27.1.12297006 to build.gradle.kts
- Functions getModuleBase, findLibraryPath, findSymbolOffset now match header declarations
@coderabbitai
Copy link

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Pull request adds ndkVersion property configuration to Android build script and modifies C++ hook implementation to use dynamic symbol resolution for hook offsets instead of hardcoded values, with system property override support and fallback mechanisms.

Changes

Cohort / File(s) Summary
Build Configuration
android/app/build.gradle.kts
Added ndkVersion property set to "27.1.12297006" in the android block.
Native Hook Implementation
android/app/src/main/cpp/l2c_fcr_hook.cpp
Introduced dynamic symbol resolution for hook offsets via helper functions (getModuleBase, findLibraryPath, findSymbolOffset) and global library detection. Replaced hardcoded offset approach with dynamic lookup strategy: system property override > dynamic symbol inspection > hardcoded fallback. Added SDP-related loader and BTA_DmSetLocalDiRecord hook implementation with comprehensive logging.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Auto-detect L2CAP function offsets via dlsym for Android' clearly and specifically summarizes the main change: adding runtime auto-detection of L2CAP function offsets using dlsym, which is the core objective of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/cpp/l2c_fcr_hook.cpp (1)

204-217: ⚠️ Potential issue | 🔴 Critical

Null pointer dereference on Line 210 — will crash if p_device_info is null.

Line 206 guards the writes with if (p_device_info), but Line 210 dereferences p_device_info->vendor unconditionally. If p_device_info is null, this crashes the Bluetooth process.

🐛 Proposed fix — move the log inside the null guard
 tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
     LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
     if (p_device_info) {
         p_device_info->vendor = 0x004C;
         p_device_info->vendor_id_source = 0x0001;
+        LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
+    } else {
+        LOGE("p_device_info is null, skipping vendor override");
     }
-    LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
     if (original_BTA_DmSetLocalDiRecord) {
         return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
     }
🤖 Fix all issues with AI agents
In `@android/app/build.gradle.kts`:
- Line 12: Update the pinned NDK version string assigned to ndkVersion from
"27.1.12297006" to a more current release; change the value to either the
conservative LTS bump "27.3.13750724" or the latest stable "29.0.14206865"
(replace the existing ndkVersion = "27.1.12297006" line accordingly).

In `@android/app/src/main/cpp/l2c_fcr_hook.cpp`:
- Around line 474-478: The fallback dlopen call using RTLD_NOW can force-load
and reinitialize the library; instead, if findLibraryPath located the library in
/proc/self/maps but dlopen(lib_path, RTLD_NOW | RTLD_NOLOAD) returns null, do
not call dlopen(lib_path, RTLD_NOW). Modify the logic around lib_handle so that
when findLibraryPath indicates the library is mapped but RTLD_NOLOAD failed, you
log a warning (use the module's logging facility) and return 0/skip hooking
rather than forcing a load; keep the fallback dlopen only for cases where
findLibraryPath did not report an existing mapping. Ensure you update the block
handling dlopen(lib_path, RTLD_NOW | RTLD_NOLOAD), dlopen(lib_path, RTLD_NOW),
lib_handle and the early return flow accordingly.
- Around line 441-468: The dlopen(NULL)/dlsym path in findSymbolOffset can
return an address from a different library, producing an incorrect offset when
you subtract getModuleBase(module_name); update findSymbolOffset to verify that
the resolved sym_addr actually lies within the target module before returning an
offset — either (A) obtain the module base via getModuleBase(module_name) and
module size (or parse /proc/self/maps) and check sym_addr is between base and
base+size, or (B) prefer a targeted lookup by dlopen(module_path) / dlsym on the
specific module (falling back to dlopen(NULL) only if the module-specific lookup
fails); ensure error logs mention when the address is out-of-range and do not
return an offset unless the address validation passes so findAndHookFunction
cannot compute a bogus hook target.
🧹 Nitpick comments (1)
android/app/src/main/cpp/l2c_fcr_hook.cpp (1)

259-377: Extract the repeated property-parsing boilerplate into a shared helper.

The hex-string-from-system-property parsing is duplicated verbatim across all five offset loaders (~20 identical lines each). This makes it easy for inconsistencies to creep in — for example, loadSdpOffset (Line 192) uses %x for uintptr_t while the changed loaders correctly use %lx with a cast.

A small helper would collapse all five to one-liners:

♻️ Sketch of a shared helper
// Returns parsed offset from the named system property, or 0 if absent / malformed.
static uintptr_t loadOffsetFromProperty(const char* property_name, const char* label) {
    char value[PROP_VALUE_MAX] = {0};
    int len = __system_property_get(property_name, value);
    if (len <= 0) return 0;

    LOGI("Read %s offset from property: %s", label, value);
    const char* parse_start = value;
    if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X'))
        parse_start = value + 2;

    char* endptr = nullptr;
    errno = 0;
    uintptr_t offset = strtoul(parse_start, &endptr, 16);

    if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
        LOGI("Parsed %s offset from property: 0x%lx", label, (unsigned long)offset);
        return offset;
    }
    LOGE("Failed to parse %s offset from property value: %s", label, value);
    return 0;
}

Each loader then simplifies to:

uintptr_t loadL2cuProcessCfgReqOffset() {
    uintptr_t off = loadOffsetFromProperty("persist.librepods.cfg_req_offset", "l2cu_process_our_cfg_req");
    if (off > 0) return off;

    // dynamic lookup …
}

android {
namespace = "me.kavishdevar.librepods"
compileSdk = 36
ndkVersion = "27.1.12297006"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Android NDK 27.1.12297006 release date current version 2025 2026

💡 Result:

  • Android NDK 27.1.12297006 corresponds to NDK r27b, which was announced/released on September 5, 2024 (stable channel in SDK Manager). [1]
  • Current (latest stable) NDK as of Feb 6, 2026: NDK r29, ndkVersion "29.0.14206865" (r29 series is dated October 2025 in the revision history). [2][3]
  • Current LTS line (r27 LTS) latest point release shown on the downloads page: NDK r27d, ndkVersion "27.3.13750724". [2]

Sources:
[1] https://groups.google.com/g/android-ndk-announce/c/rpC6OBfwq7g
[2] https://developer.android.com/ndk/downloads
[3] https://developer.android.com/ndk/downloads/revision_history


Pinning ndkVersion is good practice, but update to a more current release.

NDK 27.1.12297006 (r27b) is from September 2024 and is ~17 months behind the current stable NDK r29 (29.0.14206865, October 2025). Either update to the latest r27 LTS point release (27.3.13750724) for a conservative bump, or upgrade to the latest stable NDK r29 (29.0.14206865) to benefit from recent security and ABI improvements.

🤖 Prompt for AI Agents
In `@android/app/build.gradle.kts` at line 12, Update the pinned NDK version
string assigned to ndkVersion from "27.1.12297006" to a more current release;
change the value to either the conservative LTS bump "27.3.13750724" or the
latest stable "29.0.14206865" (replace the existing ndkVersion = "27.1.12297006"
line accordingly).

Comment on lines +441 to +468
uintptr_t findSymbolOffset(const char* symbol_name, const char* module_name) {
LOGI("findSymbolOffset: Looking up symbol '%s' in module '%s'", symbol_name, module_name);

// First, try dlopen(NULL) to search all loaded libraries
void* handle = dlopen(NULL, RTLD_NOW);
if (handle) {
dlerror(); // Clear any existing error
void* sym_addr = dlsym(handle, symbol_name);
const char* error = dlerror();

if (sym_addr && !error) {
uintptr_t base_addr = getModuleBase(module_name);
if (base_addr > 0) {
uintptr_t offset = reinterpret_cast<uintptr_t>(sym_addr) - base_addr;
LOGI("findSymbolOffset: Found '%s' via dlopen(NULL) at %p, base=%p, offset=0x%lx",
symbol_name, sym_addr, (void*)base_addr, (unsigned long)offset);
dlclose(handle);
return offset;
} else {
LOGE("findSymbolOffset: Found symbol but couldn't get module base for %s", module_name);
}
} else {
LOGI("findSymbolOffset: dlsym(NULL, '%s') failed: %s", symbol_name, error ? error : "symbol not found");
}
dlclose(handle);
} else {
LOGE("findSymbolOffset: dlopen(NULL) failed: %s", dlerror());
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

dlopen(NULL) + dlsym may resolve the symbol from a different library, producing an incorrect offset.

dlsym(handle, symbol_name) with a NULL handle searches all loaded shared objects globally. If the symbol happens to exist in a library other than module_name, sym_addr will point into that other library's address space while base_addr is the base of module_name — the subtraction on Line 454 would yield a bogus offset. When later added back to base_addr in findAndHookFunction, this hooks arbitrary memory.

Consider validating that the resolved symbol actually falls within the module's mapped range, or prefer the more targeted second approach (dlopen by path) as the primary lookup.

🛡️ Proposed fix — add a sanity check on the resolved address
         if (sym_addr && !error) {
             uintptr_t base_addr = getModuleBase(module_name);
             if (base_addr > 0) {
                 uintptr_t offset = reinterpret_cast<uintptr_t>(sym_addr) - base_addr;
+                // Sanity check: offset should be reasonable (< 256MB) and sym should be above base
+                if (reinterpret_cast<uintptr_t>(sym_addr) >= base_addr && offset < 0x10000000) {
                 LOGI("findSymbolOffset: Found '%s' via dlopen(NULL) at %p, base=%p, offset=0x%lx",
                      symbol_name, sym_addr, (void*)base_addr, (unsigned long)offset);
                 dlclose(handle);
                 return offset;
+                } else {
+                    LOGI("findSymbolOffset: Symbol '%s' at %p is not within module %s (base=%p), skipping",
+                         symbol_name, sym_addr, module_name, (void*)base_addr);
+                }
             } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
uintptr_t findSymbolOffset(const char* symbol_name, const char* module_name) {
LOGI("findSymbolOffset: Looking up symbol '%s' in module '%s'", symbol_name, module_name);
// First, try dlopen(NULL) to search all loaded libraries
void* handle = dlopen(NULL, RTLD_NOW);
if (handle) {
dlerror(); // Clear any existing error
void* sym_addr = dlsym(handle, symbol_name);
const char* error = dlerror();
if (sym_addr && !error) {
uintptr_t base_addr = getModuleBase(module_name);
if (base_addr > 0) {
uintptr_t offset = reinterpret_cast<uintptr_t>(sym_addr) - base_addr;
LOGI("findSymbolOffset: Found '%s' via dlopen(NULL) at %p, base=%p, offset=0x%lx",
symbol_name, sym_addr, (void*)base_addr, (unsigned long)offset);
dlclose(handle);
return offset;
} else {
LOGE("findSymbolOffset: Found symbol but couldn't get module base for %s", module_name);
}
} else {
LOGI("findSymbolOffset: dlsym(NULL, '%s') failed: %s", symbol_name, error ? error : "symbol not found");
}
dlclose(handle);
} else {
LOGE("findSymbolOffset: dlopen(NULL) failed: %s", dlerror());
}
uintptr_t findSymbolOffset(const char* symbol_name, const char* module_name) {
LOGI("findSymbolOffset: Looking up symbol '%s' in module '%s'", symbol_name, module_name);
// First, try dlopen(NULL) to search all loaded libraries
void* handle = dlopen(NULL, RTLD_NOW);
if (handle) {
dlerror(); // Clear any existing error
void* sym_addr = dlsym(handle, symbol_name);
const char* error = dlerror();
if (sym_addr && !error) {
uintptr_t base_addr = getModuleBase(module_name);
if (base_addr > 0) {
uintptr_t offset = reinterpret_cast<uintptr_t>(sym_addr) - base_addr;
// Sanity check: offset should be reasonable (< 256MB) and sym should be above base
if (reinterpret_cast<uintptr_t>(sym_addr) >= base_addr && offset < 0x10000000) {
LOGI("findSymbolOffset: Found '%s' via dlopen(NULL) at %p, base=%p, offset=0x%lx",
symbol_name, sym_addr, (void*)base_addr, (unsigned long)offset);
dlclose(handle);
return offset;
} else {
LOGI("findSymbolOffset: Symbol '%s' at %p is not within module %s (base=%p), skipping",
symbol_name, sym_addr, module_name, (void*)base_addr);
}
} else {
LOGE("findSymbolOffset: Found symbol but couldn't get module base for %s", module_name);
}
} else {
LOGI("findSymbolOffset: dlsym(NULL, '%s') failed: %s", symbol_name, error ? error : "symbol not found");
}
dlclose(handle);
} else {
LOGE("findSymbolOffset: dlopen(NULL) failed: %s", dlerror());
}
🤖 Prompt for AI Agents
In `@android/app/src/main/cpp/l2c_fcr_hook.cpp` around lines 441 - 468, The
dlopen(NULL)/dlsym path in findSymbolOffset can return an address from a
different library, producing an incorrect offset when you subtract
getModuleBase(module_name); update findSymbolOffset to verify that the resolved
sym_addr actually lies within the target module before returning an offset —
either (A) obtain the module base via getModuleBase(module_name) and module size
(or parse /proc/self/maps) and check sym_addr is between base and base+size, or
(B) prefer a targeted lookup by dlopen(module_path) / dlsym on the specific
module (falling back to dlopen(NULL) only if the module-specific lookup fails);
ensure error logs mention when the address is out-of-range and do not return an
offset unless the address validation passes so findAndHookFunction cannot
compute a bogus hook target.

Comment on lines +474 to +478
void* lib_handle = dlopen(lib_path, RTLD_NOW | RTLD_NOLOAD);
if (!lib_handle) {
// Library might not be loaded yet, try loading it
lib_handle = dlopen(lib_path, RTLD_NOW);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

RTLD_NOW fallback may force-load a library with side effects.

If findLibraryPath found the library in /proc/self/maps, it's already mapped. RTLD_NOLOAD failing here likely indicates the mapping isn't from the dynamic linker (e.g., mapped by the runtime in a non-standard way). Falling through to dlopen(lib_path, RTLD_NOW) in that situation could trigger constructors or re-initialize state in the Bluetooth stack.

Consider logging a warning and skipping to return 0 instead of the fallback load.

🤖 Prompt for AI Agents
In `@android/app/src/main/cpp/l2c_fcr_hook.cpp` around lines 474 - 478, The
fallback dlopen call using RTLD_NOW can force-load and reinitialize the library;
instead, if findLibraryPath located the library in /proc/self/maps but
dlopen(lib_path, RTLD_NOW | RTLD_NOLOAD) returns null, do not call
dlopen(lib_path, RTLD_NOW). Modify the logic around lib_handle so that when
findLibraryPath indicates the library is mapped but RTLD_NOLOAD failed, you log
a warning (use the module's logging facility) and return 0/skip hooking rather
than forcing a load; keep the fallback dlopen only for cases where
findLibraryPath did not report an existing mapping. Ensure you update the block
handling dlopen(lib_path, RTLD_NOW | RTLD_NOLOAD), dlopen(lib_path, RTLD_NOW),
lib_handle and the early return flow accordingly.

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.

1 participant