Executive Summary
The esp-rs/espflash repository maintains a non-ephemeral fleet of physical self-hosted runners (BrnoRPIRS01–BrnoRPIRS13, BrnoVMRS06) dedicated to running Hardware-In-the-Loop (HIL) tests against real connected ESP32 SoC devices. The primary automated workflow (.github/workflows/hil.yml) triggered on pull_request events against the base repository, executing on runs-on: [self-hosted, linux, ARM64, ...]. Because GitHub Actions runs workflow definitions from the pull request head (i.e., from the contributor's fork), and repository policies lacked approval requirements for fork pull-request workflows, any attacker could push arbitrary workflow commands that executed directly on Espressif's self-hosted runners without maintainer review or authorization.
Coordinated Disclosure Timeline
Discovered self-hosted runner execution vulnerability; verified via GitHub REST API that 82 of 82 recent fork PR runs executed with zero approval gating.
Submitted private vulnerability disclosure to esp-rs maintainers detailing unauthenticated remote code execution on the Brno self-hosted runner cluster.
Executed live, non-destructive PoC (PR #1070, run 34771920489) using a neutral provenance marker confirming unprivileged command execution on self-hosted host CI17045-RPI.
Maintainer (@SergioGasquez) confirmed the vulnerability: 'The vulnerability report has been addressed with #1074 and some GH options. Thanks for opening the report!'
Pull Request #1074 ('Add slash commands for HIL testing') merged into main by @MabezDev, establishing maintainer slash-command gates (/hil, /trust) and fork approval controls.
01.Self-Hosted Runner Fleet Architecture & Non-Ephemeral Hosts
To validate firmware flashing across diverse ESP32 SoC architectures, esp-rs/espflash maintains a self-hosted runner cluster of Raspberry Pi single-board computers (BrnoRPIRS01 through BrnoRPIRS13) and virtual machines (BrnoVMRS06). These runners are long-lived and persistent: identical hostnames and serial device nodes (/dev/serial_ports/*) were observed serving multiple workflow runs across days and weeks. Because these machines are non-ephemeral, any state alteration or planted binary persists across workflow runs.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
run-tests:
if: github.repository_owner == 'esp-rs' # Ineffective guard
runs-on: [self-hosted, linux, ARM64, "${{ matrix.target.runner }}"]02.The Flawed Fork Guard: github.repository_owner Antipattern
The workflow attempted to restrict execution using if: github.repository_owner == 'esp-rs'. However, in GitHub Actions pull_request triggers, github.repository_owner consistently evaluates to the owner of the target base repository (esp-rs)—never the owner of the head fork. Consequently, this check evaluated to true for every fork pull request, offering zero protection against untrusted external contributors.
03.Execution Without Approval Gating
Public GitHub Actions run logs confirmed that the repository had not enabled the mandatory approval policy for fork pull requests ('Require approval for all outside collaborators'). An audit of the 100 most recent workflow runs revealed that 82 originated from external fork heads, and all 82 ran immediately to completion (47 success, 26 failure, 9 cancelled) with zero runs held in 'action_required'. This meant any GitHub user could fork the repository, modify the workflow file, submit a pull request, and achieve immediate arbitrary execution on Espressif's self-hosted runners.
04.Persistence Vectors & Supply Chain Blast Radius
Because the runners operate with access to host devices (dialout, gpio, i2c, spi, and docker groups) and persistent file systems, an adversary could execute an equivalent of the known TensorFlow self-hosted runner attack vector: registering a secondary background runner (RUNNER_TRACKING_ID=0 && nohup ./run.sh &), harvesting credentials (~/.runner, SSH agent keys), flashing compromised firmware onto attached test chips, or stealing repository tokens minted for subsequent base-branch runs.
Reproduction & Proof of Concept (PoC)
The vulnerability was validated end-to-end against the live repository using a neutral, non-destructive provenance marker (PR #1070, workflow run 34771920489):
- Phase A (Trigger Precondition Verification): Queried GitHub Actions REST API to verify that external fork pull requests were executing on self-hosted runners without maintainer approval gates.
- Phase B (PoC Workflow Formulation): Forked esp-rs/espflash and added .github/workflows/poc.yml containing a benign diagnostic step printing hostname, whoami, id, uname, and runner environment variables.
- Phase C (Execution Trigger): Created pull request #1070 from fork branch ghsa-poc against base branch main. The workflow triggered automatically without maintainer interaction.
- Phase D (Host Execution Verification): Monitored run 34771920489; verified successful execution on self-hosted runner 'CI17045-RPI' running Linux 6.12 aarch64 under user 'pi' with dialout, gpio, i2c, spi, and docker group privileges.
- Phase E (Blast Radius Modeling): Documented persistence channels (secondary runner registration, token exfiltration from subsequent runs) while strictly avoiding destructive testing.
name: PoC - RCE on self-hosted runner
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
prove-rce:
if: github.repository_owner == 'esp-rs' # Bypassed on fork PRs
name: Prove arbitrary code execution on self-hosted HIL runner
runs-on: [self-hosted, linux, ARM64]
steps:
- name: marker
run: |
echo "=== PROVENANCE VERIFICATION ==="
hostname
whoami
id
uname -a
echo "RUNNER_NAME=$RUNNER_NAME"
echo "RUNNER_ARCH=$RUNNER_ARCH"
echo "RUNNER_OS=$RUNNER_OS"Impact & Blast Radius
Remediation & Mitigation
Adds slash commands for selectively running HIL tests:
/hil quick
/hil full
/hil sdm
/hil [chip2...]
/trust user
/revoke user
Full HIL remains required in the merge queue.References & Documentation
- esp-rs/espflash Pull Request #1074: Add slash commands for HIL testing
- Proof of Concept Pull Request #1070 (Closed)
- GitHub Actions Workflow Execution Run #34771920489
- CWE-284: Improper Access Control
- CWE-306: Missing Authentication for Critical Function
- GitHub Security Lab: Self-hosted runner security guidelines
