YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
luci Circle importer β OOB heap read in STRING const-tensor offset-table deserialization
Target: Samsung/ONE β compiler/luci, Circle model importer
File: compiler/luci/import/src/Nodes/CircleConst.cpp β copy_data<loco::DataType::STRING>()
Version: master (reproduced against the current released Circle schema in res/CircleSchema; the vulnerable loop is present after the CVE-2026-6839 / PR #16481 offset-value hardening)
Class: Out-of-bounds heap READ (CWE-125) β attacker-controlled model file
Reachability: default-registered CircleConstNodeBuilder::build(), run for every constant tensor by luci::Importer::importModule() (circle2circle, circle-quantizer, record-minmax, β¦)
Summary
The STRING specialization of copy_data() deserializes a string offset table straight
out of the tensor's backing Buffer via a raw int32_t pointer, reading num_elements + 1
offsets without any check that the Buffer is large enough to contain them.
num_elementsis the product of the tensor's declared shape (attacker-controlled).raw_datais the tensor's Buffer bytes (independently attacker-controlled).
Nothing verifies the Buffer is large enough to hold num_elements + 1 int32 offsets before
they are read. A tiny Buffer paired with a huge declared shape drives the read far past the
end of the heap allocation.
Root cause
// compiler/luci/import/src/Nodes/CircleConst.cpp
template <> void copy_data<loco::DataType::STRING>(const VectorWrapper<uint8_t> &raw_data,
uint32_t num_elements,
CircleConst *const_node)
{
...
const auto *i32d = reinterpret_cast<const int32_t *>(raw_data.data());
// assert(*i32d == num_elements); // no-op under NDEBUG
i32d++; // skip count
std::vector<int32_t> offsets;
offsets.push_back(*i32d++);
for (uint32_t i = 0; i < num_elements; ++i)
offsets.push_back(*i32d++); // <-- OOB READ: no bound vs raw_data.size()
...
// (PR #16481) validate offset VALUES -- runs only AFTER the OOB reads above
for (uint32_t i = 0; i < offsets.size(); ++i) { ... }
}
num_elements + 1 int32 words (here 2,000,001 β ~8 MB) are read from a Buffer that is only
8 bytes long.
Why existing defenses do not cover it
- CVE-2026-6839 / PR #16481 added only offset-value validation (non-negative /
monotonic / bounded by
raw_data.size()). That loop runs after the offsets have already been read out of bounds, so it cannot prevent the over-read β the crash happens during the read loop, before validation is reached. circle::VerifyModelBuffer()checks only structural flatbuffer validity, not this cross-field (shape vs. buffer-size) semantic invariant, so the crafted file passes verification.
Reachability
CircleConstNodeBuilder::build() is the default-registered builder run for every constant
tensor by luci::Importer::importModule(). For a STRING tensor with num_elements > 0 it
dispatches to copy_data<loco::DataType::STRING>(). Every luci-based tool that imports an
untrusted .circle (circle2circle, circle-quantizer, record-minmax, β¦) is affected.
PoC
Faithful flatbuffer harness (same methodology as the two prior accepted Circle findings),
built with the project's own Circle schema (flatc-generated from res/CircleSchema) and the
real flatbuffers runtime. copy_data<STRING>() is copied verbatim from CircleConst.cpp;
only the CircleConst IR node is replaced by a minimal size<>/at<> mock β irrelevant,
since the crash occurs in the offset-read loop before the mock is ever touched.
Files:
build_malicious_string.cppβmalicious_string.circle: one STRING const tensorshape=[2000000](num_elements = 2,000,000) with an 8-byte Buffer. Passescircle::VerifyModelBuffer(244 bytes).repro_string.cppβrepro_string_asan/repro_string_release: verbatimcopy_data<STRING>driven by real schema accessors.benign_string.circle: negative control (Buffer large enough to hold all offsets).
Run
./build_malicious_string malicious_string.circle 2000000 2
./repro_string_asan malicious_string.circle # ASan heap-buffer-overflow READ
./repro_string_release malicious_string.circle # O2/NDEBUG -> SIGSEGV (139)
./repro_string_asan benign_string.circle # negative control -> clean
Captured evidence (verbatim)
[load] read 244 bytes from malicious_string.circle
[verify] circle::VerifyModelBuffer() => PASS
[build] tensor type=STRING(5) shape dims=1 -> num_elements=2000000; buffer bytes=8
[copy_data<STRING>] raw_data.size()=8 bytes; about to read count+ (num_elements=2000000) offsets ...
=================================================================
==1365790==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7cbd6d3e0140 at pc 0x5613b421f7f9 bp 0x7ffe0c90b4f0 sp 0x7ffe0c90b4e8
READ of size 4 at 0x7cbd6d3e0140 thread T0
#0 ... std::__new_allocator<int>::construct<int, int const&>(int*, int const&) new_allocator.h:191
#2 ... std::vector<int, std::allocator<int> >::push_back(int const&) stl_vector.h:1421
#3 ... copy_data_STRING(VectorWrapper<unsigned char> const&, unsigned int, MockCircleConst*) repro_string.cpp:71
#4 ... main repro_string.cpp:122
0x7cbd6d3e0140 is located 0 bytes after 256-byte region [0x7cbd6d3e0040,0x7cbd6d3e0140)
SUMMARY: AddressSanitizer: heap-buffer-overflow repro_string.cpp:71 in copy_data_STRING(...)
Release build (g++ -O2 -DNDEBUG) on the same file:
[verify] circle::VerifyModelBuffer() => PASS
[build] tensor type=STRING(5) shape dims=1 -> num_elements=2000000; buffer bytes=8
[copy_data<STRING>] raw_data.size()=8 bytes; about to read count+ (num_elements=2000000) offsets ...
<process terminates: SIGSEGV, exit code 139>
Negative control (Buffer sized for all offsets):
[build] tensor type=STRING(5) shape dims=1 -> num_elements=2; buffer bytes=24
[copy_data<STRING>] raw_data.size()=24 bytes; about to read count+ (num_elements=2) offsets ...
[done] no crash (offsets read within bounds)
Deduplication
- Distinct from CVE-2026-6839 / PR #16481. That fix validates offset values after they are read; this report is the read itself going out of bounds (a missing buffer-size bound on the read loop). The fix does not address it β verified by including PR #16481's exact validation loop in the harness: the crash occurs before it runs.
- Distinct from the prior accepted Circle findings (opcode-index and other importer OOB
reports); this is a separate code path (
copy_data<STRING>offset-table read) inCircleConst.cpp.
Suggested fix
Before the read loop, require
raw_data.size() >= (num_elements + 2) * sizeof(int32_t) (count + num_elements+1 offsets),
throwing on failure β mirroring the existing offset-value checks but performed before the
raw pointer is dereferenced.