Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
DiceCTF2026Quals/Garden.en.md READ_ONLY

Garden

Challenge Data

Field Value
CTF DiceCTF 2026 Quals
Challenge Garden
Category Pwn
Flag dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}

Challenge Description

This challenge revolved around a VM that handled normal arrays and off-heap objects. The garbage collector performed a mark and compact pass over the main heap and then updated references. Up to that point, everything looked normal.

The problem appeared when a root with value 0 entered during marking. Instead of discarding it, the GC treated it as if heap_base + 0 pointed to a valid object and started compacting from there.

That ended up introducing a 4 byte shift over all live objects. And because the source and destination of the compaction overlapped, it was possible to overwrite the next object's header before it was copied. That was the really useful primitive in the challenge.


Reconnaissance

The first thing I did was go to the collector code to understand exactly where the heap layout broke.

for (size_t i = 0; i < num_roots; i++) {
    mark_reachable(roots[i], &ctx);
}

size_t new_heap_used = 128;
for (size_t i = 0; i < ctx.obj_count; i++) {
    arr_header_t *arr = AS_ARR_HEADER(ctx.objs[i].object);
    size_t total_size = sizeof(arr_header_t) + ctx.objs[i].size * sizeof(ref_t);
    memmove(heap_base + new_heap_used, arr, total_size);
    ctx.new_locations[i] = (ref_t)new_heap_used;
    new_heap_used += total_size;
}

Reviewing the logic showed that mark_reachable() did not filter out ref == 0 before treating that reference as an object. Since the first 128 bytes of the heap were reserved, the collector ended up interpreting that area as a fake zero-sized header and started compacting with the offset miscalculated.

Put more simply, the GC became misaligned and that allowed overwriting the header of the next live object. From there it was no longer just a weird collector bug, but a fairly interesting primitive for shaping objects.


Analysis

With that primitive, there were two paths that were especially convenient.

  1. Convert the original off-heap object back into a numeric object of length 5 so its data pointer and obj_size could be edited.
  2. Forge new off-heap objects from normal numeric arrays using a bridge -> attacker -> target sequence before triggering a fake-root GC.

When I looked at the exploit, the constants already made the intended direction fairly clear.

OFFHEAP_HDR = 0x00040005

LIBC_LEAK_OFF = 0x203B20
ENVIRON_DELTA = 0x20AD58 - LIBC_LEAK_OFF
SYSTEM_DELTA = 0x58750 - LIBC_LEAK_OFF
POP_RDI_DELTA = 0x10F78B - LIBC_LEAK_OFF
RET_DELTA = 0x2882F - LIBC_LEAK_OFF

With that in mind, the useful chain became this.

  1. Use the original object once to enlarge obj_size.
  2. Leak a stable pointer to libc + 0x203b20 from metadata freed by the GC at indices 380/381.
  3. Forge a new off-heap reader pointing to __environ.
  4. Read from there to get the real address of the environment area on the stack.
  5. Forge an off-heap writer toward saved_rip = __environ_value - 0x130.
  6. Write a minimal ROP chain that ended up calling system("cat flag.txt").

The hardest part here was not finding the final target, but keeping the exploit stable. With this kind of challenge, as soon as the same modified object is reused too much, the heap state becomes uncomfortable to predict.


Solution

The delicate part was building fake objects, because it depended heavily on the order in which the GC compacted live objects. Because of that, an intermediate "safe" GC phase was needed before the fake-root GC. Without it, dead temporaries stayed in the middle and the newly forged objects moved to the wrong place.

Once that detail was solved, the final part of the exploit consisted of writing the return chain and the string for system directly.

vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 0, LIBC_LO_REG, 0, RET_DELTA)
vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 2, LIBC_LO_REG, 0, POP_RDI_DELTA)
vm.write_offheap_from_offheap(OFFHEAP_STACK_REG, 4, OFFHEAP_ENV_REG, 0, -0xF0)
vm.write_offheap_from_reg_elem(OFFHEAP_STACK_REG, 6, LIBC_LO_REG, 0, SYSTEM_DELTA)

vm.write_offheap_const(OFFHEAP_STACK_REG, 16, 0x20746163)
vm.write_offheap_const(OFFHEAP_STACK_REG, 17, 0x67616C66)
vm.write_offheap_const(OFFHEAP_STACK_REG, 18, 0x7478742E)

Those last values are not mysterious. They are "cat ", "flag", and ".txt" in little endian.

Locally, the exploit ended up printing this.

dice{dummy_flag}

Against remote, after also solving the service PoW, it returned the real flag.

dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}

The stable version came together once I stopped trying to squeeze the same objects again and again and started forging new readers and writers for each important phase. Sometimes the exploit does not improve by adding more tricks, but by simplifying what gets reused and what does not.


Flag

dice{m1tig4ti0ns_nev3r_w0rk_y0u_escap3d_th3_w4ll3d_g4Rd3n}