Skip to content

πŸ”΄ Red Team Audit β€” High: VSO command injection via file_path in upload tool Stage 3 messagesΒ #499

@github-actions

Description

@github-actions

πŸ”΄ Red Team Security Audit

Audit focus: Category A (Input Sanitization & Injection) β€” Stage 3 safe output executors
Severity: High

Findings

# Vulnerability Severity File(s) Exploitable?
1 VSO injection via file_path in upload success messages High src/safeoutputs/upload_workitem_attachment.rs:334-338, src/safeoutputs/upload_build_attachment.rs:540-544, src/safeoutputs/upload_pipeline_artifact.rs:614-618 Yes
2 Ironic double-injection: the ##vso[ content guard's own failure message injects via filename High src/safeoutputs/upload_workitem_attachment.rs:225-228 Yes

Details

Finding 1 & 2: file_path in Stage 3 upload messages printed unsanitized to stdout

Description: All three upload safe-output tools (upload-workitem-attachment, upload-build-attachment, upload-pipeline-artifact) include the agent-supplied file_path field verbatim in ExecutionResult::success / ExecutionResult::failure messages. These messages are printed to stdout by execute.rs:217:

println!("[{}/{}] {} - {} - {}", i + 1, total, tool_name, symbol, result.message);

The log_and_print_entry_result function applies no neutralization to result.message in the Ok(ExecutionResult) arm. Neutralization only happens in the Err(e) arm (hard errors from ? propagation).

Root cause: file_path validation (validate()) in all three tools blocks .., absolute paths, :, null bytes, newlines, and .git components β€” but does not block ##vso[ or ##[ sequences. sanitize_content_fields() for these tools does not include file_path.

Vulnerable code paths:

  1. upload_workitem_attachment.rs success path (line 334–338):
Ok(ExecutionResult::success_with_data(
    format!(
        "Uploaded '{}' and linked to work item #{}",
        filename, self.work_item_id   // ← filename derived from self.file_path
    ),
    ...
))
  1. upload_workitem_attachment.rs content guard failure (line 225–228) β€” the check designed to prevent injection causes injection via the filename:
return Ok(ExecutionResult::failure(format!(
    "File '{}' contains '##vso[' command injection sequence",
    self.file_path  // ← agent-controlled, printed without neutralization
)));
  1. upload_build_attachment.rs success path (line 540–544):
Ok(ExecutionResult::success_with_data(
    format!(
        "Attached '{}' to build #{} as artifact '{}'",
        self.file_path, effective_build_id, final_name  // ← unsanitized
    ),
    ...
))
  1. upload_pipeline_artifact.rs success path (line 614–618):
Ok(ExecutionResult::success_with_data(
    format!(
        "Published '{}' as pipeline artifact '{}' on build #{}",
        self.file_path, final_name, effective_build_id  // ← unsanitized
    ),
    ...
))
```

**Attack vector**:

1. In Stage 1, the agent's bash step creates a file with an ADO logging command embedded in the name:
   ```bash
   touch '##vso[task.setvariable variable=EXPLOIT]value.txt'
   ```
2. The agent calls the `upload-workitem-attachment` (or `upload-build-attachment` / `upload-pipeline-artifact`) MCP tool with:
   ```json
   { "file_path": "##vso[task.setvariable variable=EXPLOIT]value.txt", "work_item_id": 123 }
   ```
3. `validate()` passes: no `..`, no absolute path, no `:`, no null bytes, no newlines, no `.git`.
4. At Stage 3, `canonicalize()` succeeds (file exists), `starts_with(source_dir)` passes.
5. The success message `"Uploaded '##vso[task.setvariable variable=EXPLOIT]value.txt' and linked to work item #123"` is printed to stdout.
6. Azure DevOps interprets `##vso[task.setvariable variable=EXPLOIT]` as a pipeline logging command.

**Alternative exploitation path** (upload-workitem-attachment only β€” note irony): if the file content contains `##vso[`, the content guard returns a failure message that itself contains the malicious filename unsanitized:
```
File '##vso[task.setvariable variable=EXPLOIT]evil.txt' contains '##vso[' command injection sequence

ADO parses the first ##vso[...] occurrence in this output and executes it.

Impact:

  • Pipeline variable manipulation: ##vso[task.setvariable variable=X]value sets pipeline variables that persist for the duration of the Stage 3 job and affect any subsequent steps.
  • Task completion override: ##vso[task.complete result=Succeeded] can mark the Stage 3 task as succeeded even if subsequent write operations fail, hiding failures from the pipeline summary.
  • Build tagging: ##vso[build.addbuildtag tag=X] adds build tags that may affect release gate conditions.
  • Log injection: ##vso[task.logissue type=error;...]message creates spurious error annotations visible in the ADO build results UI.

Suggested fix:

  1. Primary fix: Apply neutralize_pipeline_commands to result.message in log_and_print_entry_result before the println! call:

    let safe_msg = neutralize_pipeline_commands(&result.message);
    println!("[{}/{}] {} - {} - {}", i + 1, total, tool_name, symbol, safe_msg);
  2. Belt-and-suspenders: Add ##vso[ / ##[ blocking to the file_path validation in all three upload tools, and/or include file_path in sanitize_content_fields().

  3. Upload-workitem-attachment content guard fix: use a safe local variable for the failure message instead of embedding self.file_path:

    return Ok(ExecutionResult::failure(
        "Uploaded file contains '##vso[' command injection sequence β€” upload rejected".to_string()
    ));

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 Β· ● 7.2M Β· β—·

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