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.
| Package | Affected versions | Patched versions |
|---|---|---|
bullfrogsec/bullfrogGitHub Actions | < 0.8.4 | 0.8.4 |
Affected products
1- Range: < 0.8.4
Patches
1ae7744ae4b3afix: dns over tcp bypasses domain filtering (#175)
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 modifiedaction/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- github.com/advisories/GHSA-m32f-fjw2-37v3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-47775ghsaADVISORY
- github.com/bullfrogsec/bullfrog/commit/ae7744ae4b3a6f8ffc2e49f501e30bf1a43d4671ghsax_refsource_MISCWEB
- github.com/bullfrogsec/bullfrog/releases/tag/v0.8.4ghsax_refsource_MISCWEB
- github.com/bullfrogsec/bullfrog/security/advisories/GHSA-m32f-fjw2-37v3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.