Wednesday, May 27, 2026

CLRHack: unwind-protect and catch-throw

Handling of unwind-protect

The CLRHack compiler maps Lisp unwind-protect semantics directly onto the Structured Exception Handling (SEH) infrastructure of the .NET Common Language Runtime (CLR). Specifically, it utilizes the try...finally construct provided by the Common Intermediate Language (CIL).

Lisp semantics require that the cleanup forms in an unwind-protect block be executed regardless of how control leaves the protected form—whether via normal return, a non-local throw, or a lexical exit like return-from. The CLR guarantees that a finally block will execute during stack unwinding, which is exactly the hook required for Lisp. The implementation details are as follows:

  • Protected Form: The compiler generates the code for the protected form inside a CIL try block. Upon successful completion, the primary return value is stored in a local variable, and a leave instruction is used to exit the try block, which automatically triggers the transition to the finally block.
  • Side-Channel Preservation: A unique challenge in Lisp is that unwind-protect must return the values of the protected form, but cleanup forms may themselves perform operations that alter the Multiple Return Value (MRV) side-channel. CLRHack exploits method-local variables to save the ReturnCount and the contents of Value1 through Value63 at the very beginning of the finally block and restore them at the very end.
  • Unwinding: If a throw or other exception occurs within the try block, the CLR stack walker identifies the finally block and executes it before propagating the exception further. This ensures Lisp's "cleanup guarantee" is maintained even during catastrophic or non-local control transfers.

Handling of catch and throw

Lisp's catch and throw are implemented as a Dynamic Non-Local Exit system built on top of .NET's exception propagation mechanism. While CLR exceptions are typically filtered by type, Lisp requires filtering by a dynamic "tag" object (compared via eq).

The throw Mechanism

When a (throw tag value) is evaluated, CLRHack does not simply perform a jump. Instead, it performs the following steps:

  1. Evaluates the tag and the primary value.
  2. Captures the current state of the MRV side-channel into an object[].
  3. Instantiates a specialized exception class: [LispBase]Lisp.CatchThrowException. This object acts as a carrier for the tag, the primary value, and the captured MRV array.
  4. Executes the CIL throw instruction. This initiates the CLR's SEH stack walk.

The catch Mechanism

The (catch tag body) form is compiled into a try...catch block where the catch handler specifically targets CatchThrowException:

  1. Tag Setup: The catch tag is evaluated and stored in a method-local variable.
  2. Body Execution: The body forms are executed within a try block.
  3. The Catch Handler: When a CatchThrowException is intercepted, the handler performs a "Dynamic Filter":
    • It extracts the tag from the exception object and compares it to the local catch tag using System.Object.Equals (simulating Lisp's eq for reference types).
    • Match: If the tags match, the handler "claims" the exception. It extracts the primary value and the MRV array from the exception, restores them to the thread-local side-channel, and resumes normal execution after the catch block.
    • Mismatch: If the tags do not match, the handler executes the CIL rethrow instruction. This allows the exception to continue up the stack to find a matching catch tag in a higher frame.

Exploiting SEH for Lisp Semantics

CLRHack exploits the CLR's SEH in three fundamental ways to bridge the gap between .NET and Lisp:

  • Automatic Stack Unwinding: By using throw and try...catch, the compiler delegates the complex task of cleaning up stack frames, registers, and intermediate states to the highly optimized .NET runtime.
  • Guaranteed Cleanup: The finally block is the "silicon reality" of Lisp's unwind-protect. The CLR ensures it runs even if an exception is re-thrown multiple times or if a thread is being terminated.
  • Payload-Heavy Exceptions: Unlike standard .NET exceptions which often carry only metadata, CatchThrowException is exploited as a transport mechanism. It carries the entire "return state" of a Lisp expression (primary value + MRV side-channel) across an arbitrary number of stack frames, allowing a throw to behave exactly like a multi-valued return to a dynamic point.

No comments: