Skip to content

Commit 73064ad

Browse files
committed
fix use-after-free of a blackboard entry held by getAnyLocked
Blackboard::getAnyLocked() builds the returned AnyPtrLocked from a local shared_ptr<Entry>, so the caller holds a raw Any* and mutex* into an entry that nothing keeps alive. unset() and clear() take storage_mutex_ but never entry_mutex, so erasing the key destroys the entry while its mutex may still be locked by the caller. AnyPtrLocked is returned by value from exported functions, so it can't grow an owner handle without breaking the ABI of existing binaries. Instead, the removal paths (unset(), clear(), the stale-key removal in cloneInto() and ~Blackboard()) take the entry out of the storage and destroy it only if its entry_mutex can be acquired with try_lock(); otherwise the entry is parked in a process-wide list and destroyed by a later removal that finds it unlocked. getAnyLocked() re-checks the key under the lock, so an entry can't be dropped between lookup and lock. Nothing blocks, so removing an entry while holding a lock on it, from the same or another thread, can't deadlock. unset() and the destructor move out of the header, so that future changes to the removal logic reach callers through the library. The regression tests cover unset(), clear(), cloneInto() and destruction of the blackboard while an AnyPtrLocked is alive, two threads each removing the entry the other one holds, and the deferred destruction of a released entry.
1 parent 879522c commit 73064ad

4 files changed

Lines changed: 256 additions & 29 deletions

File tree

‎include/behaviortree_cpp/blackboard.h‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class Blackboard
7676
return std::shared_ptr<Blackboard>(new Blackboard(parent));
7777
}
7878

79-
virtual ~Blackboard() = default;
79+
virtual ~Blackboard();
8080

8181
void enableAutoRemapping(bool remapping);
8282

@@ -115,6 +115,11 @@ class Blackboard
115115
template <typename T>
116116
void set(const std::string& key, const T& value);
117117

118+
/**
119+
* @brief Remove the entry with the given key, if any. This never blocks:
120+
* if an AnyPtrLocked to that entry is still alive, the entry is destroyed
121+
* only after the lock has been released.
122+
*/
118123
void unset(const std::string& key);
119124

120125
[[nodiscard]] const TypeInfo* entryInfo(const std::string& key);
@@ -253,21 +258,6 @@ inline T Blackboard::get(const std::string& key) const
253258
throw RuntimeError("Blackboard::get() error. Missing key [", key, "]");
254259
}
255260

256-
inline void Blackboard::unset(const std::string& key)
257-
{
258-
std::unique_lock storage_lock(storage_mutex_);
259-
260-
// check local storage
261-
auto it = storage_.find(key);
262-
if(it == storage_.end())
263-
{
264-
// No entry, nothing to do.
265-
return;
266-
}
267-
268-
storage_.erase(it);
269-
}
270-
271261
template <typename T>
272262
inline void Blackboard::set(const std::string& key, const T& value)
273263
{

‎include/behaviortree_cpp/utils/locked_reference.hpp‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ namespace BT
1212
*
1313
* As long as the object remains in scope, the mutex is locked, therefore
1414
* you must destroy this instance as soon as the pointer was used.
15+
*
16+
* LockedPtr does not own the object it points to: the owner is only expected
17+
* to keep it alive while the mutex is locked. In particular, the pointer must
18+
* not be considered valid after calling unlock().
1519
*/
1620
template <typename T>
1721
class LockedPtr

‎src/blackboard.cpp‎

Lines changed: 128 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <tuple>
66
#include <unordered_set>
7+
#include <vector>
78

89
namespace BT
910
{
@@ -14,27 +15,104 @@ bool IsPrivateKey(StringView str)
1415
{
1516
return str.size() >= 1 && str.data()[0] == '_';
1617
}
18+
19+
// Entries removed from a Blackboard while an AnyPtrLocked still held their
20+
// entry_mutex. An AnyPtrLocked does not own the entry, and the only signal that
21+
// the holder is done with it is the release of the mutex, so such entries are
22+
// parked here and destroyed by a later removal that finds them unlocked.
23+
struct DeferredEntries
24+
{
25+
std::mutex mutex;
26+
std::vector<std::shared_ptr<Blackboard::Entry>> entries;
27+
};
28+
29+
DeferredEntries& GetDeferredEntries()
30+
{
31+
// Never destroyed: a Blackboard with static storage duration may be
32+
// destroyed after this object during program exit.
33+
static auto* const instance = new DeferredEntries();
34+
return *instance;
35+
}
36+
37+
// Destroy the entries that were removed from a Blackboard, except those whose
38+
// entry_mutex is still locked by an outstanding AnyPtrLocked: these are parked
39+
// until a later call finds them unlocked. This never blocks, so removing an
40+
// entry while holding a lock on it (from any thread) cannot deadlock.
41+
void ReleaseEntries(std::vector<std::shared_ptr<Blackboard::Entry>> entries)
42+
{
43+
std::vector<std::shared_ptr<Blackboard::Entry>> to_destroy;
44+
auto& deferred = GetDeferredEntries();
45+
{
46+
const std::scoped_lock lock(deferred.mutex);
47+
entries.insert(entries.end(), std::make_move_iterator(deferred.entries.begin()),
48+
std::make_move_iterator(deferred.entries.end()));
49+
deferred.entries.clear();
50+
for(auto& entry : entries)
51+
{
52+
// A removed entry can't be found by getAnyLocked() anymore, so once
53+
// try_lock() succeeds nobody holds it.
54+
if(entry->entry_mutex.try_lock())
55+
{
56+
entry->entry_mutex.unlock();
57+
to_destroy.push_back(std::move(entry));
58+
}
59+
else
60+
{
61+
deferred.entries.push_back(std::move(entry));
62+
}
63+
}
64+
}
65+
// "to_destroy" is released here, outside the lock, because the destructor
66+
// of a stored value may call back into a Blackboard.
67+
}
1768
} // namespace
1869

70+
Blackboard::~Blackboard()
71+
{
72+
// An AnyPtrLocked may still refer to one of the entries: let ReleaseEntries()
73+
// defer the destruction of that entry until the lock is released.
74+
std::vector<std::shared_ptr<Entry>> entries;
75+
entries.reserve(storage_.size());
76+
for(auto& [key, entry] : storage_)
77+
{
78+
entries.push_back(std::move(entry));
79+
}
80+
storage_.clear();
81+
ReleaseEntries(std::move(entries));
82+
}
83+
1984
void Blackboard::enableAutoRemapping(bool remapping)
2085
{
2186
autoremapping_ = remapping;
2287
}
2388

2489
AnyPtrLocked Blackboard::getAnyLocked(const std::string& key)
2590
{
26-
if(auto entry = getEntry(key))
91+
while(auto entry = getEntry(key))
2792
{
28-
return AnyPtrLocked(&entry->value, &entry->entry_mutex);
93+
AnyPtrLocked locked(&entry->value, &entry->entry_mutex);
94+
// Re-check under entry_mutex: the removal paths erase the entry from the
95+
// storage first and destroy it only once its mutex can be acquired (see
96+
// ReleaseEntries). If the key still resolves to this entry, it will stay
97+
// alive as long as the lock is held; otherwise release the lock and look
98+
// the key up again.
99+
if(getEntry(key) == entry)
100+
{
101+
return locked;
102+
}
29103
}
30104
return {};
31105
}
32106

33107
AnyPtrLocked Blackboard::getAnyLocked(const std::string& key) const
34108
{
35-
if(auto entry = getEntry(key))
109+
while(auto entry = getEntry(key))
36110
{
37-
return AnyPtrLocked(&entry->value, const_cast<std::mutex*>(&entry->entry_mutex));
111+
AnyPtrLocked locked(&entry->value, const_cast<std::mutex*>(&entry->entry_mutex));
112+
if(getEntry(key) == entry)
113+
{
114+
return locked;
115+
}
38116
}
39117
return {};
40118
}
@@ -155,10 +233,38 @@ std::vector<StringView> Blackboard::getKeys() const
155233
return out;
156234
}
157235

236+
void Blackboard::unset(const std::string& key)
237+
{
238+
std::vector<std::shared_ptr<Entry>> removed;
239+
{
240+
const std::unique_lock storage_lock(storage_mutex_);
241+
242+
// check local storage
243+
auto it = storage_.find(key);
244+
if(it == storage_.end())
245+
{
246+
// No entry, nothing to do.
247+
return;
248+
}
249+
removed.push_back(std::move(it->second));
250+
storage_.erase(it);
251+
}
252+
ReleaseEntries(std::move(removed));
253+
}
254+
158255
void Blackboard::clear()
159256
{
160-
const std::unique_lock storage_lock(storage_mutex_);
161-
storage_.clear();
257+
std::vector<std::shared_ptr<Entry>> removed;
258+
{
259+
const std::unique_lock storage_lock(storage_mutex_);
260+
removed.reserve(storage_.size());
261+
for(auto& [key, entry] : storage_)
262+
{
263+
removed.push_back(std::move(entry));
264+
}
265+
storage_.clear();
266+
}
267+
ReleaseEntries(std::move(removed));
162268
}
163269

164270
void Blackboard::createEntry(const std::string& key, const TypeInfo& info)
@@ -258,15 +364,24 @@ void Blackboard::cloneInto(Blackboard& dst) const
258364
// Step 3: insert new entries and remove stale ones under dst.storage_mutex_.
259365
if(!new_entries.empty() || !keys_to_remove.empty())
260366
{
261-
const std::unique_lock dst_lock(dst.storage_mutex_);
262-
for(auto& [key, entry] : new_entries)
263-
{
264-
dst.storage_.try_emplace(key, std::move(entry));
265-
}
266-
for(const auto& key : keys_to_remove)
367+
std::vector<std::shared_ptr<Entry>> removed_entries;
267368
{
268-
dst.storage_.erase(key);
369+
const std::unique_lock dst_lock(dst.storage_mutex_);
370+
for(auto& [key, entry] : new_entries)
371+
{
372+
dst.storage_.try_emplace(key, std::move(entry));
373+
}
374+
for(const auto& key : keys_to_remove)
375+
{
376+
auto it = dst.storage_.find(key);
377+
if(it != dst.storage_.end())
378+
{
379+
removed_entries.push_back(std::move(it->second));
380+
dst.storage_.erase(it);
381+
}
382+
}
269383
}
384+
ReleaseEntries(std::move(removed_entries));
270385
}
271386
}
272387

‎tests/gtest_blackboard.cpp‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
#include "behaviortree_cpp/blackboard.h"
1414
#include "behaviortree_cpp/bt_factory.h"
1515

16+
#include <atomic>
17+
#include <memory>
18+
#include <thread>
19+
1620
#include <gtest/gtest.h>
1721

1822
#include "../sample_nodes/dummy_nodes.h"
@@ -303,6 +307,120 @@ TEST(BlackboardTest, AnyPtrLocked)
303307
}
304308
#endif
305309

310+
// An entry removed from the blackboard must stay alive as long as an
311+
// AnyPtrLocked refers to it, and removing it must never block, not even
312+
// from the thread holding the lock.
313+
314+
TEST(BlackboardTest, AnyPtrLockedSurvivesUnset)
315+
{
316+
auto blackboard = Blackboard::create();
317+
blackboard->set("value", 42);
318+
319+
auto locked = blackboard->getAnyLocked("value");
320+
ASSERT_TRUE(bool(locked));
321+
322+
blackboard->unset("value");
323+
ASSERT_TRUE(blackboard->getKeys().empty());
324+
325+
// the entry must stay alive as long as we hold the lock
326+
ASSERT_EQ(locked.get()->cast<int>(), 42);
327+
328+
locked = {};
329+
ASSERT_FALSE(bool(blackboard->getAnyLocked("value")));
330+
}
331+
332+
TEST(BlackboardTest, AnyPtrLockedSurvivesClear)
333+
{
334+
auto blackboard = Blackboard::create();
335+
blackboard->set("value", 42);
336+
337+
auto locked = blackboard->getAnyLocked("value");
338+
ASSERT_TRUE(bool(locked));
339+
340+
blackboard->clear();
341+
ASSERT_TRUE(blackboard->getKeys().empty());
342+
ASSERT_EQ(locked.get()->cast<int>(), 42);
343+
}
344+
345+
TEST(BlackboardTest, AnyPtrLockedSurvivesCloneInto)
346+
{
347+
auto src = Blackboard::create();
348+
auto dst = Blackboard::create();
349+
dst->set("stale", 42);
350+
351+
auto locked = dst->getAnyLocked("stale");
352+
ASSERT_TRUE(bool(locked));
353+
354+
// "stale" doesn't exist in src, so cloneInto() removes it from dst
355+
src->cloneInto(*dst);
356+
ASSERT_TRUE(dst->getKeys().empty());
357+
ASSERT_EQ(locked.get()->cast<int>(), 42);
358+
}
359+
360+
TEST(BlackboardTest, AnyPtrLockedSurvivesBlackboardDestruction)
361+
{
362+
auto blackboard = Blackboard::create();
363+
blackboard->set("value", 42);
364+
365+
auto locked = blackboard->getAnyLocked("value");
366+
ASSERT_TRUE(bool(locked));
367+
368+
blackboard.reset();
369+
ASSERT_EQ(locked.get()->cast<int>(), 42);
370+
}
371+
372+
TEST(BlackboardTest, AnyPtrLockedCrossUnsetDoesNotDeadlock)
373+
{
374+
auto blackboard = Blackboard::create();
375+
blackboard->set("A", 1);
376+
blackboard->set("B", 2);
377+
378+
// Each thread holds a lock on one entry and removes the other one, while
379+
// the other thread does the opposite.
380+
std::atomic<int> ready = 0;
381+
int values[2] = { 0, 0 };
382+
auto hold_and_unset = [&](const char* held, const char* removed, int& out) {
383+
auto locked = blackboard->getAnyLocked(held);
384+
ready++;
385+
while(ready < 2)
386+
{
387+
std::this_thread::yield();
388+
}
389+
blackboard->unset(removed);
390+
out = locked ? locked.get()->cast<int>() : 0;
391+
};
392+
393+
std::thread t1(hold_and_unset, "A", "B", std::ref(values[0]));
394+
std::thread t2(hold_and_unset, "B", "A", std::ref(values[1]));
395+
t1.join();
396+
t2.join();
397+
398+
ASSERT_EQ(values[0], 1);
399+
ASSERT_EQ(values[1], 2);
400+
ASSERT_TRUE(blackboard->getKeys().empty());
401+
}
402+
403+
TEST(BlackboardTest, AnyPtrLockedDeferredEntryIsDestroyed)
404+
{
405+
auto blackboard = Blackboard::create();
406+
auto value = std::make_shared<int>(42);
407+
std::weak_ptr<int> weak_value = value;
408+
blackboard->set("value", value);
409+
value.reset();
410+
411+
{
412+
auto locked = blackboard->getAnyLocked("value");
413+
ASSERT_TRUE(bool(locked));
414+
blackboard->unset("value");
415+
// still alive, since we hold the lock
416+
ASSERT_FALSE(weak_value.expired());
417+
}
418+
// the lock was released: the entry is destroyed by the next removal
419+
blackboard->set("other", 1);
420+
blackboard->unset("other");
421+
ASSERT_TRUE(weak_value.expired());
422+
}
423+
306424
TEST(BlackboardTest, SetStringView)
307425
{
308426
auto bb = Blackboard::create();

0 commit comments

Comments
 (0)