Skip to content

Commit 9d6ffbf

Browse files
facontidavideaysha-afrah26claude
authored
fix use-after-free of a blackboard entry held by getAnyLocked (alternative to #1181) (#1202)
* fix use-after-free of a blackboard entry held by getAnyLocked AnyPtrLocked holds raw pointers into a Blackboard::Entry and can not own it without changing its layout (ABI). Attach a custom deleter to every Entry at its two allocation sites: an entry whose last shared_ptr is dropped while its mutex is still locked is parked, and destroyed by a later retirement that finds it unlocked. Being attached at allocation time, the deleter covers unset(), clear(), cloneInto() and ~Blackboard with no change to them, including the unset() already inlined in binaries built against the 4.10 headers. No header change. Alternative to #1181; the AnyPtrLocked* regression tests come from that PR. Co-Authored-By: Aysha Afrah Ziya <aysha26@digiscrypt.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * blackboard entry deleter: noexcept fast path, destroy values outside the storage lock Changes requested by two independent reviews: - RetireEntry is noexcept, and neither locks nor allocates when nothing is parked - unset(), clear() and cloneInto() destroy the removed values after releasing storage_mutex_ - parked entries are also swept when a new entry is created, to bound the delay of the stored value's destructor - document that LockedPtr is not valid after unlock() Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Aysha Afrah Ziya <aysha26@digiscrypt.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 7c1ff81 commit 9d6ffbf

4 files changed

Lines changed: 277 additions & 4 deletions

File tree

‎include/behaviortree_cpp/blackboard.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ inline T Blackboard::get(const std::string& key) const
255255

256256
inline void Blackboard::unset(const std::string& key)
257257
{
258+
// the entry (i.e. the stored value) is destroyed outside the lock
259+
std::shared_ptr<Entry> removed;
258260
std::unique_lock storage_lock(storage_mutex_);
259261

260262
// check local storage
@@ -265,6 +267,7 @@ inline void Blackboard::unset(const std::string& key)
265267
return;
266268
}
267269

270+
removed = std::move(it->second);
268271
storage_.erase(it);
269272
}
270273

‎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: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
#include "behaviortree_cpp/json_export.h"
44

5+
#include <algorithm>
6+
#include <atomic>
7+
#include <mutex>
58
#include <tuple>
69
#include <unordered_set>
10+
#include <vector>
711

812
namespace BT
913
{
@@ -14,6 +18,94 @@ bool IsPrivateKey(StringView str)
1418
{
1519
return str.size() >= 1 && str.data()[0] == '_';
1620
}
21+
22+
// Blackboard::getAnyLocked() returns an AnyPtrLocked: raw pointers to the value
23+
// and to the (locked) entry_mutex of an Entry. It does not own the Entry and its
24+
// layout can not change (ABI), so the only sign that the holder is done is the
25+
// release of the mutex. Therefore an Entry whose last shared_ptr is dropped
26+
// (unset(), clear(), cloneInto(), ~Blackboard) while its mutex is still locked
27+
// is parked here, and destroyed later by the first RetireEntry() that finds it
28+
// unlocked.
29+
struct RetiredEntries
30+
{
31+
std::mutex mutex;
32+
std::vector<Blackboard::Entry*> entries;
33+
std::atomic_size_t count = 0;
34+
};
35+
36+
RetiredEntries& GetRetiredEntries()
37+
{
38+
// Never destroyed: a Blackboard with static storage duration may be
39+
// destroyed after this object during program exit.
40+
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
41+
static auto* const instance = new RetiredEntries();
42+
return *instance;
43+
}
44+
45+
// No shared_ptr is left when this is called, so nobody can lock the entry
46+
// anymore: if try_lock() succeeds, the entry is unreachable.
47+
// Note: the calling thread may be the one holding the AnyPtrLocked. A
48+
// try_lock() on a std::mutex owned by the caller is formally undefined, but
49+
// it simply fails on all the supported platforms (pthread: EBUSY).
50+
bool IsUnlocked(Blackboard::Entry* entry)
51+
{
52+
if(entry->entry_mutex.try_lock())
53+
{
54+
entry->entry_mutex.unlock();
55+
return true;
56+
}
57+
return false;
58+
}
59+
60+
// Destroy the parked entries that have been unlocked in the meantime.
61+
// If "retired" is not null, it is parked first.
62+
void SweepRetiredEntries(Blackboard::Entry* retired = nullptr) noexcept
63+
{
64+
auto& parked = GetRetiredEntries();
65+
std::vector<Blackboard::Entry*> unlocked;
66+
try
67+
{
68+
const std::scoped_lock lock(parked.mutex);
69+
if(retired != nullptr)
70+
{
71+
parked.entries.push_back(retired);
72+
}
73+
const auto it = std::partition(parked.entries.begin(), parked.entries.end(),
74+
[](auto* entry) { return !IsUnlocked(entry); });
75+
unlocked.assign(it, parked.entries.end());
76+
parked.entries.erase(it, parked.entries.end());
77+
parked.count = parked.entries.size();
78+
}
79+
catch(...) // NOLINT(bugprone-empty-catch)
80+
{
81+
// Out of memory. Whatever could not be moved to "unlocked" remains parked
82+
// (or leaks, in the case of "retired"), which is safe.
83+
}
84+
// Destroyed outside the lock: the destructor of a stored value may remove
85+
// other entries, i.e. call this function again.
86+
for(auto* entry : unlocked)
87+
{
88+
delete entry; // NOLINT(cppcoreguidelines-owning-memory)
89+
}
90+
}
91+
92+
// Deleter of every shared_ptr<Entry>. Being attached at allocation time, it
93+
// covers all the removal paths, including those inlined in the user's binary.
94+
void RetireEntry(Blackboard::Entry* retired) noexcept
95+
{
96+
// Common case: nothing is parked and nobody holds this entry
97+
if(GetRetiredEntries().count == 0 && IsUnlocked(retired))
98+
{
99+
delete retired; // NOLINT(cppcoreguidelines-owning-memory)
100+
return;
101+
}
102+
SweepRetiredEntries(retired);
103+
}
104+
105+
std::shared_ptr<Blackboard::Entry> MakeEntry(const TypeInfo& info)
106+
{
107+
return { new Blackboard::Entry(info), &RetireEntry };
108+
}
17109
} // namespace
18110

19111
void Blackboard::enableAutoRemapping(bool remapping)
@@ -157,8 +249,10 @@ std::vector<StringView> Blackboard::getKeys() const
157249

158250
void Blackboard::clear()
159251
{
252+
// the entries (i.e. the stored values) are destroyed outside the lock
253+
decltype(storage_) removed;
160254
const std::unique_lock storage_lock(storage_mutex_);
161-
storage_.clear();
255+
removed.swap(storage_);
162256
}
163257

164258
void Blackboard::createEntry(const std::string& key, const TypeInfo& info)
@@ -248,7 +342,7 @@ void Blackboard::cloneInto(Blackboard& dst) const
248342
{
249343
// create new entry from src
250344
std::scoped_lock src_lock(task.src->entry_mutex);
251-
auto new_entry = std::make_shared<Entry>(task.src->info);
345+
auto new_entry = MakeEntry(task.src->info);
252346
new_entry->value = task.src->value;
253347
new_entry->string_converter = task.src->string_converter;
254348
new_entries.emplace_back(task.key, std::move(new_entry));
@@ -258,14 +352,20 @@ void Blackboard::cloneInto(Blackboard& dst) const
258352
// Step 3: insert new entries and remove stale ones under dst.storage_mutex_.
259353
if(!new_entries.empty() || !keys_to_remove.empty())
260354
{
355+
// the stale entries are destroyed outside the lock
356+
std::vector<std::shared_ptr<Entry>> removed;
261357
const std::unique_lock dst_lock(dst.storage_mutex_);
262358
for(auto& [key, entry] : new_entries)
263359
{
264360
dst.storage_.try_emplace(key, std::move(entry));
265361
}
266362
for(const auto& key : keys_to_remove)
267363
{
268-
dst.storage_.erase(key);
364+
if(auto it = dst.storage_.find(key); it != dst.storage_.end())
365+
{
366+
removed.push_back(std::move(it->second));
367+
dst.storage_.erase(it);
368+
}
269369
}
270370
}
271371
}
@@ -291,6 +391,12 @@ std::shared_ptr<Blackboard::Entry> Blackboard::createEntryImpl(const std::string
291391
return rootBlackboard()->createEntryImpl(key.substr(1, key.size() - 1), info);
292392
}
293393

394+
// Bound the lifetime of the entries that were removed while locked
395+
if(GetRetiredEntries().count != 0)
396+
{
397+
SweepRetiredEntries();
398+
}
399+
294400
const std::unique_lock storage_lock(storage_mutex_);
295401
// This function might be called recursively, when we do remapping, because we move
296402
// to the top scope to find already existing entries
@@ -336,7 +442,7 @@ std::shared_ptr<Blackboard::Entry> Blackboard::createEntryImpl(const std::string
336442
}
337443
// not remapped, not found. Create locally.
338444

339-
auto entry = std::make_shared<Entry>(info);
445+
auto entry = MakeEntry(info);
340446
// even if empty, let's assign to it a default type
341447
entry->value = Any(info.type());
342448
storage_.insert({ key, entry });

‎tests/gtest_blackboard.cpp‎

Lines changed: 160 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,162 @@ 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 when the next one is
419+
// created (or removed)
420+
blackboard->set("other", 1);
421+
ASSERT_TRUE(weak_value.expired());
422+
}
423+
424+
// Stress test, meaningful with ASan/TSan: readers hold an AnyPtrLocked while
425+
// another thread keeps removing and re-creating the same entry.
426+
TEST(BlackboardTest, AnyPtrLockedConcurrentUnsetStress)
427+
{
428+
auto blackboard = Blackboard::create();
429+
std::atomic_bool stop = false;
430+
std::atomic_int reads = 0;
431+
432+
auto reader = [&]() {
433+
while(!stop)
434+
{
435+
if(auto locked = blackboard->getAnyLocked("value"))
436+
{
437+
// set() inserts the entry before assigning the value: it may be empty.
438+
// Otherwise, read it while the writer may unset the key.
439+
if(!locked->empty())
440+
{
441+
ASSERT_EQ(locked->cast<int>(), 42);
442+
reads++;
443+
}
444+
}
445+
}
446+
};
447+
std::thread reader_a(reader);
448+
std::thread reader_b(reader);
449+
450+
for(int i = 0; i < 5000; i++)
451+
{
452+
blackboard->set("value", 42);
453+
blackboard->unset("value");
454+
}
455+
// make sure that the readers had the chance to run
456+
blackboard->set("value", 42);
457+
while(reads == 0)
458+
{
459+
std::this_thread::yield();
460+
}
461+
stop = true;
462+
reader_a.join();
463+
reader_b.join();
464+
}
465+
306466
TEST(BlackboardTest, SetStringView)
307467
{
308468
auto bb = Blackboard::create();

0 commit comments

Comments
 (0)