Post

feintnet: I got tired of FakeNet-NG not running on my ARM box, so I built my own

FakeNet-NG's WinDivert driver wouldn't load on my ARM Windows box, so I built feintnet: a single Go binary that fakes DNS, TLS, and C2 for malware analysis.

feintnet: I got tired of FakeNet-NG not running on my ARM box, so I built my own

The views and opinions expressed in this post are my own and do not represent those of my employer. This is a personal blog where I share research and things I’m learning.

TL;DR

feintnet is a single static Go binary that turns an analysis VM into a fake internet for a detonated sample - DNS sinkhole, protocol responders, TLS interception with JA3/JA4 fingerprinting, and a JSON/IOC report - built because FakeNet-NG’s WinDivert driver won’t load on ARM64 or emulated Windows. It’s capability-tiered: transparent redirect where a driver exists, a DNS funnel where it doesn’t, always logging something rather than refusing to run.

If you do dynamic analysis across a mix of hardware, start here:

  • Grab the release for your platform and run feintnet run --auto --dir ./feintnet-runs on a snapshotted, isolated lab VM.
  • On Windows ARM64 or emulated Windows, --auto drops to the DNS funnel - hardcoded-IP C2 needs an x86-64 Windows VM or a Linux nftables gateway to catch it.
  • Read summary.md after each run for the C2 endpoints, domains, and JA3/JA4 hashes - it’s the IOC report, ready to paste into a ticket.

The repo link is in References; the rest of this post is why it’s shaped this way, and what building it with an AI pair actually taught me.

The itch

Here’s the situation that kicked this off. I had a sample to detonate, and my analysis VM was an ARM Windows box. Reach for the usual fake-network rig - FakeNet-NG, genuinely excellent and the direct inspiration for everything here - and you hit a wall: its packet interception leans on a kernel driver that won’t load on that platform. Hard stop. The Python build wants a pile of libraries installed on a box I’m about to throw malware at; the bundled build wants a driver that isn’t there.

So I did what I suspect a lot of you have done: duct-taped it. A Python script to sinkhole DNS, a PowerShell one-liner to stand up a listener, a third thing to eyeball what the sample was reaching for - it sort of worked, the way a car held together with cable ties sort of works. Every new sample meant re-remembering which script did what; every new VM meant setting the whole circus up again.

At some point I looked at the pile of scripts and thought: I’m most of the way to a real tool here. Why not just build the thing I actually want? One binary. No dependencies to install on the analysis box. Copy it across, run it, get my C2 and my IOCs out. And - the part I was quietly most interested in - do it as a proper exercise in AI-assisted development, on a problem I genuinely had rather than a toy.

That tool is feintnet. It’s also the first thing I’ve built in Go - everything before this was Python or PowerShell - and the language turned out to fit the problem exactly: one static binary, no runtime, and cross-compiling for four OS/arch combinations from a single source tree instead of four separate environments. This post is what it does, why it’s shaped the way it is, and what building it with an AI pair actually felt like - the good and the bits that needed a human to catch. Let’s dig in.

What feintnet is, in one breath

feintnet is a single static binary that turns your analysis VM into a convincing fake internet. It sinkholes DNS, answers as whatever service the sample expects (HTTP, TLS, SMTP, FTP, POP3, IMAP, IRC, raw), logs every connection with the owning PID and process, terminates TLS with a throwaway CA so you can read HTTPS, and writes the whole lot out as a clean JSON event stream plus a human-readable IOC report. On x86-64 Windows it’ll also capture a pcap for you, so you don’t have to babysit Wireshark in another window.

Same-host, like FakeNet-NG’s SingleHost mode: you run feintnet on the VM, then detonate on the same VM. No second machine, no gateway to configure. That co-location is exactly why the connection logger can name the process behind each connection.

It’s also grown past “duct tape with extra steps”: around 14,000 lines of Go across a dozen-plus packages - DNS, TLS, responders, packet capture, config, the revert journal. That’s roughly the point I stopped thinking of it as a script and started thinking of it as a tool.

The one design decision that mattered

Every real choice in feintnet comes back to a single principle: adapt to the host instead of failing on it. The thing that drove me up the wall was the hard stop - “driver not supported, goodbye”. So feintnet is built in capability tiers, and it drops to the strongest one the host can actually do:

1
2
3
4
5
6
7
8
9
10
Tier 2  transparent   Divert ALL outbound traffic to feintnet.
                      Catches hardcoded-IP / no-DNS C2.
                      -> WinDivert on x86-64 Windows (driver)
                      -> nftables on Linux (in-kernel, no driver)

Tier 1  DNS funnel    Repoint the host resolver at feintnet.
                      Domain-based C2. Works almost everywhere.

Tier 0  passive       Listen + log only. Mutates nothing.
                      Always available, even unprivileged.

Ask for --auto and feintnet probes the box, engages the strongest tier available, and - this is the important bit - tells you loudly when it had to settle for less. On my ARM box there’s no x64 WinDivert driver, so --auto degrades to the DNS funnel and warns that hardcoded-IP C2 won’t be caught this run. A weaker tier beats a clean error message, as long as it’s honest about what you’re giving up - the failure I hated wasn’t the missing driver, it was being told “no” instead of “here’s what I can do”.

That table is a plain-English read of the actual decision code - here’s the real function that decides whether the transparent tier is available on Windows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// transparentReason reports why the WinDivert transparent backend can't run, or
// "" when it can: x86-64 (there is no reliable ARM64 WinDivert driver — the
// reason feintnet exists), elevated, and WinDivert.dll loadable (present with its
// signed driver and dependencies). Otherwise --auto falls back to the DNS funnel.
func transparentReason(c Capability) string {
	if c.Arch != "amd64" {
		return "the transparent tier needs x86-64 (no WinDivert driver on ARM64)"
	}
	if !c.Root {
		return "not elevated (run from an elevated PowerShell)"
	}
	if !winDivertAvailable() {
		return "WinDivert.dll / WinDivert64.sys not found next to feintnet.exe"
	}
	return ""
}

That comment says it outright: “the reason feintnet exists.” Past-me left that note mid-build and it’s still accurate.

The other constraint was no dependencies on the analysis box. It’s a Go binary built CGO_ENABLED=0 - static, nothing to pip install, nothing to apt, no runtime. The Windows archive bundles the WinDivert driver and DLL inside the zip, next to the exe, so even the strongest tier works straight out of the download. Copy one file, run it. That’s the bar I set.

What a run actually looks like

Enough talk - here’s a real session, run while writing this post. Fire it up:

1
.\feintnet.exe run --auto --pcap --dir .\feintnet-runs

Before anything changes on the host, feintnet says exactly what it’s about to do and gates on an explicit yes:

1
2
3
4
5
6
7
8
feintnet: ISOLATED-LAB USE ONLY — host-only network, snapshot, revert.
feintnet: --auto/mutating flags will change THIS machine:
  - install a WinDivert redirect (all outbound traffic → feintnet)
  - install feintnet's session CA into the OS trust store (VM only)
  - terminate TLS with a minted per-host cert
  environment: looks like a VM ✓
All changes are reverted on exit; `feintnet revert` recovers after a crash.
Type 'yes' to proceed: yes

That environment: looks like a VM line isn’t decoration - feintnet checks before it mutates anything. Past the gate, the banner shows the full picture: transparent interception, IPv6 containment, ICMP, and pcap capture all live.

1
2
3
4
5
6
7
8
9
10
11
12
13
── feintnet ─────────────────────────────
 Interception : transparent(windivert)
 Sink IP      : 192.168.1.100
 Session dir  : feintnet-runs\20260707T075912Z
 Listeners    : 14 up, 0 failed to bind
 Ports        : 14 TCP served, DNS sinkhole on :53
 TLS          : mitm=true trust-ca=true
 DNS repoint  : false
 IPv6         : divert+drop — hardcoded-IPv6 contained; DNS AAAA→::1
 ICMP         : respond — echo requests answered
 PCAP         : capture.pcapng — original+diverted frames, pid-annotated
 revert journal armed — changes revert on exit / `feintnet revert`
───────────────────────────────────────

I ran test lookups and requests against it instead of a live sample; everything lands as events.jsonl in a timestamped session directory, one JSON object per line - genuine output from that run:

1
2
3
4
5
{"type":"dns","qname":"evil.com","qtype":1,"answer":"192.168.1.100","src":"192.168.1.100:50908","ts":"2026-07-07T07:59:32.5000957Z"}
{"type":"dns","qname":"evil.com","qtype":28,"answer":"::1","src":"192.168.1.100:64144","ts":"2026-07-07T07:59:32.5000957Z"}
{"type":"http","method":"GET","path":"/","host":"really-bad-site.com","src":"192.168.1.100:53440","ts":"2026-07-07T07:59:26.6068307Z"}
{"type":"conn","state":"redirected","dst_ip":"192.168.1.141","dst_port":7680,"src":"192.168.1.100:49945","ts":"2026-07-07T07:59:30.011978Z"}
{"type":"connlog","state":"ESTABLISHED","dst_ip":"192.168.1.141","dst_port":7680,"pid":6132,"process":"svchost.exe","ts":"2026-07-07T07:59:30.5623652Z"}

From a second window on the same VM, the fake internet holds up under a quick poke (trimmed for space):

1
2
3
4
5
6
7
8
9
PS> Resolve-DnsName evil.com
evil.com    A     192.168.1.100
evil.com    AAAA  ::1

PS> curl really-bad-site.com
StatusCode : 200

PS> ssh [email protected]
ssh: connect to host reallybadsite.com port 22: Connection refused

DNS resolves both A and AAAA to the sink, HTTP gets a convincing 200, and SSH - a port feintnet doesn’t fake - gets an honest refusal instead of a black hole: a served port lies convincingly, an unserved one just isn’t there, closer to how a real host actually behaves.

The conn/connlog pair above is the interesting one: 192.168.1.141:7680 got redirected and resolved back to svchost.exe - Windows Delivery Optimization talking to a peer, not the sample. summary.md says so plainly instead of dressing it up as a finding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
## C2 endpoints

_none_

## Domains

- `array811.prod.do.dsp.mp.microsoft.com`
- `evil.com`
- `really-badc2.com`
- `reallybadsite.com`

## Suppressed (benign)

- `192.168.1.141:7680 [pid 6132 svchost.exe]`

No C2 endpoints, because nothing beaconed. The export is the point of the whole exercise, so it has to be something I’d actually trust enough to paste into a ticket.

On my x86-64 boxes I used to run Wireshark alongside FakeNet-NG to grab a pcap. --pcap bundles that in - the capture.pcapng from this same run has both the original and diverted frames, annotated per-packet with the PID, and opens straight in Wireshark. One fewer window to babysit.

The AI-assisted development bit

This is the part I actually want to talk about - solving a real problem was only half the goal; the other half was learning what building with an AI pair is like when the stakes are code I’m running next to live malware.

The honest summary: it’s a force multiplier, not an autopilot. It got me from “pile of scripts” to “structured, tested Go tool” faster than I’d have managed alone, especially across platform-specific plumbing like WinDivert and nftables, where I’d otherwise have spent evenings in documentation. Working in small, spec-driven increments with tests written first kept it honest - when a change touched three files, all three moved together instead of drifting.

But the moment that stuck with me was a bug that only showed up on Linux. After a sudo run, the whole output directory came out owned by root, and I - the actual user - got permission denied on my own IOC report. Annoying, clear enough to fix. The first fix worked: chown the files back to the invoking user as they’re created.

Then a multi-agent self-review pass - a second round specifically tasked with red-teaming the first fix, not just checking it compiled - caught something a single pass, mine or the AI’s, would have missed. Handing ownership back mid-run was itself the vulnerability: in a detonation VM the sample often runs as that same user, so once the directory became user-owned mid-run, a malicious sample could symlink-swap it and trick root into writing through it to somewhere it shouldn’t. In a malware-analysis tool, that’s precisely the foothold you don’t want to hand out. The fix: keep the whole session tree root-owned for the entire run - tamper-proof while it matters - and only hand it back at teardown, innermost files first, so nothing can be swapped underneath the cleanup.

I flag that one because it’s the clearest lesson from the build. AI assistance is brilliant at “make this work”; it’s not a substitute for a human asking “how does this fail, and who’s standing next to it when it does”. The privesc wasn’t in the happy path or any test - it was in the threat model, and the threat model is the bit you own. Fast help writing code raised the stakes on thinking clearly about it, if anything - that felt like the real skill worth practising.

Where it works, and where it doesn’t

I’ll be straight about the limits, because “adapts to the host” cuts both ways:

  • x86-64 Windows is the primary target and gets everything: transparent WinDivert capture, MITM, pcap, connlog with PID.
  • Windows on ARM / emulated Windows - no x64 driver, so --auto drops to the DNS funnel and says so. Hardcoded-IP C2 only shows up as a connlog IOC, not intercepted; catch it by running the interceptor on an x86-64 Windows VM or a Linux nftables gateway instead.
  • Linux gets the transparent tier via nftables (no driver - it’s in the kernel already) and MITM, but no pcap yet; that’s on the list.
  • macOS is passive-only for now - DNS sinkhole and responders, no transparent tier.

Cross-platform reach is a deliberate gift to whoever finds it useful - but Windows is where I live, so that’s where it’s strongest. And the non-negotiable caveat: this is built to run beside live malware, on a snapshotted, isolated lab VM you can revert - never anything you care about. It mutates host networking to do its job, which is why every change is journaled before it’s applied and reverted on exit, or via feintnet revert if something dies mid-run - the same self-heal runs automatically at the start of the next --auto.

Closing

feintnet started as a specific, stupid annoyance - a tool I relied on wouldn’t run on the box in front of me - and turned into the thing I reach for first. That’s the tooling I like: built to scratch a real itch, honest about what it can and can’t do, and small enough I’ll actually keep iterating on it. If you do dynamic analysis across a mix of hardware, especially ARM, it might save you the pile-of-scripts phase I went through.

The AI-assisted angle turned out to be the part I learned the most from, and not in the way I expected: the machine is fast at making things work, and that just makes it more important that a human stays on the hook for how they break. I’ll be iterating on this one as I hit new walls - and if you kick the tyres and something falls over, I’d genuinely like to hear about it.

Stay curious.

  • Luke

On methodology: the tool’s design and every threat-model call - the capability tiers, what counts as safe to automate, the ownership-handling fix described above - are mine. Implementation was carried out with AI workflows (Claude, primarily). Errors are mine - ping me on X or Instagram if you spot something off.

References

This post is licensed under CC BY 4.0 by the author.