╔════════════════════════════════════════════════╗
║ BLOG POST ║
╚════════════════════════════════════════════════╝
Vulnerability Research

OOB Write in QEMU's Aspeed ADC Emulation

Author: SAAITAAMAA | Date: February 2026

How a wrong keyword became a heap corruption primitive


Target Selection

Back in January I decided to kickstart my manual auditing journey with QEMU as my initial target. It checked every box I was looking for: a large complex C codebase, widely deployed in production, a well-understood attack surface (guest-to-host escape), and a long history of prior vulnerabilities to learn from. A good target to start on — and potentially find something real.


Methodology — Source-to-Sink Auditing

Before diving deep, a quick explanation of the approach for people less familiar with manual code auditing.

Source-to-sink auditing means you start from untrusted input — the source — and trace how that data flows through the codebase until it reaches a dangerous operation — the sink. In QEMU device emulation, the sources are always guest MMIO reads and writes: any value the guest supplies via a memory-mapped register is completely attacker-controlled.

The sinks are operations that become dangerous when fed attacker-controlled data — array indexing, pointer arithmetic, memcpy, function pointers, etc ...

The goal is to find paths where untrusted guest data reaches a sink without sufficient validation in between.

My initial plan was to go source-to-sink and scorch the earth — maximum code coverage, understand everything. But before investing too much time reading the entire codebase blind, I had a better idea: launch a few basic Semgrep rules first to quickly map out what suspicious sinks look like across the codebase, then prioritise from there.


The Accidental Find

A few minutes into going through the Semgrep results, I landed on this memcpy():

/* hw/adc/aspeed_adc.c:260 */
memcpy(s->regs, aspeed_adc_resets, sizeof(aspeed_adc_resets));

I checked it — both arrays are uint32_t[ASPEED_ADC_NR_REGS], size is a compile-time constant, no user input involved. Not vulnerable. Moving on.

But right above it, at line 235, something else caught my eye completely by accident:

s->regs[reg] = value;

My Sploity-Senses were tingling — what if reg is attacker-controlled?

I traced back through the entire function. It is. Completely. And so is value.

static void aspeed_adc_engine_write(void *opaque, hwaddr addr,
                                    uint64_t value, unsigned int size)
{
    AspeedADCEngineState *s = ASPEED_ADC_ENGINE(opaque);
    int reg = TO_REG(addr);   /* addr >> 2 — derived from guest MMIO offset */

    switch (reg) {
        case ENGINE_CONTROL:
            /* ... */ break;
        /* ... all valid register cases ... */
        case COMPENSATING_AND_TRIMMING:
            value &= 0xf; break;
        default:
            qemu_log_mask(LOG_UNIMP, "%s: engine[%u]: "
                          "0x%" HWADDR_PRIx " 0x%" PRIx64 "\n",
                          __func__, s->engine_id, addr, value);
            break;   /* <-- the bug: break instead of return */
    }

    s->regs[reg] = value;   /* reached for ALL cases, including default */
}

The mistake is a single keyword: break instead of return in the default case. Every undefined register offset falls through the switch and still reaches the unconditional write at the bottom. And since value comes directly from the guest MMIO write with no sanitisation in the default path — both what gets written and where it lands are fully attacker-controlled.


The Primitive

regs[] is declared as:

uint32_t regs[ASPEED_ADC_NR_REGS];   /* NR_REGS = 52 */

The MMIO window is 0x100 bytes — 64 possible dword-aligned offsets. The valid register space ends at offset 0xC4 (index 49). Offsets 0xD00xFC map to indices 5263 — all past the end of the array.

What this gives an attacker is a limited Write-What-Where primitive:

  • What: 32-bit attacker-controlled value (unmasked in the default path)
  • Where: s->regs base + attacker-controlled index

The "limited" part is that the write is bounded by the MMIO window — you can only reach 12 dwords (48 bytes) past the end of regs[]. You cannot write arbitrarily far. But 48 bytes past this particular array is not a boring neighbourhood.


Why It Matters

The AspeedADCEngineState structs for both ADC engines are embedded inline inside the parent AspeedADCState heap object:

[ AspeedADCState — heap object ]
├── AspeedADCEngineState engines[0]
│   ├── MemoryRegion mmio    ← holds ops function pointer table
│   ├── ...
│   └── uint32_t regs[52]   ← OOB write lands here and beyond
└── AspeedADCEngineState engines[1]
    ├── MemoryRegion mmio    ← ops pointer potentially reachable
    └── ...

The MemoryRegion struct contains a pointer to MemoryRegionOps — the dispatch table holding the read and write function pointers called on every MMIO access. Depending on compiled struct layout, the 48-byte OOB window from engines[0] reaches into engines[1]'s fields, potentially including that ops pointer.

Corrupting it gives you control of the host instruction pointer on the next MMIO access — a possible guest-to-host code execution primitive.

whether this is truly exploitable or not depends on further analysis.


The Fix

One keyword change:

    default:
        qemu_log_mask(LOG_UNIMP, "%s: engine[%u]: "
                      "0x%" HWADDR_PRIx " 0x%" PRIx64 "\n",
                      __func__, s->engine_id, addr, value);
        /* Do not update the regs[] array */
        return;   /* was: break */

return exits the function before reaching s->regs[reg] = value.
break did not. That's the entire fix.

The patch was authored by the Red Hat Team and later fixed upstream.


Disclosure Timeline

Date Event
2026-01-14 Bug found while reviewing Semgrep results in hw/adc/aspeed_adc.c
2026-01-15 Reported to QEMU security team
2026-01-26 Patch authored by Red Hat
2026-02-10 Patch merged into QEMU stable branches

CVE? Not Quite.

After reporting it, I reached out to the QEMU security team asking whether this would qualify for a CVE. The response from Red Hat was straightforward:

"In many projects this would indeed qualify for assignment of a CVE, but this falls outside the scope of QEMU's security policy "

QEMU's security policy only assigns CVEs for vulnerabilities in code that explicitly provides a security boundary. The Aspeed ADC is emulated-only hardware — not a virtualisation boundary — so it falls outside that scope.
Although the first vulnerability I found wasn't assigned a CVE — and that was a little discouraging — it's a good start. A good birthday gift to myself. Hopefully many more to come.


References