AWS VDP · Report #4077800
Unescaped consent-prompt renderer (HTML()) enables concealed-approval OS command injection in strands-agents/tools use_computer — RCE
How it was found
Summary
strands-agents-tools gates dangerous tool actions behind an interactive human-consent prompt. That prompt is rendered through prompt_toolkit.HTML() with no escaping (utils/user_input.py:35), so markup embedded in a tool argument is interpreted, not displayed. An attacker who can influence a tool argument (via indirect prompt injection in content the agent reads) can conceal part of the string the operator is asked to approve, while the concealed string is the one that executes.
Chained through use_computer, whose app_name argument flows unsanitized into three OS command sinks, this yields arbitrary command execution on the agent host after an operator approves a prompt that looks benign. The same renderer weakness independently undermines the framework's other consent surfaces, including its dedicated human-handoff tool.
Affected version: current HEAD, commit 1dd0465 (2026-09-30) — post-0.8.0 and post-0.8.2, still unpatched.
Distinct from CVE-2026-18733 (please read before triage)
CVE-2026-18733 / GHSA-mqvc-p852-wf8x is the shell tool's non_interactive schema parameter skipping the gate; its 0.8.0 fix touched shell.py, python_repl.py, load_tool.py — the only files where non_interactive exists. This report is a different root cause: markup parsing of an untrusted argument at the prompt-rendering layer (user_input.py:35), affecting tools that gate via BYPASS_TOOL_CONSENT + get_user_input. non_interactive does not appear in use_computer.py. The renderer is still unescaped at current HEAD (commit 1dd0465, 2026-09-30), after both the 0.8.0 and 0.8.2 fixes. It is a separate, unpatched issue.
Root cause 1 — unescaped consent renderer (utils/user_input.py:35)
response = await session.prompt_async(HTML(f"{prompt} "))
HTML() parses prompt_toolkit markup. Two concealment primitives were confirmed through the parser's own to_formatted_text, resolving to invisible spans: <hidden> produces SGR \x1b[0;8m, and <style fg="#1e1e1e" bg="#1e1e1e"> produces 38;5;234;48;5;234 (foreground == background). Feeding the real get_user_input() a concealed prompt and typing y returned 'y' — the human-review step is defeated.
Root cause 2 — unsanitized command sinks (use_computer.py)
app_name is free-form Optional[str] (no enum/allow-list) and reaches:
line 687 (Windows) — Sink A: direct shell execution
subprocess.run(f"start {actual_app_name}", shell=True, ...)
line 747 (macOS) — Sink D: AppleScript injection -> do shell script
script = f'tell application "{app_name}" to activate'; subprocess.run(["osascript","-e",script], ...)
line 769 (Windows) — Sink B: PowerShell statement injection
f"...AppActivate('{app_name}')" -> powershell -Command
The consent prompt (~line 1003) is built from locals() including raw app_name, then rendered via Root cause 1. Reachability is broad: the auto-focus block (~1033) routes ten actions — click, type, key_press, key_hold, hotkey, drag, scroll, scroll_to_bottom, screenshot, analyze_screen — through focus_application(app_name) into Sinks B/D, so the sink is not limited to open_app.
Blast radius of the renderer (verified at HEAD 1dd0465)
- use_computer.py:1003 (app_name) — proven concealable (live exec + raw-byte capture) — OS command execution (3 sinks)
- load_tool.py:211 (name/path) — proven concealable (raw-byte capture) — arbitrary code execution
- handoff_to_user.py:183 (message) — proven concealable (parser fragment) — approval/consent misrepresentation
- use_aws.py:449/454 (operation_name) — proven concealable (parser fragment) — mutative/sensitive AWS-op misrepresentation
- shell, python_repl, file_write, environment, cron — not concealable — static literal prompt, no interpolation
- editor (command), memory (action), http_request (method) — not concealable — interpolated token allow-list/enum-validated before the prompt (editor @345->457, memory @724->828) or gated + upper-cased (http_request @761/807)
The two deception surfaces (handoff_to_user, use_aws) broaden reach but are a different impact class with no execution sink; they are listed for completeness and are not part of the RCE severity.
Steps to reproduce
Self-contained; requires only prompt_toolkit (heavy deps stubbed by the attached harness). Run uc_repro.py (attached). It demonstrates: Sink A command execution (marker file created on two hosts); Sinks B/D payloads reaching the interpreter argv unmodified (Sink D marker created); and the consent stream containing SGR-conceal / fg==bg bytes with the gate returning y. See screenshot_concealment.png (operator sees app_name: Calculator; Start-Process calc concealed) and screenshot_injection.png.
Exploit chain
- Agent ingests untrusted content -> indirect prompt injection drives a use_computer call with a crafted app_name.
- The consent prompt renders it via HTML(); the malicious portion is concealed.
- Operator sees a benign prompt and approves with y.
- The sink executes the raw string -> command execution with the agent's privileges.
Severity rationale
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H = 8.8 (AV:L = 7.8 on local-agent hosts). Same-user execution, Scope Unchanged, consistent with AWS's CVSS 4.0 scoring of the related CVE-2026-18733 (SC:N/SI:N/SA:N). Not rated Critical: the sink is always downstream of the consent gate (no attacker-reachable gate-free path), so UI:R is retained, and no privilege/authority boundary is crossed.
Remediation
- open_application: drop shell=True; use an argument vector, e.g. subprocess.run(["cmd","/c","start","",app_name]).
- focus_application: pass app_name as interpreter data (osascript argv / PowerShell -ArgumentList); add an allow-list.
- user_input.py: render with FormattedText/plain text (auto-escaped); never HTML() on strings containing untrusted input. Reject control/markup characters in any value shown for approval. This fixes all four surfaces at once.
- Treat the consent decision as a security control subject to the same input neutralization as the sinks it guards.
Honesty notes
Sink A live execution was demonstrated via a forced system=="windows" branch on Linux and on WSL; not run on native Windows. Sinks B/D proven at interpreter-argv level (Sink D marker created). handoff_to_user and use_aws are parser-fragment-proven (no exec to demonstrate). BYPASS_TOOL_CONSENT=true is operator-set and is explicitly not used to inflate severity.
References
CWE-78, CWE-116, CWE-451. CVE-2026-18733 (related class, distinct root cause). AWS security bulletin 2026-072-AWS.
Impact
Arbitrary OS command execution on the agent host, running as the agent process's user — full read/write/execute over local secrets, source code, keys, and any network services that host can reach.
Where the agent holds AWS credentials — environment variables, ~/.aws, or instance/task/execution-role endpoints (EC2 IMDS, ECS task role, Lambda/Bedrock AgentCore) — a single injected command can retrieve the role's temporary IAM credentials, extending impact from the host to whatever that role is permitted to access. Because the trigger is untrusted content the agent reads (indirect prompt injection), no network access to the host is required; any poisoned resource the agent ingests is an entry point. On developer or CI hosts, exposure of publish/VCS tokens additionally creates supply-chain risk.
In a local lab using a mock credential endpoint, a single injected command retrieved credentials from that endpoint and copied ~/.aws/credentials, demonstrating the retrieval mechanism. The credentials were mock values; no live AWS account or role was involved.