Lesson 6.2 · Dynamic Analysis· 40 min
Simulating and Capturing Network Traffic
Keep a sample's traffic inside the lab with INetSim and FakeNet-NG, capture it, and read DNS, HTTP, beacon timing and TLS fingerprints as network IOCs.
Objectives
- Explain how DNS redirection and fake services let a sample talk without leaving the lab
- Configure INetSim or FakeNet-NG and know where TLS interception stops working
- Capture traffic with tcpdump or Wireshark from a vantage point the sample cannot see
- Read a capture for DNS queries, HTTP requests, user-agents, beacon intervals and TLS fingerprints
- Turn network observations into IOCs while never touching real attacker infrastructure
Most malware is useless on its own. It needs to fetch a second stage, receive commands or send stolen data, and the first thing many samples do after starting is look for their server. That makes the network the most revealing channel you can watch — and the most dangerous one to get wrong. The goal of this lesson is a contradiction you have to engineer: the sample must believe it is online, and nothing it sends may reach the real internet.
Building a Safe Analysis Lab already put INetSim on the REMnux helper and FakeNet-NG in the Windows VM, and proved the real internet is unreachable. Behavioural Monitoring is its host-side partner — this lesson assumes you run both halves in the same detonation routine, with the packet capture started at the Record step.
What a simulator has to fake
A sample reaching for its server goes through three layers. Each has to be answered, or the conversation stops before it shows you anything interesting.
| Layer | What the sample does | What the simulator does | What you learn |
|---|---|---|---|
| Name resolution | Resolves cdn-update.example.net | Answers every query with the simulator's own IP | Every domain the sample tries, in order |
| Transport | Opens TCP/UDP to an IP and port | Accepts the connection on any port, or redirects it to a listener | Ports, protocols, hard-coded IPs |
| Application | Sends an HTTP request, a TLS handshake, a custom binary protocol | Returns a plausible response: a web page, a file, a certificate | Paths, headers, payload formats, what the sample does with the reply |
A failure at any layer is also data. A sample that receives no DNS answer may retry a list of fallback domains, revealing more infrastructure. One that receives a nonsense HTTP reply may crash, sleep, or fall back to a different protocol. Recording those reactions is part of the job.
DNS redirection
The simplest control is the resolver: point the analysis VM's DNS at a server
that answers every name with the address of your fake services. INetSim's
dns_default_ip and FakeNet-NG's built-in DNS listener both do exactly that.
Two traps are worth knowing. First, a resolver that answers everything is itself detectable: some samples resolve a random or known-nonexistent name and stop if it resolves. The best-known example is WannaCry, whose "kill switch" halted the worm when an unregistered domain answered. If a run is suspiciously quiet, try again with the simulator configured to return NXDOMAIN for unknown names. Second, samples that connect to hard-coded IP addresses never ask DNS. In the two-VM layout, make REMnux the default gateway and redirect every inbound connection to its local listeners:
# On REMnux: accept connections for any destination IP arriving on the lab interface
sudo iptables -t nat -A PREROUTING -i ens33 -p tcp -j REDIRECT
sudo iptables -t nat -A PREROUTING -i ens33 -p udp -j REDIRECTREDIRECT keeps the original destination port, so INetSim's HTTP listener on
port 80 still answers traffic that was meant for 203.0.113.7:80. Replace
ens33 with your lab interface name. FakeNet-NG handles the same case inside
Windows without any of this: its diverter intercepts outbound packets for every
destination and hands them to a local listener, or to a default listener for
ports it does not know.
Fake HTTP and HTTPS
INetSim serves fake files chosen by extension — a request ending in .exe
receives a small harmless executable, .jpg a placeholder image — and logs
every request. FakeNet-NG does the same and can be configured with custom
responses for specific hosts or paths, which is how you feed a sample the reply
it expects once you have reversed its protocol.
Encrypted traffic is where simulation gets hard:
| Situation | What you see | What to do |
|---|---|---|
| Sample uses the Windows TLS stack and trusts the system store | Handshake succeeds if the simulator's CA is installed in the VM; plaintext in the simulator's log | Install the simulator's root certificate in the clean snapshot |
| Sample ignores certificate errors | Handshake succeeds with any certificate | Nothing extra needed |
| Sample pins a certificate or ships its own CA | Handshake fails; sample may retry, fall back or exit | Read the plaintext before encryption with a debugger or API hooks |
| Custom encryption over plain TCP | Opaque bytes on a raw listener | Recover the algorithm from code |
When interception fails, the plaintext still exists inside the process just before it is encrypted. Tracing API and System Calls shows how to hook the send functions, and Dumping Memory and Extracting Payloads how to pull configuration and keys out of a running sample.
Setting up the simulators
INetSim on REMnux (building on the lab lesson's configuration):
- In
/etc/inetsim/inetsim.conf, keepservice_bind_addressanddns_default_ipset to REMnux's lab address, and enable the services you need withstart_servicelines (dns,http,https,smtp,ftp,ircare typical). - Review the
http_fakefileentries so common extensions return a benign file, and sethttp_default_fakefilefor everything else. - Add the
iptablesredirects above if you want hard-coded IPs caught. - Start
sudo inetsimand keep its per-session report and/var/log/inetsim/service.log: they list every request with a timestamp.
FakeNet-NG in the Windows VM (single-VM sessions):
- Open an elevated prompt and run
fakenet. The default configuration diverts all traffic, starts DNS, HTTP, HTTPS, SMTP and raw listeners, and logs to the console. - Add analysis tools that talk on the network to the process blacklist in the configuration file, so their traffic does not pollute the log.
- Keep the packet capture FakeNet-NG writes in its working directory, and copy its console log to your notes before reverting.
Warning: FakeNet-NG runs inside the same VM as the sample. A sample that checks for its process, its driver or the redirection of its own traffic can see it. INetSim on a separate VM is invisible to the guest, which is why the two-VM layout is the default in this course.
Capturing traffic
Where you capture matters as much as how. A capture outside the analysis VM — on REMnux's lab interface, or on the host's virtual network — is invisible to the sample and records everything, including traffic from processes the sample injected into. A capture inside the guest (Wireshark on Windows, FakeNet-NG's own pcap) is convenient but is one more tool the sample can detect.
On REMnux, start the capture before the sample and rotate files so a long run cannot fill the disk:
# Full packets, new file every 100 MB, keep at most 20 files
sudo tcpdump -i ens33 -s 0 -C 100 -W 20 -w /cases/run1.pcapWireshark's dumpcap does the same with -b filesize: and -b files:. Open
the result in Wireshark on REMnux, or copy it out through the same channel you
use for other artefacts.
Reading a capture
Start broad, then narrow. Statistics → Conversations and Endpoints show who talked to whom and how much; Protocol Hierarchy shows what kinds of traffic exist. Then work through the layers with display filters:
| Display filter | Shows |
|---|---|
dns.flags.response == 0 | Every name the sample looked up |
http.request | HTTP requests: method, host, URI, headers |
tls.handshake.type == 1 | TLS Client Hellos, with the SNI server name |
tcp.flags.syn == 1 && tcp.flags.ack == 0 | Connection attempts, including ones nobody answered |
| `!(dns |
tshark turns the same filters into columns you can script:
tshark -r run1.pcap -Y 'dns.flags.response == 0' -T fields -e frame.time_relative -e dns.qry.name
tshark -r run1.pcap -Y http.request -T fields -e http.host -e http.request.uri -e http.user_agent
tshark -r run1.pcap -Y 'tls.handshake.type == 1' -T fields -e tls.handshake.extensions_server_nameUser-agents and headers
Malware authors write their own HTTP clients or reuse a library, and both leave
marks. A hard-coded user-agent that claims to be an old browser, a misspelling,
a missing Accept header that every real browser sends, an unusual header
order, or a library's default string (Python-urllib/3.x, Go-http-client/1.1)
are all distinctive. Follow a whole request with Follow → TCP Stream and read
it as the server would.
Beacon intervals
An implant with nothing to do still checks in, usually on a timer with some random jitter. Plot connection times, or compute the gaps between successive requests to the same host: a steady 60 seconds plus or minus ten percent is a beacon, not a person browsing. Remember that sandboxes which shorten sleeps distort this timing, and that some samples measure it (sleep acceleration detection).
TLS fingerprints: JA3 and JA4
When the content is encrypted, the handshake is still visible. The Client Hello
lists the TLS version, cipher suites, extensions and supported groups the
client offers, and that list depends on the TLS library and how the program
configured it. JA3 hashes those fields into an MD5; JA4 produces a
readable, sorted fingerprint such as t13d1711..._..._... whose first part
encodes protocol, TLS version, SNI presence, cipher count, extension count and
ALPN. Recent Wireshark releases compute both as fields of the Client Hello.
Treat them as supporting evidence. A fingerprint identifies a TLS stack and configuration, not a program: every application built on the same library with default settings looks the same. A JA4 is strong when it is rare in your network and paired with other signals — the SNI, the destination, the timing.
From observations to network IOCs
| Indicator | Example (defanged) | Durability | Where it is used |
|---|---|---|---|
| Domain | updates.example[.]net | Days to months | DNS logs, proxy block lists |
| IP address | 203.0.113[.]7 | Hours to weeks | Firewall, NetFlow |
| URI pattern | /api/v1/checkin?id= | Stable per family version | Proxy logs, IDS signatures |
| User-agent / header set | LabAgent/1.0 | Stable until rebuilt | Proxy logs, IDS signatures |
| Beacon interval | 5 s ± 10 % | Often a config value | Network analytics |
| TLS fingerprint | JA4 of the client | Stable per build | TLS-aware sensors |
One rule overrides everything else in that table: in a simulated lab, the
IP addresses you capture are your own. INetSim answered every DNS query with
REMnux's address, so the "C2 IP" in your pcap is 10.0.0.1. Real addresses
come from the sample's configuration, from strings, or from passive DNS and
threat-intelligence records — never from your fake resolver. The planned
lesson Writing Network Signatures turns the durable rows of this table into
Suricata and Snort rules.
OPSEC: why the sample stays offline
It is tempting to "just let it connect" to see what the server sends. Don't,
and don't reach attacker infrastructure yourself either — no browsing to the
C2 URL, no curl against it, no port scan, from any network you or your
employer own.
- You alert the operator. Actors watch their servers. A check-in from a security vendor's or a corporate IP range, at an odd time, from a machine that never behaves like a victim, tells them their tool has been found.
- You become a victim. A live C2 can push a second stage, a ransomware payload, or instructions to spread. Your lab then holds a real intrusion.
- You attack someone else. C2 servers are often compromised third-party machines. Probing them can breach law and policy and can disrupt an investigation already under way.
- You leak your identity. Your IP, resolver, TLS fingerprint and user-agent are all visible to the other end.
Passive sources — passive DNS, certificate transparency, public sandbox reports, your own proxy logs — answer most questions without contact. When live interaction is genuinely needed, it is a decision for your team, carried out from dedicated, approved infrastructure, not an analyst's shortcut.
Lab: capture and dissect a simulated beacon
This lab runs entirely on your own machine over the loopback interface, with no
sample at all. A tiny fake server plays INetSim; a harmless Python client plays
an implant that checks in on a timer. The outputs below come from a macOS
machine; on Linux, capture on lo instead of lo0.
-
Create a working directory and a virtual environment with
dpkt:bash mkdir m6a && cd m6a python3 -m venv .venv .venv/bin/pip install dpkt -
Write the fake server. It answers any path with the same page and logs one JSON line per request:
python # fake_http.py - answer every HTTP request with a canned body and log it import http.server, json, sys, time BODY = b"<html><body>lab fake server</body></html>\n" LOG = open(sys.argv[2] if len(sys.argv) > 2 else "requests.jsonl", "a") class AnyPath(http.server.BaseHTTPRequestHandler): def _answer(self): length = int(self.headers.get("Content-Length") or 0) body = self.rfile.read(length) if length else b"" LOG.write(json.dumps({ "ts": round(time.time(), 3), "src": f"{self.client_address[0]}:{self.client_address[1]}", "method": self.command, "path": self.path, "host": self.headers.get("Host"), "user_agent": self.headers.get("User-Agent"), "body_len": len(body), }) + "\n") LOG.flush() self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(BODY))) self.end_headers() self.wfile.write(BODY) do_GET = do_POST = do_HEAD = _answer def log_message(self, fmt, *args): pass port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 print(f"fake HTTP listening on 127.0.0.1:{port}", flush=True) http.server.ThreadingHTTPServer(("127.0.0.1", port), AnyPath).serve_forever() -
Write the beacon. It sends six requests with a fixed user-agent, about five seconds apart with ten percent jitter. The
Hostheader stands in for the domain a fake resolver would have pointed at your server:python # beacon.py - a harmless client that "checks in" like a simple implant import random, time, urllib.request URL = "http://127.0.0.1:8080/api/v1/checkin?id=LAB-0001" UA = "LabAgent/1.0" HOST = "updates.example.net" COUNT, INTERVAL, JITTER = 6, 5.0, 0.10 for i in range(COUNT): req = urllib.request.Request(URL, headers={"User-Agent": UA, "Host": HOST}) with urllib.request.urlopen(req, timeout=3) as resp: print(f"beacon {i + 1}/{COUNT}: HTTP {resp.status}, {len(resp.read())} bytes", flush=True) if i < COUNT - 1: time.sleep(INTERVAL * random.uniform(1 - JITTER, 1 + JITTER)) -
Start the server and the capture, then run the beacon. Capturing on
lo0needs permission to open the BPF devices (on macOS, membership of theaccess_bpfgroup that Wireshark's installer sets up; otherwisesudo). If you cannot capture, the server's JSON log still gives you steps 6 and 7.bash .venv/bin/python fake_http.py 8080 requests.jsonl & tcpdump -i lo0 -s 0 -w beacon.pcap 'tcp port 8080' & .venv/bin/python beacon.py kill %2 %1text beacon 1/6: HTTP 200, 42 bytes ... beacon 6/6: HTTP 200, 42 bytes 76 packets captured -
Look at one request as the wire saw it.
tcpdump -r beacon.pcap -nnshows six short TCP connections to port 8080; the payload of the first request is:text GET /api/v1/checkin?id=LAB-0001 HTTP/1.1 Accept-Encoding: identity User-Agent: LabAgent/1.0 Host: updates.example.net Connection: closeNotice the two headers you did not write.
Accept-Encoding: identityandConnection: close, in that order, are Pythonurllibdefaults — exactly the kind of library fingerprint that survives when an author changes the user-agent. -
Extract the IOCs with Python. This script parses the capture with
dpkt(macOS loopback captures use the BSD loopback link type), keeps HTTP requests, and measures the gaps between them:python # extract_iocs.py - pull HTTP request IOCs and beacon timing out of a pcap import collections, statistics, sys import dpkt def packets(path): with open(path, "rb") as fh: for ts, buf in dpkt.pcap.Reader(fh): ip = dpkt.loopback.Loopback(buf).data if isinstance(ip, (dpkt.ip.IP, dpkt.ip6.IP6)) and isinstance(ip.data, dpkt.tcp.TCP): yield ts, ip.data requests = [] for ts, tcp in packets(sys.argv[1]): if tcp.dport != 8080 or not tcp.data: continue try: req = dpkt.http.Request(tcp.data) except (dpkt.dpkt.NeedData, dpkt.dpkt.UnpackError): continue requests.append((ts, req.method, req.headers.get("host"), req.uri, req.headers.get("user-agent"))) print(f"{len(requests)} HTTP requests\n") for ts, method, host, uri, ua in requests: print(f"{ts:.3f} {method} http://{host}{uri} UA={ua!r}") print("\nDistinct (host, path, user-agent):") for key, n in collections.Counter((h, u, a) for _, _, h, u, a in requests).items(): print(f" {n} x {key}") gaps = [b[0] - a[0] for a, b in zip(requests, requests[1:])] if gaps: print("\nInter-request gaps (s):", " ".join(f"{g:.2f}" for g in gaps)) mean = statistics.mean(gaps) cv = statistics.pstdev(gaps) / mean * 100 print(f"mean {mean:.2f} s, stdev {statistics.pstdev(gaps):.2f} s (variation {cv:.0f}% of mean)")bash .venv/bin/python extract_iocs.py beacon.pcaptext 6 HTTP requests 1790694614.524 GET http://updates.example.net/api/v1/checkin?id=LAB-0001 UA='LabAgent/1.0' 1790694619.565 GET http://updates.example.net/api/v1/checkin?id=LAB-0001 UA='LabAgent/1.0' ... 1790694639.749 GET http://updates.example.net/api/v1/checkin?id=LAB-0001 UA='LabAgent/1.0' Distinct (host, path, user-agent): 6 x ('updates.example.net', '/api/v1/checkin?id=LAB-0001', 'LabAgent/1.0') Inter-request gaps (s): 5.04 5.39 4.80 5.11 4.88 mean 5.05 s, stdev 0.20 s (variation 4% of mean)Your gaps will differ — the jitter is random — but the mean should sit close to five seconds. The server-side
requests.jsonlholds the same host, path and user-agent with its own timestamps, a useful cross-check. -
Cross-check with tshark if Wireshark is installed:
bash tshark -r beacon.pcap -Y http.request -T fields -e frame.time_relative -e http.host -e http.request.uri -e http.user_agenttext 0.000208000 updates.example.net /api/v1/checkin?id=LAB-0001 LabAgent/1.0 5.041049000 updates.example.net /api/v1/checkin?id=LAB-0001 LabAgent/1.0 ... 25.225550000 updates.example.net /api/v1/checkin?id=LAB-0001 LabAgent/1.0 -
Optional: see what encryption hides. Start a throwaway TLS server with a self-signed certificate (
openssl s_server -accept 127.0.0.1:8443 -wwwwith a key and certificate fromopenssl req -x509), capture port 8443, and connect once withcurl -k -A "LabAgent/1.0"and once with a Pythonsslclient sending the same user-agent, both with the server nameupdates.example.net. Then extract the Client Hello fields:bash tshark -r tls.pcap -Y 'tls.handshake.type == 1' -T fields \ -e tls.handshake.extensions_server_name -e tls.handshake.ja3 -e tls.handshake.ja4On the machine used for this lesson (curl 8.7.1 on macOS, and Python linked against OpenSSL 3.6):
text updates.example.net 375c6162a492dfbf2795909110ce8424 t13d4907h2_0d8feac7bc37_7395dae3b2f3 updates.example.net f21f8e6cf70d5980ecfe9fa2e0ef401c t13d171100_ab0a1bf427ad_8e6e362c5eacThe user-agent appears nowhere in the capture — it travelled inside the encrypted session — but the SNI is in clear text, and the two clients have different fingerprints: 49 offered ciphers and HTTP/2 ALPN for curl, 17 ciphers and no ALPN for Python.
Questions to answer: Which of the IOCs from step 6 would still match if the author changed only the user-agent? What would the IP address column of your IOC table contain if this beacon had run in the two-VM lab with INetSim, and why must you not publish it? In step 8, which fields would a network sensor still see, and why is a JA4 alone weak evidence that two connections came from the same malware? How would you distinguish this beacon from a legitimate update checker that also polls on a timer?
Key takeaways
- A simulator must answer at three layers — DNS, transport and application — and every failure or fallback the sample shows is extra evidence.
- Redirect DNS for domains and route or divert traffic for hard-coded IPs; INetSim on a separate VM is invisible to the sample, FakeNet-NG is convenient but detectable.
- TLS interception works only when the sample trusts your certificate; for pinned or custom crypto, read the plaintext in the process instead.
- Capture from outside the analysis VM, then read DNS queries, HTTP requests, headers, beacon gaps and Client Hello fingerprints.
- IP addresses in a simulated capture are the simulator's; real infrastructure comes from configuration and passive sources.
- Never let a sample, or yourself, contact real attacker infrastructure from a network you own.