DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Making a tampered build phone home: report-home across .NET and .NET Framework

Most tamper detection has exactly one outcome: the altered build fails closed. That is correct, and it is also silent. The customer who cracked your license check, the repackager who slipped a payload into your assembly, the sandbox someone is poking your binary in — all of them are machines you will never see, failing quietly in the dark. You learn nothing, so you can do nothing.

report-home adds the missing half. When a protected build notices its own bytes have changed, it sends a small, fire-and-forget report to a URL you control before it decides what to do about it. The feature sounds trivial — "POST a message" — and the injection is anything but, because the HTTP call Nebula weaves in has to compile and run on everything you ship: modern .NET and .NET Framework 4.x, which disagree about what HttpClient even is. This post is what the reporter sends, how it is woven in so it never blocks or throws, and why binding System.Net.Http against your target framework is the detail the whole thing turns on. (Nebula.NET injects all of this; you supply a URL.)

Turning it on

report-home is one line of config on top of the integrity protection you already have. It does nothing unless tamper detection is also enabled, because it is a consequence of a failed check, not a check itself:

{
  "inputs": ["bin/Release/net48/MyApp.exe"],
  "outputDirectory": "bin/Release/net48/obf",
  "antiTamper": true,
  "tamperReportUrl": "https://telemetry.example.com/tamper"
}
Enter fullscreen mode Exit fullscreen mode

tamperReportUrl is yours — your endpoint, your logging, your rules. Nebula emits the call that targets it; it never picks or hosts the destination. The report body is deliberately thin:

{ "assembly": "MyApp", "version": "3.2.0.0", "event": "integrity-failed", "ts": 1791662400 }
Enter fullscreen mode Exit fullscreen mode

No secrets, no license key, no personal data. A tampered process is hostile ground — anything the reporter knows, the attacker reading the dump knows too — so the body says only which build was altered, nothing that is worth stealing.

What gets injected

Conceptually, the integrity check gains a branch: if the computed hash of a method body (or the metadata region it guards) does not match the value baked in at protect time, fire a report, then take the configured tamper action. Written as source, the injected reporter is shaped like this — and the shape matters, because every line of it is a guard against the report doing more harm than the tamper:

// Injected (shown as C#); fire-and-forget, never awaited on the hot path, never throws.
static void ReportHome(string url, string asm, string ver)
{
    try
    {
        var body = "{\"assembly\":\"" + asm + "\",\"version\":\"" + ver +
                   "\",\"event\":\"integrity-failed\",\"ts\":" + Unix() + "}";
        var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
        var content = new StringContent(body, Encoding.UTF8, "application/json");
        // Fire-and-forget: start the POST, attach a swallow-everything continuation, do NOT await.
        client.PostAsync(url, content)
              .ContinueWith(_ => client.Dispose(), TaskScheduler.Default);
    }
    catch { /* a tamper report must never crash or hang the host */ }
}
Enter fullscreen mode Exit fullscreen mode

Three properties are non-negotiable. The call is not awaited, so it cannot stall startup or a splash screen. A short timeout bounds it so a black-holed connection cannot pin a thread for minutes. And the whole thing is wrapped so a DNS failure, a corporate proxy, an offline laptop — every normal networking reality — surfaces as nothing at all. A tamper report that hangs or crashes the app would be a worse bug than the one it reports.

The part that is actually hard: which HttpClient?

Here is where a naive injector breaks. HttpClient is not one type across the runtimes you ship to. On modern .NET it is part of the shared framework. On .NET Framework 4.x it lives in the System.Net.Http assembly that ships with that framework — a different assembly identity, sitting on a different mscorlib. Task<HttpResponseMessage> is likewise typed against whichever corlib the target uses.

If Nebula emitted IL that referenced its own System.Net.Http and Task, a net48 app would fail to load the protected assembly: the injected method would reference types from the wrong assemblies, and the loader would throw a TypeLoadException or FileLoadException the first time the integrity path ran. The reporter has to be typed against the references of the assembly being protected, not against Nebula's.

So the injector resolves HttpClient, StringContent, Task<HttpResponseMessage>, and ContinueWith against the target assembly's own references and emits IL typed against those. On net48 the call ends up indistinguishable from System.Net.Http code you wrote by hand in that project. The emitted body looks like this at the IL level — note the System.Net.Http references resolve to the framework assembly of the target, and the Task is that framework's Task:

.method private hidebysig static void ReportHome(string url, string asm, string ver) cil managed
{
  .try
  {
    newobj instance void [System.Net.Http]System.Net.Http.HttpClient::.ctor()
    // ... set Timeout, build StringContent ...
    callvirt instance class [System.Runtime]System.Threading.Tasks.Task`1<class [System.Net.Http]System.Net.Http.HttpResponseMessage>
        [System.Net.Http]System.Net.Http.HttpClient::PostAsync(string, class [System.Net.Http]System.Net.Http.HttpContent)
    // ContinueWith(...) to dispose; result discarded — fire-and-forget
    pop
    leave.s DONE
  }
  catch [System.Runtime]System.Object { pop leave.s DONE }   // swallow everything
  DONE: ret
}
Enter fullscreen mode Exit fullscreen mode

(On a net48 target the bracketed assembly names bind to that framework's System.Net.Http and mscorlib instead — same IL shape, target-correct references.) This target-framework binding is why report-home works on a .NET Framework 4.8 desktop app and a .NET 8 service from the same feature, instead of being a modern-.NET-only convenience.

Where the call rides

The reason report-home is not trivially strippable is where the injected call lives. It is not a tidy, obvious SendReport() sitting next to the check — it is woven into the same integrity path that decides to fail closed. A patcher who wants a quiet crack cannot just delete a method named for what it does; they have to find the branch inside the verification logic, understand which side of the comparison fires the report, and excise it without disturbing the check it rides inside — all under renaming, control-flow obfuscation, and string encryption. The honest framing is layered and worth stating plainly:

  • Integrity verification fails the build closed when bytes change — the lock.
  • report-home tells you it happened — the tripwire.
  • Neither is absolute; together they raise the cost of a quiet crack from "patch one method" to "understand and rebuild the protected check," which is exactly the economics obfuscation trades in.

What to take away

Tamper detection that only fails closed teaches you nothing about the attack. report-home gives the altered build a second behaviour — a small, guarded, fire-and-forget POST to a URL you own — so a crack in the field becomes a signal you can act on. The body carries only which build was altered, never a secret. The call never awaits, always times out, and never throws, because a report that breaks the app is worse than silence. And it runs on everything you ship because the injector binds HttpClient and Task against your target framework, not Nebula's — so a net48 desktop app phones home exactly like a .NET 8 service. Point tamperReportUrl at your own endpoint, keep antiTamper on, and the quiet cracks stop being invisible.

Top comments (0)