<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>chs.us — Carl Sampson</title><link>https://chs.us/</link><description>Application security engineer writing about web vulnerabilities, Python security tooling, and vulnerability research.</description><image><title>chs.us</title><url>https://chs.us/og-image.png</url><link>https://chs.us/</link></image><language>en-us</language><managingEditor>carl.sampson@gmail.com (Carl Sampson)</managingEditor><webMaster>carl.sampson@gmail.com (Carl Sampson)</webMaster><lastBuildDate>Tue, 13 Oct 2026 12:00:00 +0000</lastBuildDate><atom:link href="https://chs.us/tags/webassembly/index.xml" rel="self" type="application/rss+xml"/><item><title>WebAssembly security: what the sandbox actually gives you (and what it doesn't)</title><link>https://chs.us/2026/10/webassembly-security/</link><pubDate>Tue, 13 Oct 2026 12:00:00 +0000</pubDate><author>carl.sampson@gmail.com (Carl Sampson)</author><guid>https://chs.us/2026/10/webassembly-security/</guid><description>Wasm is sandboxed, but it is not memory-safe inside its own linear memory. A compiled demo of an overflow that silently corrupts a neighbor, and where the real risk lives.</description><category>Security</category><category>Webassembly</category><category>Wasm</category><category>Memory-Safety</category><category>Sandbox</category><category>Appsec</category><category>Wasi</category><content:encoded><![CDATA[<p>Every few months someone tells me WebAssembly is &ldquo;sandboxed and memory-safe,&rdquo; usually as a reason they don&rsquo;t need to worry about the C library they just compiled to it. Half of that sentence is true. The other half gets people owned, and the gap between the two halves is where I want to spend this post.</p>
<p>I&rsquo;m going to make the argument with code you can run, not adjectives. Everything below is compiled with clang&rsquo;s <code>wasm32</code> target and run in Node, and I&rsquo;ll give you the exact commands so you can reproduce it in a couple of minutes.</p>
<h2 id="what-the-sandbox-actually-guarantees">What the sandbox actually guarantees</h2>
<p>The thing WebAssembly genuinely does well is isolation from the host. A module gets a single contiguous block of memory — &ldquo;linear memory&rdquo; — and that&rsquo;s its entire world. It can&rsquo;t name an address outside that block. It has no instruction that issues a raw syscall. Indirect calls don&rsquo;t jump to an arbitrary address; they go through a table and are type-checked at the call site. Code and data live in separate address spaces, so a module can&rsquo;t write to its own instructions the way a native stack-smash flips execution to a payload.</p>
<p>When a module tries to step outside linear memory, it doesn&rsquo;t get undefined behavior. It traps. Here&rsquo;s that boundary, enforced:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="cl"><span class="c1">// demo.c — freestanding, no libc
</span></span></span><span class="line"><span class="cl"><span class="k">typedef</span> <span class="kt">unsigned</span> <span class="kt">long</span> <span class="kt">size_t</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">static</span> <span class="kt">void</span> <span class="o">*</span><span class="nf">my_memcpy</span><span class="p">(</span><span class="kt">void</span> <span class="o">*</span><span class="n">d</span><span class="p">,</span> <span class="k">const</span> <span class="kt">void</span> <span class="o">*</span><span class="n">s</span><span class="p">,</span> <span class="kt">size_t</span> <span class="n">n</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kt">char</span> <span class="o">*</span><span class="n">a</span> <span class="o">=</span> <span class="n">d</span><span class="p">;</span> <span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">b</span> <span class="o">=</span> <span class="n">s</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">d</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">struct</span> <span class="p">{</span> <span class="kt">char</span> <span class="n">name</span><span class="p">[</span><span class="mi">16</span><span class="p">];</span> <span class="kt">char</span> <span class="n">secret</span><span class="p">[</span><span class="mi">16</span><span class="p">];</span> <span class="p">}</span> <span class="n">vault</span> <span class="o">=</span> <span class="p">{</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;TOPSECRETTOKEN!&#34;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">__attribute__</span><span class="p">((</span><span class="nf">export_name</span><span class="p">(</span><span class="s">&#34;copy_name&#34;</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl"><span class="kt">void</span> <span class="nf">copy_name</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span> <span class="o">*</span><span class="n">src</span><span class="p">,</span> <span class="kt">int</span> <span class="n">len</span><span class="p">)</span> <span class="p">{</span> <span class="nf">my_memcpy</span><span class="p">(</span><span class="n">vault</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">src</span><span class="p">,</span> <span class="n">len</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">__attribute__</span><span class="p">((</span><span class="nf">export_name</span><span class="p">(</span><span class="s">&#34;name_ptr&#34;</span><span class="p">)))</span>   <span class="kt">char</span> <span class="o">*</span><span class="nf">name_ptr</span><span class="p">(</span><span class="kt">void</span><span class="p">){</span> <span class="k">return</span> <span class="n">vault</span><span class="p">.</span><span class="n">name</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="nf">__attribute__</span><span class="p">((</span><span class="nf">export_name</span><span class="p">(</span><span class="s">&#34;secret_ptr&#34;</span><span class="p">)))</span> <span class="kt">char</span> <span class="o">*</span><span class="nf">secret_ptr</span><span class="p">(</span><span class="kt">void</span><span class="p">){</span> <span class="k">return</span> <span class="n">vault</span><span class="p">.</span><span class="n">secret</span><span class="p">;</span> <span class="p">}</span>
</span></span></code></pre></div><p>Build it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">clang --target<span class="o">=</span>wasm32 -nostdlib -O2 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -Wl,--no-entry -Wl,--export-all -Wl,--allow-undefined <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o demo.wasm demo.c
</span></span></code></pre></div><p>Then drive it from Node, and ask it to write 70,000 bytes starting at <code>vault.name</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">copy_name</span><span class="p">(</span><span class="nx">scratch</span><span class="p">,</span> <span class="mi">70000</span><span class="p">);</span>   <span class="c1">// runs off the end of linear memory
</span></span></span><span class="line"><span class="cl"><span class="c1">// -&gt; RuntimeError: memory access out of bounds
</span></span></span></code></pre></div><p>That trap is the whole value proposition. In a native process the same write marches through the heap, past guard pages if you&rsquo;re lucky, into a crash or a corrupted allocator if you&rsquo;re not. In Wasm it stops dead at the edge of linear memory, every time, deterministically. The host&rsquo;s memory is never in reach. That part of the pitch is real, and it&rsquo;s genuinely good.</p>
<h2 id="what-it-does-not-give-you">What it does not give you</h2>
<p>Here&rsquo;s the half people skip. The bounds check only fires at the <em>edge</em> of linear memory. Everything inside that block is one flat array with no internal protections — no guard pages between objects, no ASLR, no stack canaries, nothing. So a write that stays inside linear memory but overruns a buffer isn&rsquo;t a trap. It&rsquo;s just a write to a legal address that happens to belong to something else.</p>
<p>Same module. This time I overflow the 16-byte <code>name</code> buffer by 16 bytes, which runs straight into <code>secret</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">TextEncoder</span><span class="p">().</span><span class="nx">encode</span><span class="p">(</span><span class="s1">&#39;AAAAAAAAAAAAAAAAPWNED-VIA-OVERFLOW&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">mem</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">scratch</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">copy_name</span><span class="p">(</span><span class="nx">scratch</span><span class="p">,</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>   <span class="c1">// len = 34 into a 16-byte buffer
</span></span></span></code></pre></div><p>Output:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">secret @ 65552, name @ 65536, gap = 16 bytes
</span></span><span class="line"><span class="cl">secret before: &#34;TOPSECRETTOKEN!&#34;
</span></span><span class="line"><span class="cl">secret after:  &#34;PWNED-VIA-OVERFL&#34;
</span></span><span class="line"><span class="cl">OOB write -&gt; RuntimeError: memory access out of bounds
</span></span><span class="line"><span class="cl">memory is 131072 bytes
</span></span></code></pre></div><p>The secret got overwritten. No trap, no warning — the bounds check had nothing to say, because <code>secret</code> is a perfectly valid offset in linear memory. This is a textbook buffer overflow, alive and well, inside a sandbox that marketing told you was memory-safe.</p>
<p>And look at the addresses: <code>secret</code> sits at offset 65552 on every single run. There&rsquo;s no ASLR, so the layout is deterministic. An attacker doesn&rsquo;t have to defeat randomization or guess an offset; they can compute it once and rely on it forever.</p>
<p>This is the uncomfortable finding that the academic world has been making for a while now — Lehmann, Kinder, and Pradel&rsquo;s <a href="https://www.usenix.org/conference/usenixsecurity20/presentation/lehmann"><em>Everything Old is New Again: Binary Security of WebAssembly</em></a> (USENIX Security 2020) is the canonical write-up. The exploitation primitives we spent twenty years building mitigations against (overflows corrupting adjacent data, overwriting function pointers, smashing the stack, clobbering heap metadata) all still work when you compile C or C++ to Wasm, and several of them work <em>more</em> reliably than native because the mitigations simply aren&rsquo;t there. The stack, by the way, lives in this same linear memory for Clang-compiled code, so stack buffers are overwritable neighbors too.</p>
<p>The sandbox contains the blast radius. It does nothing about the explosion inside.</p>
<h2 id="so-where-does-the-real-risk-live">So where does the real risk live?</h2>
<p>Three places, in roughly the order they bite people.</p>
<p><strong>The import surface.</strong> A module on its own can&rsquo;t do anything interesting — it can&rsquo;t open a file, make a network call, or read a clock. Everything it can do, you handed it, as an imported host function or through WASI. That import list is the entire attack surface between the module and the outside world, and it&rsquo;s a capability list whether you think of it that way or not. If you import a host function that takes a pointer and a length into linear memory and trusts them, module-side corruption becomes host-side impact. If you hand a module broad WASI filesystem access &ldquo;to keep things simple,&rdquo; you&rsquo;ve given untrusted code a filesystem. I wrote a whole post on <a href="/2026/07/sandboxing-untrusted-python/">sandboxing untrusted Python</a> and the same rule is the punchline here: expose the narrowest specific capabilities, validate everything crossing the boundary, and assume the code on the other side is hostile. Wasm gives you a beautiful boundary and then lets you poke as many holes in it as you like.</p>
<p><strong>The runtime itself.</strong> Your module runs inside Wasmtime, Wasmer, V8, wasm3, something. That runtime is a program parsing and JIT-compiling untrusted bytecode, and runtimes have shipped real CVEs — sandbox escapes, miscompilations, out-of-bounds in the host. The guarantees in this post are only as true as the engine&rsquo;s implementation of them. Keep it patched like you&rsquo;d patch any other code that eats attacker input, because that&rsquo;s exactly what it is.</p>
<p><strong>The supply chain.</strong> A <code>.wasm</code> file is a compiled blob. You usually can&rsquo;t read it, you rarely built it, and &ldquo;it&rsquo;s sandboxed&rdquo; is doing a lot of quiet work in the decision to run it. The sandbox limits what a malicious module can reach, which is genuinely helpful — but combine a hostile module with a too-generous import surface and you&rsquo;re back to the first bullet. Treat a third-party Wasm dependency with the same suspicion you&rsquo;d give a third-party native binary, not the free pass you&rsquo;d give a text config.</p>
<p>I&rsquo;ll also mention side channels briefly: deterministic layout and shared caches make Wasm a comfortable place for Spectre-style timing attacks, which is part of why browsers clamp timer resolution for it. Worth knowing, rarely the thing that gets you first.</p>
<h2 id="what-i-actually-do-with-this">What I actually do with this</h2>
<p>I&rsquo;m not down on WebAssembly. The isolation is real and I reach for it specifically <em>because</em> the host boundary is strong. But I treat it as exactly one property — host isolation — and not a bundle of safety I didn&rsquo;t earn.</p>
<p>Concretely:</p>
<ul>
<li><strong>Keep the import surface tiny and typed.</strong> Every host function is a capability. Don&rsquo;t import convenience you don&rsquo;t need, and validate every pointer/length and every argument coming from the module as if it were a hostile network packet, because it is.</li>
<li><strong>Set hard limits.</strong> Cap linear memory, and use the runtime&rsquo;s execution limiter — Wasmtime&rsquo;s fuel or epoch interruption — so a module can&rsquo;t spin forever or balloon memory. A sandbox with no resource ceiling is a denial-of-service waiting to happen.</li>
<li><strong>Don&rsquo;t assume the source language&rsquo;s safety survived, and don&rsquo;t assume it was there.</strong> Rust-to-Wasm keeps Rust&rsquo;s guarantees; C-to-Wasm keeps C&rsquo;s bugs and drops the native mitigations. If you must ship C/C++ to Wasm, compile with the hardening you&rsquo;d use natively and fuzz it, because the sandbox won&rsquo;t catch the overflow for you.</li>
<li><strong>Patch the runtime</strong> on the same cadence as anything else processing untrusted input.</li>
<li><strong>Audit third-party modules</strong> like binaries, not like data.</li>
</ul>
<p>The one-sentence version, which is the version I wish people led with: WebAssembly protects the host from the module; it does not protect the module from itself. Design for both and it&rsquo;s a great tool. Assume the second half for free and you&rsquo;ve just moved your memory-corruption bugs into a place with fewer mitigations and a reassuring name.</p>
<hr>
<p>The demo is a single C file and a dozen lines of Node — <code>clang --target=wasm32</code> ships with LLVM, so there&rsquo;s no toolchain to install beyond that and the <code>lld</code> linker. If you&rsquo;ve never watched a &ldquo;memory-safe sandbox&rdquo; cheerfully overwrite a secret, spend the five minutes. It changes how the word &ldquo;sandboxed&rdquo; reads on a datasheet.</p>
<p><strong>See also:</strong></p>
<ul>
<li><a href="/2026/07/sandboxing-untrusted-python/">Six layers to sandbox untrusted Python — and the escape I missed</a> — the same import-surface lesson, in a different runtime</li>
<li><a href="/2026/03/use-after-free/">Use-After-Free: a classic memory corruption bug</a> — the native version of the bugs that follow you into Wasm</li>
<li><a href="/security-playground/">Security Playground</a> — interactive demos</li>
</ul>
]]></content:encoded></item></channel></rss>