#1097·oatpp

Type confusion in 'ObjectToTreeMapper::mapAny'

Author: 2rr0r4o3Created Aug 24, 2026Updated Aug 24, 2026

Summary

AnyHandle stores a shared_ptr<void> next to a const Type* and nothing verifies that the two describe the same object. mapAny reads both fields directly and builds a fresh oatpp::Void from them, which promotes an unvalidated pair into a first-class type tag that the rest of the library trusts. Any::retrieve performs exactly the check that is missing here, and this mapAny bypasses it.

Details

src/oatpp/data/type/Any.hpp:

       class AnyHandle : public base::Countable {
       public:
         AnyHandle(const std::shared_ptr<void>& objPtr, const Type* const objType)
           : ptr(objPtr), type(objType) {}

         std::shared_ptr<void> ptr;
         const Type* const type;
       };

Two independent fields, a public constructor taking them separately, and no invariant tying them together.

src/oatpp/data/mapping/ObjectToTreeMapper.cpp:114-121:

114    void ObjectToTreeMapper::mapAny(const ObjectToTreeMapper* mapper, State& state, const oatpp::Void& polymorph) {
...
119      auto anyHandle = static_cast<data::type::AnyHandle*>(polymorph.get());
120      mapper->map(state, oatpp::Void(anyHandle->ptr, anyHandle->type));
121    }

Line 120 constructs a Void from the handle's two fields as-is. From that point on the pair is indistinguishable from a legitimately typed value, and every consumer downstream keys off it.

The check that should be here already exists in Any::retrieve (src/oatpp/data/type/Any.cpp:71-79):

71     Void Any::retrieve(const Type* type) const {
72       if(m_ptr) {
73         if(!m_ptr->type->extends(type)) {
74           throw std::runtime_error("[oatpp::data::type::Any::retrieve()]: Error. The value type doesn't match.");
75         }
...

and it is the one guard in this codebase that gets the subtype direction right, stored extends requested. mapAny reads the fields instead of calling it.

Fix: route mapAny through retrieve, or repeat the check at line 119. Validating the pair once in AnyHandle's constructor and treating it as an invariant thereafter would be better... (Just my think)

PoC

poc_ObjectToTreeMapper_mapAny.cpp:

cpp
class MyDto : public oatpp::DTO {
  DTO_INIT(MyDto, DTO)
  DTO_FIELD(String, a);
  DTO_FIELD(String, b);
};

// A std::string payload, honestly allocated - but the handle is told it is a MyDto.
auto handle = std::make_shared<oatpp::data::type::AnyHandle>(
    std::make_shared<std::string>("not-a-dto"),
    oatpp::Object<MyDto>::Class::getType());

oatpp::Any any(handle, nullptr);

oatpp::json::ObjectMapper mapper;
auto out = mapper.writeToString(any);
// mapAny (:119,:120) -> map -> mapObject
//   -> static_cast<oatpp::BaseObject*>(polymorph.get())   ObjectToTreeMapper.cpp:260
//   -> field->get(object) -> BaseObject::Property::get    Object.cpp:99

Build and run:

bash
clang++-22 -fsanitize=address,undefined,vptr -fsanitize-recover=all \
  -fno-omit-frame-pointer -frtti -g -O1 -std=c++17 -I<oatpp>/src \
  poc_ObjectToTreeMapper_mapAny.cpp liboatpp.a -lpthread -o poc_ObjectToTreeMapper_mapAny
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0:print_stacktrace=1 ./poc_ObjectToTreeMapper_mapAny

UBSan output:

src/oatpp/data/type/Object.cpp:99:18: runtime error: member call on address 0x... which does not
  point to an object of type '...'
0x...: note: object has a possibly invalid vptr: abs(offset to top) too big
    #0 in oatpp::data::type::BaseObject::Property::get(BaseObject*) const  src/oatpp/data/type/Object.cpp
    #1 in oatpp::data::mapping::ObjectToTreeMapper::mapObject(...)         src/oatpp/data/mapping/ObjectToTreeMapper.cpp
    #2 in oatpp::data::mapping::ObjectToTreeMapper::map(...)               src/oatpp/data/mapping/ObjectToTreeMapper.cpp
    #3 in oatpp::data::mapping::ObjectToTreeMapper::mapAny(...)            src/oatpp/data/mapping/ObjectToTreeMapper.cpp

A second report follows at src/oatpp/data/type/Object.cpp:38:71 for the member access itself.

The promoted tag says Object<MyDto>, so mapObject casts the std::string to BaseObject* and makes a virtual call on it.

Impact

virtual call and a member access on an object that is not of the assumed type. The pointer is honest and the tag is the lie, so the confusion propagates rather than faulting at the point it was introduced. The broader consequence is that mapAny is the entry point for several of the other findings in this set: the mapPrimitive size mismatch read and the mapTree wild dereference both arrive through the Void this function manufactures.

Reachability is application level:

  • constructing an AnyHandle whose fields disagree.