Language Runtimes · Runtime Security

WebAssembly Imports as the Only Channel to the Host

Why a validated module can reach nothing but its own linear memory and its declared imports, and how host functions, tables, and shared memories widen that surface.

A WebAssembly module has no ambient authority at all in its specification. It cannot open a file, because there is no instruction that opens a file. It cannot allocate memory outside its own linear memory, because there is no instruction that returns a pointer into the host. The module’s entire interaction with the world is a list of named imports resolved at instantiation, and everything else it can do is arithmetic on a buffer it owns.

That is a stronger starting position than any managed runtime with a foreign function interface, and it is why the format is used as a confinement layer for code the host does not trust.

Validation is the whole-module argument

The guarantee rests on validation, which happens once, before instantiation. The validator checks that every instruction’s operands have the types the instruction requires, that every branch target is a valid label in scope, that the operand stack has a consistent height and type at every point reachable by more than one path, and that every access to memory or a table is either statically in range or guarded by an explicit bounds check in the instruction itself.

For memory, an index is a 32-bit integer, and a memory may be declared with at most 2^16 pages of 64 KiB, which bounds its size at 4 GiB; the load and store instructions carry a static offset and an alignment. The effective address is the sum of the dynamic index and the static offset, computed in a wider width than the index, so the addition cannot wrap. If the sum exceeds the memory’s current size, the instruction traps before the access. The same check applies to every access, and the width of the check is what prevents the classic overflow that a naive base + index computation would allow.

For indirect calls, the instruction carries the type it expects, and the runtime compares that against the declared type of the function the table entry names. A table entry holds an opaque function reference, not a signature; the check happens at the moment of the call, and a mismatch traps. A module cannot call a function of a different type by corrupting an index.

None of these checks depend on the code being well-behaved. They are properties of the validated form, which is why an engine may compile the module to native code and let it run at full speed: the checks that remain are the ones the compiler could not prove away, and the ones it removed were removed because the types made them unnecessary.

Linear memory and the absence of pointers

A module’s memory is a contiguous array of bytes addressed by integers. There are no pointers in the sense of addresses that can designate host objects. A function cannot take the address of a host structure, because it has no way to name one. The only values it can pass to the host are numbers, and the only way to pass a structure is to write its bytes into linear memory and pass the offset.

The security consequence is that the host decides what those bytes mean. When a module calls an imported function with an offset and a length, the host must check that the range is inside the memory the module owns before reading it, and must copy whatever it needs rather than retaining a pointer into the module’s buffer. A host that keeps the pointer and reads it after the call has given the module a way to change the host’s view of the data after the host validated it, which is a time-of-check/time-of-use defect at the boundary.

The buffer’s growth is another boundary. Memory can be grown by the module up to the declared maximum, and growth may relocate the buffer: the old bytes are copied to a new allocation and the old one is released. A host that cached a pointer into the buffer across a call that might grow memory has a dangling reference. Embeddings handle this by treating the buffer’s base address as valid only until the next call into the module.

The import list as the authority set

Instantiation takes an import object and resolves each declared import against it. If a module declares an import of a function named env.read_file, the host may supply anything with that name, or refuse to instantiate. The module’s authority is exactly the set of functions and memories it received, and the host’s ability to construct that set is the policy mechanism.

The practical design is to make the import surface small and specific. An import that takes a path string and returns a descriptor is a general filesystem capability; an import that reads a named, pre-opened resource is a narrow one. The difference is the same capability-design question treated in capability references and ambient authority: authority should be a set of references handed over, not a general operation that accepts names.

WASI formalizes this. Its filesystem interface is built on pre-opened directory descriptors: the host passes in a set of directories, and the module’s path operations are resolved relative to them, with each lookup checked so that .. components cannot escape the pre-opened subtree. A module that was given one directory cannot reach its parent, even though it can name it.

The import mechanism has one asymmetry worth noting. A host function is native code executing in the host’s address space with the host’s full privilege. It receives arguments from the module and must validate every one, because the module is untrusted and the host function’s contract is not enforced by the module’s validator. The boundary is only as strong as the weakest host function, and host functions are ordinary code that can be wrong. Engines therefore expose host functions through a canonical ABI that handles type marshalling, and the marshalling layer is the part that must be correct.

Shared memory and the return of concurrency hazards

A memory can be declared shared, which permits multiple instances to access the same buffer. The specification requires that shared memories have a declared maximum, because the buffer cannot be relocated once it is shared. Atomic instructions operate on it with sequentially consistent or relaxed semantics, matching the WebAssembly threads proposal.

Sharing reintroduces the hazards that the single-instance model excluded. Two instances can race on the same bytes, and the format does not prevent it: the module’s own code must use the atomic instructions correctly, and the validator only checks that atomic accesses are aligned and within bounds. A host that shares a buffer with a module must assume the module can modify any byte of it at any time, including bytes the host wrote and believes are private.

Shared memory also creates a channel between instances. Two modules sharing a buffer can communicate through it without any import, which may be exactly the intent, or may be an unintended path around the host’s mediation. The host’s policy must decide which modules may share a memory, and the decision is a statement about which principals may communicate.

Traps, and what happens after one

An out-of-bounds access, an invalid indirect call, an unreachable instruction, or a division by zero raises a trap. The trap unwinds to the host, the module’s execution stops, and the memory and globals retain their state. The host may then discard the instance, or reset it and call again.

The important property is that a trap cannot be caught inside the module. There is no exception mechanism at the instruction level in the core specification; the exception-handling proposal adds one, but it is a language-level construct that does not let a module intercept a trap. A module therefore cannot use a trap as a probe-and-continue primitive: a probe that fails ends the call.

What a trap does not do is restore anything. If the module wrote a partial result into linear memory before trapping, the bytes remain, and the host must not treat them as complete. The host’s protocol must include an explicit success indication separate from the buffer contents, or a trap’s leftovers can be mistaken for output. This is the same discipline a C function’s return-value convention imposes, made explicit because the trap path is not visible in the buffer.

What the model does not cover

Three gaps recur in practice.

Resource exhaustion is outside the memory-safety argument. A module can loop forever, allocate up to its maximum memory, or call an import in a loop. The specification provides no fuel or instruction-counting mechanism; engines implement metering as an extension, and the metering must be inserted into the compiled code, which makes the engine’s instrumentation part of the trusted base. Without metering, the host must enforce limits from outside, by running the instance in a separate thread or process that it can terminate.

Timing is observable. A module can measure elapsed time with a clock import if the host provides one, and can measure it less directly by the duration of its own loops. That is a channel to the host’s scheduling, discussed under covert channels.

And the engine itself is a large program. The validator, the compiler, the runtime’s trap handling, and the memory management are all part of the trusted base, and the guarantee that a validated module cannot escape is a statement about a specific engine’s correctness, not about the format alone. Engines have had validation defects, and each one is a demonstration that the argument is conditional. The format’s contribution is that the conditional argument is tractable, because the property to be proved is local to a small, specified instruction set.

Why this shape generalizes

The design pattern is worth extracting from the specific technology. A sandboxed execution format gets its strength from four decisions: no ambient names, a validated instruction set with typed operands, a single explicitly declared channel to the host, and an explicit trap that ends execution rather than continuing past a violation. Each decision removes a class of escape that a general-purpose native interface would have permitted, and each has a cost in expressiveness that the host pays by implementing more of the interface as imports.

Any runtime that wants comparable isolation ends up rebuilding some subset of these. The ones that do not have a validator rely on the compiler’s type system instead, as the ownership and aliasing discipline describes, and the ones that do neither rely on the process boundary, described in syscall filtering and process confinement.

References

  1. WebAssembly Core Specification — Validation — W3C WebAssembly Community Group
  2. Bringing the Web up to Speed with WebAssembly — Andreas Haas et al., PLDI 2017
  3. WebAssembly Core Specification — Execution — W3C WebAssembly Community Group