Security Implications of Asyncify-Transformed Wasm Modules
Problem
Asyncify is a transformation applied by Emscripten that allows synchronous C/C++ code to be compiled to WebAssembly and then run in environments that require cooperative multitasking (browsers, runtimes with async host functions). The transformation works by instrumenting every function to check whether a “yield” is in progress and, if so, to save its entire stack frame to the Wasm linear memory, return to the caller, and later restore the frame when execution resumes.
The security implications of Asyncify are subtle and underappreciated:
The binary becomes structurally different from the source. A C function that was a straightforward control flow graph with stack-allocated locals becomes a function with an additional code path for stack serialisation and deserialisation. Static analysis of the pre-transformed source does not reveal the transformed control flow. A Wasm binary analyser that does not understand the Asyncify ABI will miss the additional code paths.
Stack frames are heap-serialised. When Asyncify suspends a function, it copies the local variable state to a heap-allocated buffer in linear memory. This includes all local variables, pointers, and return addresses at every call depth in the suspension chain. A heap vulnerability (buffer overflow in linear memory) that can overwrite an Asyncify stack frame can corrupt the state that will be restored on resumption — this is equivalent to corrupting a stack frame in native code, but in a location where Wasm’s linear memory bounds checking does not protect it.
The linear memory layout changes significantly. Asyncify stores its stack data in a dedicated region of linear memory. The layout of this region (what is at which offset) depends on the call depth at suspension time and on compiler-specific decisions. An attacker who can observe Asyncify’s heap layout through timing or partial-read primitives gains information about the execution state that would not be visible in a non-Asyncify module.
Increased code size widens fuzzing difficulty. Asyncify typically doubles or triples the size of transformed modules. Larger binaries have more code paths to explore, making fuzzing coverage less complete for a given time budget. Security-relevant code paths added by the transformation may not be well-covered by existing test suites.
Host function re-entrancy. Asyncify allows Wasm to “pause” at a host function call and later resume. If the Wasm module is paused at a point where it holds a lock or is partway through a multi-step operation, and another call into the module is made during the pause, the module can be in an inconsistent state. This re-entrancy window is not present in non-Asyncify modules.
Target systems: Wasm runtimes running Asyncify-transformed modules (Wasmtime, V8, Node.js, browser environments); C/C++ compiled to Wasm via Emscripten with -s ASYNCIFY=1; multi-tenant Wasm platforms where modules may be Asyncify-transformed.
Threat Model
Adversary 1 — Heap corruption via Asyncify stack frame overwrite. An attacker exploits a buffer overflow in a Wasm module’s linear memory to overwrite the Asyncify stack frame saved during a suspension. When the module resumes, it restores the corrupted frame, redirecting execution to attacker-controlled state. The attack target is heap memory rather than the call stack, bypassing stack-level mitigations.
Adversary 2 — Re-entrancy exploitation. A Wasm module using Asyncify suspends while processing a user request. During the suspension (while waiting for an async host function), another request calls into the module. The module’s global state is inconsistent during the first request’s pause. The attacker’s second call reaches a code path that assumes state invariants that are violated during the pause.
Adversary 3 — Control flow inference via timing. Asyncify suspension duration depends on which functions are in the call stack. An attacker who can measure the time between suspension and resumption can infer information about the module’s execution path — for example, distinguishing between code paths that process valid vs. invalid credentials, constituting a timing side channel.
Configuration / Implementation
Step 1 — Identify Asyncify-transformed modules
# Detect whether a Wasm binary has been Asyncify-transformed
# Method 1: Check for Asyncify-specific exported functions
wasm-objdump -x module.wasm | grep -i "asyncify"
# Asyncify-transformed modules export: asyncify_start_unwind, asyncify_stop_unwind,
# asyncify_start_rewind, asyncify_stop_rewind
# Method 2: Check for the Asyncify stack pointer globals
wasm-objdump -x module.wasm | grep "global\|asyncify"
# Method 3: Binary size comparison
# Asyncify typically 2-3× the non-transformed binary size
wc -c module.wasm module-no-asyncify.wasm 2>/dev/null
# Method 4: Check build flags in Emscripten metadata section
wasm-objdump -s --section=producers module.wasm 2>/dev/null | grep -i "asyncify\|emscripten"
Step 2 — Understand the Asyncify memory layout
// asyncify-layout-analysis.c
// Understanding the Asyncify stack data structure to inform security analysis
// Asyncify's stack frame format (simplified):
// At offset 0: next stack pointer (current unwind position)
// At offset 4: end stack pointer (limit of the stack buffer)
// Following: serialised local variables from each suspended function
// A typical Asyncify suspension chain:
// main() -> calls process_data() -> calls read_async() [host function, suspends here]
// Stack memory contains: [main's locals] [process_data's locals] [read_async's args]
// Security-relevant observation:
// If process_data's locals include a pointer (e.g., to a buffer in linear memory),
// that pointer is stored in the Asyncify heap region.
// A buffer overflow in linear memory that overwrites this region can replace
// the pointer with an attacker-controlled address.
// When execution resumes, process_data reads from the corrupted pointer.
#!/usr/bin/env python3
# scripts/analyze-asyncify-module.py
# Static analysis of an Asyncify-transformed Wasm module
# Identifies functions that participate in the unwind/rewind path
import subprocess
import re
import sys
def analyze_asyncify_functions(wasm_file: str) -> dict:
"""Identify Asyncify-instrumented functions and their stack frame sizes."""
# Disassemble the module
result = subprocess.run(
["wasm-objdump", "-d", wasm_file],
capture_output=True, text=True
)
asyncify_functions = []
current_func = None
has_asyncify_check = False
for line in result.stdout.splitlines():
# Detect function start
func_match = re.match(r'<(\w+)>:', line)
if func_match:
if current_func and has_asyncify_check:
asyncify_functions.append(current_func)
current_func = func_match.group(1)
has_asyncify_check = False
# Detect Asyncify instrumentation pattern
# Asyncify inserts a check for the unwind state at function entry
if "asyncify_stop_rewind\|asyncify_start_unwind" in line or \
"get_global asyncify_state" in line or \
"asyncify.get_state" in line:
has_asyncify_check = True
return {
"asyncify_transformed_functions": asyncify_functions,
"total_functions_in_unwind_path": len(asyncify_functions)
}
def check_asyncify_stack_buffer_size(wasm_file: str) -> int:
"""Find the Asyncify stack buffer size configured in the module."""
result = subprocess.run(
["wasm-objdump", "-x", wasm_file],
capture_output=True, text=True
)
# Look for the Asyncify stack size in the data section or globals
for line in result.stdout.splitlines():
if "asyncify_stack_size" in line.lower() or "ASYNCIFY_STACK_SIZE" in line:
# Extract the numeric value
match = re.search(r'(\d+)', line)
if match:
return int(match.group(1))
return -1 # Not found
if __name__ == "__main__":
wasm_file = sys.argv[1] if len(sys.argv) > 1 else "module.wasm"
print(f"Analyzing: {wasm_file}")
analysis = analyze_asyncify_functions(wasm_file)
print(f"\nAsyncify Analysis:")
print(f" Functions in unwind path: {analysis['total_functions_in_unwind_path']}")
print(f" Stack buffer size: {check_asyncify_stack_buffer_size(wasm_file)} bytes")
if analysis["total_functions_in_unwind_path"] > 50:
print("\nWARNING: Large unwind path — many functions' local variables are")
print(" heap-serialised during suspension. Larger attack surface for")
print(" heap corruption via Asyncify frame overwrite.")
Step 3 — Mitigations for Asyncify-specific risks
// Recommended practices when compiling with Asyncify
// 1. Limit the Asyncify unwind path
// Only functions that actually need to be async should be in the unwind path.
// Use ASYNCIFY_ONLY to restrict which functions are instrumented.
// Emscripten build flag:
// emcc -s ASYNCIFY=1 \
// -s ASYNCIFY_ONLY='["my_async_function","caller_of_async"]' \
// source.c -o module.wasm
// This prevents all other functions from being instrumented,
// reducing the heap-serialised state surface.
// 2. Use ASYNCIFY_STACK_SIZE conservatively
// emcc -s ASYNCIFY=1 -s ASYNCIFY_STACK_SIZE=16384 source.c
// Smaller stack = less heap memory used for frame serialisation
// But must be large enough for actual call depth — otherwise module aborts
// 3. Separate async and non-async modules
// If only a subset of functionality needs async behaviour,
// compile it as a separate Wasm module.
// Non-async modules are not vulnerable to Asyncify-specific attacks.
// For Wasmtime hosts: detecting and handling Asyncify modules appropriately
use wasmtime::{Engine, Module, Store, Config};
fn configure_for_asyncify_module(config: &mut Config) {
// Enable async support — required for Asyncify-transformed modules
config.async_support(true);
// Set a stack size limit for the Asyncify call stack
// This bounds the amount of heap memory the module can use for frame serialisation
// Default is 512KB; for untrusted modules, consider a lower limit
config.max_wasm_stack(512 * 1024);
// Enable epoch-based interruption — allows terminating a suspended Asyncify module
// that is taking too long or appears to be in a problematic state
config.epoch_interruption(true);
}
// Validate that a module claims to be Asyncify-transformed before granting async capabilities
fn is_asyncify_module(module: &Module) -> bool {
// Check for Asyncify-specific exports
module.exports().any(|e| {
matches!(e.name(),
"asyncify_start_unwind" |
"asyncify_stop_unwind" |
"asyncify_start_rewind" |
"asyncify_stop_rewind"
)
})
}
Step 4 — Fuzzing strategy for Asyncify-transformed modules
# Fuzzing configuration notes for Asyncify modules
asyncify_fuzzing_strategy:
challenges:
- "Standard Wasm fuzzers (wasm-smith) generate modules but not Asyncify-specific patterns"
- "Coverage of unwind/rewind paths requires triggering suspension points"
- "Heap corruption in Asyncify frame region is a specific fuzzing target"
approaches:
corpus_based:
- "Generate inputs that trigger the async path (reach a host function that suspends)"
- "Include both suspended and non-suspended execution paths in corpus"
targeted_mutation:
- "Mutate the Asyncify stack buffer region in linear memory while module is suspended"
- "Specifically target offsets where pointer values would be stored in frames"
- "Test resume from corrupted frame state"
re_entrancy:
- "Call module functions while another call is suspended"
- "Test that module correctly handles interleaved calls"
- "Specifically test calls that modify global state during a suspension"
tools:
- name: "wasm-smith"
note: "Does not generate Asyncify-specific patterns; supplement with manual corpus"
- name: "libfuzzer via Wasmtime fuzzing"
note: "Can be extended with Asyncify-aware mutation operators"
- name: "Custom differential fuzzer"
note: "Compare Asyncify module output against non-Asyncify equivalent"
Step 5 — Runtime monitoring for Asyncify anomalies
// Browser-side monitoring of Asyncify module behaviour
// (applicable when running Asyncify modules in a browser context)
class AsyncifyMonitor {
constructor(wasmInstance) {
this.instance = wasmInstance;
this.suspensionCount = 0;
this.maxSuspensionDepth = 0;
this.suspensionTimes = [];
}
wrapAsyncifyExports() {
// Monitor asyncify_start_unwind calls (suspension entry)
const originalStartUnwind = this.instance.exports.asyncify_start_unwind;
if (originalStartUnwind) {
this.instance.exports.asyncify_start_unwind = (ptr) => {
this.suspensionCount++;
this.suspensionStart = performance.now();
// Alert on excessive suspension frequency
if (this.suspensionCount > 1000) {
console.warn(`AsyncifyMonitor: High suspension count (${this.suspensionCount}) — possible abuse`);
}
return originalStartUnwind(ptr);
};
}
// Monitor asyncify_start_rewind calls (resumption)
const originalStartRewind = this.instance.exports.asyncify_start_rewind;
if (originalStartRewind) {
this.instance.exports.asyncify_start_rewind = (ptr) => {
if (this.suspensionStart) {
const duration = performance.now() - this.suspensionStart;
this.suspensionTimes.push(duration);
}
return originalStartRewind(ptr);
};
}
}
getStats() {
return {
totalSuspensions: this.suspensionCount,
avgSuspensionMs: this.suspensionTimes.length > 0
? this.suspensionTimes.reduce((a, b) => a + b, 0) / this.suspensionTimes.length
: 0
};
}
}
Expected Behaviour
| Scenario | Standard Wasm module | Asyncify-transformed module |
|---|---|---|
| Stack frame location | CPU stack (inaccessible from Wasm) | Heap-serialised in linear memory (accessible to other Wasm code) |
| Buffer overflow in linear memory | Cannot corrupt stack | Can corrupt Asyncify frame; resumption executes corrupted state |
| Re-entrant call during async wait | Not possible (synchronous) | Possible; requires explicit re-entrancy safety |
| Code size | Baseline | 2–3× larger; more fuzzing effort required |
| Host function timing as side channel | Limited | Suspension timing reveals call depth information |
| Static analysis coverage | Complete | Requires understanding Asyncify ABI to cover all paths |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
ASYNCIFY_ONLY restriction |
Reduces number of heap-serialised function states | Requires careful analysis of which functions need to be in the async path | Profile the call graph; start narrow and expand as needed |
Smaller ASYNCIFY_STACK_SIZE |
Less heap used for frame serialisation | Module aborts if call depth exceeds the stack size | Profile maximum call depth in testing; add 20% margin |
| Separate async/non-async modules | Non-async code has no Asyncify attack surface | More complex build and deployment | Worth the complexity for high-security code that doesn’t need async |
| Epoch interruption in Wasmtime | Can terminate misbehaving suspended modules | Requires async host to periodically increment epoch | Low overhead; recommended for all production Asyncify deployments |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
ASYNCIFY_STACK_SIZE too small |
Module traps with unreachable during deep recursion |
Runtime trap; error message includes “asyncify stack overflow” | Increase ASYNCIFY_STACK_SIZE; instrument to measure actual max depth |
| Re-entrant call corrupts module state | Incorrect results; occasional panics | Stress test with concurrent calls; add assertion checks on invariants | Add mutex around module calls in the host; or design module for explicit re-entrancy |
| Asyncify frame overwrite via heap corruption | Module produces wrong results or crashes on resumption | Fuzzer with Asyncify-aware mutation; divergence from non-Asyncify version | Identify the buffer overflow that enables frame corruption; fix underlying memory safety bug |
| Static analysis misses Asyncify code paths | Security review marks module as clean despite transformation | Include Asyncify ABI in threat model; use Asyncify-aware analysis tools | Re-analyse with tools that understand Asyncify unwind/rewind patterns |
Related Articles
- Wasm Linear Memory Safety — the memory model that Asyncify operates within; heap corruption that affects Asyncify frames
- Wasm Threads and Shared Memory — concurrency in Wasm that interacts with Asyncify’s suspension model
- Wasmtime Epoch Interruption Security — terminating suspended Asyncify modules that are misbehaving
- Wasm Fuzzing Security Testing — fuzzing strategies relevant to Asyncify-transformed modules
- Wasm Runtime CVE Tracking Supply Chain — tracking CVEs in the runtimes that execute Asyncify modules