Skip to content

Build1 publisher3 min readPublished

Bisecting builds against VirusTotal traced a Defender trojan flag to statically linked libsodium

Roblox Account Manager's developer traced a Defender trojan verdict, from 1 of 75 VirusTotal engines, to the commit that added libsodium. Build-and-scan bisection found the dependency, though the developer can only hypothesise why the model objects to it.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying Bisecting builds against VirusTotal traced a Defender trojan flag to statically linked libsodium
Generated illustration

What happened

  • The !ml suffix on Wacatac.B!ml marks a machine-learning verdict, and the developer says no known malware signature was matched.
  • A pefile diff showed the flagged build kept the same 16 risky imports, section entropy and linker, adding only four imports the developer called boring.
  • Running git bisect, the developer built a release .exe at each step and marked it good at 0/75 or bad at 1/75 using the free VirusTotal API.
  • sodiumoxide, the Rust binding added for account-file encryption, statically links the libsodium C library into the executable.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Triage of an ML detection has to run on scans of the builds themselves, since a PE import diff looked identical across the clean and flagged binaries.
  • constraint An app like RAM cannot drop synthetic input or process control without losing features, so each new dependency has to be cleared against the classifier before release.
  • exposure Swapping crypto to satisfy a classifier puts every user's encrypted file at risk, and only a fixture made by the old library proves the new one still opens them.

The first diagnosis came from that import diff. The developer concluded "the behavior profile is identical, the model just changed its mind, nothing to fix." [6] Later in the post: "It sounded reasonable, and it was wrong. The import table isn't everything the model looks at." [20]

Bisection replaced the guess with a test. The script hashes each build with SHA-256 and asks VirusTotal for an existing report. It uploads only when the hash is new, then polls /analyses/{id} until the scan completes [8]. The oracle is one engine. On the flagged build, 74 of 75 engines reported nothing [1]. For a hash VirusTotal already knows, the script reads last_analysis_stats and does not rescan [8]. A cached result therefore reflects the engines as of that file's last analysis. Bisection needs Microsoft's model to give the same answer for the same bytes for the length of the run. The developer's own first theory was a model that had changed its mind [6]. In this case the verdict held steady enough to land on one commit [11].

The run was cheap. The free API tier allows 4 requests a minute and 500 a day [7]. Each step took a few minutes, mostly the Rust release build [9]. The one trap the developer reported was a first attempt that bisected by date and broke on merge commits [10].

The cause is less settled than the commit. The developer's explanation is hedged: native crypto inside an app that stores credentials and injects input "is apparently close enough to what ransomware and stealers ship for the model to fire." [13] The developer also wrote that the app's existing behaviour leaves "no margin for anything else to look off." [21] Two things would have to be true for the finding to carry over to another app. Its binary would need a similar set of imports that classifiers weigh heavily. The new dependency would need to add a similar block of native code. On the library, the developer wrote: "Nothing is wrong with libsodium itself. It's the combination the classifier learned to dislike." [14]

The replacement is careful work. The developer moved to RustCrypto's argon2 0.6, crypto_secretbox 0.1 for XSalsa20-Poly1305, and sha2 0.10 for the SHA-512 password hash [15]. Key derivation is pinned to libsodium's moderate crypto_pwhash settings: Argon2i v0x13, t_cost 6, 128 MiB of memory, parallelism 1 and a 32-byte output [16]. RustCrypto's secretbox already puts the 16-byte Poly1305 MAC ahead of the ciphertext, as libsodium does [17]. Users already had encrypted files on disk, some behind a password, and the new code had to read them byte for byte [23]. Before deleting libsodium, the developer encrypted a small payload with the old code. Those bytes went into a test as a hex fixture the new code must decrypt exactly [18]. The comment in the test says: "If this fails, existing users' files would be locked. Never relax it." [22]

In my view, a desktop app with this import profile should scan each release build before it ships. Here, users saw the Windows warning first [1]. The account cited here does not include a scan of the rebuilt binary, so it does not establish that the swap cleared the flag.

What to watch

  • A VirusTotal scan of the RustCrypto build showing whether Microsoft's detection drops from 1/75 to 0/75.
  • Whether other Rust or Tauri desktop apps that statically link libsodium draw the same Wacatac.B!ml verdict.
  • A rescan of the old sodiumoxide build returning a different Microsoft verdict, a sign the bisect oracle drifts over time.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories