Skip to content

πŸ”΄ Red Team Audit β€” Medium: VSO command injection via unsanitized structural fields in Stage 3 println! outputΒ #394

@github-actions

Description

@github-actions

πŸ”΄ Red Team Security Audit

Audit focus: Category A (Input Sanitization & Injection) β€” Stage 3 executor log messages
Severity: Medium (3 independent paths; same root cause)

Findings

# Vulnerability Severity File(s) Exploitable?
1 VSO injection via extract_entry_context title/path in budget-exceeded messages Medium src/execute.rs:363–386 Yes β€” any tool with title/path field
2 VSO injection via effective_path in create-wiki-page messages Medium src/safeoutputs/create_wiki_page.rs:305–315, 386, 399–402 Yes β€” success, failure, and already-exists paths
3 VSO injection via effective_path in update-wiki-page messages Medium src/safeoutputs/update_wiki_page.rs:379, 394 Yes β€” same root cause as Finding 2

Details

Root Cause

sanitize.rs exposes neutralize_pipeline_commands() which wraps ##vso[ and ##[ ADO logging-command sequences in backticks so they render as code instead of being executed. This function is correctly called by sanitize_text() / sanitize_config() for content fields (work item title, PR body, wiki content, etc.).

However, fields that developers classified as "structural identifiers" (wiki page path, NDJSON entry title in log context) are only stripped of control characters. The comments make this explicit:

// src/safeoutputs/create_wiki_page.rs:73
// Path is a structural identifier β€” sanitize lightly (remove control chars)
// but do not escape HTML or neutralize patterns that are valid in wiki paths.
self.path = self.path.chars().filter(|c| !c.is_control() || *c == '\t').collect();
// src/execute.rs:363
/// Called before sanitization, so all string values are stripped of control characters
/// to prevent log injection.
fn extract_entry_context(entry: &Value) -> String {

##vso[task.setvariable variable=X]Y contains only printable ASCII β€” it survives both filters and reaches println!() in Stage 3, which runs in an ADO pipeline task. ADO interprets any ##vso[ or ##[ sequences that appear anywhere in a task's stdout.


Finding 1: Budget-exceeded skip message (execute.rs)

Location: src/execute.rs:363–386 (extract_entry_context) and src/execute.rs:415–426 (check_budget + println!)

Attack vector: Stage 1 agent emits max + 1 entries for any tool that has a title or path field. The first max entries are executed normally. The extra entry triggers the budget-exceeded path, which calls extract_entry_context and embeds the unsanitized title (≀ 40 chars) or path (unlimited length) in the skip message.

Proof of concept: NDJSON file with two create-work-item entries (DEFAULT_MAX = 1):

{"name":"create-work-item","title":"Legitimate item","type":"Task"}
{"name":"create-work-item","title":"##vso[task.complete result=Failed]","type":"Task"}
```

The second entry triggers:
```
[2/2] create-work-item - βœ— - Skipped ("##vso[task.complete result=Failed]"): maximum create-work-item count (1) already reached.

ADO interprets ##vso[task.complete result=Failed] and marks Stage 3 as Failed, aborting remaining entries. Any earlier entries are unaffected.

The path field variant (wiki tools) is even more powerful β€” it is not truncated at 40 chars, allowing longer commands:

{"name":"create-wiki-page","path":"##vso[task.setvariable variable=MY_VAR;issecret=false]injected","content":"x","wiki-id":"..."}
{"name":"create-wiki-page","path":"any","content":"x","wiki-id":"..."}

Impact: ADO task completion manipulation, pipeline variable injection, early Stage 3 termination that silently skips remaining safe outputs.


Finding 2 & 3: Wiki page effective_path in success/failure messages

Location:

  • src/safeoutputs/create_wiki_page.rs:305–315, 386, 399–402
  • src/safeoutputs/update_wiki_page.rs:379, 394

Attack vector: No budget manipulation required. A single well-formed entry is sufficient. self.path is only control-char filtered (not VSO-neutralized) in sanitize_content_fields. After effective_path = normalize_wiki_path(&self.path), the ADO logging command survives and is embedded verbatim in three message paths:

  1. Already-exists failure (create_wiki_page.rs:313–316): format!("Wiki page '{effective_path}' already exists...")
  2. PUT failure (create_wiki_page.rs:399–402): format!("Failed to create wiki page '{}' (HTTP {}): {}", effective_path, ...)
  3. Success (create_wiki_page.rs:386): format!("Created wiki page: {effective_path}")

The wiki page path validation (Validate::validate) only rejects .., null bytes, and empty paths β€” ##, [, ], ; are all allowed.

Proof of concept: Single NDJSON entry:

{
  "name": "create-wiki-page",
  "path": "##vso[task.setvariable variable=PIPELINE_VAR;issecret=false]attacker_value",
  "content": "page content here",
  "wiki-id": "my-wiki"
}
```

Regardless of whether ADO accepts or rejects the wiki path (both success and failure messages are affected), Stage 3 stdout will contain a `##vso[task.setvariable]` command that ADO will execute, setting `PIPELINE_VAR` to `attacker_value"): [... rest of message ...]`.

The same applies to `update-wiki-page` (Finding 3).

---

### Impact

| Effect | Mechanism |
|--------|-----------|
| Pipeline variable injection | `##vso[task.setvariable variable=X;issecret=false]Y` sets pipeline variable X for subsequent steps in Stage 3 (e.g., log-copy, publish steps) |
| Stage 3 early termination | `##vso[task.complete result=Succeeded]` or `result=Failed` aborts the current bash step, leaving unprocessed safe outputs silently skipped |
| False build status | `##vso[task.complete result=SucceededWithIssues]` masks real failures |
| Artifact upload | `##vso[task.uploadfile path=/path/to/sensitive/file]` can attach local runner files to the pipeline timeline |

**Important mitigations already in place**:
- SC_WRITE_TOKEN is loaded at step startup; `##vso[task.setvariable variable=SC_WRITE_TOKEN]` only affects variables used in *subsequent* steps, not the current `ado-aw execute` step itself
- Stage 2 (Detection) is supposed to flag malicious proposals, though it may not specifically scan `title`/`path` fields for `##vso[` sequences
- Exploitation requires a compromised or prompt-injected Stage 1 agent

---

### Suggested Fix

Apply `neutralize_pipeline_commands` (already available in `sanitize.rs`) to structural fields before they are embedded in log messages:

**Option A β€” Fix at the source (`sanitize_content_fields`)**: Call `sanitize_config()` (which includes VSO neutralization) instead of the bare control-char filter for wiki `path` and similar structural fields in Stage 3 log messages. Since these strings never reach ADO API URLs unsanitized (reqwest URL-encodes query params), the backtick wrapping would only affect the local log message.

**Option B β€” Fix at the sink (`extract_entry_context` and result message formatters)**: Run `neutralize_pipeline_commands(&clean)` on the cleaned string before embedding it in any format string that will be passed to `println!`.

Both options are one-line fixes per call site.

---

### Affected call sites

```
src/execute.rs:374         title branch in extract_entry_context
src/execute.rs:383         path  branch in extract_entry_context
src/safeoutputs/create_wiki_page.rs:75–79   sanitize_content_fields path
src/safeoutputs/create_wiki_page.rs:305–306 GET-fail message
src/safeoutputs/create_wiki_page.rs:313–315 already-exists message
src/safeoutputs/create_wiki_page.rs:386     success message
src/safeoutputs/create_wiki_page.rs:399–402 PUT-fail message
src/safeoutputs/update_wiki_page.rs:71–75   sanitize_content_fields path
src/safeoutputs/update_wiki_page.rs:379     success message
src/safeoutputs/update_wiki_page.rs:394     failure message

Audit Coverage

Category Status
A: Input Sanitization βœ… Scanned
B: Path Traversal βœ… Scanned
C: Network Bypass βœ… Scanned
D: Credential Exposure βœ… Scanned
E: Logic Flaws βœ… Scanned
F: Supply Chain βœ… Scanned

This issue was created by the automated red team security auditor.

Generated by Red Team Security Auditor Β· ● 2.9M Β· β—·

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions