Skip to content

fix(loan): make BufferPool storage outlive-safe via shared_ptr<Impl> - #177

Merged
SoundMatt merged 1 commit into
mainfrom
audit-fix/batch4-loan-uaf
Aug 22, 2026
Merged

fix(loan): make BufferPool storage outlive-safe via shared_ptr<Impl>#177
SoundMatt merged 1 commit into
mainfrom
audit-fix/batch4-loan-uaf

Conversation

@SoundMatt

Copy link
Copy Markdown
Owner

Bug (HIGH, confirmed against source)

rcp::loan::BufferPool::loan()'s returned rcp::Loan captured this
(the BufferPool*) by raw pointer in its release closure.
rcp::Loan::~Loan() unconditionally invokes that closure when the Loan
is destroyed. BufferPool::~BufferPool() did not track or wait for
outstanding Loans, so a Loan that outlived the BufferPool it was
drawn from would run the release closure against an already-destroyed
BufferPool: locking a destroyed std::mutex and writing through a
dangling pointer. Genuine use-after-free — not previously documented
anywhere in the file as a precondition, and not exercised by any test.

Repro:

std::unique_ptr<rcp::Loan> l;
{
    BufferPool pool(nullptr);
    pool.loan(64, l);
} // pool destructs
l.reset(); // UAF: release closure locks a destroyed mutex, writes through a dangling ptr

Fix

Split BufferPool's shared internal state (the fixed-capacity free list,
its mutex, entries_len_, and the fault_injector_ pointer) into a
private Impl struct owned via std::shared_ptr<Impl>. BufferPool
itself is now a thin wrapper holding that shared_ptr. loan()'s release
closure captures the shared_ptr<Impl> by value instead of this, so
a Loan's own copy of the shared_ptr keeps Impl's storage alive by
refcount for as long as the Loan exists, independent of the BufferPool
wrapper's own lifetime. Impl's storage is only actually freed once its
last owner (the BufferPool wrapper or the release closure of the last
outstanding Loan) releases it.

Public API surface unchanged: same constructors, loan(), close(),
ok(), pooled_count() signatures; new_buffer_pool() unaffected.
close()'s documented idempotent / "safe with outstanding Loans"
behavior is preserved exactly — it now just sets a flag on Impl rather
than on the BufferPool itself, with identical observable behavior.
Updated the file's header/class doc comments to describe the new,
now-safe lifetime contract instead of only documenting the old hazard.

Test

Added "A Loan may safely outlive the BufferPool it was drawn from" to
tests/test_loan.cpp — the literal audit repro: loan() a buffer from a
BufferPool constructed in a nested scope into a
std::unique_ptr<rcp::Loan> declared in the outer scope, let the
BufferPool destruct, then reset the Loan. Kept the existing
close()-with-outstanding-Loans test unchanged to confirm that documented
behavior still holds after this refactor.

Verification

  • Clean rebuild (cmake -DRCP_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug):
    0 errors, 0 warnings under this project's -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-align -Wunused -Woverloaded-virtual flags.
  • ctest: 100% tests passed, 58/58 suites (test_loan: 119 assertions
    in 10 test cases, including the new regression test).
  • Sanitizer (load-bearing check for a UAF fix): reproduced this repo's
    CI asan-ubsan-regmap job configuration exactly — ubuntu-22.04,
    clang-14, -fsanitize=address,undefined -fno-omit-frame-pointer -g /
    -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined",
    ASAN_OPTIONS=halt_on_error=1, UBSAN_OPTIONS=halt_on_error=1 — via
    Docker (clang-14 unavailable on the local dev machine), and ran
    test_loan under it. Clean: All tests passed (119 assertions in 10 test cases).
  • Mutation-testing sanity check: temporarily reverted to the
    raw-this-capture version, rebuilt under the identical sanitizer
    configuration, and ran the new regression test. ASan caught it
    immediately:
==4341==ERROR: AddressSanitizer: stack-use-after-scope on address
0xffffe8c00558 at pc 0xaaaae9680b88 bp 0xffffe8bffc30 sp 0xffffe8bffc28
READ of size 8 at 0xffffe8c00558 thread T0
    #0 ... in rcp::loan::BufferPool::loan(...)::'lambda'()::operator()()
       /work/include/rcp/loan.hpp:152:21
    #1 ... std::__invoke_impl<...> .../invoke.h:61:14
    #2 ... std::__invoke_r<...> .../invoke.h:111:2
    #3 ... std::_Function_handler<void (), ...>::_M_invoke(...) .../std_function.h:290:9
    #4 ... std::function<void ()>::operator()() const .../std_function.h:590:9
    #5 ... in rcp::Loan::~Loan() /work/include/rcp/rcp.hpp:123:29
    #6 ... std::default_delete<rcp::Loan>::operator()(rcp::Loan*) const .../unique_ptr.h:85:2
    #7 ... std::__uniq_ptr_impl<...>::reset(rcp::Loan*) .../unique_ptr.h:182:4
    #8 ... std::unique_ptr<rcp::Loan, ...>::reset(rcp::Loan*) .../unique_ptr.h:456:7
    #9 ... in CATCH2_INTERNAL_TEST_12() /work/tests/test_loan.cpp:133:14
    ... (Catch2 + libc harness frames)

Address 0xffffe8c00558 is located in stack of thread T0 at offset 632 in frame
    #0 ... CATCH2_INTERNAL_TEST_12() /work/tests/test_loan.cpp:113
  This frame has 24 object(s):
    [32, 40) 'loan_out' (line 123)
    [64, 648) 'pool' (line 125) <== Memory access at offset 632 is inside this variable
    ...
SUMMARY: AddressSanitizer: stack-use-after-scope
/work/include/rcp/loan.hpp:152:21 in
rcp::loan::BufferPool::loan(...)::'lambda'()::operator()()
==4341==ABORTING

Reapplied the fix, rebuilt under the same sanitizer configuration again:
clean pass, All tests passed (119 assertions in 10 test cases).

Closes a finding from the cpp-RCP v3.0.0 deep audit (loan.hpp
BufferPool::loan()/~BufferPool() use-after-free on
Loan-outlives-pool).

Bug (HIGH, confirmed against source): rcp::loan::BufferPool::loan()'s
released rcp::Loan captured `this` (the BufferPool*) by raw pointer in its
release closure. rcp::Loan::~Loan() unconditionally invokes that closure
when the Loan is destroyed. BufferPool::~BufferPool() did not track or wait
for outstanding Loans, so a Loan that outlived the BufferPool it was drawn
from would run the release closure against an already-destroyed
BufferPool: locking a destroyed std::mutex and writing through a dangling
pointer. Genuine use-after-free, not previously documented anywhere in the
file as a precondition, and not exercised by any test.

Fix: split BufferPool's shared internal state (the fixed-capacity free
list, its mutex, entries_len_, and the fault_injector_ pointer) into a
private Impl struct owned via std::shared_ptr<Impl>. BufferPool itself is
now a thin wrapper holding that shared_ptr. loan()'s release closure
captures the shared_ptr<Impl> *by value* instead of `this`, so a Loan's own
copy of the shared_ptr keeps Impl's storage alive by refcount for as long
as the Loan exists, independent of the BufferPool wrapper's own lifetime.
Impl's storage is only actually freed once its last owner (the BufferPool
wrapper or the release closure of the last outstanding Loan) releases it.

Public API surface unchanged: same constructors, loan(), close(), ok(),
pooled_count() signatures; new_buffer_pool() unaffected. close()'s
documented idempotent/"safe with outstanding Loans" behavior is preserved
exactly -- it now just sets a flag on Impl rather than on the BufferPool
itself, with identical observable behavior. Updated the file's header/class
doc comments to describe the new, now-safe lifetime contract (a Loan may
safely outlive its BufferPool) instead of only documenting the old hazard.

Test: added "A Loan may safely outlive the BufferPool it was drawn from"
to tests/test_loan.cpp -- the literal audit repro: loan() a buffer from a
BufferPool constructed in a nested scope into a std::unique_ptr<rcp::Loan>
declared in the outer scope, let the BufferPool destruct, then reset the
Loan. Kept the existing close()-with-outstanding-Loans test unchanged to
confirm that documented behavior still holds after this refactor.

Verification:
- Clean rebuild (cmake -DRCP_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug):
  0 errors, 0 warnings under this project's -Wall -Wextra -Wpedantic
  -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-align -Wunused
  -Woverloaded-virtual flags.
- ctest: 100% tests passed, 58/58 suites (test_loan: 119 assertions in
  10 test cases, including the new regression test).
- Sanitizer (load-bearing check for a UAF fix): reproduced this repo's CI
  asan-ubsan-regmap job configuration exactly -- ubuntu-22.04, clang-14,
  `-fsanitize=address,undefined -fno-omit-frame-pointer -g` /
  `-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"`,
  ASAN_OPTIONS=halt_on_error=1, UBSAN_OPTIONS=halt_on_error=1 -- via Docker,
  and ran test_loan under it. Clean: "All tests passed (119 assertions in
  10 test cases)".
- Mutation-testing sanity check: temporarily reverted to the raw-`this`-
  capture version, rebuilt under the identical sanitizer configuration, and
  ran the new regression test. ASan caught it immediately:

    ==4341==ERROR: AddressSanitizer: stack-use-after-scope on address
    0xffffe8c00558 at pc 0xaaaae9680b88 bp 0xffffe8bffc30 sp 0xffffe8bffc28
    READ of size 8 at 0xffffe8c00558 thread T0
        #0 ... in rcp::loan::BufferPool::loan(...)::'lambda'()::operator()()
           /work/include/rcp/loan.hpp:152:21
        ...
        #5 ... in rcp::Loan::~Loan() /work/include/rcp/rcp.hpp:123:29
        ...
        #9 ... in CATCH2_INTERNAL_TEST_12() /work/tests/test_loan.cpp:133:14
        ...
    Address 0xffffe8c00558 is located in stack of thread T0 at offset 632
    in frame
        #0 ... CATCH2_INTERNAL_TEST_12() /work/tests/test_loan.cpp:113
      This frame has 24 object(s):
        [32, 40) 'loan_out' (line 123)
        [64, 648) 'pool' (line 125) <== Memory access at offset 632 is
                                          inside this variable
        ...
    SUMMARY: AddressSanitizer: stack-use-after-scope
    /work/include/rcp/loan.hpp:152:21 in
    rcp::loan::BufferPool::loan(...)::'lambda'()::operator()()
    ==4341==ABORTING

  Reapplied the fix, rebuilt under the same sanitizer configuration again:
  clean pass, "All tests passed (119 assertions in 10 test cases)".

Closes a finding from the cpp-RCP v3.0.0 deep audit (loan.hpp
BufferPool::loan()/~BufferPool() use-after-free on Loan-outlives-pool).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com>
@SoundMatt
SoundMatt merged commit 8f03713 into main Aug 22, 2026
25 checks passed
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