Skip to content

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.

LayerWhat the sample doesWhat the simulator doesWhat you learn
Name resolutionResolves cdn-update.example.netAnswers every query with the simulator's own IPEvery domain the sample tries, in order
TransportOpens TCP/UDP to an IP and portAccepts the connection on any port, or redirects it to a listenerPorts, protocols, hard-coded IPs
ApplicationSends an HTTP request, a TLS handshake, a custom binary protocolReturns a plausible response: a web page, a file, a certificatePaths, 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:

bash
# 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 REDIRECT

REDIRECT 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:

SituationWhat you seeWhat to do
Sample uses the Windows TLS stack and trusts the system storeHandshake succeeds if the simulator's CA is installed in the VM; plaintext in the simulator's logInstall the simulator's root certificate in the clean snapshot
Sample ignores certificate errorsHandshake succeeds with any certificateNothing extra needed
Sample pins a certificate or ships its own CAHandshake fails; sample may retry, fall back or exitRead the plaintext before encryption with a debugger or API hooks
Custom encryption over plain TCPOpaque bytes on a raw listenerRecover 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):

  1. In /etc/inetsim/inetsim.conf, keep service_bind_address and dns_default_ip set to REMnux's lab address, and enable the services you need with start_service lines (dns, http, https, smtp, ftp, irc are typical).
  2. Review the http_fakefile entries so common extensions return a benign file, and set http_default_fakefile for everything else.
  3. Add the iptables redirects above if you want hard-coded IPs caught.
  4. Start sudo inetsim and 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):

  1. 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.
  2. Add analysis tools that talk on the network to the process blacklist in the configuration file, so their traffic does not pollute the log.
  3. 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:

bash
# 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.pcap

Wireshark'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 filterShows
dns.flags.response == 0Every name the sample looked up
http.requestHTTP requests: method, host, URI, headers
tls.handshake.type == 1TLS Client Hellos, with the SNI server name
tcp.flags.syn == 1 && tcp.flags.ack == 0Connection attempts, including ones nobody answered
`!(dns

tshark turns the same filters into columns you can script:

bash
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_name

User-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

IndicatorExample (defanged)DurabilityWhere it is used
Domainupdates.example[.]netDays to monthsDNS logs, proxy block lists
IP address203.0.113[.]7Hours to weeksFirewall, NetFlow
URI pattern/api/v1/checkin?id=Stable per family versionProxy logs, IDS signatures
User-agent / header setLabAgent/1.0Stable until rebuiltProxy logs, IDS signatures
Beacon interval5 s ± 10 %Often a config valueNetwork analytics
TLS fingerprintJA4 of the clientStable per buildTLS-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.

  1. Create a working directory and a virtual environment with dpkt:

    bash
    mkdir m6a && cd m6a
    python3 -m venv .venv
    .venv/bin/pip install dpkt
  2. 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()
  3. Write the beacon. It sends six requests with a fixed user-agent, about five seconds apart with ten percent jitter. The Host header 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))
  4. Start the server and the capture, then run the beacon. Capturing on lo0 needs permission to open the BPF devices (on macOS, membership of the access_bpf group that Wireshark's installer sets up; otherwise sudo). 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 %1
    text
    beacon 1/6: HTTP 200, 42 bytes
    ...
    beacon 6/6: HTTP 200, 42 bytes
    76 packets captured
  5. Look at one request as the wire saw it. tcpdump -r beacon.pcap -nn shows 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: close

    Notice the two headers you did not write. Accept-Encoding: identity and Connection: close, in that order, are Python urllib defaults — exactly the kind of library fingerprint that survives when an author changes the user-agent.

  6. 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.pcap
    text
    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.jsonl holds the same host, path and user-agent with its own timestamps, a useful cross-check.

  7. 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_agent
    text
    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
  8. Optional: see what encryption hides. Start a throwaway TLS server with a self-signed certificate (openssl s_server -accept 127.0.0.1:8443 -www with a key and certificate from openssl req -x509), capture port 8443, and connect once with curl -k -A "LabAgent/1.0" and once with a Python ssl client sending the same user-agent, both with the server name updates.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.ja4

    On 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_8e6e362c5eac

    The 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.