Back to Blog
high SEVERITY8 min read

How curl-pipe-shell happens in GitHub Actions and how to fix it

A GitHub Actions workflow in `action.yml` was found to pipe the output of `curl` or `wget` directly into a shell interpreter — the classic "curl | bash" install pattern. If the remote server hosting the script is compromised or the URL is hijacked via DNS or CDN attack, an attacker gains arbitrary code execution inside the CI runner with full access to secrets and build artifacts. The fix replaces the unsafe inline execution pattern with a download-verify-then-execute approach.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

The "curl pipe shell" vulnerability (CWE-494, Download of Code Without Integrity Check) occurs when a GitHub Actions `run:` step passes the output of `curl` or `wget` directly to `bash` or `sh` without first verifying the downloaded content. In `action.yml`, this pattern means any compromise of the remote URL — through DNS hijacking, CDN takeover, or server breach — allows an attacker to execute arbitrary commands inside the CI runner. The fix downloads the file to disk first, verifies its cryptographic checksum or signature, and only then executes it, breaking the direct trust chain between the remote server and the shell.

Vulnerability at a Glance

cweCWE-494
fixDownload script to disk, verify checksum/signature, then execute separately
riskArbitrary code execution in CI runner; exposure of secrets and build artifacts
languageYAML (GitHub Actions)
root cause`run:` step pipes `curl`/`wget` output directly to `bash` without integrity verification
vulnerabilitycurl-pipe-shell (Download of Code Without Integrity Check)

How curl | bash Happens in GitHub Actions and How to Fix It

The Vulnerability at a Glance

Field Detail
Vulnerability curl-pipe-shell (Download of Code Without Integrity Check)
CWE CWE-494
Language YAML (GitHub Actions)
Risk Arbitrary code execution in CI runner
Root Cause run: step pipes curl/wget output directly to bash
Fix Download → verify checksum → execute separately

Introduction

The action.yml file defines a reusable GitHub Actions action — it controls exactly what commands run inside every CI job that invokes it. A security scan of this file revealed a run: step following the "curl pipe bash" install pattern: fetching a remote script and piping it directly into a shell interpreter in a single command. This is one of the highest-risk patterns in CI/CD infrastructure because it collapses the entire trust chain between a remote server and privileged code execution into a single, unverifiable step.

The pattern looks harmless in everyday developer usage — it is how many popular tools advertise their installers. But in a CI runner that holds repository secrets, deployment credentials, and signing keys, it is a loaded gun pointed at your supply chain.


The Vulnerability Explained

What the Vulnerable Pattern Looks Like

The problematic construct in action.yml follows this shape:

# VULNERABLE — do not use
- name: Install tool
  run: curl -sSfL https://example.com/install.sh | bash

or equivalently with wget:

# VULNERABLE — do not use
- name: Install tool
  run: wget -qO- https://example.com/install.sh | sh

The critical detail is the pipe (|). The shell never writes the downloaded content to disk. It reads bytes from curl's stdout and executes them immediately. There is no moment where you can inspect, hash, or verify what is being run.

Why the GitHub Actions Context Makes This Worse

A standard developer workstation running curl | bash risks only that one machine. A GitHub Actions runner is a different threat surface entirely:

  • Secrets are mounted as environment variables — GITHUB_TOKEN, cloud provider credentials, NPM tokens, and Docker Hub passwords are all accessible to any command that runs in the job.
  • The runner has network access — it can exfiltrate data, pivot to internal services, or push malicious artifacts to registries.
  • Build artifacts are trusted — code produced by CI is often signed, published, or deployed automatically. Injecting malicious code at build time poisons every downstream consumer.

The Attack Scenario

Consider a concrete attack against this specific action.yml:

  1. URL hijacking: The domain hosting install.sh expires or its DNS is hijacked. The attacker registers the domain and serves a modified script.
  2. CDN compromise: The CDN delivering the script is breached. The attacker swaps the script content at the edge node closest to GitHub's runner infrastructure.
  3. Subdomain takeover: The install URL points to a subdomain backed by a cloud resource (S3 bucket, Azure Blob, GitHub Pages) that has been deleted. The attacker claims the resource and serves arbitrary content.

In all three cases, the next time a workflow runs and reaches the curl | bash step, the attacker's code executes with full access to the runner environment — including every secret configured in the repository.

Why HTTPS Does Not Help Here

A common misconception is that https:// URLs are safe because the transport is encrypted. HTTPS verifies that you are talking to the server that owns the certificate for that domain. It does not verify that the server is serving the content you intended to download. A compromised origin server with a valid certificate is indistinguishable from a legitimate one over HTTPS.


The Fix

The correct remediation breaks the "download and execute" single step into three distinct, auditable steps:

Step 1 — Download to a Named File

- name: Download installer
  run: curl -sSfL https://example.com/install.sh -o /tmp/install.sh

Writing to a file gives you a concrete artifact you can inspect and hash.

Step 2 — Verify the Checksum

- name: Verify installer checksum
  run: |
    echo "a3f1c2d4e5b6789012345678901234567890abcdef1234567890abcdef123456  /tmp/install.sh" \
      | sha256sum --check --strict

The expected SHA-256 hash must be hardcoded in the workflow file (or fetched from a separate, pinned source). It cannot come from the same server as the script — that would be like asking the suspect to verify their own alibi.

For tools that provide GPG-signed releases, prefer signature verification over checksums:

- name: Verify GPG signature
  run: gpg --verify /tmp/install.sh.sig /tmp/install.sh

Step 3 — Execute the Verified File

- name: Run installer
  run: bash /tmp/install.sh

Only after the hash or signature matches does execution proceed.

Full Before/After Comparison

Before (vulnerable):

steps:
  - name: Install Rust toolchain helper
    run: curl -sSfL https://sh.rustup.rs | sh -s -- -y

After (safe):

steps:
  - name: Download rustup installer
    run: curl -sSfL https://sh.rustup.rs -o /tmp/rustup-init.sh

  - name: Verify rustup installer checksum
    run: |
      # Pin the expected SHA-256 from https://static.rust-lang.org/rustup/dist/
      echo "EXPECTED_HASH  /tmp/rustup-init.sh" | sha256sum --check --strict

  - name: Install Rust toolchain
    run: bash /tmp/rustup-init.sh -y

The three-step pattern eliminates the direct pipe between the network and the shell. Even if the remote server is compromised, the checksum step will fail and the workflow will halt before any malicious code executes.


Connection to the rustls-webpki Dependency Update

This PR also upgrades rustls-webpki from 0.103.9 to 0.103.13 in src-tauri/Cargo.lock to address GHSA-82j2-j2ch-gfr8 (denial of service via panic on malformed CRL BIT STRING). The Cargo.lock diff shows the version bump and the updated checksum:

 [[package]]
 name = "rustls-webpki"
-version = "0.103.9"
+version = "0.103.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"

The checksum field in Cargo.lock is exactly the kind of integrity verification that the curl | bash pattern in action.yml was missing. Cargo enforces that the downloaded crate bytes match the pinned hash before any code from that crate is compiled — the same principle that the fixed action.yml now applies to downloaded shell scripts.


Key Takeaways

  • The run: step in action.yml is a privileged execution context — any code that runs there has access to all repository secrets and can modify build artifacts.
  • curl | bash eliminates every integrity check — there is no point at which you can verify that the downloaded script matches what you intended to execute.
  • HTTPS is not a substitute for checksum verification — transport security and content integrity are orthogonal properties.
  • The Cargo.lock checksum field in the same PR demonstrates the correct model — pin the expected hash of every external artifact, whether it is a Rust crate or a shell installer.
  • Official versioned Actions (pinned by commit SHA) are the safest alternative to shell-based installers for common toolchain setup tasks in GitHub Actions.

How Orbis AppSec Detected This

  • Source: Remote URL passed to curl or wget in a run: step inside action.yml
  • Sink: Shell interpreter (bash / sh) receiving the piped output of the HTTP fetch — the curl <url> | bash construct in the workflow's run: field
  • Missing control: No intermediate file write, no checksum comparison, no GPG signature verification between the network fetch and shell execution
  • CWE: CWE-494 — Download of Code Without Integrity Check
  • Fix: Replace the single-step pipe with a three-step download-verify-execute sequence, pinning the expected SHA-256 hash of the installer script in the workflow file

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The curl | bash pattern is one of the most widely deployed anti-patterns in CI/CD infrastructure, normalized by years of tool documentation that prioritizes installation convenience over security. In a GitHub Actions action.yml, it is especially dangerous because the runner environment is loaded with credentials and produces trusted artifacts. The fix is straightforward: download to a named file, verify the hash against a value pinned in version control, and only then execute. This three-step pattern is the same integrity model that Cargo applies to every dependency in Cargo.lock — and it is the right model for any code you fetch from the internet and run with elevated privilege.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #1346

Related Articles

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.

high

CVE-2026-54673: builder-util-runtime Leaks Auth Headers on Redirect

electron-updater and electron-builder rely on builder-util-runtime to fetch update manifests and artifacts over HTTP. A flaw in that shared HTTP executor allowed credential headers attached to the original update-feed request to be re-sent after a redirect, exposing them to any host the redirect pointed to. The project fixes this by upgrading builder-util-runtime to 9.7.0 and collapsing a duplicate, older copy of the package that electron-updater had pinned on its own.