Describe your environment
Reproduced by source read + build on main at 11fa0db0 (also present in v1.28.0, the latest release -- not a regression). sdk/src/logs/logger.cc, Logger::EmitLogRecord.
Steps to reproduce
void Logger::EmitLogRecord(
opentelemetry::nostd::unique_ptr<opentelemetry::logs::LogRecord> &&log_record) noexcept
{
...
if (!log_record)
{
return;
}
std::unique_ptr<Recordable> recordable =
std::unique_ptr<Recordable>(static_cast<Recordable *>(log_record.release()));
recordable->SetResource(context_->GetResource());
recordable->SetInstrumentationScope(GetInstrumentationScope());
...
}
EmitLogRecord takes opentelemetry::logs::LogRecord* -- the public API type -- and unconditionally static_casts it to the SDK's internal Recordable* (which does derive from LogRecord, but that doesn't make every LogRecord a Recordable). Logger::MakeRecordable() is a public, overridable entry point, so a caller (or another SDK/wrapper sitting on top of this one) can legitimately hand EmitLogRecord a LogRecord implementation that is not actually a Recordable.
static_cast between unrelated polymorphic types performs no runtime check -- it just reinterprets the pointer. The very next lines (recordable->SetResource(...), SetInstrumentationScope(...)) then call through a vtable/member layout that doesn't match the real object, which is undefined behavior -- in practice, a call through a garbage vtable slot or a write past the real object's actual size.
What is the expected behavior?
If the supplied LogRecord isn't actually a Recordable, EmitLogRecord should drop it (and log why) rather than operate on it as if it were one.
What is the actual behavior?
The cast always "succeeds" (no runtime check), and the type confusion happens silently until something reads or writes through the mismatched layout.
Additional context
Suggested fix -- use dynamic_cast (RTTI is on by default in this project; LogRecord is already polymorphic, so this is a plain runtime-checked downcast) and reject the record if it doesn't actually hold a Recordable. Checking on .get() before releasing means a rejected record is destroyed exactly as before, through the nostd::unique_ptr's own deleter -- no change to how a foreign LogRecord implementation gets torn down on the "not applicable" path:
+#include "opentelemetry/sdk/common/global_log_handler.h"
#include "opentelemetry/sdk/instrumentationscope/instrumentation_scope.h"
...
if (!log_record)
{
return;
}
- std::unique_ptr<Recordable> recordable =
- std::unique_ptr<Recordable>(static_cast<Recordable *>(log_record.release()));
+ Recordable *recordable_ptr = dynamic_cast<Recordable *>(log_record.get());
+ if (recordable_ptr == nullptr)
+ {
+ OTEL_INTERNAL_LOG_WARN(
+ "[Logger::EmitLogRecord] Dropping log record: not a Recordable implementation.");
+ return;
+ }
+ log_record.release();
+ std::unique_ptr<Recordable> recordable(recordable_ptr);
recordable->SetResource(context_->GetResource());
recordable->SetInstrumentationScope(GetInstrumentationScope());
The success path is unchanged -- same object, same ownership transfer, same subsequent calls -- so this only affects the case that was previously undefined behavior. Compile-checked against a clean build of this file -- no warnings or errors. Happy to open a PR with this if useful.
Describe your environment
Reproduced by source read + build on
mainat11fa0db0(also present in v1.28.0, the latest release -- not a regression).sdk/src/logs/logger.cc,Logger::EmitLogRecord.Steps to reproduce
EmitLogRecordtakesopentelemetry::logs::LogRecord*-- the public API type -- and unconditionallystatic_casts it to the SDK's internalRecordable*(which does derive fromLogRecord, but that doesn't make everyLogRecordaRecordable).Logger::MakeRecordable()is a public, overridable entry point, so a caller (or another SDK/wrapper sitting on top of this one) can legitimately handEmitLogRecordaLogRecordimplementation that is not actually aRecordable.static_castbetween unrelated polymorphic types performs no runtime check -- it just reinterprets the pointer. The very next lines (recordable->SetResource(...),SetInstrumentationScope(...)) then call through a vtable/member layout that doesn't match the real object, which is undefined behavior -- in practice, a call through a garbage vtable slot or a write past the real object's actual size.What is the expected behavior?
If the supplied
LogRecordisn't actually aRecordable,EmitLogRecordshould drop it (and log why) rather than operate on it as if it were one.What is the actual behavior?
The cast always "succeeds" (no runtime check), and the type confusion happens silently until something reads or writes through the mismatched layout.
Additional context
Suggested fix -- use
dynamic_cast(RTTI is on by default in this project;LogRecordis already polymorphic, so this is a plain runtime-checked downcast) and reject the record if it doesn't actually hold aRecordable. Checking on.get()before releasing means a rejected record is destroyed exactly as before, through thenostd::unique_ptr's own deleter -- no change to how a foreignLogRecordimplementation gets torn down on the "not applicable" path:The success path is unchanged -- same object, same ownership transfer, same subsequent calls -- so this only affects the case that was previously undefined behavior. Compile-checked against a clean build of this file -- no warnings or errors. Happy to open a PR with this if useful.