Skip to content

Incomplete fix for #2669 / c2c9aae — raw \r in default/table/vertical output enables GitHub Actions workflow-command injection [via vrp team followup] #2749

@hits313

Description

@hits313

Summary

PR #2669 (commit c2c9aae) encoded \r as %0D for the --format=gh-annotations output path to fix GitHub Actions workflow-command injection. The fix is incomplete: three other code paths still emit raw \r bytes to stdout when a scanned source path contains \r.

Because the GitHub Actions runner treats \r as a line terminator while parsing ::command::value sequences from stdout, an attacker who can introduce a directory name containing \r::stop-commands::TOKEN\r into a CI run can:

  • Inject arbitrary workflow commands (::error::, ::warning::, ::add-mask::, ::stop-commands::).
  • Silently disable all subsequent command parsing via ::stop-commands::, causing every later ::error::CVE-XXXX annotation that osv-scanner itself emits to be dropped — the PR status check turns green and the maintainer merges believing the dependency change is clean.

This effectively defeats osv-scanner's value as a CI gate while the tool appears to run normally.

Severity note: this primitive silently bypasses osv-scanner's CI gate — vulnerable-dependency PRs can be merged with a green check. The trigger does not require maintainer approval — Sink 1 fires on the scan invocation itself, so a first-time external PR contributor can exploit this on any public repository using osv-scanner-action as a required check.

Confirmed against v2.3.5 (b4e672e, post-patch) by od -c of stdout raw \r bytes are present.

Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue.


Affected sinks (all post-c2c9aae)

All three paths interpolate a user-controlled source/directory path into a string that is written to stdout without \r / \n encoding.

Sink 1 — pkg/osvscanner/scan.go:153

cmdlogger.Infof("Scanning dir %s", path)   // `path` interpolated verbatim

This is emitted on every scan, before any output format is selected — so it fires regardless of whether the user passes --format=gh-annotations or not. Info level routes to stdout via internal/cmdlogger/handler.go:79:

_, err := fmt.Fprint(c.writer(record.Level), record.Message+"\n")
//                                            ^ no \r encoding

Sink 2 — internal/output/vertical.go:158

text.FgMagenta.Sprintf("%s", result.Name)   // source path printed verbatim

Sink 3 — internal/output/table.go:143

outputTable.SetTitle("Source:" + source.Name)   // source path embedded raw

The patched gh-annotations path correctly encodes \r%0D. None of the three sinks above do.


Why GitHub Actions is vulnerable to \r

The GHA runner treats both \n and \r as line terminators when scanning a step's stdout for ::command::value sequences. A single line containing:

dir\r::stop-commands::TOK\r::error::INJECTED

is parsed by the runner as three lines, including a live ::stop-commands::TOK directive. After that directive, all subsequent workflow commands are ignored until the matching ::TOK:: is emitted — which the attacker can simply choose not to.

This is the same class of bug that PR #2669 fixed for gh-annotations; it just wasn't fixed everywhere.


Proof (byte-level)

od -c of osv-scanner stdout when scanning a directory whose name contains \r:

d   i   r  \r   :   :   s   t   o   p   -   c   o   m   m   a   n   d   s   :   :   T  \r   :   :   e   r   r   o   r   :   :

Raw \r bytes in stdout, confirmed against v2.3.5 (b4e672e).


Reproducer

Tested with bash 5.x. If using zsh/dash, replace with printf '%b' or use $'...' ANSI-C quoting.

# 1. Build
git clone --depth 50 https://github.com/google/osv-scanner.git /tmp/osv-src
cd /tmp/osv-src && go build -o /tmp/osv-bin ./cmd/osv-scanner/

# 2. Create a directory whose name embeds workflow commands via \r
DIR="$(printf '/tmp/poc/dir\r::stop-commands::T\r::error::INJECTED\r::warning::pwn')"
mkdir -p "$DIR"

cat > "$DIR/package-lock.json" <<'JSON'
{
  "name": "x", "version": "1.0.0", "lockfileVersion": 3, "requires": true,
  "packages": {
    "": { "name": "x", "version": "1.0.0", "dependencies": { "lodash": "4.17.20" } },
    "node_modules/lodash": {
      "version": "4.17.20",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
      "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
      "license": "MIT"
    }
  }
}
JSON

# 3. Run scanner; map \r to \n so we can grep the injected commands
/tmp/osv-bin --offline-vulnerabilities --download-offline-databases \
  "$DIR/package-lock.json" 2>/dev/null | tr '\r' '\n' | grep '^::'

Output:

::stop-commands::T
::error::INJECTED
::warning::pwn

End-to-end PoC running the official binary inside a real GitHub Actions workflow:
https://github.com/hits313/osv-crlf-poc/actions/runs/24805983517

(Workflow file of the runner parsing the injected commands and dropping subsequent annotations are in that repo.)


Impact

Any contributor who can open a pull request against a repository that runs osv-scanner in CI can exploit this. No special permissions are required, and public repositories accepting external PRs are fully exposed. git does not sanitize \r from filenames, and \r is a valid filename byte on Linux, so a malicious PR can simply rename a directory.

In a typical exploit:

  1. Attacker opens a PR that:
    • Renames an existing directory to legit\r::stop-commands::TOK\r.
    • Bumps a dependency to a version with a known CVE.
  2. CI runs osv-scanner. The first scanned-source line emits the \r-laden directory name to stdout via Sink 1 (so this triggers regardless of --format).
  3. The GHA runner parses ::stop-commands::TOK as a live directive and stops processing all further workflow commands.
  4. Every ::error::CVE-… annotation osv-scanner emits afterwards is dropped. The vulnerability gate reports green.
  5. Reviewer sees clean check, merges the vulnerable dependency.

Additional primitives the attacker gets for free in the same step:

  • ::error:: / ::warning:: — inject arbitrary annotations that appear to originate from osv-scanner, misleading reviewers.
  • ::add-mask::VALUE — hide arbitrary strings from all subsequent log output in the workflow run.
  • On runner images that still accept the deprecated ::set-output:: / ::save-state:: commands, attacker-controlled values can also propagate to downstream steps.

The official google/osv-scanner-action is used as a required status check by hundreds of open-source projects. This issue lets an attacker silently defeat that gate.


Suggested fix

Encode \r (and ideally \n) at the writer boundary, not per-format, so future output paths inherit the protection by default. A targeted fix at internal/cmdlogger/handler.go:79 plus the two internal/output/*.go sinks would close all three known paths:

// internal/cmdlogger/handler.go
msg := strings.NewReplacer("\r", "%0D", "\n", "%0A").Replace(record.Message)
_, err := fmt.Fprint(c.writer(record.Level), msg+"\n")

…and a similar sanitization helper applied to result.Name / source.Name before they hit vertical.go and table.go.

Defense-in-depth note: scanning runs that target untrusted content should arguably never write user-controlled bytes to stdout unsanitized when running under GitHub Actions; detecting GITHUB_ACTIONS=true and applying the encoder unconditionally would prevent regressions of this class.

I'm happy to open a PR with either approach writer-boundary sanitizer (preferred, single source of truth) or per-sink sanitization. Let me know which you'd prefer.


Versions


Reporter hits313

Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue. Happy to provide additional reproducers (including a dedicated ::stop-commands:: end-to-end run) or to open the PR directly if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions