fix(loan): make BufferPool storage outlive-safe via shared_ptr<Impl> - #177
Merged
Conversation
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>
17 tasks
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.
Bug (HIGH, confirmed against source)
rcp::loan::BufferPool::loan()'s returnedrcp::Loancapturedthis(the
BufferPool*) by raw pointer in its release closure.rcp::Loan::~Loan()unconditionally invokes that closure when theLoanis destroyed.
BufferPool::~BufferPool()did not track or wait foroutstanding
Loans, so aLoanthat outlived theBufferPoolit wasdrawn from would run the release closure against an already-destroyed
BufferPool: locking a destroyedstd::mutexand writing through adangling 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 ptrFix
Split
BufferPool's shared internal state (the fixed-capacity free list,its mutex,
entries_len_, and thefault_injector_pointer) into aprivate
Implstruct owned viastd::shared_ptr<Impl>.BufferPoolitself is now a thin wrapper holding that
shared_ptr.loan()'s releaseclosure captures the
shared_ptr<Impl>by value instead ofthis, soa
Loan's own copy of theshared_ptrkeepsImpl's storage alive byrefcount for as long as the
Loanexists, independent of theBufferPoolwrapper's own lifetime.
Impl's storage is only actually freed once itslast owner (the
BufferPoolwrapper or the release closure of the lastoutstanding
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
Implratherthan on the
BufferPoolitself, 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"totests/test_loan.cpp— the literal audit repro:loan()a buffer from aBufferPoolconstructed in a nested scope into astd::unique_ptr<rcp::Loan>declared in the outer scope, let theBufferPooldestruct, then reset theLoan. Kept the existingclose()-with-outstanding-Loans test unchanged to confirm that documentedbehavior still holds after this refactor.
Verification
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-virtualflags.test_loan: 119 assertionsin 10 test cases, including the new regression test).
CI
asan-ubsan-regmapjob 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— viaDocker (clang-14 unavailable on the local dev machine), and ran
test_loanunder it. Clean:All tests passed (119 assertions in 10 test cases).raw-
this-capture version, rebuilt under the identical sanitizerconfiguration, and ran the new regression test. ASan caught it
immediately:
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.hppBufferPool::loan()/~BufferPool()use-after-free onLoan-outlives-pool).