Skip to content
By goldendivider rustgomigrationengineering

Why We Moved Exploitmatic from Go to Rust

Exploitmatic started as a Go application with a simple goal: build a runtime for writing, validating, and replaying exploit solutions.

That worked well for the first version of the product.

But as Exploitmatic evolved, the constraints around the original implementation became more obvious. We no longer wanted Exploitmatic to be just a command-line tool. We wanted it to become a proper workbench: something with a desktop interface, an embedded runtime, better tooling, and a foundation we could continue building on.

That is why we moved Exploitmatic from Go to Rust.

This was a product decision first and a performance decision second.

The rewrite did improve some performance characteristics, most notably memory usage, but that was never the reason to rewrite a working runtime. The important changes were architectural and product-oriented.

From a CLI to a workbench

The original Go implementation was built around the terminal.

You could run Exploitmatic, validate solutions, and replay them against targets. It was straightforward and did what it needed to do.

But a terminal-only interface was becoming a limitation.

We wanted a native desktop workbench where users could write solutions, replay them, inspect results, and interact with the runtime without having to build the entire experience around a terminal.

Rust gave us a much better foundation for that direction, particularly through Tauri. Instead of building a separate desktop application around the existing runtime, we could make the runtime itself part of the application architecture.

That changed the question from:

“How do we make the Go CLI faster?”

to:

“What should the next version of Exploitmatic look like?”

The answer was no longer another CLI.

It was a workbench.

The rewrite was more than changing the language

Moving from Go to Rust also gave us an opportunity to rethink some of the project’s structure.

The Go implementation had one main binary, with additional functionality living in separate tools. We had the main Exploitmatic binary, exmcheck for validation, and hex for encoding.

That meant multiple binaries to build, package, and version.

Versioning itself had an awkward edge case. A normal Go build would produce:

exploitmatic dev

The release version had to be injected manually using build flags:

go build -ldflags "-X main.version=v0.3.1"

In Rust, the crate version is part of the build itself.

So a normal build can simply report:

exploitmatic 0.1.0

It is a small thing, but it removes an entire class of build-time mistakes.

The Rust implementation also became a workspace with separate crates for the core runtime, grammar, and application:

  • exploitmatic-core
  • exploitmatic-grammar
  • exploitmatic

And instead of maintaining several user-facing binaries, we now have one primary Exploitmatic binary.

These changes are not particularly exciting in a benchmark table.

They matter a lot when you are trying to build and maintain a product.

Keeping the rewrite honest

A rewrite is dangerous when “equivalent” means “it seems to work.”

We wanted something stronger.

Exploitmatic already had a corpus of solutions and a mock-target environment in scripts/diff-mock.py. The mock server provides the protocol endpoints exercised by the corpus, including HTTP, TCP, UDP, and WebSocket targets.

That gave us a useful way to compare the two implementations.

We pointed both runtimes at the same mock target, replayed the same solutions, and compared their console output.

The Rust implementation was accepted only when its output matched the Go implementation byte-for-byte.

That constraint was important.

We were changing the implementation, not the behavior.

The corpus stayed the same. The console format stayed the same. Given the same input, Go and Rust had to produce the same result.

That compatibility check still runs today.

What happened to performance?

Once you rewrite a runtime in Rust, people naturally ask the obvious question:

Is it faster?

So we measured it.

We rebuilt the final Go release, v0.3.1, and compared it with the Rust implementation using the same mock target and the same synthetic solution containing 400 HTTP steps.

Both implementations produced the same result:

3/3 verified

The timings were:

metricGo v0.3.1Rust
replay 400 steps (best of 5)2087 ms1370 ms
replay 400 steps (median)2350 ms1591 ms
validate 400 steps (best of 10)217 ms199 ms
peak working set during replay24.7 MiB7.7 MiB
binary size8.75 MiB9.30 MiB

The replay benchmark was roughly 1.5x faster in Rust.

The more interesting result was memory usage: peak working set dropped from 24.7 MiB to 7.7 MiB, roughly a threefold reduction.

Validation was in roughly the same range.

So, yes, Rust was faster on this workload.

But these numbers do not justify the rewrite by themselves.

If performance had been our only goal, rewriting a working runtime would have been a questionable trade.

The reason we moved was the product.

Three things the benchmark does not show

There are a few things worth keeping in mind when looking at these numbers.

Binary size did not improve

The binaries are almost the same size.

The Go binary carries the Go runtime, while the Rust binary brings in dependencies such as Tokio and Axum. In the end, we measured 8.75 MiB for Go and 9.30 MiB for Rust.

So this was not a binary-size optimization.

Startup time was not the goal

We also did not publish a startup benchmark.

For these workloads, startup is largely dominated by the operating system spawning the process, rather than the runtime differences we were interested in measuring.

Validation is not a direct comparison

The validation numbers are also not perfectly apples-to-apples.

The Rust implementation performs the hand-written parser, the ANTLR grammar pass, and semantic checks. The Go exmcheck tool performed the ANTLR grammar pass.

So Rust is doing more validation work in roughly the same amount of time.

That is useful, but it is not a clean language-vs-language benchmark.

The memory improvement was real

Of all the performance numbers, memory usage is the one we care about most.

The replay path in Rust does not have a garbage collector. During long, multi-step replays, the working set therefore remains much flatter than it did in the Go implementation.

For our workloads, that took peak working-set usage from 24.7 MiB to 7.7 MiB.

That is a meaningful improvement.

But it is important to separate what Rust enabled from why we chose Rust.

We did not start the rewrite because Exploitmatic was using too much memory.

The memory improvement was something we gained from the rewrite.

What we actually gained

The benchmark table is useful, but it misses most of the reasons the rewrite was worthwhile.

The biggest gains are in the product itself.

Exploitmatic now has a native desktop workbench with:

  • a proper application window and branding,
  • a workspace for writing and replaying solutions,
  • an embedded MCP server,
  • integration points for tools such as VS Code,
  • a visible runtime status and port,
  • and an in-app protocol for communicating with the workbench.

Here is the workbench with a solution open in the editor and the Run drawer ready on the right:

The Exploitmatic Workbench: the Keycloak reset-credentials solution open in the editor, the Run drawer on the right, and the in-process MCP server shown in the status bar

The MCP server runs in-process, which means the desktop application can expose the runtime directly while it is running rather than requiring another externally managed service.

The workbench API is also served through an in-app custom protocol rather than another web port.

That architecture is much closer to where we want Exploitmatic to go.

The Rust implementation also gives us stronger boundaries between pieces of the system.

The core runtime is its own crate. Grammar handling is separated from the application. The public crate API gives us something we can build other interfaces around instead of treating the CLI as the entire product.

That distinction matters.

We are no longer building a CLI and then attaching a UI to it.

We are building a runtime that happens to have a CLI and a desktop workbench.

The rewrite also changed how we test

The Go implementation already had corpus testing through tools/exmcheck/corpus_test.go.

The difference is what happens in the Rust implementation.

The entire corpus is now validated in CI on every push.

The test suite includes checks such as:

  • check_accepts_every_corpus_solution
  • parses_every_corpus_solution
  • validates_every_corpus_solution

These tests deliberately stop short of replaying solutions against a live target.

They verify that the corpus can be accepted, parsed, and validated correctly.

That gives us a much stronger safety net around the language and runtime as the project evolves.

The goal of the rewrite was not simply to make the code compile in another language.

It was to give us a better foundation for continuing to change the product without losing compatibility.

So, would we do it again?

Yes.

Not because Rust made our 400-step benchmark 1.5x faster.

And not because the binary became smaller; it did not.

We would do it again because the rewrite removed several limitations that were becoming increasingly relevant to the product.

We went from a terminal-first implementation to a foundation for a native workbench.

We went from several user-facing tools to one primary binary.

We removed the build-time versioning trap.

We gained a cleaner workspace and public crate boundaries.

We strengthened the corpus validation in CI.

We reduced memory usage significantly.

And, perhaps most importantly, we now have an architecture that matches where we want Exploitmatic to go next.

The performance improvements are nice.

The lower memory footprint is even better.

But neither was the point.

The point was to build the Exploitmatic we actually want to ship.

Give exploit research a common language.

Bring a PoC, a writeup, or an AI-generated draft into an Exploitmatic solution: inspect it, validate it, and replay it against a target you own. The runtime and the corpus are open source.