Skip to content

Commit 8e22f83

Browse files
fix stack overflow on nested JSON in JsonToInteger and fromJson (#1274)
1 parent 06f9781 commit 8e22f83

7 files changed

Lines changed: 89 additions & 8 deletions

File tree

‎include/behaviortree_cpp/json_export.h‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,11 @@ template <typename T>
290290
{
291291
static_assert(std::is_integral_v<T> && !std::is_same_v<T, bool>);
292292
const auto not_an_integer = [&value]() {
293-
return RuntimeError(StrCat("JSON value [", value.dump(),
294-
"] is not an integer of type ", BT::demangle(typeid(T))));
293+
// dump() recurses once per nesting level and the value may come from an
294+
// untrusted JSON text: a deeply nested array would exhaust the stack here.
295+
const std::string text = value.is_structured() ? value.type_name() : value.dump();
296+
return RuntimeError(StrCat("JSON value [", text, "] is not an integer of type ",
297+
BT::demangle(typeid(T))));
295298
};
296299
if(value.is_number_unsigned())
297300
{

‎src/bt_factory.cpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,8 @@ void BehaviorTreeFactory::loadSubstitutionRuleFromJSON(const std::string& json_t
551551
// substitution rules that map to already-registered node types.
552552
if(json.contains("TestNodeConfigs"))
553553
{
554-
auto test_configs = json.at("TestNodeConfigs");
554+
// Not a copy: it would recurse once per nesting level of the JSON text
555+
const auto& test_configs = json.at("TestNodeConfigs");
555556
for(auto const& [name, test_config] : test_configs.items())
556557
{
557558
auto& config = configs[name];
@@ -595,7 +596,7 @@ void BehaviorTreeFactory::loadSubstitutionRuleFromJSON(const std::string& json_t
595596
}
596597
}
597598

598-
auto substitutions = json.at("SubstitutionRules");
599+
const auto& substitutions = json.at("SubstitutionRules");
599600
for(auto const& [node_name, test] : substitutions.items())
600601
{
601602
auto test_name = test.get<std::string>();

‎src/json_export.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ JsonExporter::ExpectedEntry JsonExporter::fromJson(const nlohmann::json& source)
108108
// basic vectors
109109
if(source.is_array() && source.size() > 0 && !source.contains("__type"))
110110
{
111-
const auto first_element = source[0];
111+
// Not a copy: it would recurse once per nesting level of the untrusted value
112+
const auto& first_element = source[0];
112113
if(first_element.is_string())
113114
{
114115
return Entry{ BT::Any(source.get<std::vector<std::string>>()),

‎tests/groot2_test_utils.hpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,11 @@ inline nlohmann::json makeHook(uint16_t uid, BT::Monitor::Hook::Mode mode,
6161
class Client
6262
{
6363
public:
64-
explicit Client(unsigned port) : socket_(context_, ZMQ_REQ)
64+
explicit Client(unsigned port, int timeout_ms = 2000) : socket_(context_, ZMQ_REQ)
6565
{
6666
socket_.set(zmq::sockopt::linger, 0);
67-
socket_.set(zmq::sockopt::rcvtimeo, 2000);
68-
socket_.set(zmq::sockopt::sndtimeo, 2000);
67+
socket_.set(zmq::sockopt::rcvtimeo, timeout_ms);
68+
socket_.set(zmq::sockopt::sndtimeo, timeout_ms);
6969
socket_.connect("tcp://127.0.0.1:" + std::to_string(port));
7070
}
7171

‎tests/gtest_groot2_publisher_integration.cpp‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,40 @@ void incompleteHookScenario()
7676
std::exit(EXIT_SUCCESS);
7777
}
7878

79+
void deeplyNestedHookScenario()
80+
{
81+
BT::BehaviorTreeFactory factory;
82+
auto tree = factory.createTreeFromText(R"(
83+
<root BTCPP_format="4">
84+
<BehaviorTree ID="MainTree">
85+
<AlwaysSuccess/>
86+
</BehaviorTree>
87+
</root>
88+
)");
89+
auto [publisher, port] = Groot2Test::makePublisher(tree);
90+
// Under the sanitizers, parsing the request below takes a few seconds.
91+
Groot2Test::Client client(port, 30000);
92+
93+
// The error for a "uid" that isn't an integer used to dump() the value, which
94+
// recurses once per nesting level and overflowed the stack of the server thread.
95+
constexpr size_t depth = 100000;
96+
const std::string nested = std::string(depth, '[') + std::string(depth, ']');
97+
auto error_reply = client.rawRequest(BT::Monitor::RequestType::HOOK_INSERT,
98+
R"({"uid":)" + nested + R"(,"position":0})");
99+
if(error_reply.size() != 2 || error_reply[0].to_string() != "error")
100+
{
101+
std::exit(EXIT_FAILURE);
102+
}
103+
104+
// The server thread must remain usable after rejecting the nested request.
105+
auto valid_reply = client.request(BT::Monitor::RequestType::FULLTREE);
106+
if(valid_reply.size() != 2)
107+
{
108+
std::exit(EXIT_FAILURE);
109+
}
110+
std::exit(EXIT_SUCCESS);
111+
}
112+
79113
BT::Tree createTreeWithNamedSubtrees(BT::BehaviorTreeFactory& factory,
80114
const BT::Blackboard::Ptr& main_blackboard)
81115
{
@@ -207,6 +241,12 @@ TEST(Groot2PublisherIntegration, IncompleteHookRequestDoesNotTerminateServer)
207241
EXPECT_EXIT(incompleteHookScenario(), ::testing::ExitedWithCode(EXIT_SUCCESS), ".*");
208242
}
209243

244+
TEST(Groot2PublisherIntegration, DeeplyNestedHookRequestDoesNotTerminateServer)
245+
{
246+
::testing::FLAGS_gtest_death_test_style = "threadsafe";
247+
EXPECT_EXIT(deeplyNestedHookScenario(), ::testing::ExitedWithCode(EXIT_SUCCESS), ".*");
248+
}
249+
210250
TEST(Groot2PublisherIntegration, BindToLoopbackAddress)
211251
{
212252
BT::BehaviorTreeFactory factory;

‎tests/gtest_json.cpp‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,26 @@ TEST_F(JsonTest, IntegerValuesAreRangeChecked)
389389
BT::RuntimeError);
390390
}
391391

392+
// nlohmann::json parses and destroys a nested value iteratively, but dumping and
393+
// copying it recurse once per nesting level, so a short text can exhaust the stack.
394+
TEST_F(JsonTest, DeeplyNestedValueDoesNotExhaustStack)
395+
{
396+
using nlohmann::json;
397+
constexpr size_t depth = 100000;
398+
const std::string nested = std::string(depth, '[') + std::string(depth, ']');
399+
400+
// The error message used to include value.dump()
401+
ASSERT_THROW(std::ignore = BT::details::JsonToInteger<int>(json::parse(nested)),
402+
BT::RuntimeError);
403+
ASSERT_THROW(std::ignore = BT::convertFromString<std::vector<int>>("json:" + nested),
404+
BT::RuntimeError);
405+
406+
// fromJson() used to copy the first element to pick the type of the array
407+
auto blackboard = BT::Blackboard::create();
408+
BT::ImportBlackboardFromJSON(json::parse(R"({"nested":)" + nested + "}"), *blackboard);
409+
ASSERT_FALSE(blackboard->getEntry("nested"));
410+
}
411+
392412
TEST_F(JsonTest, VectorSring)
393413
{
394414
BT::JsonExporter& exporter = BT::JsonExporter::get();

‎tests/gtest_substitution.cpp‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,22 @@ TEST(Substitution, IncompatibleTypeSubstitutionThrows)
610610
}
611611
}
612612

613+
// loadSubstitutionRuleFromJSON() used to copy these two sections of the JSON, and
614+
// copying a json recurses once per nesting level.
615+
TEST(Substitution, DeeplyNestedJsonDoesNotExhaustStack)
616+
{
617+
constexpr size_t depth = 100000;
618+
const std::string nested = std::string(depth, '[') + std::string(depth, ']');
619+
620+
BehaviorTreeFactory factory;
621+
EXPECT_THROW(factory.loadSubstitutionRuleFromJSON(R"({"TestNodeConfigs":)" + nested +
622+
R"(,"SubstitutionRules":{}})"),
623+
RuntimeError);
624+
EXPECT_THROW(
625+
factory.loadSubstitutionRuleFromJSON(R"({"SubstitutionRules":)" + nested + "}"),
626+
std::exception);
627+
}
628+
613629
// Destroying a TestNode while its async_delay timer is pending makes
614630
// ~TimerQueue() run the handler (aborted) during the PImpl deletion. The
615631
// handler used to reach the PImpl through _p, which std::unique_ptr may have

0 commit comments

Comments
 (0)