Picking up where I left off
At the end of my last post I said I’d write again before the end-term evaluation. Here we are. The mid-term was in the second week of July, and the coding period ended in mid-August. In between I took the best parts of QuantumStrand and folded them into the main FLOSS tool, wrote a development spec with my mentor, shipped a CLI redesign, added result caching, and built a web viewer. This post is the full account of that second half.
Quick recap for anyone new: FLOSS is the FLARE team’s tool for pulling strings out of malware. Strings (readable text like URLs, paths, and API names) are the first thing an analyst looks at. QuantumStrand (QS for short) is an experimental branch that renders those strings with context: which binary section they came from, which library they belong to, and filterable tags. My project was to bring that context into the default FLOSS output. My mentor throughout has been Moritz Raabe (@mr-tz), the main FLOSS maintainer and GSoC org admin.
The plan becomes a spec
The first half of the summer was mostly spent on the string databases and the ELF parser. That work paid off, but it was clear we needed a proper plan before we started changing the main tool. Every week my mentor and I met and took notes in a shared document, and those notes gradually turned into a real development spec.
The spec split the remaining work into three streams:
- String display decisions. Layout and tag rendering apply to static strings only, and results appear in a fixed order: static, then language, stack, tight, and decoded strings, each in its own labelled section.
- Deferred items from an earlier PR. Adding runtime timing metrics, dropping the old flags that forced you to pick a layout mode or load a saved file, and moving sample metadata into a new summary output.
- A new command line interface plus caching. Symmetrical include/exclude filters, multiple output modes, and a cache keyed by a hash of the sample so repeat analyses are instant.
Getting to an agreed spec was not smooth. I kept bringing my mentor a document to review, and he kept feeling like we were going in circles.
Mentor: I feel like I’m reviewing the same more or less here for the 3rd time… this is a really annoying way to collaborate
Mentor: did you read through the doc before sharing again?
Mentor: feels like spinning in circles here again and again
I was genuinely unsure what he wanted changed, so I asked.
me: Can I get a bit more clarification on what exactly is needed here to be changed from my side?
The fix was a reframe. Instead of a discussion document, my mentor wanted an implementation spec we could hand to coding agents.
Mentor: to have a agreed on plan we can use to implement and to pass to agents
Mentor: Let’s create a spec doc we can use for that purpose
Once I rewrote it as a clear, agent-parseable spec, everything clicked. I updated it once more, and my mentor’s relief was visible.
Mentor: I’m glad we’re done with this hopefully…
The spec is now the migration development spec, and it is what drove the rest of the summer. Lesson learned on my side: when you collaborate through documents, make the artifact’s purpose obvious before you keep editing it.
The string databases grow up
Before the integration, I spent early July finishing the automated database pipeline. The idea: FLOSS knows a lot of strings come from open-source libraries like zlib or OpenSSL, so it keeps a database of known library strings and tags matches. Building those databases by hand does not scale, so I built a script that compiles libraries, extracts their strings, and regenerates the databases, all triggered automatically.
The automated OSS database build PR landed that work, and it went through a long, careful review. My mentor’s verdict when it finally merged:
Mentor: It took a while but it should be very solid now.
Mentor: Nice work!
In parallel, my mentor built a global prevalence database from roughly 150,000 real-world samples collected over three years. That scan pulled out 1.56 billion strings. The idea is to know which strings are so common they are noise, and filter those out. His first attempt at building it ran out of memory, which produced one of my favourite messages of the summer:
Mentor: it was a little stupid to try to keep millions of strings in a dictionary 😛
The finished prevalence database got folded into FLOSS in the prevalence databases PR. I also audited the library list with my mentor, because some libraries (like duckdb or raylib) were showing up suspiciously often in samples where they almost certainly were not present. False positives in the tag output would hurt analysts’ trust, so we went through which matches actually made sense. The final list settled at around 60 libraries.
Building the databases kept hitting walls in the upstream lancelot project, the string extractor the pipeline depends on. I covered the crash fix in my last post: a panic on weak external C++ symbols, filed as a reproduction and fixed upstream. The other two problems were quieter but just as important.
First, the extractor’s output format was fragile. It wrote hand-rolled CSV, which broke whenever a string contained a comma, a quote, a newline, or a C++ mangled name. Downstream parsing kept choking on those, so I filed the issue and replaced the CSV with proper JSONL output. Each record is now one self-contained line, which removed a whole class of parsing bugs. Willi merged it the same day.
Second, a stale dependency broke the build tooling. A review comment on my database build PR surfaced that the bundled zydis library used an old CMake syntax that newer CMake versions reject, and we had been working around it with an environment variable. I filed the issue, and Willi fixed it by updating the dependency, so the workaround could be deleted.
The whole pipeline has since run successfully against the merged code. The bi-weekly rebuild completed cleanly and auto-opened the database update PR (#1385), which is the automation working end to end.
The integration: one pipeline, proven by parity
The real deliverable was taking QS’s layout and tag machinery out of its separate package and making it the default path inside FLOSS. This was the big one, roughly 4,000 lines moved and rewritten.
I split it into reviewable chunks rather than one giant dump. The first piece promoted QS analysis into first-class modules and unified the command line without changing any behavior. The second made the layout-aware, tag-enriched output the default and unified everything into a single results document.
The whole point of the rewrite was a single, predictable flow. Every string type runs through the same stages and lands in the same document, and each output mode just reads that document:
flowchart LR
A[Sample file] --> B[Static strings]
B --> C[Layout and structure]
C --> D[Tags]
D --> E[One results document]
E --> F[Recovered strings]
F --> D
E --> G[Terminal output]
E --> H[JSON]
E --> I[Summary]
E --> J[Web viewer]
The risk with any big refactor is that you quietly change behavior. My mentor and I wanted proof, not vibes. So we ran the old and new code across a thousand random samples and compared the output.
When my mentor came back with the verdict, it was two words:
Mentor: all match
That single message made the whole refactor worth it. The unified pipeline merged, and from then on, layout and tags were just what FLOSS did, no special flags needed.
A cleaner command line
With the engine integrated, the command line needed to catch up. QS had its own set of flags, and we wanted one coherent interface. The spec called for symmetrical filters: include or exclude by string type, by binary section, by structure, or by tag. Want only strings from the .rdata section, minus anything tagged as common noise? That became expressible in one command.
The work came in three parts, and each part is its own story below. Every part went through several rounds of review, including automated code reviews. My mentor encouraged me to run my own AI review before opening PRs:
Mentor: i think just asking review this pr before submit helps to catch things
I started doing that, and it caught real issues like a command line example that referenced a flag that did not exist yet. The reviews also got me thinking about how an agent would use the tool. That shaped decisions like keeping the machine-readable JSON output clean and predictable, because that is the surface an AI agent would interact with.
Part 1: the string type flags
Part 1 reworked how you choose what to extract. Before, you picked among a handful of rigid options. After, you get two matching flags: --string-type to say which kinds of strings you want, and --no-string-type to say which ones to skip. The valid kinds are static, stack, tight, decoded, and language strings, plus the catch-all all. Because they are symmetrical, the same mental model covers both include and exclude.
The same PR renamed a confusing option. The old --functions flag, which restricted emulation to a set of function addresses, became --analyze-functions, so it is obvious what it does. It also removed the old --load flag, because loading a saved results file became automatic: if the input looks like a JSON results document, FLOSS loads it instead of treating it as a binary to scan.
Finally, Part 1 added the runtime timing fields. Each stage of the analysis now records how long it took, and that lands in the results document alongside the data. Timing is the kind of thing you only need when something is slow, and having it in the output means you can find out without adding instrumentation.
Part 2: filters, the summary, and clean machine output
Part 2 is where the output became genuinely queryable. It added the render-time filters from the spec: --section and --no-section by binary section, --structure and --no-structure by binary structure, and --tag and --no-tag by classification tag. There is also --query, which filters strings by a regular expression while keeping the surrounding tree structure intact, and --max-strings, which caps how many strings each section prints. A shortcut flag --interesting drops the noisy tags like common, duplicate, and relocation noise in one go.
This PR also introduced the --summary output. A full FLOSS run can print a lot, so the summary condenses it: sample metadata, per-section counts, a histogram of which tags appeared, and the strings that carry high-value tags. It is built from the same underlying results document as the JSON output, so the summary is never out of sync with the data it describes.
The machine-readable side got stricter too. The JSON output now always sorts its keys, so output is stable and diffable. And whenever JSON output mode is active, fatal errors are emitted as a single JSON object on stderr instead of a wall of traceback text. That is a small change with a big effect for tooling: a parser can reliably read either a result or an error, never a crash dump.
Part 3: caching
Part 3 added the caching module, the last of the three. Analysts often run FLOSS over the same file repeatedly while they tweak their approach. There is no reason to redo the whole analysis each time.
The cache is keyed by a hash of the sample bytes plus the FLOSS version, so different files or different tool versions never collide. The full results document is stored, and later runs load it instead of re-analyzing. Filters apply at render time, so you can change your filter flags and still use the cached analysis.
The speedup was dramatic. My mentor benchmarked it on a sample where a fresh run took about 30 seconds. A cached run took about 3.5 seconds, of which most was Python startup, not the cache itself. Roughly a 8 to 10x speedup for repeat runs. We also hardened it against real-world problems like files being locked by other processes on Windows, and added tests for every edge case we could think of.
The web viewer
The other half of the project was visual. QS had a web viewer for browsing the tagged output, and part of my plan was making an interactive viewer. The viewer got reworked to match the new unified results format, gained light and dark themes, fuzzy search, and could handle hundreds of thousands of strings without slowing down.
Having a live deployment made iteration much faster, because my mentor could click around the real page instead of reading about it.
Mentor: UI stuff looks very cool, I love that we can see it live in action
The viewer is now deployed at https://mandiant.github.io/flare-floss/ on every change. A follow-up fix repaired the broken logo and favicon on GitHub Pages, and a new download button saves the whole viewer as one self-contained HTML file you can keep and open offline. I also prototyped serving the viewer from the standalone binary with a server flag, but after review we decided a simpler static HTML integration fits better, so that PR is being closed in favor of shipping the offline page with the release.
Reviews I did
Part of being a good contributor is reviewing others. My mentor sent several PRs my way. The notable ones:
- Improve ELF parsing robustness: made the ELF parser tolerate corrupt or stripped binaries. I checked the new fallback logic and gave it the go-ahead.
- Update prevalence databases: I asked whether the large gap in size between the Go and Rust sub-databases was intentional before approving.
- Revamp summary output: I caught that the new filtering let untagged generic strings crowd out tagged interesting ones, and flagged some now-dead code, before the fixes landed and it merged.
These reviews were a useful check on my own standards. Reading someone else’s code with the same rigor my mentor applied to mine made me a better writer of my own.
The final merge
With all the pieces in place, the last job was folding the whole QuantumStrand branch into master. That meant removing every trace of the old qs naming, retargeting the CI workflows, renaming the viewer, and cleaning up stale branches. The cleanup went in first, followed by the merge of the branch into master itself.
My mentor noticed the merge PR was hitting GitHub’s limits on commits, which prompted this exchange:
Mentor: zizmor has some notes on the PR, do you see those?
me: yeah working on a fix for those
Then came the real review. My mentor went through the whole merge diff source-level and filed an issue with ten correctness and regression issues, several of which silently produced wrong output. That was uncomfortable to read, but it was exactly the kind of review the merge needed. I went through them and fixed the ones that were code fixes in this PR: XOR-decoded layouts no longer fall back to the classic path, the yes flag now actually skips deobfuscation for Go and Rust, the cache key accounts for the input format, header-gap strings get their structure tags, and the summary plus analyze-functions combination now errors instead of silently disabling everything. The rest were product decisions rather than code, so I opened issues to track them: the viewer tag filter, the version bump and flag aliases, and the default deobfuscation behavior.
With those fixes in, the merge went through. QuantumStrand is now in master, which closes the project issue that started all of this.
By the numbers
A quick tally of what went into this stretch:
- 15 pull requests opened in the main FLOSS repo during the coding period, including 5 PRs reviewed that were not mine, on top of the pre-GSoC work I covered in my last post.
- Around 1000 samples used in the parity test, with zero mismatches against the old code.
- Databases covering roughly 60 open-source libraries, plus a global prevalence database built from 150,000 samples.
What is left
QuantumStrand is merged into master now, so the project goal is done. What remains is post-merge polish:
- Finish the UI release. The web viewer is live at https://mandiant.github.io/flare-floss/, deployed on every change, and the downloadable offline page with a getting started view has landed. What remains is shipping that static page with the standalone binary instead of hosting a server (tracked here).
- Cut a beta release of FLOSS 5. Once the open work merges, a beta gives people something to try and a channel for feedback.
- Resolve the tracked product decisions. The viewer tag filter, the version bump and flag aliases, and the default deobfuscation behavior.
- Documentation for the new flags. The proposal reserved a phase for documentation, and the usage guide update covering the new flags, the summary output, and the cache environment variables is now up for review.
- Long-term cache concerns like eviction of old entries, flagged as a v1 tradeoff.
None of these block the core goal. The project did what the proposal promised: FLOSS now renders strings with layout and tag context by default, the databases stay fresh automatically, and the whole thing is faster on reruns.
Takeaways
Three things stand out from this summer.
First, proof beats promises. The parity test over a thousand samples is what let us merge a huge refactor with confidence. Verifying even one real sample can be more valuable than spending hours on static code review, because it tests what the software actually does. Whenever I can quantify that something still works, I should.
Second, the artifact should match its purpose. Our planning doc only started working once it became an implementation spec. A discussion doc and an instruction doc are different things, and I kept mixing them up.
Third, the mentor relationship is the real output. My mentor gave honest feedback, including the uncomfortable kind:
Mentor: Hey I know it’s a lot going but I think you’ll need to put a fair amount of more work into the project for us to be able to finish what we’ve planned.
And then he offered support:
Mentor: let me know how I can best support here for the final push.
That combination, straight talk plus help, is what made the project land. Thanks for a great summer. And thanks to Willi for the many sharp reviews along the way. On to the next one.