OOB write in 'BaseObject::set'
Summary
DTO field offsets are measured from the start of the most-derived object, but they are applied to m_basePointer, which is initialised inside BaseObject and therefore holds the address of the BaseObject subobject. The two agree only when BaseObject sits at offset 0. Nothing enforces that DTO_INIT creates typedefs and never checks that the extended type is the primary base so a DTO with another base declared first writes its fields past the end of the allocation.
This is distinct from the Z__PROPERTY_OFFSET defect reported separately. That one is about the offset being computed through undefined behaviour, this one is about the correct offset being added to the wrong pointer.
Details
Producer, src/oatpp/codegen/dto/base_define.hpp:66-71, measures from buffer, i.e. from the most derived start:
67 char buffer[sizeof(Z__CLASS)]; \
68 auto obj = static_cast<Z__CLASS*>(reinterpret_cast<void*>(buffer)); \
69 auto ptr = &obj->NAME; \
70 return reinterpret_cast<v_int64>(ptr) - reinterpret_cast<v_int64>(buffer); \Consumer, src/oatpp/data/type/Object.hpp:219:
219 void* m_basePointer = this;That initialiser is evaluated inside BaseObject, so this is the BaseObject subobject address, not the address of the complete object. BaseObject::set (Object.cpp:32-35) adds the offset to it:
32 void BaseObject::set(v_int64 offset, const Void& value) {
33 Void* property = reinterpret_cast<Void*>((reinterpret_cast<v_int64>(m_basePointer)) + offset);
34 *property = value;
35 }get (:37-40) and getAsRef (:42-45) have the identical shape.
For the ordinary chain Derived -> ... -> oatpp::DTO -> BaseObject -> Countable, all single and non-virtual, the two origins coincide: Itanium and MSVC both place the primary base at offset 0, so the derived address and the BaseObject subobject address are numerically equal. Depth does not matter. a two-level DTO chain keeps the same field at the same offset in both.
It breaks as soon as BaseObject is not at offset 0. Under virtual inheritance the offset becomes uncomputable in the first place, because &obj->NAME would have to load a vbase offset through the uninitialised vptr that the Z__PROPERTY_OFFSET defect creates.
setBasePointer already exists for exactly this purpose (Object.cpp:47-49) and has no callers:
47 void BaseObject::setBasePointer(void* basePointer) {
48 m_basePointer = basePointer;
49 }Two possible fixes. Either enforce the offset-0 invariant. DTO_INIT would have to reject a TYPE_EXTEND that is not the primary base, and the constraint needs documenting either way, or call the existing setBasePointer at construction with the most-derived address so both sides agree.
PoC
poc_BaseObject_set.cpp. It contains four static_cast<oatpp::BaseObject*> and no other casts. those are derived-to-base upcasts, which are always well-defined and are simply how Property::set(BaseObject*, const Void&) has to be called. Two of the four only feed a printf that shows the subobject offset.
struct Mixin { virtual ~Mixin() = default; long pad[4]; }; // non-DTO first base
class Plain : public oatpp::DTO { // control: BaseObject at offset 0
DTO_INIT(Plain, DTO)
DTO_FIELD(oatpp::String, s);
};
class Skewed : public Mixin, public oatpp::DTO { // BaseObject at offset 40
DTO_INIT(Skewed, DTO)
DTO_FIELD(oatpp::String, s);
};
// control -- writes the field, no report
pp->set(static_cast<oatpp::BaseObject*>(plain.get()),
oatpp::Void(oatpp::String("ok").getPtr(), oatpp::String::Class::getType()));
// same call shape, offset applied to the BaseObject subobject
sp->set(static_cast<oatpp::BaseObject*>(skewed.get()),
oatpp::Void(oatpp::String("not ok").getPtr(), oatpp::String::Class::getType()));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_BaseObject_set.cpp liboatpp.a -lpthread -o poc_BaseObject_set
ASAN_OPTIONS=halt_on_error=0 UBSAN_OPTIONS=halt_on_error=0 ./poc_BaseObject_set-fsanitize-recover=all is required so the DTO property-offset defect does not abort first.
ASAN output:
Skewed* = 0x... BaseObject sub = +40 &skewed->s = +56
==3009012==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b8327ae0090 ...
WRITE of size 8 at 0x7b8327ae0090 thread T0
[...]
#2 in oatpp::data::type::Void::operator=(Void const&) src/oatpp/data/type/Type.hpp
#3 in oatpp::data::type::BaseObject::set(long, Void const&) src/oatpp/data/type/Object.cpp
#4 in oatpp::data::type::BaseObject::Property::set(BaseObject*, …) src/oatpp/data/type/Object.cpp
#5 in main poc_BaseObject_set.cpp:47:9
0x7b8327ae0090 is located 16 bytes after 96-byte regionImpact
OOB write of a shared_ptr plus a Type* past the end of the object, at a displacement equal to the BaseObject subobject offset. Both get and getAsRef have the same defect, so the read variant exists too.
Source: oatpp/oatpp