VYPR
Moderate severityNVD Advisory· Published May 14, 2025· Updated May 14, 2025

Bullfrog's DNS over TCP bypasses domain filtering

CVE-2025-47775

Description

Bullfrog is a GithHb Action to block unauthorized outbound traffic in GitHub workflows. Prior to version 0.8.4, using tcp breaks blocking and allows DNS exfiltration. This can result in sandbox bypass. Version 0.8.4 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
bullfrogsec/bullfrogGitHub Actions
< 0.8.40.8.4

Affected products

1

Patches

1
ae7744ae4b3a

fix: dns over tcp bypasses domain filtering (#175)

https://github.com/bullfrogsec/bullfrogFrancois AllardMay 14, 2025via ghsa
9 files changed · +156 65
  • action/dist/post.js+1 1 modified
    @@ -19914,7 +19914,7 @@ async function getOutboundConnections() {
         );
         console.log("Agent ready timestamp: ", agentReadyTimestamp);
         const tetragonLogFile = await import_promises2.default.open(TETRAGON_EVENTS_LOG_PATH);
    -    const functionsToTrack = ["tcp_connect"];
    +    const functionsToTrack = ["tcp_connect", "udp_sendmsg"];
         for await (const line of tetragonLogFile.readLines()) {
           const processEntry = JSON.parse(line.trimEnd())?.process_kprobe;
           if (processEntry?.["policy_name"] !== "connect") {
    
  • action/dist/post.js.map+2 2 modified
  • action/src/post.ts+1 1 modified
    @@ -78,7 +78,7 @@ async function getOutboundConnections(): Promise<TetragonLog[]> {
     
         const tetragonLogFile = await fs.open(TETRAGON_EVENTS_LOG_PATH);
     
    -    const functionsToTrack = ["tcp_connect"];
    +    const functionsToTrack = ["tcp_connect", "udp_sendmsg"];
     
         for await (const line of tetragonLogFile.readLines()) {
           const processEntry = JSON.parse(line.trimEnd())?.process_kprobe;
    
  • action/tetragon/connect.yml+2 12 modified
    @@ -15,6 +15,7 @@ spec:
                   operator: "NotDAddr"
                   values:
                     - 127.0.0.1
    +                - 127.0.0.53
         - call: "udp_sendmsg"
           syscall: false
           args:
    @@ -26,22 +27,11 @@ spec:
                   operator: "NotDAddr"
                   values:
                     - 127.0.0.1
    +                - 127.0.0.53
         - call: "udp_recvmsg"
           syscall: false
           args:
           - index: 0
             type: "sock"
           - index: 2
             type: "size_t"
    -    # - call: "tcp_close"
    -    #   syscall: false
    -    #   args:
    -    #     - index: 0
    -    #       type: "sock"
    -    # - call: "tcp_sendmsg"
    -    #   syscall: false
    -    #   args:
    -    #     - index: 0
    -    #       type: "sock"
    -    #     - index: 2
    -    #       type: int
    
  • agent/agent.go+125 47 modified
    @@ -15,7 +15,7 @@ import (
     
     var (
     	blocking          = false
    -	defaultDomains    = []string{"github.com", "api.github.com", "*.actions.githubusercontent.com", "results-receiver.actions.githubusercontent.com", "*.blob.core.windows.net"}
    +	defaultDomains    = []string{"github.com", "api.github.com", "*.actions.githubusercontent.com", "results-receiver.actions.githubusercontent.com", "*.blob.core.windows.net", "*.githubapp.com"}
     	defaultIps        = []string{"168.63.129.16", "169.254.169.254", "127.0.0.1"}
     	defaultDNSServers = []string{"127.0.0.53"}
     )
    @@ -27,6 +27,7 @@ const (
     	EGRESS_POLICY_AUDIT                   = "audit"
     	DNS_POLICY_ALLOWED_DOMAINS_ONLY       = "allowed-domains-only"
     	DNS_POLICY_ANY                        = "any"
    +	DNS_PORT                              = layers.TCPPort(53)
     )
     
     type AgentConfig struct {
    @@ -63,6 +64,7 @@ func NewAgent(config AgentConfig) *Agent {
     		netInfoProvider:   config.NetInfoProvider,
     		filesystem:        config.FileSystem,
     	}
    +
     	agent.init(config)
     	return agent
     }
    @@ -153,7 +155,7 @@ func (a *Agent) loadAllowedIp(ips []string) {
     			a.addIpToLogs("allowed", "unknown", ip)
     			continue
     		}
    -		fmt.Printf("Failed to parse IP: %s. Skipping.\n", ip)
    +		fmt.Printf("failed to parse ip: %s. skipping.\n", ip)
     	}
     }
     
    @@ -164,13 +166,13 @@ func (a *Agent) addToFirewall(ips map[string]bool, cidr []*net.IPNet) error {
     	for ip := range ips {
     		err := a.firewall.AddIp(ip)
     		if err != nil {
    -			return fmt.Errorf("Error adding %s to firewall: %v\n", ip, err)
    +			return fmt.Errorf("error adding %s to firewall: %v", ip, err)
     		}
     	}
     	for _, c := range cidr {
     		err := a.firewall.AddIp(c.String())
     		if err != nil {
    -			return fmt.Errorf("Error adding %s to firewall: %v\n", c.String(), err)
    +			return fmt.Errorf("error adding %s to firewall: %v", c.String(), err)
     		}
     	}
     	return nil
    @@ -228,22 +230,19 @@ func (a *Agent) loadAllowedDNSServers() error {
     }
     
     func getDestinationIP(packet gopacket.Packet) (string, error) {
    -	ipLayer := packet.Layer(layers.LayerTypeIPv4)
    -	if ipLayer == nil {
    -		ipLayer = packet.Layer(layers.LayerTypeIPv6)
    -	}
    -	if ipLayer == nil {
    -		return "", fmt.Errorf("Failed to get IP layer")
    +	netLayer := packet.NetworkLayer()
    +	if netLayer == nil {
    +		return "", fmt.Errorf("failed to get network layer")
     	}
    -	ip, _ := ipLayer.(*layers.IPv4)
    -	if ip == nil {
    -		ip6, _ := ipLayer.(*layers.IPv6)
    -		if ip6 == nil {
    -			return "", fmt.Errorf("Failed to get IP layer")
    -		}
    -		return ip6.DstIP.String(), nil
    +
    +	switch v := netLayer.(type) {
    +	case *layers.IPv4:
    +		return v.DstIP.String(), nil
    +	case *layers.IPv6:
    +		return v.DstIP.String(), nil
    +	default:
    +		return "", fmt.Errorf("unknown network layer type")
     	}
    -	return ip.DstIP.String(), nil
     }
     
     func extractDomainFromSRV(domain string) string {
    @@ -254,25 +253,18 @@ func extractDomainFromSRV(domain string) string {
     	return re.ReplaceAllString(domain, "")
     }
     
    -func (a *Agent) processDNSQuery(packet gopacket.Packet) uint8 {
    -	dnsLayer := packet.Layer(layers.LayerTypeDNS)
    -	dns, _ := dnsLayer.(*layers.DNS)
    +func (a *Agent) processDNSLayer(dns *layers.DNS) uint8 {
    +	if !dns.QR {
    +		return a.processDNSQuery(dns)
    +	}
    +	return a.processDNSResponse(dns)
    +}
    +
    +func (a *Agent) processDNSQuery(dns *layers.DNS) uint8 {
     	for _, q := range dns.Questions {
     		domain := string(q.Name)
     		fmt.Printf("DNS Question: %s %s\n", q.Name, q.Type)
     
    -		// making sure the DNS query is using a trusted DNS server
    -		destinationIP, err := getDestinationIP(packet)
    -		if err != nil {
    -			fmt.Println("Failed to get destination IP")
    -			a.addIpToLogs("blocked", domain, "unknown")
    -			return DROP_REQUEST
    -		}
    -		if !a.allowedDNSServers[destinationIP] {
    -			fmt.Printf("%s -> Blocked DNS Query. Untrusted DNS server %s\n", domain, destinationIP)
    -			a.addIpToLogs("blocked", domain, "unknown")
    -			return DROP_REQUEST
    -		}
     		if q.Type == layers.DNSTypeSRV {
     			originalDomain := domain
     			domain = extractDomainFromSRV(domain)
    @@ -345,11 +337,10 @@ func (a *Agent) processDNSTypeSRVResponse(domain string, answer *layers.DNSResou
     	}
     }
     
    -func (a *Agent) processDNSResponse(packet gopacket.Packet) uint8 {
    -	dnsLayer := packet.Layer(layers.LayerTypeDNS)
    -	dns, _ := dnsLayer.(*layers.DNS)
    +func (a *Agent) processDNSResponse(dns *layers.DNS) uint8 {
     	domain := string(dns.Questions[0].Name)
     	for _, answer := range dns.Answers {
    +		fmt.Printf("DNS Answer: %s %s %s\n", answer.Name, answer.Type, answer.IP)
     		if answer.Type == layers.DNSTypeA {
     			a.processDNSTypeAResponse(domain, &answer)
     		} else if answer.Type == layers.DNSTypeCNAME {
    @@ -365,21 +356,108 @@ func (a *Agent) processDNSResponse(packet gopacket.Packet) uint8 {
     	return ACCEPT_REQUEST
     }
     
    -func (a *Agent) ProcessPacket(packet gopacket.Packet) uint8 {
    -	if dnsLayer := packet.Layer(layers.LayerTypeDNS); dnsLayer != nil {
    +func (a *Agent) processDNSPacket(packet gopacket.Packet) uint8 {
    +	dnsLayer := packet.Layer(layers.LayerTypeDNS)
    +	dns, _ := dnsLayer.(*layers.DNS)
    +	for _, q := range dns.Questions {
    +		fmt.Printf("DNS Question: %s %s\n", q.Name, q.Type)
    +	}
     
    -		dns, _ := dnsLayer.(*layers.DNS)
    -		for _, q := range dns.Questions {
    -			fmt.Printf("DNS Question: %s %s\n", q.Name, q.Type)
    +	domain := string(dns.Questions[0].Name)
    +	// if we are blocking DNS queries, intercept the DNS queries and decide whether to block or allow them
    +	if !dns.QR {
    +		// making sure the DNS query is using a trusted DNS server
    +		destinationIP, err := getDestinationIP(packet)
    +		if err != nil {
    +			fmt.Printf("Failed to get destination IP: %v\n", err)
    +			a.addIpToLogs("blocked", domain, "unknown")
    +			return DROP_REQUEST
     		}
    -		// if we are blocking DNS queries, intercept the DNS queries and decide whether to block or allow them
    -		if a.blockDNS && !dns.QR {
    -			return a.processDNSQuery(packet)
    -		} else if dns.QR {
    -			return a.processDNSResponse(packet)
    +		if !a.allowedDNSServers[destinationIP] {
    +			fmt.Printf("%s -> Blocked DNS Query. Untrusted DNS server %s\n", domain, destinationIP)
    +			a.addIpToLogs("blocked", domain, destinationIP)
    +			return DROP_REQUEST
     		}
     	}
    -	return ACCEPT_REQUEST
    +
    +	// if we are not blocking DNS queries, just accept the query request
    +	if !a.blockDNS && !dns.QR {
    +		return ACCEPT_REQUEST
    +	}
    +	return a.processDNSLayer(dns)
    +}
    +
    +func (a *Agent) processDNSOverTCPPayload(payload []byte) uint8 {
    +	// Extract message length from first 2 bytes
    +	// - First byte shifted left 8 bits + second byte
    +	// - Creates 16-bit length prefix
    +	messageLen := int(payload[0])<<8 | int(payload[1])
    +	if messageLen == 0 || len(payload) < messageLen+2 {
    +		fmt.Println("Invalid DNS over TCP payload")
    +		return DROP_REQUEST
    +	}
    +
    +	// We attempt to decode the DNS over TCP payload
    +	// The only way we can accept the request is if the DNS query is contained within a single TCP packet payload
    +	dns := &layers.DNS{}
    +	err := dns.DecodeFromBytes(payload[2:messageLen+2], gopacket.NilDecodeFeedback)
    +	if err != nil {
    +		fmt.Println("Failed to decode DNS over TCP payload", err)
    +		return DROP_REQUEST
    +	}
    +	return a.processDNSLayer(dns)
    +}
    +
    +func (a *Agent) processTCPPacket(packet gopacket.Packet) uint8 {
    +	tcpLayer := packet.Layer(layers.LayerTypeTCP)
    +	tcp, _ := tcpLayer.(*layers.TCP)
    +	dstPort, srcPort, payload := tcp.DstPort, tcp.SrcPort, tcp.Payload
    +
    +	// Validate DNS server IP
    +	if dstPort == DNS_PORT {
    +		destinationIP, err := getDestinationIP(packet)
    +		if err != nil {
    +			fmt.Printf("Failed to get destination IP: %v\n", err)
    +			a.addIpToLogs("blocked", "unknown", "unknown")
    +			return DROP_REQUEST
    +		}
    +		if !a.allowedDNSServers[destinationIP] {
    +			fmt.Printf("%s -> Blocked DNS Query. Untrusted DNS server %s\n", "unknown", destinationIP)
    +			a.addIpToLogs("blocked", "unknown", destinationIP)
    +			return DROP_REQUEST
    +		}
    +	}
    +
    +	if dstPort != DNS_PORT && srcPort != DNS_PORT {
    +		fmt.Println("Warning: Destination and source port are not DNS ports. Dropping request")
    +		return DROP_REQUEST
    +	}
    +
    +	// if we are not blocking DNS queries, just accept the query request
    +	if !a.blockDNS && dstPort == DNS_PORT {
    +		return ACCEPT_REQUEST
    +	}
    +
    +	if len(payload) == 0 {
    +		// We only accept DNS over TCP packets with no payload since they are only used for initiating a connection
    +		return ACCEPT_REQUEST
    +	}
    +
    +	// Now we have a payload in the TCP packet, we need to make sure it is a valid DNS over TCP payload and the DNS query is for a known domain. We don't want to exfiltrate data over DNS over TCP
    +	return a.processDNSOverTCPPayload(payload)
    +
    +}
    +
    +func (a *Agent) ProcessPacket(packet gopacket.Packet) uint8 {
    +	if dnsLayer := packet.Layer(layers.LayerTypeDNS); dnsLayer != nil {
    +		return a.processDNSPacket(packet)
    +	}
    +	// check dns over tcp
    +	if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
    +		return a.processTCPPacket(packet)
    +	}
    +	fmt.Println("Warning: Packet is not DNS or TCP. Dropping request, this shouldn't be happening.")
    +	return DROP_REQUEST
     }
     
     func (a *Agent) disableSudo() error {
    
  • agent/agent.sha256+1 1 modified
    @@ -1 +1 @@
    -9dc0fa0203ab625fa501ac1728aef69a5424267a7839c5fee9bf0d85e459ac59  agent
    +72fdea56ef365e362fd5ae69c1d5866fc4636db4e8e4aa10855d158d3f5e439f  agent
    
  • test/block.sh+3 1 modified
    @@ -38,6 +38,8 @@ grep --quiet 'Blocked DNS request to www.bing.com from unknown process' $POST_WA
     grep --quiet 'Blocked request to 93.184.215.14:443 from processs `/usr/bin/curl https://93.184.215.14 --output /dev/null' $POST_WARNINGS_FILEPATH
     grep --quiet 'Blocked DNS request to registry-1.docker.io from unknown process' $POST_WARNINGS_FILEPATH
     grep --quiet 'Blocked DNS request to www.wikipedia.org from unknown process' $POST_WARNINGS_FILEPATH
    -grep --quiet 'Blocked DNS request to www.google.com from unknown process' $POST_WARNINGS_FILEPATH
    +grep --quiet 'Blocked DNS request to tcp.example.com from unknown process' $POST_WARNINGS_FILEPATH
    +grep --quiet 'Blocked request to www.google.com (8.8.8.8:53) from process `/usr/bin/dig @8.8.8.8 www.google.com`' $POST_WARNINGS_FILEPATH
    +grep --quiet 'Blocked request to www.google.com (8.8.8.8:53) from process `/usr/bin/dig @8.8.8.8 www.google.com +tcp`' $POST_WARNINGS_FILEPATH
     
     echo "Tests passed successfully"
    
  • test/input.js+1 0 modified
    @@ -1,6 +1,7 @@
     process.env["INPUT_EGRESS-POLICY"] = "block";
     process.env["INPUT_DNS-POLICY"] = "allowed-domains-only";
     process.env["INPUT__LOG-DIRECTORY"] = "/tmp/gha-agent/logs";
    +process.env["INPUT_ENABLE-SUDO"] = "true";
     
     process.env["INPUT_ALLOWED-IPS"] = `
     10.0.0.0/24
    
  • test/make_dns_requests.sh+20 0 modified
    @@ -5,6 +5,21 @@ if timeout 5 dig example.com; then
       exit 1
     fi
     
    +if timeout 5 dig tcp.example.com +tcp; then
    +  echo 'Expected 'dig tcp.example.com +tcp' to fail, but it succeeded'
    +  exit 1
    +fi
    +
    +if ! timeout 5 dig www.google.com; then
    +  echo 'Expected 'dig www.google.com' to succeed, but it failed'
    +  exit 1
    +fi
    +
    +if ! timeout 5 dig www.google.com +tcp; then
    +  echo 'Expected 'dig www.google.com +tcp' to succeed, but it failed'
    +  exit 1
    +fi
    +
     if timeout 5 dig www.wikipedia.org; then
       echo 'Expected 'dig www.wikipedia.org' to fail, but it succeeded'
       exit 1
    @@ -14,3 +29,8 @@ if timeout 5 dig @8.8.8.8 www.google.com; then
       echo 'Expected 'dig @8.8.8.8 www.google.com' to fail, but it succeeded'
       exit 1
     fi
    +
    +if timeout 5 dig @8.8.8.8 www.google.com +tcp; then
    +  echo 'Expected 'dig @8.8.8.8 www.google.com +tcp' to fail, but it succeeded'
    +  exit 1
    +fi
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.