Repository navigation
Description
Summary
FDv1AdapterSynchronizer wraps a legacy FDv1 IDataSynchronizer
(StreamingDataSource or PollingDataSource) so it can serve the
newer FDv2 orchestrator's pull-based interface. This is the object the
server SDK constructs whenever it falls back from the FDv2 protocol to
FDv1 — an entirely ordinary, documented, production code path (e.g.
triggered by an X-LD-FD-Fallback directive from the LaunchDarkly
streaming/polling endpoint).
FDv1AdapterSynchronizer::Close() discards the completion callback of
the wrapped source's asynchronous ShutdownAsync() call:
void FDv1AdapterSynchronizer::Close() {
if (!close_promise_.Resolve(std::monostate{})) { return; }
std::lock_guard lock(lifecycle_mutex_);
bool const was_started = started_;
started_ = true;
if (was_started) {
fdv1_source_->ShutdownAsync([] {}); // <-- completion discarded
}
}
IDataSynchronizer's own interface contract
(libs/server-sdk/src/data_interfaces/source/idata_synchronizer.hpp:34)
states that the destination pointer handed to it via StartAsync is
"invalid after the ShutdownAsync completion handler is called" —
i.e., callers must wait for that signal before destroying the
destination. Close() never stores or awaits this callback; it fires
a no-op lambda and returns immediately. ~FDv1AdapterSynchronizer()
then proceeds to destroy destination_ (astd::unique_ptr<ConvertingDestination>) and status_manager_ (aDataSourceStatusManager, shared by reference with the wrapped
source) through ordinary member destruction — with no guarantee the
wrapped source's in-flight asynchronous work has actually finished.
The wrapped source's own real implementations
(StreamingDataSource, PollingDataSource) have no custom,
blocking destructor — they use the compiler-generated default and
rely on weak_from_this()-guarded async callbacks that protect only
their own this pointer, not the externally-owned destination_/status_manager_ raw pointer/reference they were handed. A callback
already in flight when Close() runs (e.g., an HTTP response or SSE
event arriving concurrently with an FDv2 recovery/shutdown) can
therefore land on freed memory.
Activity
TerryJLU commentedon Aug 29, 2026
Proof of Concept
Both PoCs use real, unmodified production code for every class
involved (FDv1AdapterSynchronizer, ConvertingDestination,
DataSourceStatusManager, and — in the second PoC — the real
FDv2DataSystem orchestrator). The only test double, in both cases,
is the wrapped IDataSynchronizer transport itself (the thing that
would normally be a real StreamingDataSource/PollingDataSource
backed by a real network connection) — this is the one extension
point the SDK's own existing test suite (fdv1_adapter_synchronizer_test.cpp's
MockFDv1Source) already fakes for this exact interface, so no new
mocking approach was introduced.
PoC 1 — direct, minimal repro (deterministic, no threading required)
class DeferredShutdownFDv1Source final : public IDataSynchronizer {
public:
void StartAsync(IDestination* destination, ...) override {
destination_ = destination;
}
void ShutdownAsync(std::function<void()> completion) override {
deferred_completion_ = std::move(completion); // never invoked
}
IDestination* destination_ = nullptr;
std::function<void()> deferred_completion_;
};
TEST(...) {
IDestination* captured_destination = nullptr;
{
FDv1AdapterSynchronizer adapter(/* builder returning the mock above */);
adapter.Next(data_model::Selector{}); // triggers StartAsync
captured_destination = source->destination_;
} // adapter destroyed: Close() -> ShutdownAsync([]{}) -> discarded
// -> destination_ (ConvertingDestination) destroyed immediately
}
ASan result (3/3 runs, identical clean heap-use-after-free each time):
ERROR: AddressSanitizer: heap-use-after-free on address 0x503000017b90
READ/WRITE at repro_fdv1_adapter_destination_uaf_test.cpp:157
in TestBody() (the captured_destination->Upsert(...) call)
freed by thread T0 here:
#1 FDv1AdapterSynchronizer::ConvertingDestination::~ConvertingDestination()
fdv1_adapter_synchronizer.hpp:92
#4 FDv1AdapterSynchronizer::~FDv1AdapterSynchronizer()
fdv1_adapter_synchronizer.cpp:154
previously allocated by thread T0 here:
#2 FDv1AdapterSynchronizer::FDv1AdapterSynchronizer(...)
fdv1_adapter_synchronizer.cpp:126
SUMMARY: AddressSanitizer: heap-use-after-free
beekld commentedon Sep 1, 2026
I don't think this use-after-free is actually reachable from the public API, for two reasons:
That said, it would be nice to clean this up, so that the internal code at least conforms its own documented contract.