# The C2 Traffic DNS Security Never Sees

Source: https://www.egnworks.com/blog/the-c2-traffic-dns-security-never-sees  
Author: Jacob Strix  
Published: 2026-09-09  
Updated: 2026-09-09  
Category: Blue Team  
Tags: Direct-to-IP, Network Detection, Zeek, Suricata, Command and Control

> Direct-to-IP malware bypasses DNS controls entirely. Build a TTL-aware detector that correlates Zeek DNS answers with outbound connections.

---

DNS security can block a malicious domain, score a newly registered hostname, detect tunneling, and sinkhole command-and-control infrastructure. None of those controls evaluate a DNS request that never happens.

Direct-to-IP, or D2IP, communication removes the hostname from the connection path. Malware stores an IPv4 address in its configuration and opens TCP, UDP, HTTP, HTTPS, or WebSocket traffic directly to that address. The firewall still sees a destination, but a DNS security product receives no query to inspect and no domain to block.

Unit 42 measured this gap across more than four million Advanced WildFire dynamic-analysis reports collected during a 30-day period. Among malware samples with C2 activity, 45.32% made at least one D2IP connection. After bulk scanning was excluded, the value remained 41.97%. D2IP represented 23.17% of all observed C2 connection attempts.

Those numbers describe the analyzed sandbox population, not all malware on the internet and not an enterprise prevalence rate. The result is still operationally important: a DNS-only control cannot judge traffic that contains no DNS transaction.

## Direct-to-IP Is a Missing Relationship

A network connection to a public IP address is not automatically malicious. BGP peers, SIP infrastructure, STUN servers, monitoring agents, load balancer health checks, peer-to-peer applications, embedded systems, and administrator-defined services can use literal IP destinations legitimately.

The stronger signal is relational. Did an approved DNS response authorize this destination before the connection started, and was that authorization still valid?

A stateful detector records every public A and AAAA answer with its TTL. When an outbound connection begins, it looks for a matching, unexpired answer. A match produces `dns_seen`. An absent or expired entry produces `direct_to_ip`. Policy exceptions remain explicit and independently auditable.

```mermaid
flowchart TD
    A["DNS response observed"] --> B["Extract client, answer IP, TTL"]
    B --> C["Store approval until response time plus TTL"]
    D["Outbound connection starts"] --> E{"Public destination"}
    E -->|No| F["Ignore internal traffic"]
    E -->|Yes| G{"Approved exception"}
    G -->|Yes| H["Permit and log exception"]
    G -->|No| I{"Unexpired DNS approval exists"}
    I -->|Yes| J["Label dns_seen"]
    I -->|No| K["Label direct_to_ip"]
    K --> L["Enrich with process, ASN, TLS, HTTP, bytes"]
    L --> M{"Risk threshold reached"}
    M -->|No| N["Observe and baseline"]
    M -->|Yes| O["Alert or enforce"]
```

The model is deliberately narrower than reputation detection. It does not claim that DNS-approved traffic is safe. A malicious domain can resolve normally, and a compromised trusted service can provide C2 behind a legitimate hostname. The model answers only whether a connection bypassed the observed DNS authorization path.

## What the 2026 Research Actually Found

Unit 42 documented several different uses of D2IP. A Phorpiex dropper retrieved staged payloads from a literal IP with ordinary-looking HTTP paths. SectopRAT used direct-IP endpoints to relay browser URLs and submitted form fields. Mozi and a newly named Mirai variant called Boatnet used raw IP communication in IoT propagation.

The most unusual case used a request format the researchers named `\GET`. It began with a backslash followed by `GET`, then carried an encoded value between 250 and 666 characters. The outer four characters at both ends were distributed across uppercase alphanumeric values, while the middle was hex-like. Associated infrastructure rotated destination addresses and ports on a schedule and used public-cloud hosting.

This distinction matters for detection engineering. D2IP is the behavioral pivot that finds a session without DNS context. HTTP syntax, TLS metadata, traffic periodicity, process identity, autonomous system information, byte ratios, and destination history determine how urgently the session should be investigated.

## Build the DNS Seen State From Zeek JSON

Zeek provides the fields needed for an offline proof of concept. In `dns.log`, `answers` contains resolved values and `TTLs` contains the corresponding cache intervals. In `conn.log`, `id.orig_h` and `id.resp_h` identify the connection origin and destination.

The implementation below accepts Zeek JSON logs, learns public A and AAAA answers, applies their individual TTLs, and classifies outbound connections. It preserves the DNS name and resolution timestamp as evidence. Private, loopback, link-local, multicast, reserved, and unspecified destinations are excluded.

```python
#!/usr/bin/env python3
import argparse
import ipaddress
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable


@dataclass(frozen=True)
class Approval:
    query: str
    answer: str
    observed_at: float
    expires_at: float


def read_json_lines(path: Path) -> list[dict]:
    records: list[dict] = []
    with path.open("r", encoding="utf-8") as handle:
        for line_number, line in enumerate(handle, start=1):
            value = line.strip()
            if not value:
                continue
            try:
                records.append(json.loads(value))
            except json.JSONDecodeError as error:
                raise ValueError(f"{path}:{line_number}: {error}") from error
    return records


def parse_ip(value: object) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
    if not isinstance(value, str):
        return None
    try:
        return ipaddress.ip_address(value)
    except ValueError:
        return None


def is_public(value: object) -> bool:
    address = parse_ip(value)
    return address is not None and address.is_global


def load_networks(values: Iterable[str]) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
    return [ipaddress.ip_network(value, strict=False) for value in values]


def is_excepted(
    destination: str,
    networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network],
) -> bool:
    address = ipaddress.ip_address(destination)
    return any(address.version == network.version and address in network for network in networks)


def build_approvals(dns_records: list[dict], grace_seconds: float) -> dict[str, list[Approval]]:
    approvals: dict[str, list[Approval]] = {}

    for record in sorted(dns_records, key=lambda item: float(item.get("ts", 0))):
        if record.get("rejected") is True or record.get("rcode_name") not in (None, "NOERROR"):
            continue

        timestamp = float(record.get("ts", 0))
        query = str(record.get("query", ""))
        answers = record.get("answers") or []
        ttls = record.get("TTLs") or []

        for index, answer in enumerate(answers):
            if not is_public(answer):
                continue

            ttl = float(ttls[index]) if index < len(ttls) else 0.0
            if ttl < 0:
                continue

            approval = Approval(
                query=query,
                answer=str(answer),
                observed_at=timestamp,
                expires_at=timestamp + ttl + grace_seconds,
            )
            approvals.setdefault(str(answer), []).append(approval)

    return approvals


def find_approval(
    approvals: dict[str, list[Approval]],
    destination: str,
    connection_time: float,
) -> Approval | None:
    candidates = approvals.get(destination, [])
    valid = [
        approval
        for approval in candidates
        if approval.observed_at <= connection_time <= approval.expires_at
    ]
    return max(valid, key=lambda approval: approval.observed_at, default=None)


def classify_connections(
    connections: list[dict],
    approvals: dict[str, list[Approval]],
    exception_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network],
) -> Iterable[dict]:
    for connection in sorted(connections, key=lambda item: float(item.get("ts", 0))):
        source = str(connection.get("id.orig_h", ""))
        destination = str(connection.get("id.resp_h", ""))

        if not is_public(destination):
            continue

        timestamp = float(connection.get("ts", 0))
        approval = find_approval(approvals, destination, timestamp)

        if is_excepted(destination, exception_networks):
            verdict = "exception"
        elif approval is not None:
            verdict = "dns_seen"
        else:
            verdict = "direct_to_ip"

        result = {
            "ts": timestamp,
            "uid": connection.get("uid"),
            "source": source,
            "destination": destination,
            "destination_port": connection.get("id.resp_p"),
            "protocol": connection.get("proto"),
            "service": connection.get("service"),
            "duration": connection.get("duration"),
            "originator_bytes": connection.get("orig_bytes"),
            "responder_bytes": connection.get("resp_bytes"),
            "connection_state": connection.get("conn_state"),
            "verdict": verdict,
            "dns_query": approval.query if approval else None,
            "dns_observed_at": approval.observed_at if approval else None,
            "dns_expires_at": approval.expires_at if approval else None,
        }
        yield result


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--dns", required=True, type=Path)
    parser.add_argument("--conn", required=True, type=Path)
    parser.add_argument("--grace", type=float, default=300.0)
    parser.add_argument("--exception", action="append", default=[])
    parser.add_argument("--only-alerts", action="store_true")
    arguments = parser.parse_args()

    dns_records = read_json_lines(arguments.dns)
    connection_records = read_json_lines(arguments.conn)
    approvals = build_approvals(dns_records, arguments.grace)
    exceptions = load_networks(arguments.exception)

    for result in classify_connections(connection_records, approvals, exceptions):
        if arguments.only_alerts and result["verdict"] != "direct_to_ip":
            continue
        print(json.dumps(result, separators=(",", ":"), sort_keys=True))


if __name__ == "__main__":
    main()
```

Save the script as `detect_d2ip.py`. Zeek must emit JSON rather than its default tab-separated format. This setting can be added to `local.zeek` before collecting traffic:

```zeek
@load policy/tuning/json-logs
```

Run the correlator against completed log files:

```bash
python3 detect_d2ip.py \
  --dns /opt/zeek/logs/current/dns.log \
  --conn /opt/zeek/logs/current/conn.log \
  --grace 300 \
  --exception 1.1.1.1/32 \
  --only-alerts \
  > direct-to-ip-alerts.jsonl
```

The example exception is illustrative and is not a real allowlist recommendation. Production exceptions should be loaded from a version-controlled policy with an owner, reason, scope, and expiration date.

## Test the State Machine Before Trusting It

The smallest useful fixture needs four cases: an active DNS approval, an expired approval, a destination never seen in DNS, and an explicit exception. These JSON lines exercise those paths.

`dns-fixture.log`:

```json
{"ts":1000.0,"uid":"D1","id.orig_h":"10.10.20.15","id.resp_h":"10.10.20.53","id.resp_p":53,"proto":"udp","query":"updates.example","rcode_name":"NOERROR","answers":["93.184.216.34"],"TTLs":[60.0],"rejected":false}
```

`conn-fixture.log`:

```json
{"ts":1030.0,"uid":"C1","id.orig_h":"10.10.20.15","id.resp_h":"93.184.216.34","id.resp_p":443,"proto":"tcp","service":"ssl","conn_state":"SF","orig_bytes":517,"resp_bytes":4096}
{"ts":1400.0,"uid":"C2","id.orig_h":"10.10.20.15","id.resp_h":"93.184.216.34","id.resp_p":443,"proto":"tcp","service":"ssl","conn_state":"SF","orig_bytes":499,"resp_bytes":3012}
{"ts":1040.0,"uid":"C3","id.orig_h":"10.10.20.15","id.resp_h":"8.8.4.4","id.resp_p":443,"proto":"tcp","service":"ssl","conn_state":"S1","orig_bytes":285,"resp_bytes":0}
{"ts":1050.0,"uid":"C4","id.orig_h":"10.10.20.15","id.resp_h":"1.1.1.1","id.resp_p":5060,"proto":"udp","service":"sip","conn_state":"SF","orig_bytes":310,"resp_bytes":290}
```

Run the fixture without `--only-alerts` so every branch remains visible:

```bash
python3 detect_d2ip.py \
  --dns dns-fixture.log \
  --conn conn-fixture.log \
  --grace 300 \
  --exception 1.1.1.1/32 \
  | jq -r '[.uid, .verdict, (.dns_query // "-")] | @tsv'
```

The expected classifications are `dns_seen` for `C1`, `direct_to_ip` for `C2`, `direct_to_ip` for `C3`, and `exception` for `C4`. The 300-second grace period extends the 60-second fixture TTL to timestamp 1360, which is still earlier than connection `C2` at 1400.

## Decide the Scope of DNS Authorization

The proof of concept uses tenant-wide approval state. Any observed DNS answer can authorize the same public IP for any monitored client until expiry. This design tolerates shared caches and asymmetric routing better than a strict per-host table, but another endpoint can prime the state before malware connects.

A per-client key such as `(client_ip, answer_ip)` is stricter when the sensor sees stub-resolver traffic from each endpoint. It fails when every query appears to originate from a central recursive resolver. In that topology, the recursive resolver logs, endpoint DNS telemetry, or an identity-aware DNS platform must recover the original client before per-host enforcement is possible.

The production key should reflect the network path:

| Topology | Recommended state key | Main risk |
| --- | --- | --- |
| Sensor sees endpoint DNS and traffic | Client plus answer IP | NAT or roaming can split identity |
| Central recursive resolver | Tenant or zone plus answer IP | One client can prime approval for others |
| Multiple asymmetric firewalls | Shared tenant plus answer IP | Replication delay creates misses |
| Endpoint DNS telemetry available | Device ID plus answer IP | Clock skew and ingestion delay |

DNS CNAME chains require another choice. The detector should store only parseable A and AAAA answers as connection destinations while retaining the query and aliases as evidence. A hostname in `answers` is not itself an IP authorization.

## DoH and DoT Create False Direct-to-IP Verdicts

DNS over HTTPS and DNS over TLS encrypt resolution between the client and resolver. If a network sensor cannot parse the response, the destination never enters its DNS Seen Table. The later application session then looks like D2IP even though the endpoint performed a legitimate encrypted resolution.

This is an observability problem, not evidence that DoH is malicious. Enterprise designs need one of four approaches: require managed clients to use an observable resolver, ingest resolver or endpoint DNS telemetry into the approval state, integrate approved encrypted resolvers, or run D2IP in alert-only mode for segments where DNS visibility is incomplete.

The same problem appears with browser DNS caches, operating-system caches populated before sensor startup, applications that retain addresses beyond normal expectations, and stale-answer serving during resolver failure. RFC 1035 defines TTL as the interval during which a resource record may be cached. RFC 8767 permits resolvers to serve stale data under controlled conditions, so a small documented grace interval is more defensible than pretending every legitimate connection stops at exact expiry.

Palo Alto Networks documents a current 300-second grace period and recommends an initial seven-day alert-only profiling period before blocking. Those values are product guidance, not universal constants. A custom deployment should measure collection latency, cache behavior, and legitimate D2IP traffic in its own environment.

## Detect the Backslash-GET Protocol With Suricata

D2IP correlation should remain the primary behavior. A protocol signature can raise confidence for the specific `\GET` traffic reported by Unit 42. Because the request does not begin with a standards-compliant HTTP method, an HTTP parser might not populate normalized HTTP buffers. A raw TCP payload rule is therefore safer for this malformed prefix.

```suricata
alert tcp $HOME_NET any -> $EXTERNAL_NET any (msg:"EGN D2IP suspicious backslash-GET prefix"; flow:established,to_server; content:"|5c 47 45 54 20|"; startswith; dsize:>254; classtype:trojan-activity; sid:4200023; rev:1;)
```

The payload-size threshold reflects the reported encoded field minimum plus the five-byte `\GET ` prefix. Test the rule against representative PCAPs before deployment. It models the public description rather than a complete protocol specification, and stream segmentation, encoding variations, retransmission, and offload behavior can affect matching. It should enrich a D2IP alert, not replace flow correlation.

```bash
suricata -T -c /etc/suricata/suricata.yaml -S egn-d2ip.rules
suricata -r validation.pcap -c /etc/suricata/suricata.yaml -S egn-d2ip.rules
```

For standards-compliant direct-IP HTTP, the `Host` header is useful. A literal IPv4 or bracketed IPv6 host combined with no valid DNS approval is more informative than either signal alone. TLS sessions can be enriched with certificate subject, issuer, validity, JA4 or JA4S fingerprints, ALPN, byte ratios, and periodicity even when application content remains encrypted.

## Score Behavior Instead of Blocking Every Miss

A first deployment should log verdicts without enforcement. Group results by source asset, destination, port, service, process, ASN, and recurrence. Assign exceptions only after the owner and operational requirement are known.

A practical risk model can add weight for a never-before-seen destination, rare ASN, nonstandard port, periodic low-byte sessions, executable from a user-writable path, unsigned process, malformed application syntax, newly observed certificate, or outbound traffic from an OT or IoT device that has no reason to reach the internet. It can subtract weight for an approved infrastructure role, managed health check, documented SIP or STUN flow, or a temporary DNS visibility outage.

Do not treat CDN membership or public-cloud hosting as a benign verdict. Unit 42 observed D2IP activity on shared cloud infrastructure and noted that static IP blocking is weak when addresses and ports rotate. The destination's ownership is context, not authorization.

## Operational Failure Modes

The DNS Seen Table is useful only when its evidence boundaries are understood. Packet loss can remove a DNS response. A sensor restart can erase active TTL state. A resolver outside the monitored path can create systematic misses. NAT can collapse clients. Long-lived sessions can outlast the resolution that created them. QUIC, service meshes, proxies, and cloud metadata services can change what the network sensor observes.

Store the evidence needed to explain each verdict: connection timestamp, source identity, destination, protocol, sensor, matching DNS query, answer time, original TTL, grace value, exception ID, and state scope. An alert that only says “no DNS” is hard to validate and easy to suppress after false positives.

Enforcement belongs at the egress boundary that sees both DNS and subsequent connections, or at a control plane that can share state across asymmetric paths. It should apply only to outbound sessions toward publicly routable addresses. Internal traffic and inbound connections require different models.

## A Safe Rollout Plan

Start with a representative observation window. Seven days is a reasonable minimum for environments with stable weekday traffic, but monthly jobs, disaster-recovery tests, and maintenance windows may require longer. Record each exception as policy data rather than burying it in detector code.

Next, enforce only on tightly controlled segments with complete DNS visibility, such as server tiers that must use an enterprise resolver or IoT networks with a small destination set. Keep fail-open or alert-only behavior where asynchronous state replication or encrypted resolution can generate unexplained misses.

Finally, measure bypass coverage. D2IP detection closes one DNS visibility gap, but it does not stop malicious domains that resolve normally, domain fronting, compromised SaaS, or C2 hidden behind approved proxies. Network detection still needs DNS analysis, TLS and HTTP metadata, endpoint process telemetry, threat intelligence, and egress policy.

## Conclusion

Direct-to-IP C2 is not invisible to the network. It is invisible only to controls that require a DNS event before they can make a decision. The defensive opportunity is to turn that missing event into a stateful signal.

The useful question is not whether an IP address appears on a blocklist. It is whether the environment observed a valid reason for this client or security zone to contact that destination at that time. TTL-aware DNS correlation supplies that evidence, while exceptions and secondary telemetry keep the result operationally realistic.

Unit 42's dataset shows that the gap is too large to treat as an edge case. Blue teams should preserve DNS as one detection layer, then add an egress control that can reason about connections DNS never saw.

## References

[Almost Half of Malware Samples Communicate Direct to IP](https://unit42.paloaltonetworks.com/malware-bypass-dns-direct-to-ip/)

[No-DNS Detection](https://docs.paloaltonetworks.com/advanced-ip-defense/getting-started/introducing-advanced-ip-defense/advanced-ip-defense-no-dns-detection)

[Introducing Advanced IP Defense](https://docs.paloaltonetworks.com/advanced-ip-defense/getting-started/introducing-advanced-ip-defense)

[Zeek dns.log Reference](https://docs.zeek.org/en/current/reference/logs/dns.html)

[Zeek conn.log Record Reference](https://docs.zeek.org/en/current/scripts/base/protocols/conn/main.zeek.html)

[RFC 1035: Domain Names, Implementation and Specification](https://datatracker.ietf.org/doc/html/rfc1035)

[RFC 8767: Serving Stale Data to Improve DNS Resiliency](https://datatracker.ietf.org/doc/html/rfc8767)

[Suricata HTTP Keywords](https://docs.suricata.io/en/latest/rules/http-keywords.html)

[MITRE ATT&CK T1071.001: Web Protocols](https://attack.mitre.org/techniques/T1071/001/)
