Skip to content
chrncl
Writing06 / 09
C# / WASM-about 10 min read

Static Analysis Without a Server

FCAT started from an annoyance: every online static analysis tool makes you upload your binary to someone else's disk. Here is how I built a full PE analysis suite in C# and WebAssembly that runs entirely in the browser tab.

FCAT started from an annoyance. Every 'upload your file and we'll scan it' tool online means exactly that, your binary leaves your machine, sits on someone else's disk for however long their retention policy says, and you've traded privacy for convenience you didn't need. If you're doing static analysis on something sensitive, a sample you're not sure about, a binary under an NDA, anything proprietary, uploading it anywhere should be the last resort, not the default workflow. So I built the alternative: a full PE analysis suite that runs entirely in the browser tab, written in C# and compiled to WebAssembly through Blazor. No upload. No server. No network request at all, actually, you could run it with your WiFi off and nothing would break.

That constraint is the whole story of the project. Everything interesting about FCAT exists because of the 'no server' rule, not in spite of it.

The parser had to be mine

I looked at existing C# PE-parsing libraries before writing one from scratch, and none of them handled the full spec correctly, specifically around bound imports and delay-load descriptors, which almost nothing bothers to implement because almost nothing needs them. I wanted correctness on the full structure: DOS header and stub, NT headers, the optional header, every standard data directory, import, export, resource, reloc, debug, TLS, load config, and the x64 exception table. Writing it against the Microsoft PE/COFF spec directly, rather than against whatever the last library did, meant a lot of test binaries with edge-case layouts and a lot of comparing my output against what a known-good tool like PE-bear reported for the same file.

Living inside a linear memory sandbox

WASM's memory model is a single linear array of bytes, and in a browser context you're working with a memory ceiling that makes 'just load the whole file into a byte array' a real constraint rather than an assumption. For a 200MB sample, not unusual for installer bundles or anything with embedded resources, you can't just read the whole thing into managed memory and index into it freely without thinking about where that memory lives and how long it's alive.

The fix was treating the file as a stream of windows rather than a single allocated blob wherever possible. The PE header parsing needs random access to the first few KB, so that part loads fully. Everything past that, section data, resource blobs, the raw bytes feeding the entropy scanner, gets processed in chunks, with Span<byte> views into rented buffers rather than fresh allocations per region. .NET's ArrayPool<byte> carried a lot of weight here: renting and returning the same scratch buffers instead of letting the GC deal with a new large array for every section kept both allocation pressure and WASM memory growth under control.

Packer detection as a heuristic stack, not a single check

Nothing in a PE header says 'I am packed.' You infer it from a handful of signals that are individually weak and collectively convincing:

csharp
if (section.Name == ".MPRESS" || section.Name.StartsWith("UPX"))
    confidence += 0.4;

if (section.VirtualSize > section.RawSize * 8 &&
    section.Characteristics.HasFlag(IMAGE_SCN_MEM_EXECUTE))
    confidence += 0.25; // huge virtual/raw gap on an executable section

if (importTable.Count < 5 && !importTable.Any(i => i.Name == "kernel32.dll"))
    confidence += 0.3; // almost nothing imported, no kernel32 - suspicious

None of these alone proves anything, a legitimately tiny utility can have a short import table, and plenty of normal binaries have non-standard section names. Stack three or four of these together and weight them against a threshold, though, and you get something that correctly flags UPX, Themida, VMProtect, and MPRESS against a test corpus without constantly screaming about every binary that just happens to be small. I keep false positives in check by testing against a reference set of deliberately boring binaries, standard MSVC output, standard Go binaries, standard Rust binaries, and tuning the thresholds until none of them trip the detector.

The entropy heatmap

Shannon entropy per block is the other half of triage: high entropy usually means compression or encryption, which correlates strongly with packed or protected sections. I split the file into 256-byte windows and compute entropy per window, which for a 200MB file is around 800,000 windows, fine for a native process, not fine for a single synchronous loop blocking a WASM thread that also owns the UI.

The scanner runs in batches with an explicit yield back to the browser event loop between them:

csharp
for (int i = 0; i < totalBlocks; i += batchSize)
{
    ProcessBlockBatch(i, Math.Min(batchSize, totalBlocks - i));
    await Task.Yield(); // let the UI breathe
}

Without that yield, the tab visibly locks for the duration of the scan on a large file, the browser's 'page unresponsive' prompt included. With it, the canvas renders progressively as blocks complete, and the UI stays interactive the whole time. It's a small change in the code and a completely different experience for anyone actually using it.

The part I didn't expect going in: already-compressed legitimate resources, embedded PNGs, ZIPs, anything JPEG, read just as high-entropy as an encrypted payload. The heatmap alone can't distinguish 'this section is packed' from 'this section is a compressed icon resource.' That's why entropy feeds into the same weighted heuristic as everything else rather than standing alone as a verdict. A high-entropy .rsrc section full of icons is normal. A high-entropy .text section that should contain plain executable code is the thing worth looking at.

That combination, don't trust any single signal, weight several together, keep every byte on the user's machine, is the actual design of the tool. The WASM part is just the delivery mechanism that made zero server possible.