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.
- Convert the original
off-heapobject back into a numeric object of length 5 so itsdatapointer andobj_sizecould be edited. - Forge new
off-heapobjects from normal numeric arrays using abridge -> attacker -> targetsequence 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.
- Use the original object once to enlarge
obj_size. - Leak a stable pointer to
libc + 0x203b20from metadata freed by the GC at indices380/381. - Forge a new
off-heapreader pointing to__environ. - Read from there to get the real address of the environment area on the stack.
- Forge an
off-heapwriter towardsaved_rip = __environ_value - 0x130. - 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}