Type confusion in 'ObjectWrapper::cast'
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:
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 triggerBuild 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_ObjectWrapper_cast.cpp liboatpp.a -lpthread -o poc_ObjectWrapper_cast
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0 ./poc_ObjectWrapper_castliboatpp.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 regionImpact
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.
Source: oatpp/oatpp