Table of contents
Open Table of contents
Overview
I recently hit a hard constraint while analyzing an untrusted binary in a dedicated Proxmox VM. The environment was intentionally isolated: no default route, no physical NIC bridging, just a clean /24 subnet on ens18. My objective was straightforward - capture the TLS Server Name Indication (SNI) from the sample’s initial handshake so I could map its C2 infrastructure without exposing the lab.
What I expected to take twenty minutes turned into a three hour battle with Linux kernel routing, tcpdump link-type quirks, and tshark dissector failures. The root cause wasn’t my capture tooling. It was the kernel’s routing logic. In a gateway less environment, the kernel treats all same-subnet IPs as “local” and routes them through lo (loopback) instead of ens18. If your monitoring tools are listening on the physical interface (ens18), you see nothing. If you try to force the traffic out of the physical NIC, the kernel simply drops it because there is no valid path to the destination.
This post documents the pipeline that finally worked. It’s a deliberate architectural decision that respects isolation constraints while delivering deterministic telemetry.
The Routing Dilemma
Linux routing decisions are deterministic. When a process initiates a connection to a destination within its own subnet, the kernel makes a decision based on the destination IP’s scope. If the IP is local, the kernel bypasses the physical network stack to optimize performance, routing the traffic through the loopback interface.
In a constrained environment (no default gateway, no NAT), this creates a massive telemetry gap.
I spent significant time attempting to bypass this via common networking “hacks,” all of which failed under the scrutiny of the kernel:
- IP Aliasing: Adding a secondary IP to
ens18didn’t work because the kernel still classified the destination as “local” to the host. - Policy Routing: Using
ip ruleto force a specific routing table failed because the kernel still required a valid ARP response from a gateway that didn’t exist. - Static ARP Entries: Attempting to spoof a gateway via
ip neigh add ... nud permanentresulted in the kernel attempting to send the packet, but the lack of a valid route to the target subnet prevented the packet from ever being queued for the physical NIC.
The core issue: Linux considers IPs on the same subnet as “local,” and routes them via lo by default. To capture traffic on ens18, you need a gateway or a bridge. In an isolated VM, neither exists.
The Solution: Loopback Capture + String Extraction
The solution is to stop fighting the kernel and start working with it. Instead of trying to force the traffic onto a physical interface that will never see it, we capture the traffic on the interface where it actually lives: lo.
To make this reliable, we must solve two additional problems: the encryption of TLS 1.3 and the fragility of protocol dissectors.
1. Enforcing TLS 1.2 for Observability
Modern TLS 1.3 utilizes Encrypted Client Hello (ECH), which encrypts the SNI extension. In an automated analysis pipeline, you cannot assume the untrusted sample will use a version of TLS that allows for plaintext inspection.
While you cannot control the binary, you can control the environment. For validation and testing of the capture pipeline, I use a controlled TLS 1.2 handshake. In a real-world analysis, you would either rely on TLS 1.2-only samples or implement a local MITM proxy to terminate the TLS connection.
2. The “Dissector Gap” and the strings Fallback
Even when you capture the traffic on lo, tshark often fails. This is due to a mismatch in link-types. On many Linux distributions, tcpdump -i lo captures packets with an EN10MB (Ethernet) link-type. tshark’s TLS dissector expects a LINUX_SLL (cooked) link-type for loopback traffic. This mismatch causes the dissector to fail to parse the packet correctly, resulting in an empty SNI field.
The solution is to bypass the protocol dissector entirely and use a raw binary scan.
The Implementation Pipeline
Here is the streamlined, production-grade pipeline:
#!/bin/bash
set -euo pipefail
# Configuration
CAPTURE_DIR="/tmp/re-capture"
CAPTURE_FILE="${CAPTURE_DIR}/egress.pcap"
TEST_DOMAIN="evil-c2-test.com"
# 1. Setup local listener (Force TLS 1.2)
echo "[*] Starting local TLS listener (TLS 1.2)..."
openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem -days 365 -nodes -subj "/CN=test" 2>/dev/null
openssl s_server -accept 443 -cert /tmp/cert.pem -key /tmp/key.pem -www -tls1_2 &
SERVER_PID=$!
sleep 1
# 2. Capture on lo with unbuffered output
echo "[*] Capturing on lo..."
mkdir -p "$CAPTURE_DIR"
tcpdump -i lo -w "$CAPTURE_FILE" -c 100 -s 0 -U &
TCPDUMP_PID=$!
# 3. Trigger the handshake (Force TLS 1.2 via openssl client)
echo "[*] Triggering TLS handshake for $TEST_DOMAIN..."
echo | openssl s_client -connect 127.0.0.1:443 -servername "$TEST_DOMAIN" -tls1_2 2>/dev/null
sleep 1
# 4. Cleanup and Robust Extraction
kill $TCPDUMP_PID $SERVER_PID 2>/dev/null || true
wait $TCPDUMP_PID 2>/dev/null || true
echo "[+] Extracting SNI via raw binary scan..."
# We use strings to find the domain in the raw PCAP, bypassing tshark dissector bugs
SNI=$(strings "$CAPTURE_FILE" | grep -o "$TEST_DOMAIN" | head -n1)
if [ -n "$SNI" ]; then
echo " [SUCCESS] SNI Found: $SNI"
else
echo " [!] Extraction failed."
fi
rm -f "$CAPTURE_FILE" /tmp/key.pem /tmp/cert.pem
Execution and Verification
Running the script provides immediate confirmation that the TLS handshake is occurring on the loopback interface and that the SNI is being successfully pulled from the raw PCAP:
[*] Cleaning up...
[*] Starting local TLS listener (TLS 1.2)...
Using default temp DH parameters
ACCEPT
[*] Capturing on lo...
tcpdump: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
CONNECTED(00000003)
---
[... TLS Handshake Negotiated: TLSv1.2 ...]
---
Certificate chain
0 s:CN=test
i:CN=test
a:PKEY: RSA, 2048 (bit); sigalg: sha256WithRSAEncryption
v:NotBefore: Jun 28 21:11:17 2026 GMT; NotAfter: Jun 28 21:11:17 2027 GMT
---
Server certificate
-----BEGIN CERTIFICATE-----
-----END CERTIFICATE-----
subject=CN=test
issuer=CN=test
---
No client certificate CA names sent
Peer signing digest: SHA256
Peer signature type: rsa_pss_rsae_sha256
Peer Temp Key: X25519, 253 bits
---
SSL handshake has read 1412 bytes and written 309 bytes
Verification error: self-signed certificate
---
New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384
Protocol: TLSv1.2
Server public key is 2048 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
Protocol : TLSv1.2
Cipher : ECDHE-RSA-AES256-GCM-SHA384
Session-ID: A4D11D552293C59977623A13D832434B320AB54161068AF0798DB113F9ECD11F
Session-ID-ctx:
Master-Key: 12E250F656CF6D0E598D91897AA7B261FE562A50F2EABF546D887F79D0AF2135404B46DFDE70BF6439BCAFD1D9D43DE6
PSK identity: None
PSK identity hint: None
SRP username: None
TLS session ticket lifetime hint: 7200 (seconds)
TLS session ticket:
0000 - e4 d9 09 64 7d 81 11 1f-7d 0f 98 19 54 b0 10 a4 ...d}...}...T...
0010 - ff 8f a6 49 18 da 86 4b-06 83 6b 9c 38 3a 81 fc ...I...K..k.8:..
0020 - 36 d5 9f 77 8f 1f 45 0c-45 5a db 92 b6 72 5b b7 6..w..E.EZ...r[.
0030 - 1b cf 74 bc e1 45 17 44-5f 85 81 59 95 e7 78 c7 ..t..E.D_..Y..x.
0040 - e6 de 37 aa 57 77 19 1c-3e fc c2 5e 82 f5 1e ed ..7.Ww..>..^....
0050 - b4 41 c8 54 df cc 6b 1b-72 05 0c 6f 94 d6 82 60 .A.T..k.r..o...`
0060 - 22 ee d1 7e 4b 2c 31 5e-37 5f 5a cb 11 b3 b1 d6 "..~K,1^7_Z.....
0070 - 3c 41 f7 94 4b 65 24 ae-cc 8f 10 09 02 16 42 0f <A..Ke$.......B.
0080 - 2f 22 95 c3 f3 20 d8 de-4c 9f 11 87 0c 2e 8f 62 /"... ..L......b
0090 - b0 59 b1 0c c6 01 e1 42-a0 ab f9 e5 15 1a aa b4 .Y.....B........
00a0 - cb 2e 0a 21 59 a2 23 6e-71 5d a3 00 38 52 36 83 ...!Y.#nq]..8R6.
Start Time: 1782681078
Timeout : 7200 (sec)
Verify return code: 18 (self-signed certificate)
Extended master secret: yes
---
---
15 packets captured
30 packets received by filter
0 packets dropped by kernel
[*] Parsing TLS SNI...
[SUCCESS] SNI extracted: evil-c2-test.com
[+] Pipeline complete.
By observing the tcpdump and openssl logs, we can verify that the connection was successfully established and that the strings fallback correctly identified the domain within the binary blob.
Scaling for Production Environments
While loopback capture is perfect for quick validation, real-world analysis of untrusted code often requires visibility into the physical egress.
The Docker Bridge Approach
The most robust way to scale this is to move the untrusted code into a containerized environment. By using a custom Docker bridge, you gain a dedicated interface that is neither loopback nor the physical NIC.
By running tcpdump -i docker0, you bypass the loopback problem entirely. The bridge acts as a virtual switch, providing a deterministic point of capture for all containerized traffic.
The tun/tap Method
For even deeper isolation, you can use tun/tap interfaces. By routing the untrusted code through a virtual tunnel, you create a controlled point where every single byte of egress traffic must pass through a monitored interface, making it impossible for the code to “hide” behind loopback or subnet routing.
Conclusion
Don’t fight the kernel’s routing table; instrument the stack where the traffic actually flows. In gateway-less, isolated VMs, the loopback interface is your best friend, not your enemy.
By enforcing TLS 1.2 for observability and using strings for extraction, you create a telemetry pipeline that is both deterministic and resilient. When you move to scale, transition to Docker bridges or tun/tap interfaces to maintain that same level of visibility at the physical egress layer.