From Memory Corruption to a Working Exploit

This post walks through turning CVE-2023-43641, an out-of-bounds write in libcue, into a reliable 1-click remote code execution on Ubuntu 23.04 and Fedora 38. libcue parses cue sheets, a metadata format describing CD track layouts. The flaw allows an attacker to control both the array index and the value written, making it particularly powerful. Because tracker-miners automatically scans files in ~/Downloads and uses libcue on any file with a .cue extension, a single click on a malicious webpage is enough to trigger the bug.

If you understand C but are new to exploit development, this serves as an introduction to the core concepts. The techniques described range from reusable glibc malloc tricks to code specific to the tracker-extract and libcue implementations. The end goal is to get tracker-extract to execute a harmless command—the classic "pop a calc" proof, but with an added twist to start the calculator showing 1337:

gnome-calculator -e 1337

The Mitigation Landscape

Most of the work in exploiting a memory corruption bug goes into bypassing modern defenses. On Linux, the major ones are:

  • No-execute memory
  • ASLR
  • Stack canaries
  • Integrity checks in glibc malloc
  • Sandboxing

No-Execute Memory

In the past, a simple buffer overflow could place shellcode on the stack and jump to it. That's no longer possible—by default, the stack and heap are marked rw-, readable and writable but not executable. The only way around this is if the developer explicitly calls mprotect, which is rare outside of JIT compilers. Instead of injecting new code, an attacker must reuse what's already in the process, where code segments are r-x but never writable.

ASLR

No-execute memory alone isn't a major obstacle—you can simply overwrite a code pointer, such as a return address, to redirect execution. This is the idea behind ROP chains. A prime target is glibc's system function, which executes arbitrary shell commands. ASLR works against this by randomizing memory addresses so you can't predict where system or other useful functions live. This exploit doesn't use a ROP chain; it overwrites a function pointer on the heap instead, but defeating ASLR is still the hardest part of the process.

Stack Canaries

Canaries are random 64-bit values placed at the top of each stack frame, checked before a function returns. They defeat contiguous stack overflows via things like unbounded memcpy, because overwriting the return address also corrupts the canary. However, canaries are useless against out-of-bounds writes that target specific stack locations, and they don't help at all when the corruption happens on the heap. CVE-2023-43641 falls into the latter category, so canaries are irrelevant here.

glibc malloc Integrity Checks

When a bug corrupts the heap, glibc's allocator often becomes the stepping stone to code execution. By overwriting malloc metadata, you can trick it into behaviors like allocating the same block twice. This turns the allocator into a "weird machine," where its unintended logic becomes a set of instructions you can chain together—often the most useful one in the process because every program uses it. For example, getting two allocations at the same address lets you corrupt one object's fields through the other.

To prevent this, glibc has accumulated many integrity checks over the years, some of which emit errors like:

free(): double free detected in tcache 2
Aborted (core dumped)

These messages help debug corruption and make exploits harder. A relatively recent addition is safe linking, introduced in glibc 2.32. It XOR-signs certain pointers—annoying for debugging, but it has almost no practical effect on the techniques used here.

Sandboxing

Unlike the previous mitigations, sandboxing must be deliberately enabled by the developer. Tracker-extract runs inside a seccomp sandbox that forbids certain system calls, including fork and exec. However, there's a well-known loophole in tracker-extract's sandbox configuration, which is exploited here—sandbox escapes are a familiar problem, particularly in web browsers.

Are Mitigations Infallible?

These defenses make exploitation significantly harder but not impossible. Some bugs become unexploitable—stack canaries defeat the specific class they target—yet high-value exploits still sell for substantial sums, and vulnerabilities like CVE-2023-4911 show how ASLR can be brute-forced in minutes due to its limited 19 bits of entropy for stacks. Remote exploits have a harder time with brute force because they often only get one attempt, but as this example shows, a well-crafted exploit can succeed on the first click, making mitigations costly—both in performance and in the false sense of security they provide.

Building the exploit step by step

With the mitigations out of the way, exploit development becomes a matter of finding ways to work around them. Think of it like a treasure hunt where there is no direct path to the goal: you need to use what you find along the way to construct a route. The plan here is to research how libcue is used by tracker-extract, search for useful gadgets in the code, and then put together an exploit that chains them.

Thread context and what happens when the bug fires

As discussed, tracker-miners runs two processes: tracker-miner-fs runs continuously, while tracker-extract is started on demand when a new file lands in ~/Downloads. The exploit targets tracker-extract because that's where libcue is used. The vulnerability is in track_set_index:

track->index[i] = ind;

Both i and ind are fully attacker-controlled, so this is effectively an arbitrary 64-bit write at an arbitrary offset from the heap-based track pointer, with two constraints:

  1. i must be negative.
  2. ind is parsed with atoi, so only the lower 32 bits of the written value are controllable.

When the bug fires, the call stack looks like this:

  1. track_set_index (ind=-8984, i=-2864, track=0x7f2270017f00)
  2. yyparse ()
  3. cue_parse_string (string=0x7f2282060010 "PERFORMER Kev"...)
  4. tracker_extract_get_metadata (info=0x7f2270000d10, error=0x7f2282a0f2f0)
  5. get_file_metadata (task=task@entry=0x55c712f00440, info_out=info_out@entry=0x7f2282a0f2e8, error=error@entry=0x7f2282a0f2f0)
  6. get_metadata (task=0x55c712f00440)
  7. single_thread_get_metadata (queue=0x55c712eed750)
  8. g_thread_proxy (data=0x55c712ef97c0)
  9. start_thread (arg=<optimized out>)

Why this call stack matters

Three observations from this trace guide the exploit design.

1. This is a fresh thread. Tracker-extract spawns a new thread to run libcue, which makes heap layout extremely predictable. That helps with planning writes relative to fixed offsets.

2. Tracker-extract uses glib. GNOME's glib framework uses function pointers extensively, especially through its object system. The best target is the dispose method, which g_object_unref invokes:

G_OBJECT_GET_CLASS (object)->dispose (object);

That g_object_unref call happens inside tracker_extract_info_unref, the cleanup callback for the info object (a TrackerExtractInfo) allocated at the top of get_file_metadata. Because info is a heap object that we can overwrite, it's a reliable target: corrupt it and tracker_extract_info_unref will dispatch through a controlled function pointer when it runs after get_file_metadata returns.

3. The bug triggers during parsing. Every time the downloaded file contains the right line:

INDEX 4294964432 4294958312

the bug is exercised. Since the bug is triggered repeatedly and can be interleaved with other statements like TITLE and TRACK, the heap can be corrupted incrementally. The endgame: reshape info into a fake object hierarchy that, when unref'd, calls a chosen function.

Memory layout within the arena

ASLR randomizes absolute addresses, but relative distances between freshly allocated objects stay constant. Two consecutive malloc(0x500) calls, for instance, always produce chunks whose addresses differ by exactly 0x510, because fresh allocations come from a contiguous block. An out-of-bounds write exploits exactly this: you don't need to know an absolute address if you target writes relative to something you control.

The malloc implementation gets big memory regions from the OS via mmap or brk and carves them into malloc_chunk objects (chunks). Two data structures manage them: the malloc_state and the tcache_perthread_struct (tcache). The tcache caches recently freed small chunks for fast reuse, while malloc_state holds larger blocks that get divided on demand. Multithreaded apps give each thread its own mmap-backed arena with its own malloc_state and tcache. The libcue thread's arena is diagrammed here:

diagram of the layout of the per-thread arena of the libcue thread

Key detail: the base address of the mmap-ed arena is always aligned to a multiple of 0x4000000. ASLR only randomizes the upper bits, leaving the lower three bytes free of randomization. That makes it possible to forge an address by overwriting a pointer's last three bytes with part of a string. For example, the three-byte string "PA" (P is 0x50, A is 0x41) can turn 0x7f227001c500 into 0x7f2270004150:

The trailing null byte overwrites the third byte with 0x00, so forged offsets can't exceed 0xffff. Most objects of interest, like info at 0xd10, fit well within that range. The relative distances are also constant per distribution: info is always at offset 0xd10 from the mmap base, while the parse-tree root cd is always at 0x125e0. Distances vary slightly between Fedora 38 and Ubuntu 23.04, but within a distribution they never change.

Stuck on an island

Within the per-thread arena, fairly arbitrary manipulation is possible: fake objects, forged pointers to other arena contents, controlled allocations and frees. But nothing outside the arena is directly reachable, for two reasons:

  1. Other memory regions — the stack, code, and global data — have independent ASLR offsets that remain unknown.
  2. The libcue write primitive only reaches up to 16GB from the current object, which can't span across disparate ASLR-randomized regions.

The island-bound pirate, again: local resources are easy to manipulate, but the distant treasure (code elsewhere) still needs a navigation aid. glib's object model provides the way out. Instead of escaping the arena, we can build a fake object tree entirely inside it that will cause a controlled function call when the program lets go of it. The function pointer itself must still be computed without its absolute ASLR value.

To compute that pointer, we need a leak-like reference. By searching the arena's memory with gdb's x/512gx command, we can find that g_file_input_stream_real_query_info_finish is always stored at offset 0xe60:

static void
g_file_input_stream_class_init (GFileInputStreamClass *klass)
{
  klass->query_info_async = g_file_input_stream_real_query_info_async;
  klass->query_info_finish = g_file_input_stream_real_query_info_finish;
}

That pointer is set during g_file_load_contents, before libcue parsing begins, and stays there throughout parsing. This is the telescope: we know the location of one code pointer, in a known shared object (libgio). We don't want to execute that exact function, but we can get to the real target, initable_init, by adding the fixed offset between the two: 0x3b290.

Unlike heap pointers, code pointers have strong ASLR; they can't be forged by writing three bytes. We need a primitive that can add to a value in memory. And we need to be careful not to destroy the original pointer while doing so.

Gadgets in the parser

Some parts of this section will be libcue-specific, but the technique — hunting for functions that let you allocate, free, write, and add — generalizes to other targets.

Allocating and freeing strings. Statements like the following allocate a fresh string, freeing a previous one if it exists:

FILE pwned.mp3 MP3
ISRC abcd-efg
TITLE "my title"
PERFORMER "Kev"

A TITLE statement, for example, does exactly this:

void cdtext_set(int pti, char *value, Cdtext *cdtext)
{
    if (NULL != value)  /* don't pass NULL to strdup */
        for (; PTI_END != cdtext->pti; cdtext++)
            if (pti == cdtext->pti) {
                free (cdtext->value);
                cdtext->value = strdup (value);
            }
}

It calls free on the old title (NULL on the first call) and then strdup to allocate a new copy. This lets us allocate memory, corrupt its metadata with the overflow, then free it again — returning a fake chunk into the allocator's caches. The cdtext key-value store could theoretically be abused to place an allocation or free at a controlled address by forging the table, but there's an easier route through cd_add_track.

Writing a pointer anywhere. The cue parser lets us add tracks:

TRACK pwned.mp3 MP3

That triggers cd_add_track:

Track *cd_add_track(Cd *cd)
{
    if (MAXTRACK > cd->ntrack)
        cd->ntrack++;
    else
        fprintf(stderr, "too many tracks\n");

    /* this will reinit last track if there were too many */
    cd->track[cd->ntrack - 1] = track_init();

    return cd->track[cd->ntrack - 1];
}

This function checks the track count against MAXTRACK==99 to avoid an overflow of the fixed-size cd->track array. But the guard is flawed: if the count is too high, it prints an error but still proceeds to write into cd->track. By overwriting cd->ntrack with a large value (say, 1000) using the main write primitive, calling cd_add_track then stores a fresh track's pointer at cd->track[999]. That gives the ability to drop a valid heap pointer at an arbitrary arena offset — particularly useful for building fake object hierarchies and for placing pointers where their low bytes can be overwritten later to forge an address.

Adding an offset. The critical primitive for an ASLR bypass is addition. The needed computation is simple: take the pointer at 0xe60 and add 0x3b290 to get initable_init's address. The one gadget that does arithmetic is part of the parser that triggers the main vulnerability, just before track_set_index is called:

| INDEX NUMBER time '\n' {
    long prev_length;

    /* Set previous track length if it has not been set */
    if (NULL != prev_track && NULL == cur_filename
        && track_get_length (prev_track) == -1) {
        /* track shares file with previous track */
        prev_length = $3 - track_get_start(prev_track);
        track_set_length(prev_track, prev_length);
    }

    if (1 == $2) {
        /* INDEX 01 */
        track_set_start(track, $3);

        long idx00 = track_get_index (track, 0);

        if (idx00 != -1 && $3 != 0)
            track_set_zero_pre (track, $3 - idx00);
        }

    track_set_index (track, $2, $3);
}

Rather than the function call, the two if-statements above it matter:

prev_length = $3 - track_get_start(prev_track);

track_set_zero_pre (track, $3 - idx00);

The value $3 is attacker-controlled. The subtraction operations invert the sign of $3 each time, so the addition must be done in two steps to net the right result: use $3 == 0 first to produce a negated zero, then $3 == 0x3b290 in a second operation to add the required offset.

The arithmetic gadget requires two adjacent tracks with deliberate overlapping fields:

Diagram showing two separate tracks allocated in memory with overlapping addresses.

The first track must have its file.start field positioned over offset 0xe60; the second must have both prev_track->file.length and track->index[0] referencing the same address. Then two successive subtraction operations compute the shifted function pointer and store it in the second track's zero_pre.length field.

One missing piece. The ideal extra gadget would be a controllable memcpy. None exists here. The problem: the arithmetic setup allocates new track objects, which arrive zeroed. That would wipe the original pointer at 0xe60 before we can restore it. But malloc's tcache can serve as a temporary backup, as discussed below.

Debugging setup

Development proceeds with gdb and breakpoints at each step to verify that the heap has the intended structure. Debugging tracker-extract directly is straightforward:

gdb --args /usr/libexec/tracker-extract-3

The one complication is a 30-second timeout in tracker-extract's main loop that fires while stepping through code. For development, a rebuild with a longer timeout and debug flags makes life easier. Debug builds shift memory offsets slightly, so once an initial working exploit is close, it must be re-tested against the original binary to correct those small differences.

The rough plan

All primitives are in place. The exploit proceeds as follows:

  • Manipulate the heap to allow allocating two track objects with overlapping addresses.
  • Back up the function pointer at offset 0xe60.
  • Allocate the two overlapping tracks, exercising the required parser paths.
  • Restore the saved function pointer to its original location.
  • Run the two subtraction stages to add 0x3b290 to the current pointer, yielding initable_init's address.
  • Overwrite info so it points to a fake object when tracker_extract_info_unref runs, arrving at initable_init for execution.

Putting the pieces together

Rather than dissect the exploit statement by statement — much of that detail will age poorly once the bug is patched — it's more useful to walk through the main techniques and how they fit together. The full source is available on GitHub.

Tidying the heap first

Heap feng shui, or heap grooming, is about arranging memory to make the rest of the exploit tractable. glibc's malloc organizes free chunks into buckets and lists, and every heap technique depends on those structures. Starting from a clean slate with empty caches simplifies things considerably, which is why the exploit begins by creating a large number of tracks with metadata strings of varying lengths (TITLE, PERFORMER, SONGWRITER). Once that's done, all caches are empty and later allocations come from one contiguous block.

The PoC file itself is padded with newlines to a length of 0xffe0 bytes. That size was a compromise: large enough to hold however many CUE statements the exploit might need, but small enough to avoid being allocated in a separate mmap-ed region. Keeping it in the same per-thread arena matters because changing the string's size would shift the offsets of other heap objects; the size was chosen early and stuck with.

Forging chunks with House of Spirit

The core step is allocating two tracks at specific overlapping addresses so an arithmetic gadget can be run. That's done with the classic "House of Spirit" technique. The how2heap repository has a small demo, and the approach here works as follows:

Diagram outlining the "House of Spirit" technique.

First, a gap is left in memory by allocating and freeing a string with these two statements:

TITLE "for freeing to tcache index 1"
TITLE "free previous title"

Then a new track is created and given a title:

TRACK 048 AUDIO
TITLE "Allocate previously freed string"

The allocator reuses the freed string slot, so the title ends up directly below the new track. The vulnerability is then used to corrupt the title chunk's metadata:

INDEX 4294967268 149

The forged metadata (149) makes the allocator believe the chunk is much larger than it is. A single TITLE statement frees and reallocates it:

TITLE "long string to overwrite low bytes of address   ...   ð^L"

malloc returns the identical address that was just freed, even though the new string is far longer. That new string overlaps the current track, and it's used to overwrite the file.name field. Two trailing characters in that string alter the low bytes of the stored address, turning it into a forged pointer. Running House of Spirit a second time on that forged pointer — corrupting its metadata with INDEX and freeing it with FILE — makes malloc hand out the forged address on the next appropriately sized request.

Reading the metadata numbers

The values 149 and 69 in the examples are 0x95 and 0x45 in hex. The low 0x5 bits are flags: 0x1 means the previous chunk is in use, 0x4 marks a per-thread arena chunk. The remaining 0x90 or 0x40 is the chunk size, always a multiple of 0x10 and always slightly larger than the requested allocation because it includes the metadata header.

Stashing the function pointer

The exploit needs a function pointer at offset 0xe60 as input to the arithmetic gadget, but that pointer would be clobbered when the two overlapping tracks are allocated. With no memcpy gadget available, the tcache provides an elegant save/restore mechanism.

The tcache is a per-thread cache indexed by chunk size: 64 entries, where index 0 holds chunks up to size 0x20, index 1 up to 0x30, and so on. Each entry is a singly linked list of free chunks:

Diagram showing each array element is a pointer to a linked list of chunks

The next pointer in each tcache_entry forms the list:

typedef struct tcache_entry
{
  struct tcache_entry *next;
  /* This field exists to detect double frees.  */
  uintptr_t key;
} tcache_entry;

Allocating from the tcache pops the head and promotes the second chunk; freeing reverses the operation.

Diagram showing when you allocate and then free a chunk from the tcache

That push/pop behavior is precisely a save/restore primitive. Forging a tcache entry that points to offset 0xe60, allocating it, and then freeing it later moves the function pointer out to a safe location like p2 in the diagram and brings it back afterward.

Safe linking, the mitigation that XOR-"signs" tcache next pointers with (p1 >> 12) & p2, turns out to be a non-issue here. The XOR that "signs" during free is exactly undone by the XOR that "unsigns" during malloc. When the allocated chunk is freed again, the net effect is zero — the pointer chain ends up exactly as it started. The only restriction is on a variation of the technique that frees a different address than malloc returned: that address must be on the same memory page (0x1000 bytes) as the original allocation, otherwise the XOR key differs and the pointer gets corrupted.

Finding a second gadget

An early version of the exploit reached the intended target — initable_init — only to crash. That function takes three parameters:

static gboolean
initable_init (GInitable     *initable,
               GCancellable  *cancellable,
               GError       **error)

But it was being invoked as a g_object dispose method, which controls only the first argument:

G_OBJECT_GET_CLASS (object)->dispose (object);

The assumption that cancellable and error would be NULL was wrong; cancellable was non-zero and caused an immediate crash.

The fix was to find a gadget that invokes a function pointer with two arguments (the error parameter isn't used, so two suffice). A CodeQL query turned up 242 candidates:

import cpp

from Struct s, Field f, FunctionPointerType ftype, ExprCall call, VariableAccess access, int offset
where
  f = s.getAField() and
  ftype = f.getType().getUnspecifiedType() and
  ftype.getNumberOfParameters() > 1 and
  offset = f.getByteOffset() and
  offset.bitAnd(15) = 8 and
  access = f.getAnAccess() and
  call.getExpr().getAChild*() = access and
  call.getEnclosingFunction().getParameter(0).getAnAccess() =
    call.getArgument(0).getAChild*()
select f, access

The chosen gadget was g_option_context_parse, which calls pre_parse_func:

list = context->groups;
while (list)
  {
    GOptionGroup *group = list->data;

    if (group->pre_parse_func)
      {
        if (!(* group->pre_parse_func) (context, group,
                                        group->user_data, error))
          goto fail;
      }

    list = list->next;
  }

The ripple effect was needing two forged function pointers and two copies of the arithmetic gadget instead of one — more work, but no new techniques.

Stopping the crash after code execution

Earlier versions shared with the Distros list would launch the calculator and then crash a second later, because tracker-extract would return from initable_init and immediately fault. The crash happens as soon as the while loop around pre_parse_func exits, and there's no way to prevent it if that happens. The solution is to never let the loop end: make the linked list circular by pointing the next pointer back at its start.

The side effect is an infinite loop that would fork-bomb the system. A small wrapper script handles cleanup:

killall -SIGSTOP tracker-extract-3;
flock -w 3 ~/Downloads/pwned.lock -c 'gnome-calculator -e 1337' &&
  (sleep 10; rm ~/Downloads/pwned.lock; killall -SIGKILL tracker-extract-3)

The script sends SIGSTOP to tracker-extract to pause the loop, uses flock on a lock file so only one process actually launches a calculator (the rest time out after 3 seconds), then after the calculator exits, deletes the lock file and sends SIGKILL. Tracker-extract shuts down cleanly, and the user sees nothing unusual — just a calculator.

An accidental sandbox escape

tracker-extract runs in a seccomp sandbox, and the exploit sidestepped it entirely by accident. The main thread is exempt from the sandbox — a pragmatic choice, because it needs far more system calls than worker threads, and parsers like libcue run off the main thread. The exploit triggers code execution via tracker_extract_info_unref, which typically isn't called directly but is queued for the main thread:

if (!filter_module (task->extract, task->module) &&
    get_file_metadata (task, &info, &error)) {
    g_task_return_pointer (G_TASK (task->res), info,
                               (GDestroyNotify) tracker_extract_info_unref);
} else {

A second path also calls tracker_extract_info_unrefimmediately on parse errors:

if (!task->success) {
    tracker_extract_info_unref (info);
    info = NULL;
}

That path produced an error message that was initially misinterpreted:

Disallowed syscall "close_range" caught in sandbox

Fixing the wrong problem had a fortunate side effect — it flipped task->success to true, which actually resolved the issue. Carlos Garnacho has since hardened the sandbox to close this path, and written up the details in a blog post.

The final exploit flow

The finished exploit is more involved than the initial plan, mostly because of the second gadget needed to route through g_option_context_parse. Here's the updated outline, linked to the relevant source:

  1. Heap feng shui.
  2. Use House of Spirit to create numerous fake chunks in the per-thread arena.
  3. Use the first arithmetic gadget to compute the address of g_option_context_parse.
  4. Use the second arithmetic gadget to compute the address of initable_init.
  5. Create more fake heap objects to complete the info object passed to tracker_extract_info_unref.
  6. Add a back-pointer to make the linked list infinite so tracker-extract won't crash.
  7. Remove any remaining fake chunks from the tcache to restore a clean state.
  8. Pad the file with newlines to reach the target size.

The treasure hunt is complete.

Cartoon image of a pirate on an island holding up a flag that has a calculator at the end of it.

Why exploit development matters

Exploit development keeps security research grounded. Without a working proof-of-concept, assessing a vulnerability’s severity is guesswork. That can go both ways: bugs get over-hyped, or genuine risks get dismissed. Mitigations such as ASLR and stack canaries make this worse, since they create a false sense of safety. The tracker-extract bug examined here could easily have been written off as unexploitable, especially given that the component also runs inside a seccomp sandbox.

There is a second benefit to doing this kind of work. Digging into how code actually behaves under pressure tends to turn up additional findings. In this case, the exploit work revealed that tracker-extract’s sandbox was less watertight than assumed. Carlos Garnacho has since hardened it.

Lessons for exploit developers

Every exploitation challenge is unique; there is no universal playbook. Some techniques transfer well — the House of Spirit, for instance, is a classic that keeps showing up. Others, such as the custom arithmetic gadget built for this exploit, are strictly single-use. What matters more is the general method: hunting for gadgets, which are essentially odd instructions already present in the code, and chaining them as stepping stones toward a useful primitive.

For those new to exploit development, the key takeaway from this exercise is the search process itself. Familiarity with well-known techniques helps, but the real skill lies in recognizing how the code you have in front of you can be bent to your purpose, whether or not it was ever meant to do what you need.